Skip to content

Commit 3535fba

Browse files
authored
Merge pull request #400 from nlz242/feature/convert-websocket-to-pysignalr
Convert websocket to pysignalr
2 parents cbdcb0c + dc18ce7 commit 3535fba

File tree

10 files changed

+448
-756
lines changed

10 files changed

+448
-756
lines changed

pyhilo/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
from pyhilo.device.switch import Switch
77
from pyhilo.devices import Devices
88
from pyhilo.event import Event
9-
from pyhilo.exceptions import HiloError, InvalidCredentialsError, WebsocketError
9+
from pyhilo.exceptions import HiloError, InvalidCredentialsError, SignalRError
10+
from pyhilo.signalr import SignalREvent
1011
from pyhilo.util import from_utc_timestamp, time_diff
11-
from pyhilo.websocket import WebsocketEvent
1212

1313
__all__ = [
1414
"API",
@@ -17,10 +17,10 @@
1717
"Event",
1818
"HiloError",
1919
"InvalidCredentialsError",
20-
"WebsocketError",
20+
"SignalRError",
2121
"from_utc_timestamp",
2222
"time_diff",
23-
"WebsocketEvent",
23+
"SignalREvent",
2424
"UNMONITORED_DEVICES",
2525
"Switch",
2626
]

pyhilo/api.py

Lines changed: 22 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,8 @@
4545
)
4646
from pyhilo.device import DeviceAttribute, HiloDevice, get_device_attributes
4747
from pyhilo.exceptions import InvalidCredentialsError, RequestError
48-
from pyhilo.util.state import (
49-
StateDict,
50-
WebsocketDict,
51-
WebsocketTransportsDict,
52-
get_state,
53-
set_state,
54-
)
55-
from pyhilo.websocket import WebsocketClient, WebsocketManager
48+
from pyhilo.signalr import SignalRHub, SignalRManager
49+
from pyhilo.util.state import AndroidDeviceDict, StateDict, get_state, set_state
5650

5751

5852
class API:
@@ -74,27 +68,19 @@ def __init__(
7468
) -> None:
7569
"""Initialize"""
7670
self._backoff_refresh_lock_api = asyncio.Lock()
77-
self._backoff_refresh_lock_ws = asyncio.Lock()
7871
self._request_retries = request_retries
7972
self._state_yaml: str = DEFAULT_STATE_FILE
8073
self.state: StateDict = {}
8174
self.async_request = self._wrap_request_method(self._request_retries)
8275
self.device_attributes = get_device_attributes()
8376
self.session: ClientSession = session
8477
self._oauth_session = oauth_session
85-
self.websocket_devices: WebsocketClient
86-
# Backward compatibility during transition to websocket for challenges. Currently the HA Hilo integration
87-
# uses the .websocket attribute. Re-added this attribute and point to the same object as websocket_devices.
88-
# Should be removed once the transition to the challenge websocket is completed everywhere.
89-
self.websocket: WebsocketClient
90-
self.websocket_challenges: WebsocketClient
78+
self.signalr_devices: SignalRHub
79+
self.signalr_challenges: SignalRHub
9180
self.log_traces = log_traces
9281
self._get_device_callbacks: list[Callable[..., Any]] = []
93-
self.ws_url: str = ""
94-
self.ws_token: str = ""
95-
self.endpoint: str = ""
9682
self._urn: str | None = None
97-
# Device cache from websocket DeviceListInitialValuesReceived
83+
# Device cache from SignalR DeviceListInitialValuesReceived
9884
self._device_cache: list[dict[str, Any]] = []
9985
self._device_cache_event: asyncio.Event = asyncio.Event()
10086

@@ -146,7 +132,7 @@ async def async_get_access_token(self) -> str:
146132
await self._oauth_session.async_ensure_token_valid()
147133

148134
access_token = str(self._oauth_session.token["access_token"])
149-
LOG.debug("Websocket access token is %s", access_token)
135+
LOG.debug("SignalR access token is %s", access_token)
150136

