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.
@@ -1,8 +1,7 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: python-hilo
3
- Version: 2024.10.2
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,<14.0)
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
- AUTOMATION_DEVICEHUB_ENDPOINT,
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
- (self.ws_url, self.ws_token) = await self.post_devicehub_negociate()
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
- async def refresh_ws_token(self) -> None:
361
- (self.ws_url, self.ws_token) = await self.post_devicehub_negociate()
362
- await self.get_websocket_params()
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
- return (ws_url, ws_token)
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 = "2024.10.02"
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 self.appreciation_start <= now < self.appreciation_end:
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
- return parse(date_string).replace(tzinfo=from_zone).astimezone(to_zone)
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 DEFAULT_USER_AGENT, LOG
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 server: {json.dumps(payload)}"
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(f"[TRACE] Received message from websocket: {msg}")
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 callback to be called upon receiving an event.
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.replace("/DeviceHub", "%2FDeviceHub"),
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 = "2024.10.2"
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,<14.0"
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 = "^5.0.0"
86
+ pytest-cov = "^6.0.0"
87
87
  sphinx-rtd-theme = "^3.0.0"
88
- types-pytz = "^2024.1.0"
88
+ types-pytz = "^2025.1.0"
89
89
 
90
90
  [tool.pylint.BASIC]
91
91
  expected-line-ending-format = "LF"
File without changes