diff --git a/pyhilo/device/__init__.py b/pyhilo/device/__init__.py index 026b5b2..b08536d 100644 --- a/pyhilo/device/__init__.py +++ b/pyhilo/device/__init__.py @@ -94,6 +94,16 @@ 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. + # 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_", "") 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 == ""