151137
urn = self.urn
152138
LOG.debug("Extracted URN: %s", urn)
@@ -356,19 +342,7 @@ async def _async_handle_on_backoff(self, _: dict[str, Any]) -> None:
356342
err: ClientResponseError = err_info[1].with_traceback(err_info[2]) # type: ignore
357343

358344
if err.status in (401, 403):
359-
LOG.warning("Refreshing websocket token %s", err.request_info.url)
360-
if (
361-
"client/negotiate" in str(err.request_info.url)
362-
and err.request_info.method == "POST"
363-
):
364-
LOG.info(
365-
"401 detected on websocket, refreshing websocket token. Old url: {self.ws_url} Old Token: {self.ws_token}"
366-
)
367-
LOG.info("401 detected on %s", err.request_info.url)
368-
async with self._backoff_refresh_lock_ws:
369-
await self.refresh_ws_token()
370-
await self.get_websocket_params()
371-
return
345+
LOG.warning("Refreshing API token on %s", err.request_info.url)
372346

373347
@staticmethod
374348
def _handle_on_giveup(_: dict[str, Any]) -> None:
@@ -413,57 +387,13 @@ def enable_request_retries(self) -> None:
413387

414388
async def _async_post_init(self) -> None:
415389
"""Perform some post-init actions."""
416-
LOG.debug("Websocket _async_post_init running")
390+
LOG.debug("SignalR _async_post_init running")
417391
await self._get_fid()
418392
await self._get_device_token()
419393

420-
# Initialize WebsocketManager ic-dev21
421-
self.websocket_manager = WebsocketManager(
422-
self.session, self.async_request, self._state_yaml, set_state
423-
)
424-
await self.websocket_manager.initialize_websockets()
425-
426-
# Create both websocket clients
427-
# ic-dev21 need to work on this as it can't lint as is, may need to
428-
# instantiate differently
429-
# TODO: fix type ignore after refactor
430-
self.websocket_devices = WebsocketClient(self.websocket_manager.devicehub) # type: ignore
431-
432-
# For backward compatibility during the transition to challengehub websocket
433-
self.websocket = self.websocket_devices
434-
self.websocket_challenges = WebsocketClient(self.websocket_manager.challengehub) # type: ignore
435-
436-
async def refresh_ws_token(self) -> None:
437-
"""Refresh the websocket token."""
438-
await self.websocket_manager.refresh_token(self.websocket_manager.devicehub)
439-
await self.websocket_manager.refresh_token(self.websocket_manager.challengehub)
440-
441-
async def get_websocket_params(self) -> None:
442-
"""Retrieves and constructs WebSocket connection parameters from the negotiation endpoint."""
443-
uri = parse.urlparse(self.ws_url)
444-
LOG.debug("Getting websocket params")
445-
LOG.debug("Getting uri %s", uri)
446-
resp: dict[str, Any] = await self.async_request(
447-
"post",
448-
f"{uri.path}negotiate?{uri.query}",
449-
host=uri.netloc,
450-
headers={
451-
"authorization": f"Bearer {self.ws_token}",
452-
},
453-
)
454-
conn_id: str = resp.get("connectionId", "")
455-
self.full_ws_url = f"{self.ws_url}&id={conn_id}&access_token={self.ws_token}"
456-
LOG.debug("Getting full ws URL %s", self.full_ws_url)
457-
transport_dict: list[WebsocketTransportsDict] = resp.get(
458-
"availableTransports", []
459-
)
460-
websocket_dict: WebsocketDict = {
461-
"connection_id": conn_id,
462-
"available_transports": transport_dict,
463-
"full_ws_url": self.full_ws_url,
464-
}
465-
LOG.debug("Calling set_state from get_websocket_params")
466-
await set_state(self._state_yaml, "websocket", websocket_dict)
394+
signalr_manager = SignalRManager(self.async_request)
395+
self.signalr_devices = signalr_manager.build_hub("/DeviceHub")
396+
self.signalr_challenges = signalr_manager.build_hub("/ChallengeHub")
467397

468398
async def fb_install(self, fb_id: str) -> None:
469399
"""Registers a Firebase installation and stores the authentication token state."""
@@ -535,9 +465,7 @@ async def android_register(self) -> None:
535465
await set_state(
536466
self._state_yaml,
537467
"android",
538-
{
539-
"token": token,
540-
},
468+
cast(AndroidDeviceDict, {"token": token}),
541469
)
542470

