python-hilo 2026.3.1__tar.gz → 2026.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/PKG-INFO +2 -2
  2. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/api.py +83 -11
  3. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/const.py +2 -1
  4. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/devices.py +74 -10
  5. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/graphql.py +17 -2
  6. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyproject.toml +2 -2
  7. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/LICENSE +0 -0
  8. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/README.md +0 -0
  9. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/__init__.py +0 -0
  10. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/__init__.py +0 -0
  11. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/climate.py +0 -0
  12. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/graphql_value_mapper.py +0 -0
  13. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/light.py +0 -0
  14. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/sensor.py +0 -0
  15. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/switch.py +0 -0
  16. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/event.py +0 -0
  17. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/exceptions.py +0 -0
  18. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/oauth2helper.py +0 -0
  19. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/util/__init__.py +0 -0
  20. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/util/state.py +0 -0
  21. {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/websocket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hilo
3
- Version: 2026.3.1
3
+ Version: 2026.3.2
4
4
  Summary: A Python3, async interface to the Hilo API
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -29,7 +29,7 @@ Requires-Dist: async-timeout (>=4.0.0)
29
29
  Requires-Dist: attrs (>=21.2.0)
30
30
  Requires-Dist: backoff (>=1.11.1)
31
31
  Requires-Dist: httpx-sse (>=0.4.0)
32
- Requires-Dist: httpx[http2] (>=0.20.0)
32
+ Requires-Dist: httpx[http2] (>=0.27.0)
33
33
  Requires-Dist: python-dateutil (>=2.8.2)
34
34
  Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
35
35
  Requires-Dist: voluptuous (>=0.13.1)
@@ -94,6 +94,9 @@ class API:
94
94
  self.ws_token: str = ""
95
95
  self.endpoint: str = ""
96
96
  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()
97
100
 
98
101
  @classmethod
99
102
  async def async_create(
@@ -544,17 +547,86 @@ class API:
544
547
  req: list[dict[str, Any]] = await self.async_request("get", url)
545
548
  return (req[0]["id"], req[0]["locationHiloId"])
546
549
 
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"))
558
630
 
559
631
  async def _set_device_attribute(
560
632
  self,
@@ -11,7 +11,7 @@ INSTANCE_ID: Final = str(uuid.uuid4())[24:]
11
11
  LOG: Final = logging.getLogger(__package__)
12
12
  DEFAULT_STATE_FILE: Final = "hilo_state.yaml"
13
13
  REQUEST_RETRY: Final = 9
14
- PYHILO_VERSION: Final = "2026.3.01"
14
+ PYHILO_VERSION: Final = "2026.3.02"
15
15
  # TODO: Find a way to keep previous line in sync with pyproject.toml automatically
16
16
 
17
17
  CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded"
@@ -201,6 +201,7 @@ HILO_DEVICE_TYPES: Final = {
201
201
  "Cee": "Switch",
202
202
  "Thermostat24V": "Climate",
203
203
  "Tracker": "Sensor",
204
+ "SinopeWaterHeater": "Switch",
204
205
  }
205
206
 
206
207
  HILO_UNIT_CONVERSION: Final = {
@@ -58,7 +58,13 @@ class Devices:
58
58
  device_identifier: Union[int, str] = reading.device_id
59
59
  if device_identifier == 0:
60
60
  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:
62
68
  device.update_readings(reading)
63
69
  LOG.debug("%s Received %s", device, reading)
64
70
  if device not in updated_devices:
@@ -93,27 +99,78 @@ class Devices:
93
99
  return dev
94
100
 
95
101
  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)
97
105
  generated_devices = []
98
- for raw_device in fresh_devices:
106
+ for raw_device in cached_devices:
99
107
  LOG.debug("Generating device %s", raw_device)
100
108
  dev = self.generate_device(raw_device)
101
109
  generated_devices.append(dev)
102
110
  if dev not in self.devices:
103
111
  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
+
104
135
  for device in self.devices:
105
136
  if device not in generated_devices:
106
137
  LOG.debug("Device unpaired %s", device)
107
138
  # Don't do anything with unpaired device for now.
108
- # self.devices.remove(device)
109
139
 
110
140
  async def update_devicelist_from_signalr(
111
141
  self, values: list[dict[str, Any]]
112
142
  ) -> 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
+
114
151
  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)
117
174
  dev = self.generate_device(raw_device)
118
175
  if dev not in self.devices:
119
176
  self.devices.append(dev)
@@ -122,9 +179,16 @@ class Devices:
122
179
  return new_devices
123
180
 
124
181
  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")
127
189
  location_ids = await self._api.get_location_ids()
128
190
  self.location_id = location_ids[0]
129
191
  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.
@@ -1,8 +1,10 @@
1
1
  import asyncio
2
2
  import hashlib
3
3
  import json
4
+ import ssl
4
5
  from typing import Any, Callable, Dict, List, Optional
5
6
 
7
+ import certifi
6
8
  import httpx
7
9
  from httpx_sse import aconnect_sse
8
10
 
@@ -24,6 +26,17 @@ class GraphQlHelper:
24
26
 
25
27
  async def async_init(self) -> None:
26
28
  """Initialize the Hilo "GraphQlHelper" class."""
29
+ loop = asyncio.get_running_loop()
30
+
31
+ def _create_ssl_context() -> ssl.SSLContext:
32
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
33
+ ctx.check_hostname = True
34
+ ctx.verify_mode = ssl.CERT_REQUIRED
35
+ ctx.load_verify_locations(certifi.where())
36
+ return ctx
37
+
38
+ self._ssl_context = await loop.run_in_executor(None, _create_ssl_context)
39
+
27
40
  await self.call_get_location_query(self._devices.location_hilo_id)
28
41
 
29
42
  QUERY_GET_LOCATION: str = """query getLocation($locationHiloId: String!) {
@@ -548,7 +561,7 @@ class GraphQlHelper:
548
561
  "variables": {"locationHiloId": location_hilo_id},
549
562
  }
550
563
 
551
- async with httpx.AsyncClient(http2=True) as client:
564
+ async with httpx.AsyncClient(http2=True, verify=self._ssl_context) as client:
552
565
  try:
553
566
  response = await client.post(url, json=payload, headers=headers)
554
567
  response.raise_for_status()
@@ -634,7 +647,9 @@ class GraphQlHelper:
634
647
 
635
648
  retry_with_full_query = False
636
649
 
637
- async with httpx.AsyncClient(http2=True, timeout=None) as client:
650
+ async with httpx.AsyncClient(
651
+ http2=True, timeout=None, verify=self._ssl_context
652
+ ) as client:
638
653
  async with aconnect_sse(
639
654
  client, "POST", url, json=payload, headers=headers
640
655
  ) as event_source:
@@ -40,7 +40,7 @@ exclude = ".venv/.*"
40
40
 
41
41
  [tool.poetry]
42
42
  name = "python-hilo"
43
- version = "2026.3.1"
43
+ version = "2026.3.2"
44
44
  description = "A Python3, async interface to the Hilo API"
45
45
  readme = "README.md"
46
46
  authors = ["David Vallee Delisle <me@dvd.dev>"]
@@ -74,7 +74,7 @@ python-dateutil = ">=2.8.2"
74
74
  python = "^3.9.0"
75
75
  voluptuous = ">=0.13.1"
76
76
  pyyaml = "^6.0.2"
77
- httpx = {version = ">=0.20.0", extras = ["http2"]}
77
+ httpx = {version = ">=0.27.0", extras = ["http2"]}
78
78
  httpx-sse = ">=0.4.0"
79
79
 
80
80
  [poetry.group.dev.dependencies]
File without changes
File without changes