python-hilo 2026.1.2__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.1.2 → python_hilo-2026.3.2}/PKG-INFO +3 -3
  2. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/__init__.py +1 -0
  3. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/api.py +94 -20
  4. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/const.py +9 -4
  5. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/__init__.py +5 -5
  6. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/climate.py +1 -0
  7. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/graphql_value_mapper.py +5 -4
  8. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/devices.py +75 -11
  9. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/event.py +2 -2
  10. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/exceptions.py +1 -0
  11. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/graphql.py +170 -85
  12. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/util/__init__.py +1 -0
  13. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/util/state.py +69 -22
  14. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/websocket.py +11 -8
  15. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyproject.toml +3 -3
  16. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/LICENSE +0 -0
  17. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/README.md +0 -0
  18. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/light.py +0 -0
  19. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/sensor.py +0 -0
  20. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/switch.py +0 -0
  21. {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/oauth2helper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hilo
3
- Version: 2026.1.2
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
@@ -28,11 +28,11 @@ Requires-Dist: aiosignal (>=1.2.0)
28
28
  Requires-Dist: async-timeout (>=4.0.0)
29
29
  Requires-Dist: attrs (>=21.2.0)
30
30
  Requires-Dist: backoff (>=1.11.1)
31
- Requires-Dist: gql (>=3.5.2,<5.0.0)
31
+ Requires-Dist: httpx-sse (>=0.4.0)
32
+ Requires-Dist: httpx[http2] (>=0.27.0)
32
33
  Requires-Dist: python-dateutil (>=2.8.2)
33
34
  Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
34
35
  Requires-Dist: voluptuous (>=0.13.1)
35
- Requires-Dist: websockets (>=8.1,<16.0)
36
36
  Project-URL: Repository, https://github.com/dvd-dev/python-hilo
37
37
  Description-Content-Type: text/markdown
38
38
 
@@ -1,4 +1,5 @@
1
1
  """Define the hilo package."""
2
+
2
3
  from pyhilo.api import API
3
4
  from pyhilo.const import UNMONITORED_DEVICES
4
5
  from pyhilo.device import HiloDevice
@@ -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(
@@ -193,9 +196,11 @@ class API:
193
196
  for x in self.device_attributes
194
197
  if x.hilo_attribute == attribute or x.attr == attribute
195
198
  ),
196
- DeviceAttribute(attribute, HILO_READING_TYPES.get(value_type, "null"))
197
- if value_type
198
- else attribute,
199
+ (
200
+ DeviceAttribute(attribute, HILO_READING_TYPES.get(value_type, "null"))
201
+ if value_type
202
+ else attribute
203
+ ),
199
204
  )
200
205
 
201
206
  async def _get_fid_state(self) -> bool:
@@ -289,7 +294,7 @@ class API:
289
294
  try:
290
295
  data = await resp.json(content_type=None)
291
296
  except json.decoder.JSONDecodeError:
292
- LOG.warning(f"JSON Decode error: {resp.__dict__}")
297
+ LOG.warning("JSON Decode error: %s", resp.__dict__)
293
298
  message = await resp.text()
294
299
  data = {"error": message}
295
300
  else:
@@ -351,7 +356,7 @@ class API:
351
356
  err: ClientResponseError = err_info[1].with_traceback(err_info[2]) # type: ignore
352
357
 
353
358
  if err.status in (401, 403):
354
- LOG.warning(f"Refreshing websocket token {err.request_info.url}")
359
+ LOG.warning("Refreshing websocket token %s", err.request_info.url)
355
360
  if (
356
361
  "client/negotiate" in str(err.request_info.url)
357
362
  and err.request_info.method == "POST"
@@ -359,7 +364,7 @@ class API:
359
364
  LOG.info(
360
365
  "401 detected on websocket, refreshing websocket token. Old url: {self.ws_url} Old Token: {self.ws_token}"
361
366
  )
362
- LOG.info(f"401 detected on {err.request_info.url}")
367
+ LOG.info("401 detected on %s", err.request_info.url)
363
368
  async with self._backoff_refresh_lock_ws:
364
369
  await self.refresh_ws_token()
365
370
  await self.get_websocket_params()
@@ -478,7 +483,7 @@ class API:
478
483
  json=body,
479
484
  )
480
485
  except ClientResponseError as err:
481
- LOG.error(f"ClientResponseError: {err}")
486
+ LOG.error("ClientResponseError: %s", err)
482
487
  if err.status in (401, 403):
483
488
  raise InvalidCredentialsError("Invalid credentials") from err
484
489
  raise RequestError(err) from err
@@ -516,14 +521,14 @@ class API:
516
521
  data=parsed_body,
