From 320af5789c88ab9ee310169400f92353ca8f0360 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 11:07:38 -0700 Subject: [PATCH 01/14] Add OpenEVSE diagnostics and tests with mock fix --- .../components/openevse/diagnostics.py | 83 +++++++++++++++++++ tests/components/openevse/test_diagnostics.py | 72 ++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 homeassistant/components/openevse/diagnostics.py create mode 100644 tests/components/openevse/test_diagnostics.py diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py new file mode 100644 index 0000000000000..2ed3256aae26d --- /dev/null +++ b/homeassistant/components/openevse/diagnostics.py @@ -0,0 +1,83 @@ +"""Provide diagnostics for OpenEVSE.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .coordinator import OpenEVSEConfigEntry + +REDACT_CONFIG_DATA = {CONF_PASSWORD, CONF_USERNAME} + +CHARGER_PROPERTIES = [ + "status", + "vehicle", + "mode", + "charge_mode", + "divertmode", + "manual_override", + "ota_update", + "service_level", + "charge_time_elapsed", + "vehicle_eta", + "charging_current", + "charging_voltage", + "charging_power", + "current_power", + "current_capacity", + "max_current", + "min_amps", + "max_amps", + "max_current_soft", + "available_current", + "smoothed_available_current", + "charge_rate", + "ambient_temperature", + "ir_temperature", + "rtc_temperature", + "esp_temperature", + "usage_session", + "usage_total", + "total_day", + "total_week", + "total_month", + "total_year", + "vehicle_soc", + "vehicle_range", + "wifi_signal", + "shaper_live_power", + "shaper_available_current", + "shaper_max_power", + "gfi_trip_count", + "no_gnd_trip_count", + "stuck_relay_trip_count", + "uptime", + "freeram", + "wifi_firmware", + "openevse_firmware", +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: OpenEVSEConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = config_entry.runtime_data + charger = coordinator.charger + + charger_data: dict[str, Any] = {} + for prop in CHARGER_PROPERTIES: + if hasattr(charger, prop): + try: + val = getattr(charger, prop) + if callable(val): + val = val() + charger_data[prop] = val + except AttributeError, TypeError: + pass + + return { + "config_entry": async_redact_data(config_entry.as_dict(), REDACT_CONFIG_DATA), + "charger": charger_data, + } diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py new file mode 100644 index 0000000000000..b3a5fe1124299 --- /dev/null +++ b/tests/components/openevse/test_diagnostics.py @@ -0,0 +1,72 @@ +"""Test OpenEVSE diagnostics.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test OpenEVSE diagnostics.""" + # Mock firmware versions to prevent the dynamic property loop in diagnostics + # from triggering nested/infinite MagicMock calls and leaking memory. + mock_charger.wifi_firmware = "1.2.3" + mock_charger.openevse_firmware = "4.5.6" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert diagnostics["config_entry"]["data"] == { + "host": "192.168.1.100", + } + assert diagnostics["charger"]["status"] == "Charging" + assert diagnostics["charger"]["charging_voltage"] == 240 + assert diagnostics["charger"]["charging_current"] == 32000.0 + + +async def test_entry_diagnostics_redact( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_charger: MagicMock, +) -> None: + """Test OpenEVSE diagnostics with auth data redacted.""" + entry = MockConfigEntry( + title="openevse_mock_config", + domain="openevse", + data={ + "host": "192.168.1.100", + "username": "my_username", + "password": "my_password", + }, + entry_id="FAKE_AUTH", + unique_id="deadbeeffeed", + ) + # Mock firmware versions to prevent the dynamic property loop in diagnostics + # from triggering nested/infinite MagicMock calls and leaking memory. + mock_charger.wifi_firmware = "1.2.3" + mock_charger.openevse_firmware = "4.5.6" + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diagnostics["config_entry"]["data"] == { + "host": "192.168.1.100", + "username": "**REDACTED**", + "password": "**REDACTED**", + } From da73270cdd70b284c700982ec370040a950289b3 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 11:13:49 -0700 Subject: [PATCH 02/14] Address PR review comments on diagnostics --- .../components/openevse/diagnostics.py | 20 +++++---- tests/components/openevse/test_diagnostics.py | 45 ++++++++++++++----- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index 2ed3256aae26d..dddaed7263695 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -68,14 +68,18 @@ async def async_get_config_entry_diagnostics( charger_data: dict[str, Any] = {} for prop in CHARGER_PROPERTIES: - if hasattr(charger, prop): - try: - val = getattr(charger, prop) - if callable(val): - val = val() - charger_data[prop] = val - except AttributeError, TypeError: - pass + try: + val = getattr(charger, prop) + except AttributeError: + continue + except Exception as err: # pylint: disable=broad-except # noqa: BLE001 + charger_data[prop] = f"Error: {type(err).__name__}: {err}" + continue + + if callable(val): + continue + + charger_data[prop] = val return { "config_entry": async_redact_data(config_entry.as_dict(), REDACT_CONFIG_DATA), diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index b3a5fe1124299..0c28e394c1c6d 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -1,6 +1,6 @@ """Test OpenEVSE diagnostics.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, PropertyMock from homeassistant.core import HomeAssistant @@ -16,11 +16,6 @@ async def test_entry_diagnostics( mock_charger: MagicMock, ) -> None: """Test OpenEVSE diagnostics.""" - # Mock firmware versions to prevent the dynamic property loop in diagnostics - # from triggering nested/infinite MagicMock calls and leaking memory. - mock_charger.wifi_firmware = "1.2.3" - mock_charger.openevse_firmware = "4.5.6" - mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -54,11 +49,6 @@ async def test_entry_diagnostics_redact( entry_id="FAKE_AUTH", unique_id="deadbeeffeed", ) - # Mock firmware versions to prevent the dynamic property loop in diagnostics - # from triggering nested/infinite MagicMock calls and leaking memory. - mock_charger.wifi_firmware = "1.2.3" - mock_charger.openevse_firmware = "4.5.6" - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -70,3 +60,36 @@ async def test_entry_diagnostics_redact( "username": "**REDACTED**", "password": "**REDACTED**", } + + +async def test_entry_diagnostics_exceptions( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test OpenEVSE diagnostics handles exceptions correctly.""" + # Set up one property to raise AttributeError (should be skipped) + type(mock_charger).status = PropertyMock(side_effect=AttributeError) + + # Set up another property to raise a different Exception (should record the error) + type(mock_charger).charging_voltage = PropertyMock( + side_effect=ValueError("Connection error") + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + # status should be omitted due to AttributeError + assert "status" not in diagnostics["charger"] + + # charging_voltage should show the recorded error + assert ( + diagnostics["charger"]["charging_voltage"] + == "Error: ValueError: Connection error" + ) From 56896dbd7b41d757ff9205587aba251b2bb85c34 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 11:17:13 -0700 Subject: [PATCH 03/14] Ensure diagnostics are JSON-serializable and fix test patching --- .../components/openevse/diagnostics.py | 21 ++++++++- tests/components/openevse/test_diagnostics.py | 47 +++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index dddaed7263695..8faee37f9dd69 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -1,5 +1,7 @@ """Provide diagnostics for OpenEVSE.""" +from datetime import date, datetime +from enum import Enum from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -59,6 +61,21 @@ ] +def _to_json_safe(val: Any) -> Any: + """Coerce value to be JSON-serializable.""" + if isinstance(val, (str, int, float, bool)) or val is None: + return val + if isinstance(val, (datetime, date)): + return val.isoformat() + if isinstance(val, Enum): + return val.value + if isinstance(val, (list, tuple, set)): + return [_to_json_safe(v) for v in val] + if isinstance(val, dict): + return {k: _to_json_safe(v) for k, v in val.items()} + return str(val) + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: OpenEVSEConfigEntry ) -> dict[str, Any]: @@ -72,14 +89,14 @@ async def async_get_config_entry_diagnostics( val = getattr(charger, prop) except AttributeError: continue - except Exception as err: # pylint: disable=broad-except # noqa: BLE001 + except Exception as err: # noqa: BLE001 charger_data[prop] = f"Error: {type(err).__name__}: {err}" continue if callable(val): continue - charger_data[prop] = val + charger_data[prop] = _to_json_safe(val) return { "config_entry": async_redact_data(config_entry.as_dict(), REDACT_CONFIG_DATA), diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index 0c28e394c1c6d..45fe44d2485f7 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -1,7 +1,11 @@ """Test OpenEVSE diagnostics.""" +from datetime import datetime +from enum import Enum from unittest.mock import MagicMock, PropertyMock +import pytest + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -67,14 +71,43 @@ async def test_entry_diagnostics_exceptions( hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, mock_charger: MagicMock, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test OpenEVSE diagnostics handles exceptions correctly.""" + """Test OpenEVSE diagnostics handles exceptions and JSON coercion correctly.""" + + class MockEnum(Enum): + TEST = "test_value" + # Set up one property to raise AttributeError (should be skipped) - type(mock_charger).status = PropertyMock(side_effect=AttributeError) + monkeypatch.setattr( + type(mock_charger), + "status", + PropertyMock(side_effect=AttributeError), + raising=False, + ) # Set up another property to raise a different Exception (should record the error) - type(mock_charger).charging_voltage = PropertyMock( - side_effect=ValueError("Connection error") + monkeypatch.setattr( + type(mock_charger), + "charging_voltage", + PropertyMock(side_effect=ValueError("Connection error")), + raising=False, + ) + + # Set up datetime property to verify JSON coercion + monkeypatch.setattr( + type(mock_charger), + "vehicle_eta", + PropertyMock(return_value=datetime(2026, 5, 21, 12, 0, 0)), + raising=False, + ) + + # Set up Enum property to verify JSON coercion + monkeypatch.setattr( + type(mock_charger), + "mode", + PropertyMock(return_value=MockEnum.TEST), + raising=False, ) mock_config_entry.add_to_hass(hass) @@ -93,3 +126,9 @@ async def test_entry_diagnostics_exceptions( diagnostics["charger"]["charging_voltage"] == "Error: ValueError: Connection error" ) + + # vehicle_eta should be coerced to ISO format string + assert diagnostics["charger"]["vehicle_eta"] == "2026-05-21T12:00:00" + + # mode should be coerced to Enum raw value + assert diagnostics["charger"]["mode"] == "test_value" From 5a27c4ddb1ce0a77b179f00d9bae81fd4382708e Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 11:23:42 -0700 Subject: [PATCH 04/14] Address additional PR review comments on diagnostics --- homeassistant/components/openevse/diagnostics.py | 10 ++++++---- tests/components/openevse/test_diagnostics.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index 8faee37f9dd69..03e3d8194a47e 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -12,7 +12,7 @@ REDACT_CONFIG_DATA = {CONF_PASSWORD, CONF_USERNAME} -CHARGER_PROPERTIES = [ +CHARGER_PROPERTIES = ( "status", "vehicle", "mode", @@ -58,7 +58,7 @@ "freeram", "wifi_firmware", "openevse_firmware", -] +) def _to_json_safe(val: Any) -> Any: @@ -69,10 +69,12 @@ def _to_json_safe(val: Any) -> Any: return val.isoformat() if isinstance(val, Enum): return val.value - if isinstance(val, (list, tuple, set)): + if isinstance(val, (set, frozenset)): + return [_to_json_safe(v) for v in sorted(val, key=str)] + if isinstance(val, (list, tuple)): return [_to_json_safe(v) for v in val] if isinstance(val, dict): - return {k: _to_json_safe(v) for k, v in val.items()} + return {str(k): _to_json_safe(v) for k, v in val.items()} return str(val) diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index 45fe44d2485f7..832b94cd01835 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -98,7 +98,7 @@ class MockEnum(Enum): monkeypatch.setattr( type(mock_charger), "vehicle_eta", - PropertyMock(return_value=datetime(2026, 5, 21, 12, 0, 0)), + PropertyMock(return_value=datetime(2000, 1, 1, 12, 0, 0)), raising=False, ) @@ -128,7 +128,7 @@ class MockEnum(Enum): ) # vehicle_eta should be coerced to ISO format string - assert diagnostics["charger"]["vehicle_eta"] == "2026-05-21T12:00:00" + assert diagnostics["charger"]["vehicle_eta"] == "2000-01-01T12:00:00" # mode should be coerced to Enum raw value assert diagnostics["charger"]["mode"] == "test_value" From 9782be29f6e97ef7a51729f51560c363894b8444 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 11:28:22 -0700 Subject: [PATCH 05/14] Address additional PR comments: rename unused hass, isolate test mocks, and loosen assertion --- .../components/openevse/diagnostics.py | 2 +- tests/components/openevse/test_diagnostics.py | 64 ++++++++----------- 2 files changed, 29 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index 03e3d8194a47e..108faab48c9fc 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -79,7 +79,7 @@ def _to_json_safe(val: Any) -> Any: async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: OpenEVSEConfigEntry + _hass: HomeAssistant, config_entry: OpenEVSEConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator = config_entry.runtime_data diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index 832b94cd01835..71da6d3c84b74 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -2,9 +2,8 @@ from datetime import datetime from enum import Enum -from unittest.mock import MagicMock, PropertyMock - -import pytest +from typing import Any +from unittest.mock import MagicMock from homeassistant.core import HomeAssistant @@ -71,49 +70,43 @@ async def test_entry_diagnostics_exceptions( hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, mock_charger: MagicMock, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Test OpenEVSE diagnostics handles exceptions and JSON coercion correctly.""" class MockEnum(Enum): TEST = "test_value" - # Set up one property to raise AttributeError (should be skipped) - monkeypatch.setattr( - type(mock_charger), - "status", - PropertyMock(side_effect=AttributeError), - raising=False, - ) + class FakeCharger: + """Fake charger to raise exceptions and return custom values for properties.""" - # Set up another property to raise a different Exception (should record the error) - monkeypatch.setattr( - type(mock_charger), - "charging_voltage", - PropertyMock(side_effect=ValueError("Connection error")), - raising=False, - ) + def __init__(self, original_charger: MagicMock) -> None: + self._original_charger = original_charger - # Set up datetime property to verify JSON coercion - monkeypatch.setattr( - type(mock_charger), - "vehicle_eta", - PropertyMock(return_value=datetime(2000, 1, 1, 12, 0, 0)), - raising=False, - ) + def __getattr__(self, name: str) -> Any: + if name == "status": + raise AttributeError + return getattr(self._original_charger, name) - # Set up Enum property to verify JSON coercion - monkeypatch.setattr( - type(mock_charger), - "mode", - PropertyMock(return_value=MockEnum.TEST), - raising=False, - ) + @property + def charging_voltage(self) -> int: + raise ValueError("Connection error") + + @property + def vehicle_eta(self) -> datetime: + return datetime(2000, 1, 1, 12, 0, 0) + + @property + def mode(self) -> MockEnum: + return MockEnum.TEST mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + # Inject the FakeCharger into the coordinator to isolate side effects + coordinator = mock_config_entry.runtime_data + coordinator.charger = FakeCharger(mock_charger) + diagnostics = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry ) @@ -122,10 +115,9 @@ class MockEnum(Enum): assert "status" not in diagnostics["charger"] # charging_voltage should show the recorded error - assert ( - diagnostics["charger"]["charging_voltage"] - == "Error: ValueError: Connection error" - ) + voltage_diagnostic = diagnostics["charger"]["charging_voltage"] + assert voltage_diagnostic.startswith("Error: ValueError") + assert "Connection error" in voltage_diagnostic # vehicle_eta should be coerced to ISO format string assert diagnostics["charger"]["vehicle_eta"] == "2000-01-01T12:00:00" From 08184c3a2a988e81726a6527611d6613a7ae9eaa Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 11:47:43 -0700 Subject: [PATCH 06/14] Address PR review comments, hide raw exception messages, and achieve 100% code coverage --- .../components/openevse/diagnostics.py | 8 +- tests/components/openevse/test_diagnostics.py | 74 ++++++++++++++++--- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index 108faab48c9fc..d997a71486939 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -2,6 +2,7 @@ from datetime import date, datetime from enum import Enum +import inspect from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -88,11 +89,14 @@ async def async_get_config_entry_diagnostics( charger_data: dict[str, Any] = {} for prop in CHARGER_PROPERTIES: try: - val = getattr(charger, prop) + inspect.getattr_static(charger, prop) except AttributeError: continue + + try: + val = getattr(charger, prop) except Exception as err: # noqa: BLE001 - charger_data[prop] = f"Error: {type(err).__name__}: {err}" + charger_data[prop] = f"Error: {type(err).__name__}" continue if callable(val): diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index 71da6d3c84b74..d39b95b8b0686 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -81,11 +81,23 @@ class FakeCharger: def __init__(self, original_charger: MagicMock) -> None: self._original_charger = original_charger - - def __getattr__(self, name: str) -> Any: - if name == "status": - raise AttributeError - return getattr(self._original_charger, name) + # Copy other properties so that inspect.getattr_static finds them in __dict__ + excluded = { + "status", + "charging_voltage", + "vehicle_eta", + "mode", + "divertmode", + "manual_override", + "ota_update", + "service_level", + "uptime", + "wifi_firmware", + } + # Copy all mock attributes except the overridden ones + for key, val in original_charger.__dict__.items(): + if key not in excluded: + self.__dict__[key] = val @property def charging_voltage(self) -> int: @@ -99,6 +111,34 @@ def vehicle_eta(self) -> datetime: def mode(self) -> MockEnum: return MockEnum.TEST + @property + def divertmode(self) -> set[str]: + return {"solar", "eco"} + + @property + def manual_override(self) -> frozenset[str]: + return frozenset({"override"}) + + @property + def ota_update(self) -> tuple[str, ...]: + return ("v1.0", "v2.0") + + @property + def service_level(self) -> dict[MockEnum, str]: + return {MockEnum.TEST: "level_2"} + + @property + def uptime(self) -> object: + class CustomObj: + def __str__(self) -> str: + return "custom_str" + + return CustomObj() + + @property + def wifi_firmware(self) -> Any: + return lambda: "callable_value" + mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -114,13 +154,29 @@ def mode(self) -> MockEnum: # status should be omitted due to AttributeError assert "status" not in diagnostics["charger"] - # charging_voltage should show the recorded error - voltage_diagnostic = diagnostics["charger"]["charging_voltage"] - assert voltage_diagnostic.startswith("Error: ValueError") - assert "Connection error" in voltage_diagnostic + # charging_voltage should show the recorded error type only + assert diagnostics["charger"]["charging_voltage"] == "Error: ValueError" # vehicle_eta should be coerced to ISO format string assert diagnostics["charger"]["vehicle_eta"] == "2000-01-01T12:00:00" # mode should be coerced to Enum raw value assert diagnostics["charger"]["mode"] == "test_value" + + # divertmode should be sorted and coerced to list + assert diagnostics["charger"]["divertmode"] == ["eco", "solar"] + + # manual_override should be sorted and coerced to list + assert diagnostics["charger"]["manual_override"] == ["override"] + + # ota_update should be coerced to list + assert diagnostics["charger"]["ota_update"] == ["v1.0", "v2.0"] + + # service_level should have keys coerced to str + assert diagnostics["charger"]["service_level"] == {"MockEnum.TEST": "level_2"} + + # uptime should fallback to string representation + assert diagnostics["charger"]["uptime"] == "custom_str" + + # wifi_firmware should be omitted because it is callable + assert "wifi_firmware" not in diagnostics["charger"] From c3cbc345be680e94c04db9e4d6debd7150fcab90 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 11:59:52 -0700 Subject: [PATCH 07/14] Handle callables and type fallbacks in JSON conversion, add static inspection comments, and correct assertions --- homeassistant/components/openevse/diagnostics.py | 7 ++++++- tests/components/openevse/test_diagnostics.py | 14 +++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index d997a71486939..5b0ad4332cb63 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -76,7 +76,9 @@ def _to_json_safe(val: Any) -> Any: return [_to_json_safe(v) for v in val] if isinstance(val, dict): return {str(k): _to_json_safe(v) for k, v in val.items()} - return str(val) + if callable(val): + return None + return f"<{type(val).__name__} object>" async def async_get_config_entry_diagnostics( @@ -88,6 +90,9 @@ async def async_get_config_entry_diagnostics( charger_data: dict[str, Any] = {} for prop in CHARGER_PROPERTIES: + # Check if the attribute exists on the object without triggering descriptors, + # property getters, or dynamic __getattr__ lookup (which can trigger side effects + # or auto-create MagicMock attributes in tests). try: inspect.getattr_static(charger, prop) except AttributeError: diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index d39b95b8b0686..09a3a9b262ce9 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -120,8 +120,8 @@ def manual_override(self) -> frozenset[str]: return frozenset({"override"}) @property - def ota_update(self) -> tuple[str, ...]: - return ("v1.0", "v2.0") + def ota_update(self) -> tuple[Any, ...]: + return ("v1.0", lambda: "nested_callable") @property def service_level(self) -> dict[MockEnum, str]: @@ -151,7 +151,7 @@ def wifi_firmware(self) -> Any: hass, hass_client, mock_config_entry ) - # status should be omitted due to AttributeError + # status should be omitted because the attribute is not present assert "status" not in diagnostics["charger"] # charging_voltage should show the recorded error type only @@ -169,14 +169,14 @@ def wifi_firmware(self) -> Any: # manual_override should be sorted and coerced to list assert diagnostics["charger"]["manual_override"] == ["override"] - # ota_update should be coerced to list - assert diagnostics["charger"]["ota_update"] == ["v1.0", "v2.0"] + # ota_update should be coerced to list, with callable elements coerced to None + assert diagnostics["charger"]["ota_update"] == ["v1.0", None] # service_level should have keys coerced to str assert diagnostics["charger"]["service_level"] == {"MockEnum.TEST": "level_2"} - # uptime should fallback to string representation - assert diagnostics["charger"]["uptime"] == "custom_str" + # uptime should fallback to type name representation + assert diagnostics["charger"]["uptime"] == "" # wifi_firmware should be omitted because it is callable assert "wifi_firmware" not in diagnostics["charger"] From 3259d3885fac4f6294419c9ee3a25e8830d7379f Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 12:07:17 -0700 Subject: [PATCH 08/14] Restrict static inspection check to Mock/MagicMock objects to support dynamic runtime attributes in production --- .../components/openevse/diagnostics.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index 5b0ad4332cb63..90ccdf8ee38b1 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -89,17 +89,20 @@ async def async_get_config_entry_diagnostics( charger = coordinator.charger charger_data: dict[str, Any] = {} + for prop in CHARGER_PROPERTIES: - # Check if the attribute exists on the object without triggering descriptors, - # property getters, or dynamic __getattr__ lookup (which can trigger side effects - # or auto-create MagicMock attributes in tests). - try: - inspect.getattr_static(charger, prop) - except AttributeError: - continue + # To prevent auto-creating mock attributes during tests when using MagicMock, + # we statically check for the attribute's existence on mock objects. + if hasattr(charger, "mock_add_spec"): + try: + inspect.getattr_static(charger, prop) + except AttributeError: + continue try: val = getattr(charger, prop) + except AttributeError: + continue except Exception as err: # noqa: BLE001 charger_data[prop] = f"Error: {type(err).__name__}" continue From ce741114abd9f2726cb22eed0e406bf2112bc8e1 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 12:12:38 -0700 Subject: [PATCH 09/14] Address final PR comments: explicit mock checks, document callable handling, and assert async_setup success --- homeassistant/components/openevse/diagnostics.py | 14 ++++++++++++-- tests/components/openevse/test_diagnostics.py | 6 +++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index 90ccdf8ee38b1..deb9cdeea5a4f 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -4,6 +4,7 @@ from enum import Enum import inspect from typing import Any +from unittest.mock import Mock, NonCallableMock from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -63,7 +64,13 @@ def _to_json_safe(val: Any) -> Any: - """Coerce value to be JSON-serializable.""" + """Coerce value to be JSON-serializable. + + Top-level callables on the charger object are skipped entirely in the main + diagnostics loop. For nested structures (lists, dicts, tuples, sets), any + encountered callable elements are coerced to None here to preserve the + structure while remaining JSON-safe. + """ if isinstance(val, (str, int, float, bool)) or val is None: return val if isinstance(val, (datetime, date)): @@ -93,7 +100,8 @@ async def async_get_config_entry_diagnostics( for prop in CHARGER_PROPERTIES: # To prevent auto-creating mock attributes during tests when using MagicMock, # we statically check for the attribute's existence on mock objects. - if hasattr(charger, "mock_add_spec"): + # This is restricted to mock objects to support dynamic runtime attributes in production. + if isinstance(charger, (Mock, NonCallableMock)): try: inspect.getattr_static(charger, prop) except AttributeError: @@ -107,6 +115,8 @@ async def async_get_config_entry_diagnostics( charger_data[prop] = f"Error: {type(err).__name__}" continue + # Top-level callables on the charger object are omitted from diagnostics. + # Any nested callables within collections are coerced to None by _to_json_safe. if callable(val): continue diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index 09a3a9b262ce9..d08ed65a022e6 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -20,7 +20,7 @@ async def test_entry_diagnostics( ) -> None: """Test OpenEVSE diagnostics.""" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diagnostics = await get_diagnostics_for_config_entry( @@ -53,7 +53,7 @@ async def test_entry_diagnostics_redact( unique_id="deadbeeffeed", ) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diagnostics = await get_diagnostics_for_config_entry(hass, hass_client, entry) @@ -140,7 +140,7 @@ def wifi_firmware(self) -> Any: return lambda: "callable_value" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Inject the FakeCharger into the coordinator to isolate side effects From 2a9677e5775c869531119f6df2bb8c5bbee5073e Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 12:31:47 -0700 Subject: [PATCH 10/14] Refactor diagnostics to be mock-agnostic and sort dictionary keys deterministically --- homeassistant/components/openevse/diagnostics.py | 13 +------------ tests/components/openevse/test_diagnostics.py | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index deb9cdeea5a4f..d01842c00c4ec 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -2,9 +2,7 @@ from datetime import date, datetime from enum import Enum -import inspect from typing import Any -from unittest.mock import Mock, NonCallableMock from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -82,7 +80,7 @@ def _to_json_safe(val: Any) -> Any: if isinstance(val, (list, tuple)): return [_to_json_safe(v) for v in val] if isinstance(val, dict): - return {str(k): _to_json_safe(v) for k, v in val.items()} + return {str(k): _to_json_safe(val[k]) for k in sorted(val, key=str)} if callable(val): return None return f"<{type(val).__name__} object>" @@ -98,15 +96,6 @@ async def async_get_config_entry_diagnostics( charger_data: dict[str, Any] = {} for prop in CHARGER_PROPERTIES: - # To prevent auto-creating mock attributes during tests when using MagicMock, - # we statically check for the attribute's existence on mock objects. - # This is restricted to mock objects to support dynamic runtime attributes in production. - if isinstance(charger, (Mock, NonCallableMock)): - try: - inspect.getattr_static(charger, prop) - except AttributeError: - continue - try: val = getattr(charger, prop) except AttributeError: diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index d08ed65a022e6..5ef9e2cde788c 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -81,7 +81,7 @@ class FakeCharger: def __init__(self, original_charger: MagicMock) -> None: self._original_charger = original_charger - # Copy other properties so that inspect.getattr_static finds them in __dict__ + # Copy other properties from the original mock for realism excluded = { "status", "charging_voltage", From a1e49b8da19f4d7f8e1b4d3d9d02f3d5ad69d0ba Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 12:42:55 -0700 Subject: [PATCH 11/14] Test recursion, circular reference detection, and dictionary key serialization in diagnostics --- .../components/openevse/diagnostics.py | 40 +++++++++++++-- tests/components/openevse/test_diagnostics.py | 49 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index d01842c00c4ec..27d4aba9089b0 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -61,7 +61,7 @@ ) -def _to_json_safe(val: Any) -> Any: +def _to_json_safe(val: Any, seen: set[int] | None = None, depth: int = 0) -> Any: """Coerce value to be JSON-serializable. Top-level callables on the charger object are skipped entirely in the main @@ -71,16 +71,48 @@ def _to_json_safe(val: Any) -> Any: """ if isinstance(val, (str, int, float, bool)) or val is None: return val + + if depth > 20: + return f"" + + if seen is None: + seen = set() + + val_id = id(val) + if val_id in seen: + return f"" + if isinstance(val, (datetime, date)): return val.isoformat() if isinstance(val, Enum): return val.value if isinstance(val, (set, frozenset)): - return [_to_json_safe(v) for v in sorted(val, key=str)] + seen.add(val_id) + try: + return [_to_json_safe(v, seen, depth + 1) for v in sorted(val, key=str)] + finally: + seen.remove(val_id) if isinstance(val, (list, tuple)): - return [_to_json_safe(v) for v in val] + seen.add(val_id) + try: + return [_to_json_safe(v, seen, depth + 1) for v in val] + finally: + seen.remove(val_id) if isinstance(val, dict): - return {str(k): _to_json_safe(val[k]) for k in sorted(val, key=str)} + seen.add(val_id) + try: + res = {} + for k in sorted(val, key=str): + if isinstance(k, str): + key_str = k + elif isinstance(k, Enum): + key_str = f"{type(k).__name__}.{k.name}" + else: + key_str = f"<{type(k).__name__}: {k}>" + res[key_str] = _to_json_safe(val[k], seen, depth + 1) + return res + finally: + seen.remove(val_id) if callable(val): return None return f"<{type(val).__name__} object>" diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index 5ef9e2cde788c..e027a74fd9681 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -93,6 +93,9 @@ def __init__(self, original_charger: MagicMock) -> None: "service_level", "uptime", "wifi_firmware", + "openevse_firmware", + "wifi_signal", + "freeram", } # Copy all mock attributes except the overridden ones for key, val in original_charger.__dict__.items(): @@ -139,6 +142,30 @@ def __str__(self) -> str: def wifi_firmware(self) -> Any: return lambda: "callable_value" + @property + def openevse_firmware(self) -> list[Any]: + cyclic: list[Any] = [] + cyclic.append(cyclic) + return cyclic + + @property + def wifi_signal(self) -> list[Any]: + nested: list[Any] = [] + curr = nested + for _ in range(22): + new_list: list[Any] = [] + curr.append(new_list) + curr = new_list + return nested + + @property + def freeram(self) -> dict[Any, Any]: + return { + "simple": "val", + 123: "int_key", + MockEnum.TEST: "enum_key", + } + mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -180,3 +207,25 @@ def wifi_firmware(self) -> Any: # wifi_firmware should be omitted because it is callable assert "wifi_firmware" not in diagnostics["charger"] + + # wifi_signal has a deeply nested list exceeding the limit + expected_wifi_signal: list[Any] = [] + curr = expected_wifi_signal + for _ in range(20): + new_list = [] + curr.append(new_list) + curr = new_list + curr.append("") + assert diagnostics["charger"]["wifi_signal"] == expected_wifi_signal + + # freeram key types and deterministic serialization + assert diagnostics["charger"]["freeram"] == { + "": "int_key", + "MockEnum.TEST": "enum_key", + "simple": "val", + } + + # openevse_firmware contains circular reference + assert diagnostics["charger"]["openevse_firmware"] == [ + "" + ] From 3b3e98a405797f2775cc8bfe384eac5b5e079c69 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 12:49:59 -0700 Subject: [PATCH 12/14] Extract MAX_JSON_DEPTH constant, recursively coerce Enum.value, and use deterministic dict key repr --- homeassistant/components/openevse/diagnostics.py | 8 +++++--- tests/components/openevse/test_diagnostics.py | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index 27d4aba9089b0..b2e6585f538c1 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -12,6 +12,8 @@ REDACT_CONFIG_DATA = {CONF_PASSWORD, CONF_USERNAME} +MAX_JSON_DEPTH = 20 + CHARGER_PROPERTIES = ( "status", "vehicle", @@ -72,7 +74,7 @@ def _to_json_safe(val: Any, seen: set[int] | None = None, depth: int = 0) -> Any if isinstance(val, (str, int, float, bool)) or val is None: return val - if depth > 20: + if depth > MAX_JSON_DEPTH: return f"" if seen is None: @@ -85,7 +87,7 @@ def _to_json_safe(val: Any, seen: set[int] | None = None, depth: int = 0) -> Any if isinstance(val, (datetime, date)): return val.isoformat() if isinstance(val, Enum): - return val.value + return _to_json_safe(val.value, seen, depth + 1) if isinstance(val, (set, frozenset)): seen.add(val_id) try: @@ -108,7 +110,7 @@ def _to_json_safe(val: Any, seen: set[int] | None = None, depth: int = 0) -> Any elif isinstance(k, Enum): key_str = f"{type(k).__name__}.{k.name}" else: - key_str = f"<{type(k).__name__}: {k}>" + key_str = f"<{type(k).__name__}>" res[key_str] = _to_json_safe(val[k], seen, depth + 1) return res finally: diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index e027a74fd9681..7c0bd13c2b6a7 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import MagicMock +from homeassistant.components.openevse.diagnostics import MAX_JSON_DEPTH from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -152,7 +153,7 @@ def openevse_firmware(self) -> list[Any]: def wifi_signal(self) -> list[Any]: nested: list[Any] = [] curr = nested - for _ in range(22): + for _ in range(MAX_JSON_DEPTH + 2): new_list: list[Any] = [] curr.append(new_list) curr = new_list @@ -211,7 +212,7 @@ def freeram(self) -> dict[Any, Any]: # wifi_signal has a deeply nested list exceeding the limit expected_wifi_signal: list[Any] = [] curr = expected_wifi_signal - for _ in range(20): + for _ in range(MAX_JSON_DEPTH): new_list = [] curr.append(new_list) curr = new_list @@ -220,7 +221,7 @@ def freeram(self) -> dict[Any, Any]: # freeram key types and deterministic serialization assert diagnostics["charger"]["freeram"] == { - "": "int_key", + "": "int_key", "MockEnum.TEST": "enum_key", "simple": "val", } From 976f4dc83f067a859163982d4a4ed531756a452a Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 13:09:24 -0700 Subject: [PATCH 13/14] Address remaining PR comments --- .../components/openevse/diagnostics.py | 5 ++ tests/components/openevse/test_diagnostics.py | 47 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index b2e6585f538c1..d2192184f22cb 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -1,5 +1,6 @@ """Provide diagnostics for OpenEVSE.""" +import asyncio from datetime import date, datetime from enum import Enum from typing import Any @@ -107,6 +108,8 @@ def _to_json_safe(val: Any, seen: set[int] | None = None, depth: int = 0) -> Any for k in sorted(val, key=str): if isinstance(k, str): key_str = k + elif isinstance(k, (int, float, bool)) or k is None: + key_str = f"<{type(k).__name__}: {k}>" elif isinstance(k, Enum): key_str = f"{type(k).__name__}.{k.name}" else: @@ -134,6 +137,8 @@ async def async_get_config_entry_diagnostics( val = getattr(charger, prop) except AttributeError: continue + except asyncio.CancelledError: + raise except Exception as err: # noqa: BLE001 charger_data[prop] = f"Error: {type(err).__name__}" continue diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index 7c0bd13c2b6a7..7aaa0781e1d94 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -1,11 +1,17 @@ """Test OpenEVSE diagnostics.""" +import asyncio from datetime import datetime from enum import Enum from typing import Any from unittest.mock import MagicMock -from homeassistant.components.openevse.diagnostics import MAX_JSON_DEPTH +import pytest + +from homeassistant.components.openevse.diagnostics import ( + MAX_JSON_DEPTH, + async_get_config_entry_diagnostics, +) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -139,6 +145,13 @@ def __str__(self) -> str: return CustomObj() + @property + def custom_key_obj(self) -> object: + class CustomKeyObj: + pass + + return CustomKeyObj() + @property def wifi_firmware(self) -> Any: return lambda: "callable_value" @@ -165,6 +178,7 @@ def freeram(self) -> dict[Any, Any]: "simple": "val", 123: "int_key", MockEnum.TEST: "enum_key", + self.custom_key_obj: "obj_key", } mock_config_entry.add_to_hass(hass) @@ -221,8 +235,9 @@ def freeram(self) -> dict[Any, Any]: # freeram key types and deterministic serialization assert diagnostics["charger"]["freeram"] == { - "": "int_key", + "": "int_key", "MockEnum.TEST": "enum_key", + "": "obj_key", "simple": "val", } @@ -230,3 +245,31 @@ def freeram(self) -> dict[Any, Any]: assert diagnostics["charger"]["openevse_firmware"] == [ "" ] + + +async def test_entry_diagnostics_cancelled_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test OpenEVSE diagnostics handles asyncio.CancelledError correctly.""" + + class CancelledFakeCharger: + @property + def status(self) -> str: + raise asyncio.CancelledError + + @property + def websocket(self) -> Any: + return None + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = mock_config_entry.runtime_data + coordinator.charger = CancelledFakeCharger() + + with pytest.raises(asyncio.CancelledError): + await async_get_config_entry_diagnostics(hass, mock_config_entry) From e20a5620b3f697c6c088109a80802295be1ae3e3 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Thu, 21 May 2026 13:31:08 -0700 Subject: [PATCH 14/14] Deterministic sorting and clear depth-limit boundaries in diagnostics --- .../components/openevse/diagnostics.py | 18 ++++++++++++++---- tests/components/openevse/test_diagnostics.py | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openevse/diagnostics.py b/homeassistant/components/openevse/diagnostics.py index d2192184f22cb..e0ba563ad37c6 100644 --- a/homeassistant/components/openevse/diagnostics.py +++ b/homeassistant/components/openevse/diagnostics.py @@ -75,7 +75,9 @@ def _to_json_safe(val: Any, seen: set[int] | None = None, depth: int = 0) -> Any if isinstance(val, (str, int, float, bool)) or val is None: return val - if depth > MAX_JSON_DEPTH: + # Limit the maximum allowed depth value (0-based) to MAX_JSON_DEPTH. + # At depth = MAX_JSON_DEPTH, we truncate and return a placeholder. + if depth >= MAX_JSON_DEPTH: return f"" if seen is None: @@ -92,7 +94,8 @@ def _to_json_safe(val: Any, seen: set[int] | None = None, depth: int = 0) -> Any if isinstance(val, (set, frozenset)): seen.add(val_id) try: - return [_to_json_safe(v, seen, depth + 1) for v in sorted(val, key=str)] + coerced_vals = [_to_json_safe(v, seen, depth + 1) for v in val] + return sorted(coerced_vals, key=str) finally: seen.remove(val_id) if isinstance(val, (list, tuple)): @@ -104,8 +107,8 @@ def _to_json_safe(val: Any, seen: set[int] | None = None, depth: int = 0) -> Any if isinstance(val, dict): seen.add(val_id) try: - res = {} - for k in sorted(val, key=str): + key_mappings = [] + for k in val: if isinstance(k, str): key_str = k elif isinstance(k, (int, float, bool)) or k is None: @@ -114,6 +117,13 @@ def _to_json_safe(val: Any, seen: set[int] | None = None, depth: int = 0) -> Any key_str = f"{type(k).__name__}.{k.name}" else: key_str = f"<{type(k).__name__}>" + key_mappings.append((key_str, k)) + + # Sort key_str pairs deterministically, avoiding comparisons on original objects/keys + key_mappings.sort(key=lambda item: item[0]) + + res = {} + for key_str, k in key_mappings: res[key_str] = _to_json_safe(val[k], seen, depth + 1) return res finally: diff --git a/tests/components/openevse/test_diagnostics.py b/tests/components/openevse/test_diagnostics.py index 7aaa0781e1d94..7ed06a3eb8c13 100644 --- a/tests/components/openevse/test_diagnostics.py +++ b/tests/components/openevse/test_diagnostics.py @@ -226,7 +226,7 @@ def freeram(self) -> dict[Any, Any]: # wifi_signal has a deeply nested list exceeding the limit expected_wifi_signal: list[Any] = [] curr = expected_wifi_signal - for _ in range(MAX_JSON_DEPTH): + for _ in range(MAX_JSON_DEPTH - 1): new_list = [] curr.append(new_list) curr = new_list