python-hilo 2026.1.1__tar.gz → 2026.3.1__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.1 → python_hilo-2026.3.1}/PKG-INFO +3 -3
  2. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/__init__.py +1 -0
  3. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/api.py +12 -10
  4. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/const.py +11 -3
  5. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/__init__.py +5 -5
  6. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/climate.py +1 -0
  7. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/graphql_value_mapper.py +5 -4
  8. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/devices.py +1 -1
  9. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/event.py +2 -2
  10. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/exceptions.py +1 -0
  11. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/graphql.py +156 -86
  12. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/util/__init__.py +1 -0
  13. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/util/state.py +69 -22
  14. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/websocket.py +11 -8
  15. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyproject.toml +3 -3
  16. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/LICENSE +0 -0
  17. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/README.md +0 -0
  18. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/light.py +0 -0
  19. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/sensor.py +0 -0
  20. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/switch.py +0 -0
  21. {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/oauth2helper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hilo
3
- Version: 2026.1.1
3
+ Version: 2026.3.1
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.20.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
@@ -193,9 +193,11 @@ class API:
193
193
  for x in self.device_attributes
194
194
  if x.hilo_attribute == attribute or x.attr == attribute
195
195
  ),
196
- DeviceAttribute(attribute, HILO_READING_TYPES.get(value_type, "null"))
197
- if value_type
198
- else attribute,
196
+ (
197
+ DeviceAttribute(attribute, HILO_READING_TYPES.get(value_type, "null"))
198
+ if value_type
199
+ else attribute
200
+ ),
199
201
  )
200
202
 
201
203
  async def _get_fid_state(self) -> bool:
@@ -289,7 +291,7 @@ class API:
289
291
  try:
290
292
  data = await resp.json(content_type=None)
291
293
  except json.decoder.JSONDecodeError:
292
- LOG.warning(f"JSON Decode error: {resp.__dict__}")
294
+ LOG.warning("JSON Decode error: %s", resp.__dict__)
293
295
  message = await resp.text()
294
296
  data = {"error": message}
295
297
  else:
@@ -351,7 +353,7 @@ class API:
351
353
  err: ClientResponseError = err_info[1].with_traceback(err_info[2]) # type: ignore
352
354
 
353
355
  if err.status in (401, 403):
354
- LOG.warning(f"Refreshing websocket token {err.request_info.url}")
356
+ LOG.warning("Refreshing websocket token %s", err.request_info.url)
355
357
  if (
356
358
  "client/negotiate" in str(err.request_info.url)
357
359
  and err.request_info.method == "POST"
@@ -359,7 +361,7 @@ class API:
359
361
  LOG.info(
360
362
  "401 detected on websocket, refreshing websocket token. Old url: {self.ws_url} Old Token: {self.ws_token}"
361
363
  )
362
- LOG.info(f"401 detected on {err.request_info.url}")
364
+ LOG.info("401 detected on %s", err.request_info.url)
363
365
  async with self._backoff_refresh_lock_ws:
364
366
  await self.refresh_ws_token()
365
367
  await self.get_websocket_params()
@@ -478,7 +480,7 @@ class API:
478
480
  json=body,
479
481
  )
480
482
  except ClientResponseError as err:
481
- LOG.error(f"ClientResponseError: {err}")
483
+ LOG.error("ClientResponseError: %s", err)
482
484
  if err.status in (401, 403):
483
485
  raise InvalidCredentialsError("Invalid credentials") from err
484
486
  raise RequestError(err) from err
@@ -516,14 +518,14 @@ class API:
516
518
  data=parsed_body,
517
519
  )
518
520
  except ClientResponseError as err:
519
- LOG.error(f"ClientResponseError: {err}")
521
+ LOG.error("ClientResponseError: %s", err)
520
522
  if err.status in (401, 403):
521
523
  raise InvalidCredentialsError("Invalid credentials") from err
522
524
  raise RequestError(err) from err
523
525
  LOG.debug("Android client register: %s", resp)
524
526
  msg: str = resp.get("message", "")
525
527
  if msg.startswith("Error="):
526
- LOG.error(f"Android registration error: {msg}")
528
+ LOG.error("Android registration error: %s", msg)
527
529
  raise RequestError
528
530
  token = msg.split("=")[-1]
529
531
  LOG.debug("Calling set_state android_register")
@@ -762,4 +764,4 @@ class API:
762
764
  LOG.debug("Weather URL is %s", url)
763
765
  response = await self.async_request("get", url)
764
766
  LOG.debug("Weather API response: %s", response)
765
- return cast(dict[str, Any], await self.async_request("get", url))
767
+ return cast(dict[str, Any], response)
@@ -1,13 +1,17 @@
1
1
  import logging
2
2
  import platform
3
3
  from typing import Final
4
+ import uuid
4
5
 
5
6
  import aiohttp
6
7
 
8
+ # THe instance ID is random and unique to a specific instance/run.
9
+ # Helps identifying multiple instances behind the same public IP, can be useful to the Hilo/HQ devs for debugging purposes
10
+ INSTANCE_ID: Final = str(uuid.uuid4())[24:]
7
11
  LOG: Final = logging.getLogger(__package__)
