From 4877f5dda5571319922d0005c1ad6ab628344a12 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Fri, 9 Oct 2026 15:36:00 -0400 Subject: [PATCH 1/2] Coerce null identifier to empty string The Hilo cloud returns a null serial/identifier for some devices (observed for the Hilo gateway since ~2026-09-10). HiloDevice.update() stored None, and the HA integration builds entity unique ids as f"{device.identifier.lower()}-...", crashing with AttributeError and taking down the whole hilo sensor platform on every boot. Coerce None back to the empty string, which is what the cloud used to return and what existing device registries / unique ids were built from. --- pyhilo/device/__init__.py | 7 +++++++ tests/test_device.py | 37 ++++++++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/pyhilo/device/__init__.py b/pyhilo/device/__init__.py index 026b5b2..fd0467a 100644 --- a/pyhilo/device/__init__.py +++ b/pyhilo/device/__init__.py @@ -94,6 +94,13 @@ def update(self, **kwargs: Dict[str, Union[str, int, Dict]]) -> None: elif att == "model_number": att = "model" new_val = val # type: ignore + if att == "identifier" and new_val is None: + # The cloud returns a null serial for some devices (observed + # for the Hilo gateway since ~2026-09-10). Consumers build + # unique ids as f"{device.identifier.lower()}-..." and crash + # on None, so coerce back to the historic empty-string value + # that existing device registries were built from. + new_val = "" setattr(self, att, new_val) if self.model: self.model = self.model.replace("Model_", "") diff --git a/tests/test_device.py b/tests/test_device.py index 36a824a..cdf5d27 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -1,10 +1,13 @@ -from datetime import datetime, timezone +from datetime import datetime from unittest.mock import MagicMock -import pytest - from pyhilo.const import HILO_READING_TYPES -from pyhilo.device import DeviceAttribute, DeviceReading, HiloDevice, get_device_attributes +from pyhilo.device import ( + DeviceAttribute, + DeviceReading, + HiloDevice, + get_device_attributes, +) class TestDeviceAttribute: @@ -97,7 +100,9 @@ def test_equality_by_attr_name(self): def test_inequality_different_attr(self): r1 = self._make_reading(device_attribute=DeviceAttribute("Power", "Watt")) - r2 = self._make_reading(device_attribute=DeviceAttribute("Intensity", "Percentage")) + r2 = self._make_reading( + device_attribute=DeviceAttribute("Intensity", "Percentage") + ) assert r1 != r2 def test_repr(self): @@ -227,13 +232,19 @@ def test_update_readings_replaces_existing(self): d = HiloDevice(api, id=1) da = DeviceAttribute("Power", "Watt") r1 = DeviceReading( - deviceId=1, hiloId="h-1", locationId=10, - timeStampUTC="2024-01-15T10:30:00Z", value=100.0, + deviceId=1, + hiloId="h-1", + locationId=10, + timeStampUTC="2024-01-15T10:30:00Z", + value=100.0, device_attribute=da, ) r2 = DeviceReading( - deviceId=1, hiloId="h-1", locationId=10, - timeStampUTC="2024-01-15T11:00:00Z", value=200.0, + deviceId=1, + hiloId="h-1", + locationId=10, + timeStampUTC="2024-01-15T11:00:00Z", + value=200.0, device_attribute=da, ) d.update_readings(r1) @@ -325,3 +336,11 @@ def test_get_attribute_unknown(self): d = HiloDevice(api, id=1) result = d.get_attribute("nonexistent") assert result is None + + def test_identifier_none_coerces_to_empty_string(self): + """The cloud returns a null identifier for some devices (observed for + the Hilo gateway since ~2026-09-10); consumers build unique ids from + device.identifier and must not receive None.""" + api = _mock_api() + d = HiloDevice(api, id=1, identifier=None) + assert d.identifier == "" From 6ffa3ec7c4f2beff6a4dd6202dbbdc77fb0b761c Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Fri, 9 Oct 2026 16:48:46 -0400 Subject: [PATCH 2/2] Silence mypy unreachable on the null-identifier guard The declared kwargs type excludes None, but the cloud really does send nulls (that is the bug being guarded). Explain and ignore locally on the assignment. --- pyhilo/device/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyhilo/device/__init__.py b/pyhilo/device/__init__.py index fd0467a..b08536d 100644 --- a/pyhilo/device/__init__.py +++ b/pyhilo/device/__init__.py @@ -100,7 +100,10 @@ def update(self, **kwargs: Dict[str, Union[str, int, Dict]]) -> None: # unique ids as f"{device.identifier.lower()}-..." and crash # on None, so coerce back to the historic empty-string value # that existing device registries were built from. - new_val = "" + # mypy: the declared kwargs type excludes None, but the + # cloud really does send nulls — which is exactly the case + # being guarded here, hence the ignore below. + new_val = "" # type: ignore[unreachable] setattr(self, att, new_val) if self.model: self.model = self.model.replace("Model_", "")