python-hilo 2024.10.2__tar.gz → 2025.2.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-2024.10.2 → python_hilo-2025.2.2}/PKG-INFO +3 -4
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/api.py +53 -28
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/const.py +3 -1
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/event.py +29 -2
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/util/__init__.py +5 -1
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/websocket.py +162 -9
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyproject.toml +4 -4
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/LICENSE +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/README.md +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/__init__.py +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/device/__init__.py +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/device/climate.py +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/device/light.py +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/device/sensor.py +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/device/switch.py +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/devices.py +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/exceptions.py +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/oauth2.py +0 -0
- {python_hilo-2024.10.2 → python_hilo-2025.2.2}/pyhilo/util/state.py +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: python-hilo
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2025.2.2
|
|
4
4
|
Summary: A Python3, async interface to the Hilo API
|
|
5
|
-
Home-page: https://github.com/dvd-dev/python-hilo
|
|
6
5
|
License: MIT
|
|
7
6
|
Author: David Vallee Delisle
|
|
8
7
|
Author-email: me@dvd.dev
|
|
@@ -30,7 +29,7 @@ Requires-Dist: backoff (>=1.11.1)
|
|
|
30
29
|
Requires-Dist: python-dateutil (>=2.8.2)
|
|
31
30
|
Requires-Dist: ruyaml (>=0.91.0)
|
|
32
31
|
Requires-Dist: voluptuous (>=0.13.1)
|
|
33
|
-
Requires-Dist: websockets (>=8.1,<
|
|
32
|
+
Requires-Dist: websockets (>=8.1,<16.0)
|
|
34
33
|
Project-URL: Repository, https://github.com/dvd-dev/python-hilo
|
|
35
34
|
Description-Content-Type: text/markdown
|
|
36
35
|
|
|
@@ -27,7 +27,7 @@ from pyhilo.const import (
|
|
|
27
27
|
API_NOTIFICATIONS_ENDPOINT,
|
|
28
28
|
API_REGISTRATION_ENDPOINT,
|
|
29
29
|
API_REGISTRATION_HEADERS,
|
|
30
|
-
|
|
30
|
+
AUTOMATION_CHALLENGE_ENDPOINT,
|
|
31
31
|
DEFAULT_STATE_FILE,
|
|
32
32
|
DEFAULT_USER_AGENT,
|
|
33
33
|
FB_APP_ID,
|
|
@@ -51,7 +51,7 @@ from pyhilo.util.state import (
|
|
|
51
51
|
get_state,
|
|
52
52
|
set_state,
|
|
53
53
|
)
|
|
54
|
-
from pyhilo.websocket import WebsocketClient
|
|
54
|
+
from pyhilo.websocket import WebsocketClient, WebsocketManager
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
class API:
|
|
@@ -81,9 +81,17 @@ class API:
|
|
|
81
81
|
self.device_attributes = get_device_attributes()
|
|
82
82
|
self.session: ClientSession = session
|
|
83
83
|
self._oauth_session = oauth_session
|
|
84
|
+
self.websocket_devices: WebsocketClient
|
|
85
|
+
# Backward compatibility during transition to websocket for challenges. Currently the HA Hilo integration
|
|
86
|
+
# uses the .websocket attribute. Re-added this attribute and point to the same object as websocket_devices.
|
|
87
|
+
# Should be removed once the transition to the challenge websocket is completed everywhere.
|
|
84
88
|
self.websocket: WebsocketClient
|
|
89
|
+
self.websocket_challenges: WebsocketClient
|
|
85
90
|
self.log_traces = log_traces
|
|
86
91
|
self._get_device_callbacks: list[Callable[..., Any]] = []
|
|
92
|
+
self.ws_url: str = ""
|
|
93
|
+
self.ws_token: str = ""
|
|
94
|
+
self.endpoint: str = ""
|
|
87
95
|
|
|
88
96
|
@classmethod
|
|
89
97
|
async def async_create(
|
|
@@ -132,6 +140,9 @@ class API:
|
|
|
132
140
|
if not self._oauth_session.valid_token:
|
|
133
141
|
await self._oauth_session.async_ensure_token_valid()
|
|
134
142
|
|
|
143
|
+
access_token = str(self._oauth_session.token["access_token"])
|
|
144
|
+
LOG.debug(f"ic-dev21 access token is {access_token}")
|
|
145
|
+
|
|
135
146
|
return str(self._oauth_session.token["access_token"])
|
|
136
147
|
|
|
137
148
|
def dev_atts(
|
|
@@ -216,6 +227,8 @@ class API:
|
|
|
216
227
|
:rtype: dict[str, Any]
|
|
217
228
|
"""
|
|
218
229
|
kwargs.setdefault("headers", self.headers)
|
|
230
|
+
access_token = await self.async_get_access_token()
|
|
231
|
+
|
|
219
232
|
if endpoint.startswith(API_REGISTRATION_ENDPOINT):
|
|
220
233
|
kwargs["headers"] = {**kwargs["headers"], **API_REGISTRATION_HEADERS}
|
|
221
234
|
if endpoint.startswith(FB_INSTALL_ENDPOINT):
|
|
@@ -223,10 +236,15 @@ class API:
|
|
|
223
236
|
if endpoint.startswith(ANDROID_CLIENT_ENDPOINT):
|
|
224
237
|
kwargs["headers"] = {**kwargs["headers"], **ANDROID_CLIENT_HEADERS}
|
|
225
238
|
if host == API_HOSTNAME:
|
|
226
|
-
access_token = await self.async_get_access_token()
|
|
227
239
|
kwargs["headers"]["authorization"] = f"Bearer {access_token}"
|
|
228
240
|
kwargs["headers"]["Host"] = host
|
|
229
241
|
|
|
242
|
+
# ic-dev21 trying Leicas suggestion
|
|
243
|
+
if endpoint.startswith(AUTOMATION_CHALLENGE_ENDPOINT):
|
|
244
|
+
# remove Ocp-Apim-Subscription-Key header to avoid 401 error
|
|
245
|
+
kwargs["headers"].pop("Ocp-Apim-Subscription-Key", None)
|
|
246
|
+
kwargs["headers"]["authorization"] = f"Bearer {access_token}"
|
|
247
|
+
|
|
230
248
|
data: dict[str, Any] = {}
|
|
231
249
|
url = parse.urljoin(f"https://{host}", endpoint)
|
|
232
250
|
if self.log_traces:
|
|
@@ -303,8 +321,9 @@ class API:
|
|
|
303
321
|
LOG.info(
|
|
304
322
|
"401 detected on websocket, refreshing websocket token. Old url: {self.ws_url} Old Token: {self.ws_token}"
|
|
305
323
|
)
|
|
324
|
+
LOG.info(f"401 detected on {err.request_info.url}")
|
|
306
325
|
async with self._backoff_refresh_lock_ws:
|
|
307
|
-
|
|
326
|
+
await self.refresh_ws_token()
|
|
308
327
|
await self.get_websocket_params()
|
|
309
328
|
return
|
|
310
329
|
|
|
@@ -354,33 +373,31 @@ class API:
|
|
|
354
373
|
LOG.debug("Websocket postinit")
|
|
355
374
|
await self._get_fid()
|
|
356
375
|
await self._get_device_token()
|
|
357
|
-
await self.refresh_ws_token()
|
|
358
|
-
self.websocket = WebsocketClient(self)
|
|
359
376
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
async def post_devicehub_negociate(self) -> tuple[str, str]:
|
|
365
|
-
LOG.debug("Getting websocket url")
|
|
366
|
-
url = f"{AUTOMATION_DEVICEHUB_ENDPOINT}/negotiate"
|
|
367
|
-
resp = await self.async_request("post", url)
|
|
368
|
-
ws_url = resp.get("url")
|
|
369
|
-
ws_token = resp.get("accessToken")
|
|
370
|
-
LOG.debug("Calling set_state")
|
|
371
|
-
await set_state(
|
|
372
|
-
self._state_yaml,
|
|
373
|
-
"websocket",
|
|
374
|
-
{
|
|
375
|
-
"url": ws_url,
|
|
376
|
-
"token": ws_token,
|
|
377
|
-
},
|
|
377
|
+
# Initialize WebsocketManager ic-dev21
|
|
378
|
+
self.websocket_manager = WebsocketManager(
|
|
379
|
+
self.session, self.async_request, self._state_yaml, set_state
|
|
378
380
|
)
|
|
379
|
-
|
|
381
|
+
await self.websocket_manager.initialize_websockets()
|
|
382
|
+
|
|
383
|
+
# Create both websocket clients
|
|
384
|
+
# ic-dev21 need to work on this as it can't lint as is, may need to
|
|
385
|
+
# instantiate differently
|
|
386
|
+
self.websocket_devices = WebsocketClient(self.websocket_manager.devicehub)
|
|
387
|
+
|
|
388
|
+
# For backward compatibility during the transition to challengehub websocket
|
|
389
|
+
self.websocket = self.websocket_devices
|
|
390
|
+
self.websocket_challenges = WebsocketClient(self.websocket_manager.challengehub)
|
|
391
|
+
|
|
392
|
+
async def refresh_ws_token(self) -> None:
|
|
393
|
+
"""Refresh the websocket token."""
|
|
394
|
+
await self.websocket_manager.refresh_token(self.websocket_manager.devicehub)
|
|
395
|
+
await self.websocket_manager.refresh_token(self.websocket_manager.challengehub)
|
|
380
396
|
|
|
381
397
|
async def get_websocket_params(self) -> None:
|
|
382
398
|
uri = parse.urlparse(self.ws_url)
|
|
383
399
|
LOG.debug("Getting websocket params")
|
|
400
|
+
LOG.debug(f"Getting uri {uri}")
|
|
384
401
|
resp: dict[str, Any] = await self.async_request(
|
|
385
402
|
"post",
|
|
386
403
|
f"{uri.path}negotiate?{uri.query}",
|
|
@@ -391,6 +408,7 @@ class API:
|
|
|
391
408
|
)
|
|
392
409
|
conn_id: str = resp.get("connectionId", "")
|
|
393
410
|
self.full_ws_url = f"{self.ws_url}&id={conn_id}&access_token={self.ws_token}"
|
|
411
|
+
LOG.debug(f"Getting full ws URL {self.full_ws_url}")
|
|
394
412
|
transport_dict: list[WebsocketTransportsDict] = resp.get(
|
|
395
413
|
"availableTransports", []
|
|
396
414
|
)
|
|
@@ -399,7 +417,7 @@ class API:
|
|
|
399
417
|
"available_transports": transport_dict,
|
|
400
418
|
"full_ws_url": self.full_ws_url,
|
|
401
419
|
}
|
|
402
|
-
LOG.debug("Calling set_state")
|
|
420
|
+
LOG.debug("Calling set_state websocket_params")
|
|
403
421
|
await set_state(self._state_yaml, "websocket", websocket_dict)
|
|
404
422
|
|
|
405
423
|
async def fb_install(self, fb_id: str) -> None:
|
|
@@ -425,7 +443,7 @@ class API:
|
|
|
425
443
|
raise RequestError(err) from err
|
|
426
444
|
LOG.debug(f"FB Install data: {resp}")
|
|
427
445
|
auth_token = resp.get("authToken", {})
|
|
428
|
-
LOG.debug("Calling set_state")
|
|
446
|
+
LOG.debug("Calling set_state fb_install")
|
|
429
447
|
await set_state(
|
|
430
448
|
self._state_yaml,
|
|
431
449
|
"firebase",
|
|
@@ -467,7 +485,7 @@ class API:
|
|
|
467
485
|
LOG.error(f"Android registration error: {msg}")
|
|
468
486
|
raise RequestError
|
|
469
487
|
token = msg.split("=")[-1]
|
|
470
|
-
LOG.debug("Calling set_state")
|
|
488
|
+
LOG.debug("Calling set_state android_register")
|
|
471
489
|
await set_state(
|
|
472
490
|
self._state_yaml,
|
|
473
491
|
"android",
|
|
@@ -478,12 +496,14 @@ class API:
|
|
|
478
496
|
|
|
479
497
|
async def get_location_id(self) -> int:
|
|
480
498
|
url = f"{API_AUTOMATION_ENDPOINT}/Locations"
|
|
499
|
+
LOG.debug(f"LocationId URL is {url}")
|
|
481
500
|
req: list[dict[str, Any]] = await self.async_request("get", url)
|
|
482
501
|
return int(req[0]["id"])
|
|
483
502
|
|
|
484
503
|
async def get_devices(self, location_id: int) -> list[dict[str, Any]]:
|
|
485
504
|
"""Get list of all devices"""
|
|
486
505
|
url = self._get_url("Devices", location_id)
|
|
506
|
+
LOG.debug(f"Devices URL is {url}")
|
|
487
507
|
devices: list[dict[str, Any]] = await self.async_request("get", url)
|
|
488
508
|
devices.append(await self.get_gateway(location_id))
|
|
489
509
|
# Now it's time to add devices coming from external sources like hass
|
|
@@ -499,6 +519,7 @@ class API:
|
|
|
499
519
|
value: Union[str, float, int, None],
|
|
500
520
|
) -> None:
|
|
501
521
|
url = self._get_url(f"Devices/{device.id}/Attributes", device.location_id)
|
|
522
|
+
LOG.debug(f"Device Attribute URL is {url}")
|
|
502
523
|
await self.async_request("put", url, json={key.hilo_attribute: value})
|
|
503
524
|
|
|
504
525
|
async def get_event_notifications(self, location_id: int) -> dict[str, Any]:
|
|
@@ -526,6 +547,7 @@ class API:
|
|
|
526
547
|
"viewed": false
|
|
527
548
|
}"""
|
|
528
549
|
url = self._get_url(None, location_id, events=True)
|
|
550
|
+
LOG.debug(f"Event Notifications URL is {url}")
|
|
529
551
|
return cast(dict[str, Any], await self.async_request("get", url))
|
|
530
552
|
|
|
531
553
|
async def get_gd_events(
|
|
@@ -597,6 +619,8 @@ class API:
|
|
|
597
619
|
url += "?active=true"
|
|
598
620
|
else:
|
|
599
621
|
url += f"/{event_id}"
|
|
622
|
+
|
|
623
|
+
LOG.debug(f"get_gd_events URL is {url}")
|
|
600
624
|
return cast(dict[str, Any], await self.async_request("get", url))
|
|
601
625
|
|
|
602
626
|
async def get_seasons(self, location_id: int) -> dict[str, Any]:
|
|
@@ -624,6 +648,7 @@ class API:
|
|
|
624
648
|
|
|
625
649
|
async def get_gateway(self, location_id: int) -> dict[str, Any]:
|
|
626
650
|
url = self._get_url("Gateways/Info", location_id)
|
|
651
|
+
LOG.debug(f"Gateway URL is {url}")
|
|
627
652
|
req = await self.async_request("get", url)
|
|
628
653
|
saved_attrs = [
|
|
629
654
|
"zigBeePairingActivated",
|
|
@@ -8,7 +8,7 @@ import homeassistant.core
|
|
|
8
8
|
LOG: Final = logging.getLogger(__package__)
|
|
9
9
|
DEFAULT_STATE_FILE: Final = "hilo_state.yaml"
|
|
10
10
|
REQUEST_RETRY: Final = 9
|
|
11
|
-
PYHILO_VERSION: Final = "
|
|
11
|
+
PYHILO_VERSION: Final = "2025.2.02"
|
|
12
12
|
# TODO: Find a way to keep previous line in sync with pyproject.toml automatically
|
|
13
13
|
|
|
14
14
|
CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded"
|
|
@@ -42,6 +42,8 @@ API_REGISTRATION_HEADERS: Final = {
|
|
|
42
42
|
|
|
43
43
|
# Automation server constant
|
|
44
44
|
AUTOMATION_DEVICEHUB_ENDPOINT: Final = "/DeviceHub"
|
|
45
|
+
AUTOMATION_CHALLENGE_ENDPOINT: Final = "/ChallengeHub"
|
|
46
|
+
|
|
45
47
|
|
|
46
48
|
# Request constants
|
|
47
49
|
DEFAULT_USER_AGENT: Final = f"PyHilo/{PYHILO_VERSION} HomeAssistant/{homeassistant.core.__version__} aiohttp/{aiohttp.__version__} Python/{platform.python_version()}"
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
"""Event object """
|
|
2
2
|
from datetime import datetime, timedelta, timezone
|
|
3
|
+
import logging
|
|
3
4
|
import re
|
|
4
5
|
from typing import Any, cast
|
|
5
6
|
|
|
6
7
|
from pyhilo.util import camel_to_snake, from_utc_timestamp
|
|
7
8
|
|
|
9
|
+
LOG = logging.getLogger(__package__)
|
|
10
|
+
|
|
8
11
|
|
|
9
12
|
class Event:
|
|
13
|
+
"""This class is used to populate the data of a Hilo Challenge Event, contains datetime info and consumption data"""
|
|
10
14
|
setting_deadline: datetime
|
|
11
15
|
pre_cold_start: datetime
|
|
12
16
|
pre_cold_end: datetime
|
|
@@ -59,6 +63,20 @@ class Event:
|
|
|
59
63
|
"last_update",
|
|
60
64
|
]
|
|
61
65
|
|
|
66
|
+
def update_wh(self, used_wH: float) -> None:
|
|
67
|
+
"""This function is used to update the used_kWh attribute during a Hilo Challenge Event"""
|
|
68
|
+
LOG.debug(f"Updating Wh: {used_wH}")
|
|
69
|
+
self.used_kWh = round(used_wH / 1000, 2)
|
|
70
|
+
self.last_update = datetime.now(timezone.utc).astimezone()
|
|
71
|
+
|
|
72
|
+
def should_check_for_allowed_wh(self) -> bool:
|
|
73
|
+
"""This function is used to authorize subscribing to a specific event in Hilo to receive the allowed_kWh
|
|
74
|
+
that is made available in the pre_heat phase"""
|
|
75
|
+
now = datetime.now(self.preheat_start.tzinfo)
|
|
76
|
+
time_since_preheat_start = (self.preheat_start - now).total_seconds()
|
|
77
|
+
already_has_allowed_wh = self.allowed_kWh > 0
|
|
78
|
+
return 1800 <= time_since_preheat_start <= 2700 and not already_has_allowed_wh
|
|
79
|
+
|
|
62
80
|
def as_dict(self) -> dict[str, Any]:
|
|
63
81
|
rep = {k: getattr(self, k) for k in self.dict_items}
|
|
64
82
|
rep["phases"] = {k: getattr(self, k) for k in self.phases_list}
|
|
@@ -66,6 +84,7 @@ class Event:
|
|
|
66
84
|
return rep
|
|
67
85
|
|
|
68
86
|
def _convert_phases(self, phases: dict[str, Any]) -> None:
|
|
87
|
+
"""Formats phase times for later use"""
|
|
69
88
|
self.phases_list = []
|
|
70
89
|
for key, value in phases.items():
|
|
71
90
|
phase_match = re.match(r"(.*)(DateUTC|Utc)", key)
|
|
@@ -86,6 +105,7 @@ class Event:
|
|
|
86
105
|
def _create_phases(
|
|
87
106
|
self, hours: int, phase_name: str, parent_phase: str
|
|
88
107
|
) -> datetime:
|
|
108
|
+
"""Creates optional "appreciation" and "pre_cold" phases according to Hilo phases datetimes"""
|
|
89
109
|
parent_start = getattr(self, f"{parent_phase}_start")
|
|
90
110
|
phase_start = f"{phase_name}_start"
|
|
91
111
|
phase_end = f"{phase_name}_end"
|
|
@@ -125,10 +145,14 @@ class Event:
|
|
|
125
145
|
|
|
126
146
|
@property
|
|
127
147
|
def state(self) -> str:
|
|
148
|
+
"""Defines state in the next_event attribute"""
|
|
128
149
|
now = datetime.now(self.preheat_start.tzinfo)
|
|
129
|
-
if self.pre_cold_start <= now < self.pre_cold_end:
|
|
150
|
+
if self.pre_cold_start and self.pre_cold_start <= now < self.pre_cold_end:
|
|
130
151
|
return "pre_cold"
|
|
131
|
-
elif
|
|
152
|
+
elif (
|
|
153
|
+
self.appreciation_start
|
|
154
|
+
and self.appreciation_start <= now < self.appreciation_end
|
|
155
|
+
):
|
|
132
156
|
return "appreciation"
|
|
133
157
|
elif self.preheat_start > now:
|
|
134
158
|
return "scheduled"
|
|
@@ -138,9 +162,12 @@ class Event:
|
|
|
138
162
|
return "reduction"
|
|
139
163
|
elif self.recovery_start <= now < self.recovery_end:
|
|
140
164
|
return "recovery"
|
|
165
|
+
elif now >= self.recovery_end + timedelta(minutes=5):
|
|
166
|
+
return "off"
|
|
141
167
|
elif now >= self.recovery_end:
|
|
142
168
|
return "completed"
|
|
143
169
|
elif self.progress:
|
|
144
170
|
return self.progress
|
|
171
|
+
|
|
145
172
|
else:
|
|
146
173
|
return "unknown"
|
|
@@ -35,7 +35,11 @@ def snake_to_camel(string: str) -> str:
|
|
|
35
35
|
def from_utc_timestamp(date_string: str) -> datetime:
|
|
36
36
|
from_zone = tz.tzutc()
|
|
37
37
|
to_zone = tz.tzlocal()
|
|
38
|
-
|
|
38
|
+
dt = parse(date_string)
|
|
39
|
+
if dt.tzinfo is None: # Only replace tzinfo if not already set
|
|
40
|
+
dt = dt.replace(tzinfo=from_zone)
|
|
41
|
+
output = dt.astimezone(to_zone)
|
|
42
|
+
return output
|
|
39
43
|
|
|
40
44
|
|
|
41
45
|
def time_diff(ts1: datetime, ts2: datetime) -> timedelta:
|
|
@@ -7,9 +7,10 @@ from datetime import datetime, timedelta
|
|
|
7
7
|
from enum import IntEnum
|
|
8
8
|
import json
|
|
9
9
|
from os import environ
|
|
10
|
-
from typing import TYPE_CHECKING, Any, Callable, Dict
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
|
|
11
|
+
from urllib import parse
|
|
11
12
|
|
|
12
|
-
from aiohttp import ClientWebSocketResponse, WSMsgType
|
|
13
|
+
from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType
|
|
13
14
|
from aiohttp.client_exceptions import (
|
|
14
15
|
ClientError,
|
|
15
16
|
ServerDisconnectedError,
|
|
@@ -17,7 +18,12 @@ from aiohttp.client_exceptions import (
|
|
|
17
18
|
)
|
|
18
19
|
from yarl import URL
|
|
19
20
|
|
|
20
|
-
from pyhilo.const import
|
|
21
|
+
from pyhilo.const import (
|
|
22
|
+
AUTOMATION_CHALLENGE_ENDPOINT,
|
|
23
|
+
AUTOMATION_DEVICEHUB_ENDPOINT,
|
|
24
|
+
DEFAULT_USER_AGENT,
|
|
25
|
+
LOG,
|
|
26
|
+
)
|
|
21
27
|
from pyhilo.exceptions import (
|
|
22
28
|
CannotConnectError,
|
|
23
29
|
ConnectionClosedError,
|
|
@@ -208,16 +214,19 @@ class WebsocketClient:
|
|
|
208
214
|
|
|
209
215
|
if self._api.log_traces:
|
|
210
216
|
LOG.debug(
|
|
211
|
-
f"[TRACE] Sending data to websocket
|
|
217
|
+
f"[TRACE] Sending data to websocket {self._api.endpoint} : {json.dumps(payload)}"
|
|
212
218
|
)
|
|
213
219
|
# Hilo added a control character (chr(30)) at the end of each payload they send.
|
|
214
220
|
# They also expect this char to be there at the end of every payload we send them.
|
|
221
|
+
LOG.debug(f"ic-dev21 send_json {payload}")
|
|
215
222
|
await self._client.send_str(json.dumps(payload) + chr(30))
|
|
216
223
|
|
|
217
224
|
def _parse_message(self, msg: dict[str, Any]) -> None:
|
|
218
225
|
"""Parse an incoming message."""
|
|
219
226
|
if self._api.log_traces:
|
|
220
|
-
LOG.debug(
|
|
227
|
+
LOG.debug(
|
|
228
|
+
f"[TRACE] Received message on websocket(_parse_message) {self._api.endpoint}: {msg}"
|
|
229
|
+
)
|
|
221
230
|
if msg.get("type") == SignalRMsgType.PING:
|
|
222
231
|
schedule_callback(self._async_pong)
|
|
223
232
|
return
|
|
@@ -247,7 +256,7 @@ class WebsocketClient:
|
|
|
247
256
|
return self._add_callback(self._disconnect_callbacks, callback)
|
|
248
257
|
|
|
249
258
|
def add_event_callback(self, callback: Callable[..., Any]) -> Callable[..., None]:
|
|
250
|
-
"""Add a callback
|
|
259
|
+
"""Add a callback to be called upon receiving an event.
|
|
251
260
|
Note that callbacks should expect to receive a WebsocketEvent object as a
|
|
252
261
|
parameter.
|
|
253
262
|
:param callback: The method to call after receiving an event.
|
|
@@ -261,7 +270,7 @@ class WebsocketClient:
|
|
|
261
270
|
LOG.debug("Websocket: async_connect() called but already connected")
|
|
262
271
|
return
|
|
263
272
|
|
|
264
|
-
LOG.info("Websocket: Connecting to server")
|
|
273
|
+
LOG.info("Websocket: Connecting to server %s", self._api.endpoint)
|
|
265
274
|
if self._api.log_traces:
|
|
266
275
|
LOG.debug(f"[TRACE] Websocket URL: {self._api.full_ws_url}")
|
|
267
276
|
headers = {
|
|
@@ -281,7 +290,7 @@ class WebsocketClient:
|
|
|
281
290
|
try:
|
|
282
291
|
self._client = await self._api.session.ws_connect(
|
|
283
292
|
URL(
|
|
284
|
-
self._api.full_ws_url
|
|
293
|
+
self._api.full_ws_url,
|
|
285
294
|
encoded=True,
|
|
286
295
|
),
|
|
287
296
|
heartbeat=55,
|
|
@@ -296,7 +305,7 @@ class WebsocketClient:
|
|
|
296
305
|
LOG.error(f"Unable to connect to WS server {err}")
|
|
297
306
|
raise CannotConnectError(err) from err
|
|
298
307
|
|
|
299
|
-
LOG.info("Connected to websocket server")
|
|
308
|
+
LOG.info(f"Connected to websocket server {self._api.endpoint}")
|
|
300
309
|
self._watchdog.trigger()
|
|
301
310
|
for callback in self._connect_callbacks:
|
|
302
311
|
schedule_callback(callback)
|
|
@@ -368,6 +377,9 @@ class WebsocketClient:
|
|
|
368
377
|
except asyncio.TimeoutError:
|
|
369
378
|
return
|
|
370
379
|
self._ready_event.clear()
|
|
380
|
+
LOG.debug(
|
|
381
|
+
f"ic-dev21 invoke argument: {arg}, invocationId: {inv_id}, target: {target}, type: {type}"
|
|
382
|
+
)
|
|
371
383
|
await self._async_send_json(
|
|
372
384
|
{
|
|
373
385
|
"arguments": arg,
|
|
@@ -376,3 +388,144 @@ class WebsocketClient:
|
|
|
376
388
|
"type": inv_type,
|
|
377
389
|
}
|
|
378
390
|
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass
|
|
394
|
+
class WebsocketConfig:
|
|
395
|
+
"""Configuration for a websocket connection"""
|
|
396
|
+
|
|
397
|
+
endpoint: str
|
|
398
|
+
url: Optional[str] = None
|
|
399
|
+
token: Optional[str] = None
|
|
400
|
+
connection_id: Optional[str] = None
|
|
401
|
+
full_ws_url: Optional[str] = None
|
|
402
|
+
log_traces: bool = True
|
|
403
|
+
session: ClientSession | None = None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class WebsocketManager:
|
|
407
|
+
"""Manages multiple websocket connections for the Hilo API"""
|
|
408
|
+
|
|
409
|
+
def __init__(
|
|
410
|
+
self,
|
|
411
|
+
session: ClientSession,
|
|
412
|
+
async_request: Callable[..., Any],
|
|
413
|
+
state_yaml: str,
|
|
414
|
+
set_state_callback: Callable[..., Any],
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Initialize the websocket manager.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
session: The aiohttp client session
|
|
420
|
+
async_request: The async request method from the API class
|
|
421
|
+
state_yaml: Path to the state file
|
|
422
|
+
set_state_callback: Callback to save state
|
|
423
|
+
"""
|
|
424
|
+
self.session = session
|
|
425
|
+
self.async_request = async_request
|
|
426
|
+
self._state_yaml = state_yaml
|
|
427
|
+
self._set_state = set_state_callback
|
|
428
|
+
self._shared_token: Optional[str] = None
|
|
429
|
+
# Initialize websocket configurations, more can be added here
|
|
430
|
+
self.devicehub = WebsocketConfig(
|
|
431
|
+
endpoint=AUTOMATION_DEVICEHUB_ENDPOINT, session=session
|
|
432
|
+
)
|
|
433
|
+
self.challengehub = WebsocketConfig(
|
|
434
|
+
endpoint=AUTOMATION_CHALLENGE_ENDPOINT, session=session
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
async def initialize_websockets(self) -> None:
|
|
438
|
+
"""Initialize both websocket connections"""
|
|
439
|
+
# ic-dev21 get token from device hub
|
|
440
|
+
await self.refresh_token(self.devicehub, get_new_token=True)
|
|
441
|
+
# ic-dev21 get token from challenge hub
|
|
442
|
+
await self.refresh_token(self.challengehub, get_new_token=True)
|
|
443
|
+
|
|
444
|
+
async def refresh_token(
|
|
445
|
+
self, config: WebsocketConfig, get_new_token: bool = True
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Refresh token for a specific websocket configuration.
|
|
448
|
+
Args:
|
|
449
|
+
config: The websocket configuration to refresh
|
|
450
|
+
"""
|
|
451
|
+
if get_new_token:
|
|
452
|
+
config.url, self._shared_token = await self._negotiate(config)
|
|
453
|
+
config.token = self._shared_token
|
|
454
|
+
else:
|
|
455
|
+
config.url, _ = await self._negotiate(config)
|
|
456
|
+
config.token = self._shared_token
|
|
457
|
+
|
|
458
|
+
await self._get_websocket_params(config)
|
|
459
|
+
|
|
460
|
+
async def _negotiate(self, config: WebsocketConfig) -> Tuple[str, str]:
|
|
461
|
+
"""Negotiate websocket connection and get URL and token.
|
|
462
|
+
Args:
|
|
463
|
+
config: The websocket configuration to negotiate
|
|
464
|
+
Returns:
|
|
465
|
+
Tuple containing the websocket URL and access token
|
|
466
|
+
"""
|
|
467
|
+
LOG.debug(f"Getting websocket url for {config.endpoint}")
|
|
468
|
+
url = f"{config.endpoint}/negotiate"
|
|
469
|
+
LOG.debug(f"Negotiate URL is {url}")
|
|
470
|
+
|
|
471
|
+
resp = await self.async_request("post", url)
|
|
472
|
+
ws_url = resp.get("url")
|
|
473
|
+
ws_token = resp.get("accessToken")
|
|
474
|
+
|
|
475
|
+
# Save state
|
|
476
|
+
state_key = (
|
|
477
|
+
"websocketDevices"
|
|
478
|
+
if config.endpoint == AUTOMATION_DEVICEHUB_ENDPOINT
|
|
479
|
+
else "websocketChallenges"
|
|
480
|
+
)
|
|
481
|
+
await self._set_state(
|
|
482
|
+
self._state_yaml,
|
|
483
|
+
state_key,
|
|
484
|
+
{
|
|
485
|
+
"url": ws_url,
|
|
486
|
+
"token": ws_token,
|
|
487
|
+
},
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
return ws_url, ws_token
|
|
491
|
+
|
|
492
|
+
async def _get_websocket_params(self, config: WebsocketConfig) -> None:
|
|
493
|
+
"""Get websocket parameters including connection ID.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
config: The websocket configuration to get parameters for
|
|
497
|
+
"""
|
|
498
|
+
uri = parse.urlparse(config.url)
|
|
499
|
+
LOG.debug(f"Getting websocket params for {config.endpoint}")
|
|
500
|
+
LOG.debug(f"Getting uri {uri}")
|
|
501
|
+
|
|
502
|
+
resp = await self.async_request(
|
|
503
|
+
"post",
|
|
504
|
+
f"{uri.path}negotiate?{uri.query}", # type: ignore
|
|
505
|
+
host=uri.netloc,
|
|
506
|
+
headers={
|
|
507
|
+
"authorization": f"Bearer {config.token}",
|
|
508
|
+
},
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
config.connection_id = resp.get("connectionId", "")
|
|
512
|
+
config.full_ws_url = (
|
|
513
|
+
f"{config.url}&id={config.connection_id}&access_token={config.token}"
|
|
514
|
+
)
|
|
515
|
+
LOG.debug(f"Getting full ws URL {config.full_ws_url}")
|
|
516
|
+
|
|
517
|
+
transport_dict = resp.get("availableTransports", [])
|
|
518
|
+
websocket_dict = {
|
|
519
|
+
"connection_id": config.connection_id,
|
|
520
|
+
"available_transports": transport_dict,
|
|
521
|
+
"full_url": config.full_ws_url,
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
# Save state
|
|
525
|
+
state_key = (
|
|
526
|
+
"websocketDevices"
|
|
527
|
+
if config.endpoint == AUTOMATION_DEVICEHUB_ENDPOINT
|
|
528
|
+
else "websocketChallenges"
|
|
529
|
+
)
|
|
530
|
+
LOG.debug(f"Calling set_state {state_key}_params")
|
|
531
|
+
await self._set_state(self._state_yaml, state_key, websocket_dict)
|
|
@@ -40,7 +40,7 @@ exclude = ".venv/.*"
|
|
|
40
40
|
|
|
41
41
|
[tool.poetry]
|
|
42
42
|
name = "python-hilo"
|
|
43
|
-
version = "
|
|
43
|
+
version = "2025.2.2"
|
|
44
44
|
description = "A Python3, async interface to the Hilo API"
|
|
45
45
|
readme = "README.md"
|
|
46
46
|
authors = ["David Vallee Delisle <me@dvd.dev>"]
|
|
@@ -74,7 +74,7 @@ python-dateutil = ">=2.8.2"
|
|
|
74
74
|
ruyaml = ">=0.91.0"
|
|
75
75
|
python = "^3.9.0"
|
|
76
76
|
voluptuous = ">=0.13.1"
|
|
77
|
-
websockets = ">=8.1,<
|
|
77
|
+
websockets = ">=8.1,<16.0"
|
|
78
78
|
|
|
79
79
|
[tool.poetry.dev-dependencies]
|
|
80
80
|
Sphinx = "^7.1.2"
|
|
@@ -83,9 +83,9 @@ asynctest = "^0.13.0"
|
|
|
83
83
|
pre-commit = "^4.0.0"
|
|
84
84
|
pytest = "^8.0.0"
|
|
85
85
|
pytest-aiohttp = "^1.0.4"
|
|
86
|
-
pytest-cov = "^
|
|
86
|
+
pytest-cov = "^6.0.0"
|
|
87
87
|
sphinx-rtd-theme = "^3.0.0"
|
|
88
|
-
types-pytz = "^
|
|
88
|
+
types-pytz = "^2025.1.0"
|
|
89
89
|
|
|
90
90
|
[tool.pylint.BASIC]
|
|
91
91
|
expected-line-ending-format = "LF"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|