543471
async def get_location_ids(self) -> tuple[int, str]:
@@ -548,26 +476,26 @@ async def get_location_ids(self) -> tuple[int, str]:
548476
return (req[0]["id"], req[0]["locationHiloId"])
549477

550478
def set_device_cache(self, devices: list[dict[str, Any]]) -> None:
551-
"""Store devices received from websocket DeviceListInitialValuesReceived.
479+
"""Store devices received from SignalR DeviceListInitialValuesReceived.
552480
553-
This replaces the old REST API get_devices call. The websocket sends
481+
This replaces the old REST API get_devices call. SignalR sends
554482
device data with list-type attributes (supportedAttributesList, etc.)
555483
which need to be converted to comma-separated strings to match the
556484
format that HiloDevice.update() expects.
557485
"""
558486
self._device_cache = [self._convert_ws_device(device) for device in devices]
559487
LOG.debug(
560-
"Device cache populated with %d devices from websocket",
488+
"Device cache populated with %d devices from SignalR",
561489
len(self._device_cache),
562490
)
563491
self._device_cache_event.set()
564492

565493
@staticmethod
566494
def _convert_ws_device(ws_device: dict[str, Any]) -> dict[str, Any]:
567-
"""Convert a websocket device dict to the format generate_device expects.
495+
"""Convert a SignalR device dict to the format generate_device expects.
568496
569497
The REST API returned supportedAttributes/settableAttributes as
570-
comma-separated strings. The websocket returns supportedAttributesList/
498+
comma-separated strings. SignalR returns supportedAttributesList/
571499
settableAttributesList/supportedParametersList as Python lists.
572500
We convert to the old format so HiloDevice.update() works unchanged.
573501
"""
@@ -590,25 +518,25 @@ def _convert_ws_device(ws_device: dict[str, Any]) -> dict[str, Any]:
590518
return device
591519