8
12
  DEFAULT_STATE_FILE: Final = "hilo_state.yaml"
9
13
  REQUEST_RETRY: Final = 9
10
- PYHILO_VERSION: Final = "2026.1.01"
14
+ PYHILO_VERSION: Final = "2026.3.01"
11
15
  # TODO: Find a way to keep previous line in sync with pyproject.toml automatically
12
16
 
13
17
  CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded"
@@ -20,7 +24,9 @@ AUTH_AUTHORIZE: Final = f"https://{AUTH_HOSTNAME}{AUTH_ENDPOINT}authorize"
20
24
  AUTH_TOKEN: Final = f"https://{AUTH_HOSTNAME}{AUTH_ENDPOINT}token"
21
25
  AUTH_CHALLENGE_METHOD: Final = "S256"
22
26
  AUTH_CLIENT_ID: Final = "1ca9f585-4a55-4085-8e30-9746a65fa561"
23
- 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
+ )
24
30
  SUBSCRIPTION_KEY: Final = "20eeaedcb86945afa3fe792cea89b8bf"
25
31
 
26
32
  # API constants
@@ -46,7 +52,9 @@ AUTOMATION_CHALLENGE_ENDPOINT: Final = "/ChallengeHub"
46
52
 
47
53
 
48
54
  # Request constants
49
- DEFAULT_USER_AGENT: Final = f"PyHilo/{PYHILO_VERSION} 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
+ )
50
58
 
51
59
 
52
60
  # NOTE(dvd): Not sure how to get new ones so I'm using the ones from my emulator
@@ -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
  )
@@ -87,7 +87,7 @@ class Devices:
87
87
  try:
88
88
  device_type = HILO_DEVICE_TYPES[dev.type]
89
89
  except KeyError:
90
- LOG.warning(f"Unknown device type {dev.type}, adding as Sensor")
90
+ LOG.warning("Unknown device type %s, adding as Sensor", dev.type)
91
91
  device_type = "Sensor"
92
92
  dev.__class__ = globals()[device_type]
93
93
  return dev
@@ -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,10 @@
1
1
  import asyncio
2
- import logging
3
- import ssl
4
- from typing import Any, Dict, List, Optional
2
+ import hashlib
3
+ import json
4
+ from typing import Any, Callable, Dict, List, Optional
5
5
 
6
- from gql import Client, gql
7
- from gql.transport.aiohttp import AIOHTTPTransport
8
- from gql.transport.websockets import WebsocketsTransport
6
+ import httpx
7
+ from httpx_sse import aconnect_sse
9
8
 
10
9
  from pyhilo import API
11
10
  from pyhilo.const import LOG, PLATFORM_HOST
@@ -533,91 +532,162 @@ class GraphQlHelper:
533
532
  async def call_get_location_query(self, location_hilo_id: str) -> None:
534
533
  """This functions calls the digital-twin and requests location id"""
535
534
  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)
535
+ url = f"https://{PLATFORM_HOST}/api/digital-twin/v3/graphql"
536
+ headers = {"Authorization": f"Bearer {access_token}"}
537
+
538
+ query = self.QUERY_GET_LOCATION
539
+ query_hash = hashlib.sha256(query.encode("utf-8")).hexdigest()
540
+
541
+ payload = {
542
+ "extensions": {
543
+ "persistedQuery": {
544
+ "version": 1,
545
+ "sha256Hash": query_hash,
546
+ }
547
+ },
548
+ "variables": {"locationHiloId": location_hilo_id},
549
+ }
550
+
551
+ async with httpx.AsyncClient(http2=True) as client:
552
+ try:
553
+ response = await client.post(url, json=payload, headers=headers)
554
+ response.raise_for_status()
555
+ response_json = response.json()
556
+ except Exception as e:
557
+ LOG.error("Error parsing response: %s", e)
558
+ return
542
559
 
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)
560
+ if "errors" in response_json:
561
+ for error in response_json["errors"]:
562
+ if error.get("message") == "PersistedQueryNotFound":
563
+ payload["query"] = query
564
+ try:
565
+ response = await client.post(
566
+ url, json=payload, headers=headers
567
+ )
568
+ response.raise_for_status()
569
+ response_json = response.json()
570
+ except Exception as e:
571
+ LOG.error("Error parsing response on retry: %s", e)
572
+ return
573
+ break
574
+
575
+ if "errors" in response_json:
576
+ LOG.error("GraphQL errors: %s", response_json["errors"])
577
+ return
578
+
579
+ if "data" in response_json:
580
+ self._handle_query_result(response_json["data"])
548
581
 
549
582
  async def subscribe_to_device_updated(
550
- self, location_hilo_id: str, callback: callable = None
583
+ self,
584
+ location_hilo_id: str,
585
+ callback: Optional[Callable[[str], None]] = None,
551
586
  ) -> None:
552
587
  LOG.debug("subscribe_to_device_updated called")
