Skip to content

Commit d2631dd

Browse files
authored
Merge pull request #389 from dvd-dev/remove_get
Change REST API call for device list to websocket event DeviceListInitialValuesReceived
2 parents 29303f7 + e5c3933 commit d2631dd

File tree

4 files changed

+159
-23
lines changed

4 files changed

+159
-23
lines changed

pyhilo/api.py

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ def __init__(
9494
self.ws_token: str = ""
9595
self.endpoint: str = ""
9696
self._urn: str | None = None
97+
# Device cache from websocket DeviceListInitialValuesReceived
98+
self._device_cache: list[dict[str, Any]] = []
99+
self._device_cache_event: asyncio.Event = asyncio.Event()
97100

98101
@classmethod
99102
async def async_create(
@@ -544,17 +547,86 @@ async def get_location_ids(self) -> tuple[int, str]:
544547
req: list[dict[str, Any]] = await self.async_request("get", url)
545548
return (req[0]["id"], req[0]["locationHiloId"])
546549

547-
async def get_devices(self, location_id: int) -> list[dict[str, Any]]:
548-
"""Get list of all devices"""
549-
url = self._get_url("Devices", location_id=location_id)
550-
LOG.debug("Devices URL is %s", url)
551-
devices: list[dict[str, Any]] = await self.async_request("get", url)
552-
devices.append(await self.get_gateway(location_id))
553-
# Now it's time to add devices coming from external sources like hass
554-
# integration.
555-
for callback in self._get_device_callbacks:
556-
devices.append(callback())
557-
return devices
550+
def set_device_cache(self, devices: list[dict[str, Any]]) -> None:
551+
"""Store devices received from websocket DeviceListInitialValuesReceived.
552+
553+
This replaces the old REST API get_devices call. The websocket sends
554+
device data with list-type attributes (supportedAttributesList, etc.)
555+
which need to be converted to comma-separated strings to match the
556+
format that HiloDevice.update() expects.
557+
"""
558+
self._device_cache = [self._convert_ws_device(device) for device in devices]
559+
LOG.debug(
560+
"Device cache populated with %d devices from websocket",
561+
len(self._device_cache),
562+
)
563+
self._device_cache_event.set()
564+
565+
@staticmethod
566+
def _convert_ws_device(ws_device: dict[str, Any]) -> dict[str, Any]:
567+
"""Convert a websocket device dict to the format generate_device expects.
568+
569+
The REST API returned supportedAttributes/settableAttributes as
570+
comma-separated strings. The websocket returns supportedAttributesList/
571+
settableAttributesList/supportedParametersList as Python lists.
572+
We convert to the old format so HiloDevice.update() works unchanged.
573+
"""
574+
device = dict(ws_device)
575+
576+
# Convert list attributes to comma-separated strings
577+
list_to_csv_mappings = {
578+
"supportedAttributesList": "supportedAttributes",
579+
"settableAttributesList": "settableAttributes",
580+
"supportedParametersList": "supportedParameters",
581+
}
582+
for list_key, csv_key in list_to_csv_mappings.items():
583+
if list_key in device:
584+
items = device.pop(list_key)
585+
if isinstance(items, list):
586+
device[csv_key] = ", ".join(str(i) for i in items)
587+
else:
588+
device[csv_key] = str(items) if items else ""
589+
590+
return device
591+
592+
async def wait_for_device_cache(self, timeout: float = 30.0) -> None:
593+
"""Wait for the device cache to be populated from websocket.
594+
595+
:param timeout: Maximum time to wait in seconds
596+
:raises TimeoutError: If the device cache is not populated in time
597+
"""
598+
if self._device_cache_event.is_set():
599+
return
600+
LOG.debug("Waiting for device cache from websocket (timeout=%ss)", timeout)
601+
try:
602+
await asyncio.wait_for(self._device_cache_event.wait(), timeout=timeout)
603+
except asyncio.TimeoutError:
604+
LOG.error(
605+
"Timed out waiting for device list from websocket after %ss",
606+
timeout,
607+
)
608+
raise
609+
610+
def get_device_cache(self, location_id: int) -> list[dict[str, Any]]:
611+
"""Return cached devices from websocket.
612+
613+
:param location_id: Hilo location id (unused but kept for interface compat)
614+
:return: List of device dicts ready for generate_device()
615+
"""
616+
return list(self._device_cache)
617+
618+
def add_to_device_cache(self, devices: list[dict[str, Any]]) -> None:
619+
"""Append new devices to the existing cache (e.g. from DeviceAdded).
620+
621+
Converts websocket format and adds to the cache without replacing
622+
existing entries. Skips devices already in cache (by id).
623+
"""
624+
existing_ids = {d.get("id") for d in self._device_cache}
625+
for device in devices:
626+
converted = self._convert_ws_device(device)
627+
if converted.get("id") not in existing_ids:
628+
self._device_cache.append(converted)
629+
LOG.debug("Added device %s to cache", converted.get("id"))
558630

559631
async def _set_device_attribute(
560632
self,

pyhilo/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
LOG: Final = logging.getLogger(__package__)
1212
DEFAULT_STATE_FILE: Final = "hilo_state.yaml"
1313
REQUEST_RETRY: Final = 9
14-
PYHILO_VERSION: Final = "2026.3.01"
14+
PYHILO_VERSION: Final = "2026.3.02"
1515
# TODO: Find a way to keep previous line in sync with pyproject.toml automatically
1616

1717
CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded"

pyhilo/devices.py

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ def _map_readings_to_devices(
5858
device_identifier: Union[int, str] = reading.device_id
5959
if device_identifier == 0:
6060
device_identifier = reading.hilo_id
61-
if device := self.find_device(device_identifier):
61+
device = self.find_device(device_identifier)
62+
# If device_id was 0 and hilo_id lookup failed, this is likely
63+
# a gateway reading that arrives before GatewayValuesReceived
64+
# assigns the real ID. Fall back to the gateway device.
65+
if device is None and reading.device_id == 0:
66+
device = next((d for d in self.devices if d.type == "Gateway"), None)
67+
if device:
6268
device.update_readings(reading)
6369
LOG.debug("%s Received %s", device, reading)
6470
if device not in updated_devices:
@@ -93,27 +99,78 @@ def generate_device(self, device: dict) -> HiloDevice:
9399
return dev
94100

95101
async def update(self) -> None:
96-
fresh_devices = await self._api.get_devices(self.location_id)
102+
"""Update device list from websocket cache + gateway from REST."""
103+
# Get devices from websocket cache (already populated by DeviceListInitialValuesReceived)
104+
cached_devices = self._api.get_device_cache(self.location_id)
97105
generated_devices = []
98-
for raw_device in fresh_devices:
106+
for raw_device in cached_devices:
99107
LOG.debug("Generating device %s", raw_device)
100108
dev = self.generate_device(raw_device)
101109
generated_devices.append(dev)
102110
if dev not in self.devices:
103111
self.devices.append(dev)
112+
113+
# Append gateway from REST API (still available)
114+
try:
115+
gw = await self._api.get_gateway(self.location_id)
116+
LOG.debug("Generating gateway device %s", gw)
117+
gw_dev = self.generate_device(gw)
118+
generated_devices.append(gw_dev)
119+
if gw_dev not in self.devices:
120+
self.devices.append(gw_dev)
121+
except Exception as err:
122+
LOG.error("Failed to get gateway: %s", err)
123+
124+
# Now add devices from external sources (e.g. unknown source tracker)
125+
for callback in self._api._get_device_callbacks:
126+
try:
127+
cb_device = callback()
128+
dev = self.generate_device(cb_device)
129+
generated_devices.append(dev)
130+
if dev not in self.devices:
131+
self.devices.append(dev)
132+
except Exception as err:
133+
LOG.error("Failed to generate callback device: %s", err)
134+
104135
for device in self.devices:
105136
if device not in generated_devices:
106137
LOG.debug("Device unpaired %s", device)
107138
# Don't do anything with unpaired device for now.
108-
# self.devices.remove(device)
109139

110140
async def update_devicelist_from_signalr(
111141
self, values: list[dict[str, Any]]
112142
) -> list[HiloDevice]:
113-
# ic-dev21 not sure if this is dead code?
143+
"""Process device list received from SignalR websocket.
144+
145+
This is called when DeviceListInitialValuesReceived arrives.
146+
It populates the API device cache and generates HiloDevice objects.
147+
"""
148+
# Populate the API cache so future update() calls use this data
149+
self._api.set_device_cache(values)
150+
114151
new_devices = []
115-
for raw_device in values:
116-
LOG.debug("Generating device %s", raw_device)
152+
for raw_device in self._api.get_device_cache(self.location_id):
153+
LOG.debug("Generating device from SignalR %s", raw_device)
154+
dev = self.generate_device(raw_device)
155+
if dev not in self.devices:
156+
self.devices.append(dev)
157+
new_devices.append(dev)
158+
159+
return new_devices
160+
161+
async def add_device_from_signalr(
162+
self, values: list[dict[str, Any]]
163+
) -> list[HiloDevice]:
164+
"""Process individual device additions from SignalR websocket.
165+
166+
This is called when DeviceAdded arrives. It appends to the existing
167+
cache rather than replacing it.
168+
"""
169+
self._api.add_to_device_cache(values)
170+
171+
new_devices = []
172+
for raw_device in self._api.get_device_cache(self.location_id):
173+
LOG.debug("Generating added device from SignalR %s", raw_device)
117174
dev = self.generate_device(raw_device)
118175
if dev not in self.devices:
119176
self.devices.append(dev)
@@ -122,9 +179,16 @@ async def update_devicelist_from_signalr(
122179
return new_devices
123180

124181
async def async_init(self) -> None:
125-
"""Initialize the Hilo "manager" class."""
126-
LOG.info("Initialising after websocket is connected")
182+
"""Initialize the Hilo "manager" class.
183+
184+
Gets location IDs from REST API, then waits for the websocket
185+
to deliver the device list via DeviceListInitialValuesReceived.
186+
The gateway is appended from REST.
187+
"""
188+
LOG.info("Initialising: getting location IDs")
127189
location_ids = await self._api.get_location_ids()
128190
self.location_id = location_ids[0]
129191
self.location_hilo_id = location_ids[1]
130-
await self.update()
192+
# Device list will be populated when DeviceListInitialValuesReceived
193+
# arrives on the websocket. The hilo integration's async_init will
194+
# call wait_for_device_cache() and then update() after subscribing.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ exclude = ".venv/.*"
4040

4141
[tool.poetry]
4242
name = "python-hilo"
43-
version = "2026.3.1"
43+
version = "2026.3.2"
4444
description = "A Python3, async interface to the Hilo API"
4545
readme = "README.md"
4646
authors = ["David Vallee Delisle <me@dvd.dev>"]

0 commit comments

Comments
 (0)