592520
async def wait_for_device_cache(self, timeout: float = 30.0) -> None:
593-
"""Wait for the device cache to be populated from websocket.
521+
"""Wait for the device cache to be populated from SignalR.
594522
595523
:param timeout: Maximum time to wait in seconds
596524
:raises TimeoutError: If the device cache is not populated in time
597525
"""
598526
if self._device_cache_event.is_set():
599527
return
600-
LOG.debug("Waiting for device cache from websocket (timeout=%ss)", timeout)
528+
LOG.debug("Waiting for device cache from SignalR (timeout=%ss)", timeout)
601529
try:
602530
await asyncio.wait_for(self._device_cache_event.wait(), timeout=timeout)
603531
except asyncio.TimeoutError:
604532
LOG.error(
605-
"Timed out waiting for device list from websocket after %ss",
533+
"Timed out waiting for device list from SignalR after %ss",
606534
timeout,
607535
)
608536
raise
609537

610538
def get_device_cache(self, location_id: int) -> list[dict[str, Any]]:
611-
"""Return cached devices from websocket.
539+
"""Return cached devices from SignalR.
612540
613541
:param location_id: Hilo location id (unused but kept for interface compat)
614542
:return: List of device dicts ready for generate_device()
@@ -618,7 +546,7 @@ def get_device_cache(self, location_id: int) -> list[dict[str, Any]]:
618546
def add_to_device_cache(self, devices: list[dict[str, Any]]) -> None:
619547
"""Append new devices to the existing cache (e.g. from DeviceAdded).
620548
621-
Converts websocket format and adds to the cache without replacing
549+
Converts SignalR format and adds to the cache without replacing
622550
existing entries. Skips devices already in cache (by id).
623551
"""
624552
existing_ids = {d.get("id") for d in self._device_cache}

pyhilo/devices.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def all(self) -> list[HiloDevice]:
2323

2424
@property
2525
def attributes_list(self) -> list[Union[int, dict[int, list[str]]]]:
26-
"""This is sent to websocket to subscribe to the device attributes updates
26+
"""This is sent to the SignalR hub to subscribe to the device attributes updates
2727
2828
:return: Dict of devices (key) with their attributes.
2929
:rtype: list
@@ -99,8 +99,8 @@ def generate_device(self, device: dict) -> HiloDevice:
9999
return dev
100100

101101
async def update(self) -> None:
102-
"""Update device list from websocket cache + gateway from REST."""
103-
# Get devices from websocket cache (already populated by DeviceListInitialValuesReceived)
102+
"""Update device list from SignalR cache + gateway from REST."""
103+
# Get devices from SignalR cache (already populated by DeviceListInitialValuesReceived)
104104
cached_devices = self._api.get_device_cache(self.location_id)
105105
generated_devices = []
106106
for raw_device in cached_devices:
@@ -140,7 +140,7 @@ async def update(self) -> None:
140140
async def update_devicelist_from_signalr(
141141
self, values: list[dict[str, Any]]
142142
) -> list[HiloDevice]:
143-
"""Process device list received from SignalR websocket.
143+
"""Process device list received from SignalR hub.
144144
145145
This is called when DeviceListInitialValuesReceived arrives.
146146
It populates the API device cache and generates HiloDevice objects.
@@ -161,7 +161,7 @@ async def update_devicelist_from_signalr(
161161
async def add_device_from_signalr(
162162
self, values: list[dict[str, Any]]
163163
) -> list[HiloDevice]:
164-
"""Process individual device additions from SignalR websocket.
164+
"""Process individual device additions from SignalR hub.
165165
166166
This is called when DeviceAdded arrives. It appends to the existing
167167
cache rather than replacing it.
@@ -181,7 +181,7 @@ async def add_device_from_signalr(
181181
async def async_init(self) -> None:
182182
"""Initialize the Hilo "manager" class.
183183
184-
Gets location IDs from REST API, then waits for the websocket
184+
Gets location IDs from REST API, then waits for the SignalR hub
185185
to deliver the device list via DeviceListInitialValuesReceived.
186186
The gateway is appended from REST.
187187
"""
@@ -190,5 +190,5 @@ async def async_init(self) -> None:
190190
self.location_id = location_ids[0]
191191
self.location_hilo_id = location_ids[1]
192192
# Device list will be populated when DeviceListInitialValuesReceived
193-
# arrives on the websocket. The hilo integration's async_init will
193+
# arrives on the SignalR hub. The hilo integration's async_init will
194194
# call wait_for_device_cache() and then update() after subscribing.

pyhilo/exceptions.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,37 +27,37 @@ class RequestError(HiloError):
2727
pass
2828

2929

30-
class WebsocketError(HiloError):
31-
"""An error related to generic websocket errors."""
30+
class SignalRError(HiloError):
31+
"""An error related to generic SignalR errors."""
3232

3333
pass
3434

3535

36-
class CannotConnectError(WebsocketError):
37-
"""Define a error when the websocket can't be connected to."""
36+
class CannotConnectError(SignalRError):
37+
"""Define a error when the SignalR hub can't be connected to."""
3838

3939
pass
4040

4141

42-
class ConnectionClosedError(WebsocketError):
43-
"""Define a error when the websocket closes unexpectedly."""
42+
class ConnectionClosedError(SignalRError):
43+
"""Define a error when the SignalR hub closes unexpectedly."""
4444

4545
pass
4646

4747

48-
class ConnectionFailedError(WebsocketError):
49-
"""Define a error when the websocket connection fails."""
48+
class ConnectionFailedError(SignalRError):
49+
"""Define a error when the SignalR connection fails."""
5050

5151
pass
5252

5353

54-
class InvalidMessageError(WebsocketError):
55-
"""Define a error related to an invalid message from the websocket server."""
54+
class InvalidMessageError(SignalRError):
55+
"""Define a error related to an invalid message from the SignalR server."""
5656

5757
pass
5858

5959

60-
class NotConnectedError(WebsocketError):
61-
"""Define a error when the websocket isn't properly connected to."""
60+
class NotConnectedError(SignalRError):
61+
"""Define a error when the SignalR hub isn't properly connected to."""
6262

6363
pass

0 commit comments

Comments
 (0)