python-hilo 2026.3.1__tar.gz → 2026.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/PKG-INFO +2 -2
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/api.py +83 -11
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/const.py +2 -1
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/devices.py +74 -10
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/graphql.py +17 -2
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyproject.toml +2 -2
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/LICENSE +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/README.md +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/__init__.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/__init__.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/climate.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/graphql_value_mapper.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/light.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/sensor.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/device/switch.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/event.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/exceptions.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/oauth2helper.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/util/__init__.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/util/state.py +0 -0
- {python_hilo-2026.3.1 → python_hilo-2026.3.2}/pyhilo/websocket.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-hilo
|
|
3
|
-
Version: 2026.3.
|
|
3
|
+
Version: 2026.3.2
|
|
4
4
|
Summary: A Python3, async interface to the Hilo API
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -29,7 +29,7 @@ Requires-Dist: async-timeout (>=4.0.0)
|
|
|
29
29
|
Requires-Dist: attrs (>=21.2.0)
|
|
30
30
|
Requires-Dist: backoff (>=1.11.1)
|
|
31
31
|
Requires-Dist: httpx-sse (>=0.4.0)
|
|
32
|
-
Requires-Dist: httpx[http2] (>=0.
|
|
32
|
+
Requires-Dist: httpx[http2] (>=0.27.0)
|
|
33
33
|
Requires-Dist: python-dateutil (>=2.8.2)
|
|
34
34
|
Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
|
|
35
35
|
Requires-Dist: voluptuous (>=0.13.1)
|
|
@@ -94,6 +94,9 @@ class API:
|
|
|
94
94
|
self.ws_token: str = ""
|
|
95
95
|
self.endpoint: str = ""
|
|
96
96
|
self._urn: str | None = None
|
|
97
|
+
# Device cache from websocket DeviceListInitialValuesReceived
|
|
98
|
+
self._device_cache: list[dict[str, Any]] = []
|
|
99
|
+
self._device_cache_event: asyncio.Event = asyncio.Event()
|
|
97
100
|
|
|
98
101
|
@classmethod
|
|
99
102
|
async def async_create(
|
|
@@ -544,17 +547,86 @@ class API:
|
|
|
544
547
|
req: list[dict[str, Any]] = await self.async_request("get", url)
|
|
545
548
|
return (req[0]["id"], req[0]["locationHiloId"])
|
|
546
549
|
|
|
547
|
-
|
|
548
|
-
"""
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
for
|
|
556
|
-
|
|
557
|
-
|
|
550
|
+
def set_device_cache(self, devices: list[dict[str, Any]]) -> None:
|
|
551
|
+
"""Store devices received from websocket DeviceListInitialValuesReceived.
|
|
552
|
+
|
|
553
|
+
This replaces the old REST API get_devices call. The websocket sends
|
|
554
|
+
device data with list-type attributes (supportedAttributesList, etc.)
|
|
555
|
+
which need to be converted to comma-separated strings to match the
|
|
556
|
+
format that HiloDevice.update() expects.
|
|
557
|
+
"""
|
|
558
|
+
self._device_cache = [self._convert_ws_device(device) for device in devices]
|
|
559
|
+
LOG.debug(
|
|
560
|
+
"Device cache populated with %d devices from websocket",
|
|
561
|
+
len(self._device_cache),
|
|
562
|
+
)
|
|
563
|
+
self._device_cache_event.set()
|
|
564
|
+
|
|
565
|
+
@staticmethod
|
|
566
|
+
def _convert_ws_device(ws_device: dict[str, Any]) -> dict[str, Any]:
|
|
567
|
+
"""Convert a websocket device dict to the format generate_device expects.
|
|
568
|
+
|
|
569
|
+
The REST API returned supportedAttributes/settableAttributes as
|
|
570
|
+
comma-separated strings. The websocket returns supportedAttributesList/
|
|
571
|
+
settableAttributesList/supportedParametersList as Python lists.
|
|
572
|
+
We convert to the old format so HiloDevice.update() works unchanged.
|
|
573
|
+
"""
|
|
574
|
+
device = dict(ws_device)
|
|
575
|
+
|
|
576
|
+
# Convert list attributes to comma-separated strings
|
|
577
|
+
list_to_csv_mappings = {
|
|
578
|
+
"supportedAttributesList": "supportedAttributes",
|
|
579
|
+
"settableAttributesList": "settableAttributes",
|
|
580
|
+
"supportedParametersList": "supportedParameters",
|
|
581
|
+
}
|
|
582
|
+
for list_key, csv_key in list_to_csv_mappings.items():
|
|
583
|
+
if list_key in device:
|
|
584
|
+
items = device.pop(list_key)
|
|
585
|
+
if isinstance(items, list):
|
|
586
|
+
device[csv_key] = ", ".join(str(i) for i in items)
|
|
587
|
+
else:
|
|
588
|
+
device[csv_key] = str(items) if items else ""
|
|
589
|
+
|
|
590
|
+
return device
|
|
591
|
+
|
|
592
|
+
async def wait_for_device_cache(self, timeout: float = 30.0) -> None:
|
|
593
|
+
"""Wait for the device cache to be populated from websocket.
|
|
594
|
+
|
|
595
|
+
:param timeout: Maximum time to wait in seconds
|
|
596
|
+
:raises TimeoutError: If the device cache is not populated in time
|
|
597
|
+
"""
|
|
598
|
+
if self._device_cache_event.is_set():
|
|
599
|
+
return
|
|
600
|
+
LOG.debug("Waiting for device cache from websocket (timeout=%ss)", timeout)
|
|
601
|
+
try:
|
|
602
|
+
await asyncio.wait_for(self._device_cache_event.wait(), timeout=timeout)
|
|
603
|
+
except asyncio.TimeoutError:
|
|
604
|
+
LOG.error(
|
|
605
|
+
"Timed out waiting for device list from websocket after %ss",
|
|
606
|
+
timeout,
|
|
607
|
+
)
|
|
608
|
+
raise
|
|
609
|
+
|
|
610
|
+
def get_device_cache(self, location_id: int) -> list[dict[str, Any]]:
|
|
611
|
+
"""Return cached devices from websocket.
|
|
612
|
+
|
|
613
|
+
:param location_id: Hilo location id (unused but kept for interface compat)
|
|
614
|
+
:return: List of device dicts ready for generate_device()
|
|
615
|
+
"""
|
|
616
|
+
return list(self._device_cache)
|
|
617
|
+
|
|
618
|
+
def add_to_device_cache(self, devices: list[dict[str, Any]]) -> None:
|
|
619
|
+
"""Append new devices to the existing cache (e.g. from DeviceAdded).
|
|
620
|
+
|
|
621
|
+
Converts websocket format and adds to the cache without replacing
|
|
622
|
+
existing entries. Skips devices already in cache (by id).
|
|
623
|
+
"""
|
|
624
|
+
existing_ids = {d.get("id") for d in self._device_cache}
|
|
625
|
+
for device in devices:
|
|
626
|
+
converted = self._convert_ws_device(device)
|
|
627
|
+
if converted.get("id") not in existing_ids:
|
|
628
|
+
self._device_cache.append(converted)
|
|
629
|
+
LOG.debug("Added device %s to cache", converted.get("id"))
|
|
558
630
|
|
|
559
631
|
async def _set_device_attribute(
|
|
560
632
|
self,
|
|
@@ -11,7 +11,7 @@ INSTANCE_ID: Final = str(uuid.uuid4())[24:]
|
|
|
11
11
|
LOG: Final = logging.getLogger(__package__)
|
|
12
12
|
DEFAULT_STATE_FILE: Final = "hilo_state.yaml"
|
|
13
13
|
REQUEST_RETRY: Final = 9
|
|
14
|
-
PYHILO_VERSION: Final = "2026.3.
|
|
14
|
+
PYHILO_VERSION: Final = "2026.3.02"
|
|
15
15
|
# TODO: Find a way to keep previous line in sync with pyproject.toml automatically
|
|
16
16
|
|
|
17
17
|
CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded"
|
|
@@ -201,6 +201,7 @@ HILO_DEVICE_TYPES: Final = {
|
|
|
201
201
|
"Cee": "Switch",
|
|
202
202
|
"Thermostat24V": "Climate",
|
|
203
203
|
"Tracker": "Sensor",
|
|
204
|
+
"SinopeWaterHeater": "Switch",
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
HILO_UNIT_CONVERSION: Final = {
|
|
@@ -58,7 +58,13 @@ class Devices:
|
|
|
58
58
|
device_identifier: Union[int, str] = reading.device_id
|
|
59
59
|
if device_identifier == 0:
|
|
60
60
|
device_identifier = reading.hilo_id
|
|
61
|
-
|
|
61
|
+
device = self.find_device(device_identifier)
|
|
62
|
+
# If device_id was 0 and hilo_id lookup failed, this is likely
|
|
63
|
+
# a gateway reading that arrives before GatewayValuesReceived
|
|
64
|
+
# assigns the real ID. Fall back to the gateway device.
|
|
65
|
+
if device is None and reading.device_id == 0:
|
|
66
|
+
device = next((d for d in self.devices if d.type == "Gateway"), None)
|
|
67
|
+
if device:
|
|
62
68
|
device.update_readings(reading)
|
|
63
69
|
LOG.debug("%s Received %s", device, reading)
|
|
64
70
|
if device not in updated_devices:
|
|
@@ -93,27 +99,78 @@ class Devices:
|
|
|
93
99
|
return dev
|
|
94
100
|
|
|
95
101
|
async def update(self) -> None:
|
|
96
|
-
|
|
102
|
+
"""Update device list from websocket cache + gateway from REST."""
|
|
103
|
+
# Get devices from websocket cache (already populated by DeviceListInitialValuesReceived)
|
|
104
|
+
cached_devices = self._api.get_device_cache(self.location_id)
|
|
97
105
|
generated_devices = []
|
|
98
|
-
for raw_device in
|
|
106
|
+
for raw_device in cached_devices:
|
|
99
107
|
LOG.debug("Generating device %s", raw_device)
|
|
100
108
|
dev = self.generate_device(raw_device)
|
|
101
109
|
generated_devices.append(dev)
|
|
102
110
|
if dev not in self.devices:
|
|
103
111
|
self.devices.append(dev)
|
|
112
|
+
|
|
113
|
+
# Append gateway from REST API (still available)
|
|
114
|
+
try:
|
|
115
|
+
gw = await self._api.get_gateway(self.location_id)
|
|
116
|
+
LOG.debug("Generating gateway device %s", gw)
|
|
117
|
+
gw_dev = self.generate_device(gw)
|
|
118
|
+
generated_devices.append(gw_dev)
|
|
119
|
+
if gw_dev not in self.devices:
|
|
120
|
+
self.devices.append(gw_dev)
|
|
121
|
+
except Exception as err:
|
|
122
|
+
LOG.error("Failed to get gateway: %s", err)
|
|
123
|
+
|
|
124
|
+
# Now add devices from external sources (e.g. unknown source tracker)
|
|
125
|
+
for callback in self._api._get_device_callbacks:
|
|
126
|
+
try:
|
|
127
|
+
cb_device = callback()
|
|
128
|
+
dev = self.generate_device(cb_device)
|
|
129
|
+
generated_devices.append(dev)
|
|
130
|
+
if dev not in self.devices:
|
|
131
|
+
self.devices.append(dev)
|
|
132
|
+
except Exception as err:
|
|
133
|
+
LOG.error("Failed to generate callback device: %s", err)
|
|
134
|
+
|
|
104
135
|
for device in self.devices:
|
|
105
136
|
if device not in generated_devices:
|
|
106
137
|
LOG.debug("Device unpaired %s", device)
|
|
107
138
|
# Don't do anything with unpaired device for now.
|
|
108
|
-
# self.devices.remove(device)
|
|
109
139
|
|
|
110
140
|
async def update_devicelist_from_signalr(
|
|
111
141
|
self, values: list[dict[str, Any]]
|
|
112
142
|
) -> list[HiloDevice]:
|
|
113
|
-
|
|
143
|
+
"""Process device list received from SignalR websocket.
|
|
144
|
+
|
|
145
|
+
This is called when DeviceListInitialValuesReceived arrives.
|
|
146
|
+
It populates the API device cache and generates HiloDevice objects.
|
|
147
|
+
"""
|
|
148
|
+
# Populate the API cache so future update() calls use this data
|
|
149
|
+
self._api.set_device_cache(values)
|
|
150
|
+
|
|
114
151
|
new_devices = []
|
|
115
|
-
for raw_device in
|
|
116
|
-
LOG.debug("Generating device %s", raw_device)
|
|
152
|
+
for raw_device in self._api.get_device_cache(self.location_id):
|
|
153
|
+
LOG.debug("Generating device from SignalR %s", raw_device)
|
|
154
|
+
dev = self.generate_device(raw_device)
|
|
155
|
+
if dev not in self.devices:
|
|
156
|
+
self.devices.append(dev)
|
|
157
|
+
new_devices.append(dev)
|
|
158
|
+
|
|
159
|
+
return new_devices
|
|
160
|
+
|
|
161
|
+
async def add_device_from_signalr(
|
|
162
|
+
self, values: list[dict[str, Any]]
|
|
163
|
+
) -> list[HiloDevice]:
|
|
164
|
+
"""Process individual device additions from SignalR websocket.
|
|
165
|
+
|
|
166
|
+
This is called when DeviceAdded arrives. It appends to the existing
|
|
167
|
+
cache rather than replacing it.
|
|
168
|
+
"""
|
|
169
|
+
self._api.add_to_device_cache(values)
|
|
170
|
+
|
|
171
|
+
new_devices = []
|
|
172
|
+
for raw_device in self._api.get_device_cache(self.location_id):
|
|
173
|
+
LOG.debug("Generating added device from SignalR %s", raw_device)
|
|
117
174
|
dev = self.generate_device(raw_device)
|
|
118
175
|
if dev not in self.devices:
|
|
119
176
|
self.devices.append(dev)
|
|
@@ -122,9 +179,16 @@ class Devices:
|
|
|
122
179
|
return new_devices
|
|
123
180
|
|
|
124
181
|
async def async_init(self) -> None:
|
|
125
|
-
"""Initialize the Hilo "manager" class.
|
|
126
|
-
|
|
182
|
+
"""Initialize the Hilo "manager" class.
|
|
183
|
+
|
|
184
|
+
Gets location IDs from REST API, then waits for the websocket
|
|
185
|
+
to deliver the device list via DeviceListInitialValuesReceived.
|
|
186
|
+
The gateway is appended from REST.
|
|
187
|
+
"""
|
|
188
|
+
LOG.info("Initialising: getting location IDs")
|
|
127
189
|
location_ids = await self._api.get_location_ids()
|
|
128
190
|
self.location_id = location_ids[0]
|
|
129
191
|
self.location_hilo_id = location_ids[1]
|
|
130
|
-
|
|
192
|
+
# Device list will be populated when DeviceListInitialValuesReceived
|
|
193
|
+
# arrives on the websocket. The hilo integration's async_init will
|
|
194
|
+
# call wait_for_device_cache() and then update() after subscribing.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import hashlib
|
|
3
3
|
import json
|
|
4
|
+
import ssl
|
|
4
5
|
from typing import Any, Callable, Dict, List, Optional
|
|
5
6
|
|
|
7
|
+
import certifi
|
|
6
8
|
import httpx
|
|
7
9
|
from httpx_sse import aconnect_sse
|
|
8
10
|
|
|
@@ -24,6 +26,17 @@ class GraphQlHelper:
|
|
|
24
26
|
|
|
25
27
|
async def async_init(self) -> None:
|
|
26
28
|
"""Initialize the Hilo "GraphQlHelper" class."""
|
|
29
|
+
loop = asyncio.get_running_loop()
|
|
30
|
+
|
|
31
|
+
def _create_ssl_context() -> ssl.SSLContext:
|
|
32
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
33
|
+
ctx.check_hostname = True
|
|
34
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
35
|
+
ctx.load_verify_locations(certifi.where())
|
|
36
|
+
return ctx
|
|
37
|
+
|
|
38
|
+
self._ssl_context = await loop.run_in_executor(None, _create_ssl_context)
|
|
39
|
+
|
|
27
40
|
await self.call_get_location_query(self._devices.location_hilo_id)
|
|
28
41
|
|
|
29
42
|
QUERY_GET_LOCATION: str = """query getLocation($locationHiloId: String!) {
|
|
@@ -548,7 +561,7 @@ class GraphQlHelper:
|
|
|
548
561
|
"variables": {"locationHiloId": location_hilo_id},
|
|
549
562
|
}
|
|
550
563
|
|
|
551
|
-
async with httpx.AsyncClient(http2=True) as client:
|
|
564
|
+
async with httpx.AsyncClient(http2=True, verify=self._ssl_context) as client:
|
|
552
565
|
try:
|
|
553
566
|
response = await client.post(url, json=payload, headers=headers)
|
|
554
567
|
response.raise_for_status()
|
|
@@ -634,7 +647,9 @@ class GraphQlHelper:
|
|
|
634
647
|
|
|
635
648
|
retry_with_full_query = False
|
|
636
649
|
|
|
637
|
-
async with httpx.AsyncClient(
|
|
650
|
+
async with httpx.AsyncClient(
|
|
651
|
+
http2=True, timeout=None, verify=self._ssl_context
|
|
652
|
+
) as client:
|
|
638
653
|
async with aconnect_sse(
|
|
639
654
|
client, "POST", url, json=payload, headers=headers
|
|
640
655
|
) as event_source:
|
|
@@ -40,7 +40,7 @@ exclude = ".venv/.*"
|
|
|
40
40
|
|
|
41
41
|
[tool.poetry]
|
|
42
42
|
name = "python-hilo"
|
|
43
|
-
version = "2026.3.
|
|
43
|
+
version = "2026.3.2"
|
|
44
44
|
description = "A Python3, async interface to the Hilo API"
|
|
45
45
|
readme = "README.md"
|
|
46
46
|
authors = ["David Vallee Delisle <me@dvd.dev>"]
|
|
@@ -74,7 +74,7 @@ python-dateutil = ">=2.8.2"
|
|
|
74
74
|
python = "^3.9.0"
|
|
75
75
|
voluptuous = ">=0.13.1"
|
|
76
76
|
pyyaml = "^6.0.2"
|
|
77
|
-
httpx = {version = ">=0.
|
|
77
|
+
httpx = {version = ">=0.27.0", extras = ["http2"]}
|
|
78
78
|
httpx-sse = ">=0.4.0"
|
|
79
79
|
|
|
80
80
|
[poetry.group.dev.dependencies]
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|