517
522
  )
518
523
  except ClientResponseError as err:
519
- LOG.error(f"ClientResponseError: {err}")
524
+ LOG.error("ClientResponseError: %s", err)
520
525
  if err.status in (401, 403):
521
526
  raise InvalidCredentialsError("Invalid credentials") from err
522
527
  raise RequestError(err) from err
523
528
  LOG.debug("Android client register: %s", resp)
524
529
  msg: str = resp.get("message", "")
525
530
  if msg.startswith("Error="):
526
- LOG.error(f"Android registration error: {msg}")
531
+ LOG.error("Android registration error: %s", msg)
527
532
  raise RequestError
528
533
  token = msg.split("=")[-1]
529
534
  LOG.debug("Calling set_state android_register")
@@ -542,17 +547,86 @@ class API:
542
547
  req: list[dict[str, Any]] = await self.async_request("get", url)
543
548
  return (req[0]["id"], req[0]["locationHiloId"])
544
549
 
545
- async def get_devices(self, location_id: int) -> list[dict[str, Any]]:
546
- """Get list of all devices"""
547
- url = self._get_url("Devices", location_id=location_id)
548
- LOG.debug("Devices URL is %s", url)
549
- devices: list[dict[str, Any]] = await self.async_request("get", url)
550
- devices.append(await self.get_gateway(location_id))
551
- # Now it's time to add devices coming from external sources like hass
552
- # integration.
553
- for callback in self._get_device_callbacks:
554
- devices.append(callback())
555
- 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"))
556
630
 
557
631
  async def _set_device_attribute(
558
632
  self,
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import platform
3
- import uuid
4
3
  from typing import Final
4
+ import uuid
5
5
 
6
6
  import aiohttp
7
7
 
@@ -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.1.02"
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"
@@ -24,7 +24,9 @@ AUTH_AUTHORIZE: Final = f"https://{AUTH_HOSTNAME}{AUTH_ENDPOINT}authorize"
24
24
  AUTH_TOKEN: Final = f"https://{AUTH_HOSTNAME}{AUTH_ENDPOINT}token"
25
25
  AUTH_CHALLENGE_METHOD: Final = "S256"
26
26
  AUTH_CLIENT_ID: Final = "1ca9f585-4a55-4085-8e30-9746a65fa561"
27
- AUTH_SCOPE: Final = "openid https://HiloDirectoryB2C.onmicrosoft.com/hiloapis/user_impersonation offline_access"
27
+ AUTH_SCOPE: Final = (
28
+ "openid https://HiloDirectoryB2C.onmicrosoft.com/hiloapis/user_impersonation offline_access"
29
+ )
28
30
  SUBSCRIPTION_KEY: Final = "20eeaedcb86945afa3fe792cea89b8bf"
29
31
 
30
32
  # API constants
@@ -50,7 +52,9 @@ AUTOMATION_CHALLENGE_ENDPOINT: Final = "/ChallengeHub"
50
52
 
51
53
 
52
54
  # Request constants
53
- DEFAULT_USER_AGENT: Final = f"PyHilo/{PYHILO_VERSION}-{INSTANCE_ID} aiohttp/{aiohttp.__version__} Python/{platform.python_version()}"
55
+ DEFAULT_USER_AGENT: Final = (
56
+ f"PyHilo/{PYHILO_VERSION}-{INSTANCE_ID} aiohttp/{aiohttp.__version__} Python/{platform.python_version()}"
57
+ )
54
58
 
55
59
 
56
60
  # NOTE(dvd): Not sure how to get new ones so I'm using the ones from my emulator
@@ -197,6 +201,7 @@ HILO_DEVICE_TYPES: Final = {
197
201
  "Cee": "Switch",
198
202
  "Thermostat24V": "Climate",
199
203
  "Tracker": "Sensor",
204
+ "SinopeWaterHeater": "Switch",
200
205
  }
201
206
 
202
207
  HILO_UNIT_CONVERSION: Final = {
@@ -51,7 +51,7 @@ class HiloDevice:
51
51
  def update(self, **kwargs: Dict[str, Union[str, int, Dict]]) -> None:
52
52
  # TODO(dvd): This has to be re-written, this is not dynamic at all.
53
53
  if self._api.log_traces:
54
- LOG.debug(f"[TRACE] Adding device {kwargs}")
54
+ LOG.debug("[TRACE] Adding device %s", kwargs)
55
55
  for orig_att, val in kwargs.items():
56
56
  att = camel_to_snake(orig_att)
57
57
  if reading_att := HILO_READING_TYPES.get(orig_att):
@@ -70,7 +70,7 @@ class HiloDevice:
70
70
  self.update_readings(DeviceReading(**reading)) # type: ignore
71
71
 
72
72
  if att not in HILO_DEVICE_ATTRIBUTES:
73
- LOG.warning(f"Unknown device attribute {att}: {val}")
73
+ LOG.warning("Unknown device attribute %s: %s", att, val)
74
74
  continue
75
75
  elif att in HILO_LIST_ATTRIBUTES:
76
76
  # This is where we generated the supported_attributes and settable_attributes
@@ -108,7 +108,7 @@ class HiloDevice:
108
108
 
109
109
  async def set_attribute(self, attribute: str, value: Union[str, int, None]) -> None:
110
110
  if dev_attribute := cast(DeviceAttribute, self._api.dev_atts(attribute)):
111
- LOG.debug(f"{self._tag} Setting {dev_attribute} to {value}")
111
+ LOG.debug("%s Setting %s to %s", self._tag, dev_attribute, value)
112
112
  await self._set_attribute(dev_attribute, value)
113
113
  return
114
114
  LOG.warning(
@@ -134,7 +134,7 @@ class HiloDevice:
134
134
  )
135
135
  )
136
136
  else:
137
- LOG.warning(f"{self._tag} Invalid attribute {attribute} for device")
137
+ LOG.warning("%s Invalid attribute %s for device", self._tag, attribute)
138
138
 
139
139
  def get_attribute(self, attribute: str) -> Union[DeviceReading, None]:
140
140
  if dev_attribute := cast(DeviceAttribute, self._api.dev_atts(attribute)):
@@ -245,7 +245,7 @@ class DeviceReading:
245
245
  else ""
246
246
  )
