From 1169745672699089d535674fd55fc3a41e982c09 Mon Sep 17 00:00:00 2001 From: Matt Weinberg Date: Wed, 24 Jun 2026 15:41:31 -0500 Subject: [PATCH 1/2] fix: handle missing sensor fields gracefully SimpliSafe's API can omit 'name' (and other fields) from a sensor's data payload. Direct bracket lookups raised KeyError, crashing the integration. Replace all unsafe dict[key] accesses in Device and DeviceV3 with .get() calls that return sensible defaults (serial number for name/serial, False for boolean flags, empty dict for nested dicts/settings). Add regression tests that simulate sparse API responses and verify safe fallback behavior. Fixes home-assistant/core#172599 --- simplipy/device/__init__.py | 20 ++++++++++++++------ tests/sensor/test_base.py | 22 ++++++++++++++++++++++ tests/sensor/test_v3.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/simplipy/device/__init__.py b/simplipy/device/__init__.py index b4bf677a..4b9a5619 100644 --- a/simplipy/device/__init__.py +++ b/simplipy/device/__init__.py @@ -84,7 +84,7 @@ def name(self) -> str: Returns: The device name. """ - return cast(str, self._system.sensor_data[self._serial]["name"]) + return cast(str, self._system.sensor_data[self._serial].get("name", self._serial)) @property def serial(self) -> str: @@ -93,7 +93,7 @@ def serial(self) -> str: Returns: The device serial number. """ - return cast(str, self._system.sensor_data[self._serial]["serial"]) + return cast(str, self._system.sensor_data[self._serial].get("serial", self._serial)) @property def type(self) -> DeviceTypes: @@ -146,7 +146,9 @@ def error(self) -> bool: """ return cast( bool, - self._system.sensor_data[self._serial]["status"].get("malfunction", False), + self._system.sensor_data[self._serial] + .get("status", {}) + .get("malfunction", False), ) @property @@ -156,7 +158,10 @@ def low_battery(self) -> bool: Returns: The device's low battery status. """ - return cast(bool, self._system.sensor_data[self._serial]["flags"]["lowBattery"]) + return cast( + bool, + self._system.sensor_data[self._serial].get("flags", {}).get("lowBattery", False), + ) @property def offline(self) -> bool: @@ -165,7 +170,10 @@ def offline(self) -> bool: Returns: The device's offline status. """ - return cast(bool, self._system.sensor_data[self._serial]["flags"]["offline"]) + return cast( + bool, + self._system.sensor_data[self._serial].get("flags", {}).get("offline", False), + ) @property def settings(self) -> dict[str, Any]: @@ -176,7 +184,7 @@ def settings(self) -> dict[str, Any]: Returns: A settings dictionary. """ - return cast(dict[str, Any], self._system.sensor_data[self._serial]["setting"]) + return cast(dict[str, Any], self._system.sensor_data[self._serial].get("setting", {})) def as_dict(self) -> dict[str, Any]: """Return dictionary version of this device. diff --git a/tests/sensor/test_base.py b/tests/sensor/test_base.py index b67134ba..fbd643ed 100644 --- a/tests/sensor/test_base.py +++ b/tests/sensor/test_base.py @@ -27,3 +27,25 @@ async def test_properties_base( assert sensor.type == DeviceTypes.KEYPAD aresponses.assert_plan_strictly_followed() + + +@pytest.mark.asyncio +async def test_properties_base_missing_name_falls_back_to_serial( + aresponses: ResponsesMockServer, + authenticated_simplisafe_server_v3: ResponsesMockServer, +) -> None: + """Test that name falls back to serial when the API omits the 'name' field. + + Regression test for home-assistant/core#172599. + """ + async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: + simplisafe = await API.async_from_auth( + TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session + ) + systems = await simplisafe.async_get_systems() + system = systems[TEST_SYSTEM_ID] + sensor = system.sensors["825"] + del system.sensor_data["825"]["name"] + assert sensor.name == "825" + + aresponses.assert_plan_strictly_followed() diff --git a/tests/sensor/test_v3.py b/tests/sensor/test_v3.py index 4e5c8400..23e87cba 100644 --- a/tests/sensor/test_v3.py +++ b/tests/sensor/test_v3.py @@ -44,3 +44,33 @@ async def test_properties_v3( assert siren.temperature == 42 aresponses.assert_plan_strictly_followed() + + +@pytest.mark.asyncio +async def test_properties_v3_missing_fields_return_defaults( + aresponses: ResponsesMockServer, + authenticated_simplisafe_server_v3: ResponsesMockServer, +) -> None: + """Test that V3 properties return safe defaults when API fields are absent. + + Regression test for home-assistant/core#172599. + """ + async with authenticated_simplisafe_server_v3, aiohttp.ClientSession() as session: + simplisafe = await API.async_from_auth( + TEST_AUTHORIZATION_CODE, TEST_CODE_VERIFIER, session=session + ) + systems = await simplisafe.async_get_systems() + system = systems[TEST_SYSTEM_ID] + sensor: SensorV3 = cast(SensorV3, system.sensors["825"]) + + # Remove optional nested fields to simulate a sparse API response + system.sensor_data["825"].pop("status", None) + system.sensor_data["825"].pop("flags", None) + system.sensor_data["825"].pop("setting", None) + + assert not sensor.error + assert not sensor.low_battery + assert not sensor.offline + assert sensor.settings == {} + + aresponses.assert_plan_strictly_followed() From f69e8225a975db51609554987068f154539fd33a Mon Sep 17 00:00:00 2001 From: Matt Weinberg Date: Wed, 24 Jun 2026 16:30:59 -0500 Subject: [PATCH 2/2] style: apply Black formatting to device/__init__.py --- simplipy/device/__init__.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/simplipy/device/__init__.py b/simplipy/device/__init__.py index 4b9a5619..f1bd551b 100644 --- a/simplipy/device/__init__.py +++ b/simplipy/device/__init__.py @@ -84,7 +84,9 @@ def name(self) -> str: Returns: The device name. """ - return cast(str, self._system.sensor_data[self._serial].get("name", self._serial)) + return cast( + str, self._system.sensor_data[self._serial].get("name", self._serial) + ) @property def serial(self) -> str: @@ -93,7 +95,9 @@ def serial(self) -> str: Returns: The device serial number. """ - return cast(str, self._system.sensor_data[self._serial].get("serial", self._serial)) + return cast( + str, self._system.sensor_data[self._serial].get("serial", self._serial) + ) @property def type(self) -> DeviceTypes: @@ -160,7 +164,9 @@ def low_battery(self) -> bool: """ return cast( bool, - self._system.sensor_data[self._serial].get("flags", {}).get("lowBattery", False), + self._system.sensor_data[self._serial] + .get("flags", {}) + .get("lowBattery", False), ) @property @@ -172,7 +178,9 @@ def offline(self) -> bool: """ return cast( bool, - self._system.sensor_data[self._serial].get("flags", {}).get("offline", False), + self._system.sensor_data[self._serial] + .get("flags", {}) + .get("offline", False), ) @property @@ -184,7 +192,9 @@ def settings(self) -> dict[str, Any]: Returns: A settings dictionary. """ - return cast(dict[str, Any], self._system.sensor_data[self._serial].get("setting", {})) + return cast( + dict[str, Any], self._system.sensor_data[self._serial].get("setting", {}) + ) def as_dict(self) -> dict[str, Any]: """Return dictionary version of this device.