Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 83 additions & 11 deletions pyhilo/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ def __init__(
self.ws_token: str = ""
self.endpoint: str = ""
self._urn: str | None = None
# Device cache from websocket DeviceListInitialValuesReceived
self._device_cache: list[dict[str, Any]] = []
self._device_cache_event: asyncio.Event = asyncio.Event()

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

async def get_devices(self, location_id: int) -> list[dict[str, Any]]:
"""Get list of all devices"""
url = self._get_url("Devices", location_id=location_id)
LOG.debug("Devices URL is %s", url)
devices: list[dict[str, Any]] = await self.async_request("get", url)
devices.append(await self.get_gateway(location_id))
# Now it's time to add devices coming from external sources like hass
# integration.
for callback in self._get_device_callbacks:
devices.append(callback())
return devices
def set_device_cache(self, devices: list[dict[str, Any]]) -> None:
"""Store devices received from websocket DeviceListInitialValuesReceived.

This replaces the old REST API get_devices call. The websocket sends
device data with list-type attributes (supportedAttributesList, etc.)
which need to be converted to comma-separated strings to match the
format that HiloDevice.update() expects.
"""
self._device_cache = [self._convert_ws_device(device) for device in devices]
LOG.debug(
"Device cache populated with %d devices from websocket",
len(self._device_cache),
)
self._device_cache_event.set()

@staticmethod
def _convert_ws_device(ws_device: dict[str, Any]) -> dict[str, Any]:
"""Convert a websocket device dict to the format generate_device expects.

The REST API returned supportedAttributes/settableAttributes as
comma-separated strings. The websocket returns supportedAttributesList/
settableAttributesList/supportedParametersList as Python lists.
We convert to the old format so HiloDevice.update() works unchanged.
"""
device = dict(ws_device)

# Convert list attributes to comma-separated strings
list_to_csv_mappings = {
"supportedAttributesList": "supportedAttributes",
"settableAttributesList": "settableAttributes",
"supportedParametersList": "supportedParameters",
}
for list_key, csv_key in list_to_csv_mappings.items():
if list_key in device:
items = device.pop(list_key)
if isinstance(items, list):
device[csv_key] = ", ".join(str(i) for i in items)
else:
device[csv_key] = str(items) if items else ""

return device

async def wait_for_device_cache(self, timeout: float = 30.0) -> None:
"""Wait for the device cache to be populated from websocket.

:param timeout: Maximum time to wait in seconds
:raises TimeoutError: If the device cache is not populated in time
"""
if self._device_cache_event.is_set():
return
LOG.debug("Waiting for device cache from websocket (timeout=%ss)", timeout)
try:
await asyncio.wait_for(self._device_cache_event.wait(), timeout=timeout)
except asyncio.TimeoutError:
LOG.error(
"Timed out waiting for device list from websocket after %ss",
timeout,
)
raise

def get_device_cache(self, location_id: int) -> list[dict[str, Any]]:
"""Return cached devices from websocket.

:param location_id: Hilo location id (unused but kept for interface compat)
:return: List of device dicts ready for generate_device()
"""
return list(self._device_cache)

def add_to_device_cache(self, devices: list[dict[str, Any]]) -> None:
"""Append new devices to the existing cache (e.g. from DeviceAdded).

Converts websocket format and adds to the cache without replacing
existing entries. Skips devices already in cache (by id).
"""
existing_ids = {d.get("id") for d in self._device_cache}
for device in devices:
converted = self._convert_ws_device(device)
if converted.get("id") not in existing_ids:
self._device_cache.append(converted)
LOG.debug("Added device %s to cache", converted.get("id"))

async def _set_device_attribute(
self,
Expand Down
2 changes: 1 addition & 1 deletion pyhilo/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
LOG: Final = logging.getLogger(__package__)
DEFAULT_STATE_FILE: Final = "hilo_state.yaml"
REQUEST_RETRY: Final = 9
PYHILO_VERSION: Final = "2026.3.01"
PYHILO_VERSION: Final = "2026.3.02"
# TODO: Find a way to keep previous line in sync with pyproject.toml automatically

CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded"
Expand Down
84 changes: 74 additions & 10 deletions pyhilo/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ def _map_readings_to_devices(
device_identifier: Union[int, str] = reading.device_id
if device_identifier == 0:
device_identifier = reading.hilo_id
if device := self.find_device(device_identifier):
device = self.find_device(device_identifier)
# If device_id was 0 and hilo_id lookup failed, this is likely
# a gateway reading that arrives before GatewayValuesReceived
# assigns the real ID. Fall back to the gateway device.
if device is None and reading.device_id == 0:
device = next((d for d in self.devices if d.type == "Gateway"), None)
if device:
device.update_readings(reading)
LOG.debug("%s Received %s", device, reading)
if device not in updated_devices:
Expand Down Expand Up @@ -93,27 +99,78 @@ def generate_device(self, device: dict) -> HiloDevice:
return dev

async def update(self) -> None:
fresh_devices = await self._api.get_devices(self.location_id)
"""Update device list from websocket cache + gateway from REST."""
# Get devices from websocket cache (already populated by DeviceListInitialValuesReceived)
cached_devices = self._api.get_device_cache(self.location_id)
generated_devices = []
for raw_device in fresh_devices:
for raw_device in cached_devices:
LOG.debug("Generating device %s", raw_device)
dev = self.generate_device(raw_device)
generated_devices.append(dev)
if dev not in self.devices:
self.devices.append(dev)

# Append gateway from REST API (still available)
try:
gw = await self._api.get_gateway(self.location_id)
LOG.debug("Generating gateway device %s", gw)
gw_dev = self.generate_device(gw)
generated_devices.append(gw_dev)
if gw_dev not in self.devices:
self.devices.append(gw_dev)
except Exception as err:
LOG.error("Failed to get gateway: %s", err)

# Now add devices from external sources (e.g. unknown source tracker)
for callback in self._api._get_device_callbacks:
try:
cb_device = callback()
dev = self.generate_device(cb_device)
generated_devices.append(dev)
if dev not in self.devices:
self.devices.append(dev)
except Exception as err:
LOG.error("Failed to generate callback device: %s", err)

for device in self.devices:
if device not in generated_devices:
LOG.debug("Device unpaired %s", device)
# Don't do anything with unpaired device for now.
# self.devices.remove(device)

async def update_devicelist_from_signalr(
self, values: list[dict[str, Any]]
) -> list[HiloDevice]:
# ic-dev21 not sure if this is dead code?
"""Process device list received from SignalR websocket.

This is called when DeviceListInitialValuesReceived arrives.
It populates the API device cache and generates HiloDevice objects.
"""
# Populate the API cache so future update() calls use this data
self._api.set_device_cache(values)

new_devices = []
for raw_device in values:
LOG.debug("Generating device %s", raw_device)
for raw_device in self._api.get_device_cache(self.location_id):
LOG.debug("Generating device from SignalR %s", raw_device)
dev = self.generate_device(raw_device)
if dev not in self.devices:
self.devices.append(dev)
new_devices.append(dev)

return new_devices

async def add_device_from_signalr(
self, values: list[dict[str, Any]]
) -> list[HiloDevice]:
"""Process individual device additions from SignalR websocket.

This is called when DeviceAdded arrives. It appends to the existing
cache rather than replacing it.
"""
self._api.add_to_device_cache(values)

new_devices = []
for raw_device in self._api.get_device_cache(self.location_id):
LOG.debug("Generating added device from SignalR %s", raw_device)
dev = self.generate_device(raw_device)
if dev not in self.devices:
self.devices.append(dev)
Expand All @@ -122,9 +179,16 @@ async def update_devicelist_from_signalr(
return new_devices

async def async_init(self) -> None:
"""Initialize the Hilo "manager" class."""
LOG.info("Initialising after websocket is connected")
"""Initialize the Hilo "manager" class.

Gets location IDs from REST API, then waits for the websocket
to deliver the device list via DeviceListInitialValuesReceived.
The gateway is appended from REST.
"""
LOG.info("Initialising: getting location IDs")
location_ids = await self._api.get_location_ids()
self.location_id = location_ids[0]
self.location_hilo_id = location_ids[1]
await self.update()
# Device list will be populated when DeviceListInitialValuesReceived
# arrives on the websocket. The hilo integration's async_init will
# call wait_for_device_cache() and then update() after subscribing.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ exclude = ".venv/.*"

[tool.poetry]
name = "python-hilo"
version = "2026.3.1"
version = "2026.3.2"
description = "A Python3, async interface to the Hilo API"
readme = "README.md"
authors = ["David Vallee Delisle <[email protected]>"]
Expand Down