247
247
  if not self.device_attribute:
248
- LOG.warning(f"Received invalid reading for {self.device_id}: {kwargs}")
248
+ LOG.warning("Received invalid reading for %s: %s", self.device_id, kwargs)
249
249
 
250
250
  def __repr__(self) -> str:
251
251
  return f"<Reading {self.device_attribute.attr} {self.value}{self.unit_of_measurement}>"
@@ -1,4 +1,5 @@
1
1
  """Climate object."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  from typing import Any, cast
@@ -11,7 +11,7 @@ class GraphqlValueMapper:
11
11
 
12
12
  OnState = "on"
13
13
 
14
- def map_query_values(self, values: Dict[str, Any]) -> list[Dict[str, Any]]:
14
+ def map_query_values(self, values: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
15
15
  readings: list[Dict[str, Any]] = []
16
16
  for device in values:
17
17
  if device.get("deviceType") is not None:
@@ -20,7 +20,7 @@ class GraphqlValueMapper:
20
20
  return readings
21
21
 
22
22
  def map_device_subscription_values(
23
- self, device: list[Dict[str, Any]]
23
+ self, device: Dict[str, Any]
24
24
  ) -> list[Dict[str, Any]]:
25
25
  readings: list[Dict[str, Any]] = []
26
26
  if device.get("deviceType") is not None:
@@ -32,7 +32,7 @@ class GraphqlValueMapper:
32
32
  self, values: Dict[str, Any]
33
33
  ) -> list[Dict[str, Any]]:
34
34
  readings: list[Dict[str, Any]] = []
35
- for device in values:
35
+ for device in values.get("devices", []):
36
36
  if device.get("deviceType") is not None:
37
37
  reading = self._map_devices_values(device)
38
38
  readings.extend(reading)
@@ -425,7 +425,8 @@ class GraphqlValueMapper:
425
425
  device["hiloId"], "Intensity", device["level"] / 100
426
426
  )
427
427
  )
428
- if device.get("lightType").lower() == "color":
428
+ light_type = device.get("lightType")
429
+ if light_type and light_type.lower() == "color":
429
430
  attributes.append(
430
431
  self.build_attribute(device["hiloId"], "Hue", device.get("hue") or 0)
431
432
  )
@@ -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:
@@ -87,33 +93,84 @@ class Devices:
87
93
  try:
88
94
  device_type = HILO_DEVICE_TYPES[dev.type]
89
95
  except KeyError:
90
- LOG.warning(f"Unknown device type {dev.type}, adding as Sensor")
96
+ LOG.warning("Unknown device type %s, adding as Sensor", dev.type)
91
97
  device_type = "Sensor"
92
98
  dev.__class__ = globals()[device_type]
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.
@@ -27,7 +27,7 @@ class Event:
27
27
 
28
28
  def __init__(self, **event: dict[str, Any]):
29
29
  """Initialize."""
30
- self._convert_phases(cast(dict[str, Any], event.get("phases", {})))
30
+ self._convert_phases(event.get("phases", {}))
31
31
  params: dict[str, Any] = event.get("parameters") or {}
32
32
  devices: list[dict[str, Any]] = params.get("devices", [])
33
33
  consumption: dict[str, Any] = event.get("consumption", {})
@@ -118,7 +118,7 @@ class Event:
118
118
  except TypeError:
119
119
  setattr(self, phase, value)
120
120
  self.phases_list.append(phase)
121
- for phase in self.__annotations__:
121
+ for phase in type(self).__annotations__:
122
122
  if phase not in self.phases_list:
123
123
  # On t'aime Carl
124
124
  setattr(self, phase, datetime(2099, 12, 31, tzinfo=timezone.utc))
@@ -1,4 +1,5 @@
1
1
  """Define package errors."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
 
