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.
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/PKG-INFO +3 -3
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/__init__.py +1 -0
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/api.py +12 -10
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/const.py +11 -3
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/__init__.py +5 -5
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/climate.py +1 -0
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/graphql_value_mapper.py +5 -4
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/devices.py +1 -1
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/event.py +2 -2
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/exceptions.py +1 -0
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/graphql.py +156 -86
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/util/__init__.py +1 -0
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/util/state.py +69 -22
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/websocket.py +11 -8
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyproject.toml +3 -3
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/LICENSE +0 -0
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/README.md +0 -0
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/light.py +0 -0
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/sensor.py +0 -0
- {python_hilo-2026.1.1 → python_hilo-2026.3.1}/pyhilo/device/switch.py +0 -0
- {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.
|
|
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:
|
|
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
|
|
|
@@ -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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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],
|
|
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.
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
)
|
|
@@ -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(
|
|
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(
|
|
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,10 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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
|
-
|
|
7
|
-
from
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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,
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
"
|
|
585
|
-
e,
|
|
675
|
+
"Subscription connection lost: %s. Reconnecting in 5 seconds...", e
|
|
586
676
|
)
|
|
587
677
|
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
|
-
)
|
|
678
|
+
# Reset payload to APQ only on reconnect
|
|
679
|
+
if "query" in payload:
|
|
680
|
+
del payload["query"]
|
|
599
681
|
|
|
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)
|
|
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:
|
|
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
|
-
|
|
634
|
-
attributes = self.mapper.map_device_subscription_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
|
|
708
|
+
return str(device_value.get("hiloId"))
|
|
639
709
|
|
|
640
710
|
def _handle_location_subscription_result(self, result: Dict[str, Any]) -> str:
|
|
641
|
-
|
|
642
|
-
attributes = self.mapper.map_location_subscription_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
|
|
716
|
+
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.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|