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.
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/PKG-INFO +3 -3
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/__init__.py +1 -0
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/api.py +94 -20
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/const.py +9 -4
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/__init__.py +5 -5
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/climate.py +1 -0
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/graphql_value_mapper.py +5 -4
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/devices.py +75 -11
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/event.py +2 -2
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/exceptions.py +1 -0
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/graphql.py +170 -85
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/util/__init__.py +1 -0
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/util/state.py +69 -22
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/websocket.py +11 -8
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyproject.toml +3 -3
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/LICENSE +0 -0
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/README.md +0 -0
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/light.py +0 -0
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/sensor.py +0 -0
- {python_hilo-2026.1.2 → python_hilo-2026.3.2}/pyhilo/device/switch.py +0 -0
- {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.
|
|
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:
|
|
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
|
|
|
@@ -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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
546
|
-
"""
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
for
|
|
554
|
-
|
|
555
|
-
|
|
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.
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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}>"
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,11 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
from
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
"
|
|
585
|
-
e,
|
|
690
|
+
"Subscription connection lost: %s. Reconnecting in 5 seconds...", e
|
|
586
691
|
)
|
|
587
692
|
await asyncio.sleep(5)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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:
|
|
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
|
-
|
|
634
|
-
attributes = self.mapper.map_device_subscription_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
|
|
723
|
+
return str(device_value.get("hiloId"))
|
|
639
724
|
|
|
640
725
|
def _handle_location_subscription_result(self, result: Dict[str, Any]) -> str:
|
|
641
|
-
|
|
642
|
-
attributes = self.mapper.map_location_subscription_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
|
|
731
|
+
return str(location_value.get("hiloId"))
|
|
@@ -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]) ->
|
|
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
|
-
|
|
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)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
316
|
+
LOG.error("Unable to connect to WS server %s", err)
|
|
314
317
|
raise CannotConnectError(err) from err
|
|
315
318
|
|
|
316
|
-
LOG.info(
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|