@@ -1,11 +1,12 @@
1
1
  import asyncio
2
- import logging
2
+ import hashlib
3
+ import json
3
4
  import ssl
4
- from typing import Any, Dict, List, Optional
5
+ from typing import Any, Callable, Dict, List, Optional
5
6
 
6
- from gql import Client, gql
7
- from gql.transport.aiohttp import AIOHTTPTransport
8
- from gql.transport.websockets import WebsocketsTransport
7
+ import certifi
8
+ import httpx
9
+ from httpx_sse import aconnect_sse
9
10
 
10
11
  from pyhilo import API
11
12
  from pyhilo.const import LOG, PLATFORM_HOST
@@ -25,6 +26,17 @@ class GraphQlHelper:
25
26
 
26
27
  async def async_init(self) -> None:
27
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
+
28
40
  await self.call_get_location_query(self._devices.location_hilo_id)
29
41
 
30
42
  QUERY_GET_LOCATION: str = """query getLocation($locationHiloId: String!) {
@@ -533,91 +545,164 @@ class GraphQlHelper:
533
545
  async def call_get_location_query(self, location_hilo_id: str) -> None:
534
546
  """This functions calls the digital-twin and requests location id"""
535
547
  access_token = await self._get_access_token()
536
- transport = AIOHTTPTransport(
537
- url=f"https://{PLATFORM_HOST}/api/digital-twin/v3/graphql",
538
- headers={"Authorization": f"Bearer {access_token}"},
539
- )
540
- client = Client(transport=transport, fetch_schema_from_transport=True)
541
- query = gql(self.QUERY_GET_LOCATION)
548
+ url = f"https://{PLATFORM_HOST}/api/digital-twin/v3/graphql"
549
+ headers = {"Authorization": f"Bearer {access_token}"}
542
550
 
543
- async with client as session:
544
- result = await session.execute(
545
- query, variable_values={"locationHiloId": location_hilo_id}
546
- )
547
- self._handle_query_result(result)
551
+ query = self.QUERY_GET_LOCATION
552
+ query_hash = hashlib.sha256(query.encode("utf-8")).hexdigest()
553
+
554
+ payload = {
555
+ "extensions": {
556
+ "persistedQuery": {
557
+ "version": 1,
558
+ "sha256Hash": query_hash,
559
+ }
560
+ },
561
+ "variables": {"locationHiloId": location_hilo_id},
562
+ }
563
+
564
+ async with httpx.AsyncClient(http2=True, verify=self._ssl_context) as client:
565
+ try:
566
+ response = await client.post(url, json=payload, headers=headers)
567
+ response.raise_for_status()
568
+ response_json = response.json()
569
+ except Exception as e:
570
+ LOG.error("Error parsing response: %s", e)
571
+ return
572
+
573
+ if "errors" in response_json:
574
+ for error in response_json["errors"]:
575
+ if error.get("message") == "PersistedQueryNotFound":
576
+ payload["query"] = query
577
+ try:
578
+ response = await client.post(
579
+ url, json=payload, headers=headers
580
+ )
581
+ response.raise_for_status()
582
+ response_json = response.json()
583
+ except Exception as e:
584
+ LOG.error("Error parsing response on retry: %s", e)
585
+ return
586
+ break
587
+
588
+ if "errors" in response_json:
589
+ LOG.error("GraphQL errors: %s", response_json["errors"])
590
+ return
591
+
592
+ if "data" in response_json:
593
+ self._handle_query_result(response_json["data"])
548
594
 
549
595
  async def subscribe_to_device_updated(
550
- self, location_hilo_id: str, callback: callable = None
596
+ self,
597
+ location_hilo_id: str,
598
+ callback: Optional[Callable[[str], None]] = None,
551
599
  ) -> None:
552
600
  LOG.debug("subscribe_to_device_updated called")
601
+ await self._listen_to_sse(
602
+ self.SUBSCRIPTION_DEVICE_UPDATED,
603
+ {"locationHiloId": location_hilo_id},
604
+ self._handle_device_subscription_result,
605
+ callback,
606
+ location_hilo_id,
607
+ )
608
+
609
+ async def subscribe_to_location_updated(
610
+ self,
611
+ location_hilo_id: str,
612
+ callback: Optional[Callable[[str], None]] = None,
613
+ ) -> None:
614
+ LOG.debug("subscribe_to_location_updated called")
615
+ await self._listen_to_sse(
616
+ self.SUBSCRIPTION_LOCATION_UPDATED,
617
+ {"locationHiloId": location_hilo_id},
618
+ self._handle_location_subscription_result,
619
+ callback,
620
+ location_hilo_id,
621
+ )
622
+
623
+ async def _listen_to_sse(
624
+ self,
625
+ query: str,
626
+ variables: Dict[str, Any],
627
+ handler: Callable[[Dict[str, Any]], str],
628
+ callback: Optional[Callable[[str], None]] = None,
629
+ location_hilo_id: Optional[str] = None,
630
+ ) -> None:
631
+ query_hash = hashlib.sha256(query.encode("utf-8")).hexdigest()
632
+ payload: Dict[str, Any] = {
633
+ "extensions": {
634
+ "persistedQuery": {
635
+ "version": 1,
636
+ "sha256Hash": query_hash,
637
+ }
638
+ },
639
+ "variables": variables,
640
+ }
553
641
 
554
- # Setting log level to suppress keepalive messages on gql transport
555
- logging.getLogger("gql.transport.websockets").setLevel(logging.WARNING)
556
-
557
- #
558
- loop = asyncio.get_event_loop()
559
- ssl_context = await loop.run_in_executor(None, ssl.create_default_context)
560
-
561
- while True: # Loop to reconnect if the connection is lost
562
- LOG.debug("subscribe_to_device_updated while true")
563
- access_token = await self._get_access_token()
564
- transport = WebsocketsTransport(
565
- url=f"wss://{PLATFORM_HOST}/api/digital-twin/v3/graphql?access_token={access_token}",
566
- ssl=ssl_context,
567
- )
568
- client = Client(transport=transport, fetch_schema_from_transport=True)
569
- query = gql(self.SUBSCRIPTION_DEVICE_UPDATED)
642
+ while True:
570
643
  try:
571
- async with client as session:
572
- async for result in session.subscribe(
573
- query, variable_values={"locationHiloId": location_hilo_id}
574
- ):
575
- LOG.debug(
576
- "subscribe_to_device_updated: Received subscription result %s",
577
- result,
578
- )
579
- device_hilo_id = self._handle_device_subscription_result(result)
580
- if callback:
581
- callback(device_hilo_id)
644
+ access_token = await self._get_access_token()
645
+ url = f"https://{PLATFORM_HOST}/api/digital-twin/v3/graphql"
646
+ headers = {"Authorization": f"Bearer {access_token}"}
647
+
648
+ retry_with_full_query = False
649
+
650
+ async with httpx.AsyncClient(
651
+ http2=True, timeout=None, verify=self._ssl_context
652
+ ) as client:
653
+ async with aconnect_sse(
654
+ client, "POST", url, json=payload, headers=headers
655
+ ) as event_source:
656
+ async for sse in event_source.aiter_sse():
657
+ if not sse.data:
658
+ continue
659
+ try:
660
+ data = json.loads(sse.data)
661
+ except json.JSONDecodeError:
662
+ continue
663
+
664
+ if "errors" in data:
665
+ if any(
666
+ e.get("message") == "PersistedQueryNotFound"
667
+ for e in data["errors"]
668
+ ):
669
+ retry_with_full_query = True
670
+ break
671
+ LOG.error(
672
+ "GraphQL Subscription Errors: %s", data["errors"]
673
+ )
674
+ continue
675
+
676
+ if "data" in data:
677
+ LOG.debug(
678
+ "Received subscription result %s", data["data"]
679
+ )
680
+ handler_result = handler(data["data"])
681
+ if callback:
682
+ callback(handler_result)
683
+
684
+ if retry_with_full_query:
685
+ payload["query"] = query
686
+ continue
687
+
582
688
  except Exception as e:
583
689
  LOG.debug(
584
- "subscribe_to_device_updated: Connection lost: %s. Reconnecting in 5 seconds...",
585
- e,
690
+ "Subscription connection lost: %s. Reconnecting in 5 seconds...", e
586
691
  )
587
692
  await asyncio.sleep(5)
588
- try:
589
- await self.call_get_location_query(location_hilo_id)
590
- LOG.debug(
591
- "subscribe_to_device_updated, call_get_location_query success"
592
- )
593
-
594
- except Exception as e2:
595
- LOG.error(
596
- "subscribe_to_device_updated, exception while reconnecting, retrying: %s",
597
- e2,
598
- )
693
+ # Reset payload to APQ only on reconnect
694
+ if "query" in payload:
695
+ del payload["query"]
599
696
 
600
- async def subscribe_to_location_updated(
601
- self, location_hilo_id: str, callback: callable = None
602
- ) -> None:
603
- access_token = await self._get_access_token()
604
- transport = WebsocketsTransport(
605
- url=f"wss://{PLATFORM_HOST}/api/digital-twin/v3/graphql?access_token={access_token}"
606
- )
607
- client = Client(transport=transport, fetch_schema_from_transport=True)
608
- query = gql(self.SUBSCRIPTION_LOCATION_UPDATED)
609
- try:
610
- async with client as session:
611
- async for result in session.subscribe(
612
- query, variable_values={"locationHiloId": location_hilo_id}
613
- ):
614
- LOG.debug("Received subscription result %s", result)
615
- device_hilo_id = self._handle_location_subscription_result(result)
616
- callback(device_hilo_id)
617
- except asyncio.CancelledError:
618
- LOG.debug("Subscription cancelled.")
619
- asyncio.sleep(1)
620
- await self.subscribe_to_location_updated(location_hilo_id)
697
+ if location_hilo_id:
698
+ try:
699
+ await self.call_get_location_query(location_hilo_id)
700
+ LOG.debug("call_get_location_query success after reconnect")
701
+ except Exception as e2:
702
+ LOG.error(
703
+ "exception while RE-connecting, retrying: %s",
704
+ e2,
705
+ )
621
706
 
622
707
  async def _get_access_token(self) -> str:
623
708
  """Get the access token."""
@@ -625,22 +710,22 @@ class GraphQlHelper:
625
710
 
626
711
  def _handle_query_result(self, result: Dict[str, Any]) -> None:
627
712
  """This receives query results and maps them to the proper device."""
628
- devices_values: list[any] = result["getLocation"]["devices"]
713
+ devices_values: List[Dict[str, Any]] = result["getLocation"]["devices"]
629
714
  attributes = self.mapper.map_query_values(devices_values)
630
715
  self._devices.parse_values_received(attributes)
631
716
 
632
717
  def _handle_device_subscription_result(self, result: Dict[str, Any]) -> str:
633
- devices_values: list[any] = result["onAnyDeviceUpdated"]["device"]
634
- attributes = self.mapper.map_device_subscription_values(devices_values)
718
+ device_value: Dict[str, Any] = result["onAnyDeviceUpdated"]["device"]
719
+ attributes = self.mapper.map_device_subscription_values(device_value)
635
720
  updated_device = self._devices.parse_values_received(attributes)
636
721
  # callback to update the device in the UI
637
722
  LOG.debug("Device updated: %s", updated_device)
638
- return devices_values.get("hiloId")
723
+ return str(device_value.get("hiloId"))
639
724
 
640
725
  def _handle_location_subscription_result(self, result: Dict[str, Any]) -> str:
641
- devices_values: list[any] = result["onAnyLocationUpdated"]["location"]
642
- attributes = self.mapper.map_location_subscription_values(devices_values)
726
+ location_value: Dict[str, Any] = result["onAnyLocationUpdated"]["location"]
727
+ attributes = self.mapper.map_location_subscription_values(location_value)
643
728
  updated_device = self._devices.parse_values_received(attributes)
644
729
  # callback to update the device in the UI
645
730
  LOG.debug("Device updated: %s", updated_device)
646
- return devices_values.get("hiloId")
731
+ return str(location_value.get("hiloId"))
@@ -1,4 +1,5 @@
1
1
  """Define utility modules."""
2
+
2
3
  import asyncio
3
4
  from datetime import datetime, timedelta
4
5
  import re
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  from datetime import datetime
7
+ import os
7
8
  from os.path import isfile
9
+ import tempfile
8
10
  from typing import Any, ForwardRef, TypedDict, TypeVar, get_type_hints
9
11
 
10
12
  import aiofiles
@@ -77,7 +79,7 @@ class StateDict(TypedDict, total=False):
77
79
  T = TypeVar("T", bound="StateDict")
78
80
 
79
81
 
80
- def _get_defaults(cls: type[T]) -> dict[str, Any]:
82
+ def _get_defaults(cls: type[T]) -> T:
81
83
  """Generate a default dict based on typed dict