588
+ await self._listen_to_sse(
589
+ self.SUBSCRIPTION_DEVICE_UPDATED,
590
+ {"locationHiloId": location_hilo_id},
591
+ self._handle_device_subscription_result,
592
+ callback,
593
+ location_hilo_id,
594
+ )
595
+
596
+ async def subscribe_to_location_updated(
597
+ self,
598
+ location_hilo_id: str,
599
+ callback: Optional[Callable[[str], None]] = None,
600
+ ) -> None:
601
+ LOG.debug("subscribe_to_location_updated called")
602
+ await self._listen_to_sse(
603
+ self.SUBSCRIPTION_LOCATION_UPDATED,
604
+ {"locationHiloId": location_hilo_id},
605
+ self._handle_location_subscription_result,
606
+ callback,
607
+ location_hilo_id,
608
+ )
553
609
 
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)
610
+ async def _listen_to_sse(
611
+ self,
612
+ query: str,
613
+ variables: Dict[str, Any],
614
+ handler: Callable[[Dict[str, Any]], str],
615
+ callback: Optional[Callable[[str], None]] = None,
616
+ location_hilo_id: Optional[str] = None,
617
+ ) -> None:
618
+ query_hash = hashlib.sha256(query.encode("utf-8")).hexdigest()
619
+ payload: Dict[str, Any] = {
620
+ "extensions": {
621
+ "persistedQuery": {
622
+ "version": 1,
623
+ "sha256Hash": query_hash,
624
+ }
625
+ },
626
+ "variables": variables,
627
+ }
628
+
629
+ while True:
570
630
  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)
631
+ access_token = await self._get_access_token()
632
+ url = f"https://{PLATFORM_HOST}/api/digital-twin/v3/graphql"
633
+ headers = {"Authorization": f"Bearer {access_token}"}
634
+
635
+ retry_with_full_query = False
636
+
637
+ async with httpx.AsyncClient(http2=True, timeout=None) as client:
638
+ async with aconnect_sse(
639
+ client, "POST", url, json=payload, headers=headers
640
+ ) as event_source:
641
+ async for sse in event_source.aiter_sse():
642
+ if not sse.data:
643
+ continue
644
+ try:
645
+ data = json.loads(sse.data)
646
+ except json.JSONDecodeError:
647
+ continue
648
+
649
+ if "errors" in data:
650
+ if any(
651
+ e.get("message") == "PersistedQueryNotFound"
652
+ for e in data["errors"]
653
+ ):
654
+ retry_with_full_query = True
655
+ break
656
+ LOG.error(
657
+ "GraphQL Subscription Errors: %s", data["errors"]
658
+ )
659
+ continue
660
+
661
+ if "data" in data:
662
+ LOG.debug(
663
+ "Received subscription result %s", data["data"]
664
+ )
665
+ handler_result = handler(data["data"])
666
+ if callback:
667
+ callback(handler_result)
668
+
669
+ if retry_with_full_query:
670
+ payload["query"] = query
671
+ continue
672
+
582
673
  except Exception as e:
583
674
  LOG.debug(
584
- "subscribe_to_device_updated: Connection lost: %s. Reconnecting in 5 seconds...",
585
- e,
675
+ "Subscription connection lost: %s. Reconnecting in 5 seconds...", e
586
676
  )
587
677
  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
- )
678
+ # Reset payload to APQ only on reconnect
679
+ if "query" in payload:
680
+ del payload["query"]
599
681
 
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)
682
+ if location_hilo_id:
683
+ try:
684
+ await self.call_get_location_query(location_hilo_id)
685
+ LOG.debug("call_get_location_query success after reconnect")
686
+ except Exception as e2:
687
+ LOG.error(
688
+ "exception while RE-connecting, retrying: %s",
689
+ e2,
690
+ )
621
691
 
622
692
  async def _get_access_token(self) -> str:
623
693
  """Get the access token."""
@@ -625,22 +695,22 @@ class GraphQlHelper:
625
695
 
626
696
  def _handle_query_result(self, result: Dict[str, Any]) -> None:
627
697
  """This receives query results and maps them to the proper device."""
628
- devices_values: list[any] = result["getLocation"]["devices"]
698
+ devices_values: List[Dict[str, Any]] = result["getLocation"]["devices"]
629
699
  attributes = self.mapper.map_query_values(devices_values)
630
700
  self._devices.parse_values_received(attributes)
631
701
 
632
702
  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)
703
+ device_value: Dict[str, Any] = result["onAnyDeviceUpdated"]["device"]
704
+ attributes = self.mapper.map_device_subscription_values(device_value)
635
705
  updated_device = self._devices.parse_values_received(attributes)
636
706
  # callback to update the device in the UI
637
707
  LOG.debug("Device updated: %s", updated_device)
638
- return devices_values.get("hiloId")
708
+ return str(device_value.get("hiloId"))
639
709
 
640
710
  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)
711
+ location_value: Dict[str, Any] = result["onAnyLocationUpdated"]["location"]
712
+ attributes = self.mapper.map_location_subscription_values(location_value)
643
713
  updated_device = self._devices.parse_values_received(attributes)
644
714
  # callback to update the device in the UI
645
715
  LOG.debug("Device updated: %s", updated_device)
646
- return devices_values.get("hiloId")
716
+ 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.1"
43
+ version = "2026.3.1"
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.20.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