82
84
 
83
85
  This function recursively creates a nested dictionary structure that mirrors
@@ -117,34 +119,82 @@ def _get_defaults(cls: type[T]) -> dict[str, Any]:
117
119
  return new_dict # type: ignore[return-value]
118
120
 
119
121
 
120
- async def get_state(state_yaml: str) -> StateDict:
122
+ def _write_state(state_yaml: str, state: dict[str, Any] | StateDict) -> None:
123
+ "Write state atomically to a temp file, this prevents reading a file being written to"
124
+
125
+ dir_name = os.path.dirname(os.path.abspath(state_yaml))
126
+ content = yaml.dump(state)
127
+ with tempfile.NamedTemporaryFile(
128
+ mode="w", dir=dir_name, delete=False, suffix=".tmp"
129
+ ) as tmp:
130
+ tmp.write(content)
131
+ tmp_path = tmp.name
132
+ os.chmod(tmp_path, 0o644)
133
+ os.replace(tmp_path, state_yaml)
134
+
135
+
136
+ async def get_state(state_yaml: str, _already_locked: bool = False) -> StateDict:
121
137
  """Read in state yaml.
122
138
 
123
139
  :param state_yaml: filename where to read the state
124
140
  :type state_yaml: ``str``
141
+ :param _already_locked: Whether the lock is already held by the caller (e.g. set_state).
142
+ Prevents deadlock when corruption recovery needs to write defaults.
143
+ :type _already_locked: ``bool``
125
144
  :rtype: ``StateDict``
126
145
  """
127
146
  if not isfile(
128
147
  state_yaml
129
148
  ): # noqa: PTH113 - isfile is fine and simpler in this case.
130
- return _get_defaults(StateDict) # type: ignore
131
- async with aiofiles.open(state_yaml, mode="r") as yaml_file:
132
- LOG.debug("Loading state from yaml")
133
- content = await yaml_file.read()
134
- state_yaml_payload: StateDict = yaml.safe_load(content)
135
- return state_yaml_payload
149
+ return _get_defaults(StateDict)
150
+
151
+ try:
152
+ async with aiofiles.open(state_yaml, mode="r") as yaml_file:
153
+ LOG.debug("Loading state from yaml")
154
+ content = await yaml_file.read()
155
+
156
+ state_yaml_payload: StateDict | None = yaml.safe_load(content)
157
+
158
+ # Handle corrupted/empty YAML files
159
+ if state_yaml_payload is None or not isinstance(state_yaml_payload, dict):
160
+ LOG.warning(
161
+ "State file %s is corrupted or empty, reinitializing with defaults",
162
+ state_yaml,
163
+ )
164
+ defaults = _get_defaults(StateDict)
165
+ if _already_locked:
166
+ _write_state(state_yaml, defaults)
167
+ else:
168
+ async with lock:
169
+ _write_state(state_yaml, defaults)
170
+ return defaults
171
+
172
+ return state_yaml_payload
173
+
174
+ except yaml.YAMLError as e:
175
+ LOG.error(
176
+ "Failed to parse state file %s: %s. Reinitializing with defaults.",
177
+ state_yaml,
178
+ e,
179
+ )
180
+ defaults = _get_defaults(StateDict)
181
+ if _already_locked:
182
+ _write_state(state_yaml, defaults)
183
+ else:
184
+ async with lock:
185
+ _write_state(state_yaml, defaults)
186
+ return defaults
136
187
 
137
188
 
138
189
  async def set_state(
139
190
  state_yaml: str,
140
191
  key: str,
141
- state: TokenDict
142
- | RegistrationDict
143
- | FirebaseDict
144
- | AndroidDeviceDict
145
- | WebsocketDict,
192
+ state: (
193
+ TokenDict | RegistrationDict | FirebaseDict | AndroidDeviceDict | WebsocketDict
194
+ ),
146
195
  ) -> None:
147
196
  """Save state yaml.
197
+
148
198
  :param state_yaml: filename where to read the state
149
199
  :type state_yaml: ``str``
150
200
  :param key: Key name
@@ -154,14 +204,11 @@ async def set_state(
154
204
  :rtype: ``StateDict``
155
205
  """
156
206
  async with lock: # note ic-dev21: on lock le fichier pour être sûr de finir la job
157
- current_state = await get_state(state_yaml) or {}
207
+ current_state = await get_state(state_yaml, _already_locked=True) or {}
158
208
  merged_state: dict[str, Any] = {key: {**current_state.get(key, {}), **state}} # type: ignore[dict-item]
159
209
  new_state: dict[str, Any] = {**current_state, **merged_state}
160
- async with aiofiles.open(state_yaml, mode="w") as yaml_file:
161
- LOG.debug("Saving state to yaml file")
162
- # TODO: Use asyncio.get_running_loop() and run_in_executor to write
163
- # to the file in a non blocking manner. Currently, the file writes
164
- # are properly async but the yaml dump is done synchronously on the
165
- # main event loop.
166
- content = yaml.dump(new_state)
167
- await yaml_file.write(content)
210
+ LOG.debug("Saving state to yaml file")
211
+ # TODO: Use asyncio.get_running_loop() and run_in_executor to write
212
+ # to the file in a non blocking manner. Currently, yaml.dump is
213
+ # synchronous on the main event loop.
214
+ _write_state(state_yaml, new_state)
@@ -1,4 +1,5 @@
1
1
  """Define a connection to the Hilo websocket."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import asyncio
@@ -172,7 +173,9 @@ class WebsocketClient:
172
173
  response = await self._client.receive(300)
173
174
 
174
175
  if response.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING):
175
- LOG.error(f"Websocket: Received event to close connection: {response.type}")
176
+ LOG.error(
177
+ "Websocket: Received event to close connection: %s", response.type
178
+ )
176
179
  raise ConnectionClosedError("Connection was closed.")
177
180
 
178
181
  if response.type == WSMsgType.ERROR:
@@ -182,7 +185,7 @@ class WebsocketClient:
182
185
  raise ConnectionFailedError
183
186
 
184
187
  if response.type != WSMsgType.TEXT:
185
- LOG.error(f"Websocket: Received invalid message: {response}")
188
+ LOG.error("Websocket: Received invalid message: %s", response)
186
189
  raise InvalidMessageError(f"Received non-text message: {response.type}")
187
190
 
188
191
  messages: list[Dict[str, Any]] = []
@@ -195,7 +198,7 @@ class WebsocketClient:
195
198
  except ValueError as v_exc:
196
199
  raise InvalidMessageError("Received invalid JSON") from v_exc
197
200
  except json.decoder.JSONDecodeError as j_exc:
198
- LOG.error(f"Received invalid JSON: {msg}")
201
+ LOG.error("Received invalid JSON: %s", msg)
199
202
  LOG.exception(j_exc)
200
203
  data = {}
201
204
 
@@ -306,14 +309,14 @@ class WebsocketClient:
306
309
  **proxy_env,
307
310
  )
308
311
  except (ClientError, ServerDisconnectedError, WSServerHandshakeError) as err:
309
- LOG.error(f"Unable to connect to WS server {err}")
312
+ LOG.error("Unable to connect to WS server %s", err)
310
313
  if hasattr(err, "status") and err.status in (401, 403, 404, 409):
311
314
  raise InvalidCredentialsError("Invalid credentials") from err
312
315
  except Exception as err:
313
- LOG.error(f"Unable to connect to WS server {err}")
316
+ LOG.error("Unable to connect to WS server %s", err)
314
317
  raise CannotConnectError(err) from err
315
318
 
316
- LOG.info(f"Connected to websocket server {self._api.endpoint}")
319
+ LOG.info("Connected to websocket server %s", self._api.endpoint)
317
320
 
318
321
  # Quick pause to prevent race condition
319
322
  await asyncio.sleep(0.05)
@@ -352,11 +355,11 @@ class WebsocketClient:
352
355
  LOG.info("Websocket: Listen cancelled.")
353
356
  raise
354
357
  except ConnectionClosedError as err:
355
- LOG.error(f"Websocket: Closed while listening: {err}")
358
+ LOG.error("Websocket: Closed while listening: %s", err)
356
359
  LOG.exception(err)
357
360
  pass
358
361
  except InvalidMessageError as err:
359
- LOG.warning(f"Websocket: Received invalid json : {err}")
362
+ LOG.warning("Websocket: Received invalid json : %s", err)
360
363
  pass
361
364
  finally:
362
365
  LOG.info("Websocket: Listen completed; cleaning up")
@@ -40,7 +40,7 @@ exclude = ".venv/.*"
40
40
 
41
41
  [tool.poetry]
42
42
  name = "python-hilo"
43
- version = "2026.1.2"
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>"]
@@ -73,9 +73,9 @@ backoff = ">=1.11.1"
73
73
  python-dateutil = ">=2.8.2"
74
74
  python = "^3.9.0"
75
75
  voluptuous = ">=0.13.1"
76
- websockets = ">=8.1,<16.0"
77
- gql = ">=3.5.2,<5.0.0"
78
76
  pyyaml = "^6.0.2"
77
+ httpx = {version = ">=0.27.0", extras = ["http2"]}
78
+ httpx-sse = ">=0.4.0"
79
79
 
80
80
  [poetry.group.dev.dependencies]
81
81
  Sphinx = "^7.1.2"
File without changes
File without changes