python-hilo 2026.10.2__tar.gz → 2026.11.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.10.2 → python_hilo-2026.11.2}/.github/workflows/tests.yaml +2 -2
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/PKG-INFO +1 -1
- python_hilo-2026.11.2/doc/graphql/onAnyDeviceUpdated-tstat.json +52 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/api.py +18 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/const.py +10 -1
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/device/__init__.py +37 -3
- python_hilo-2026.11.2/pyhilo/device/climate.py +254 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/device/graphql_value_mapper.py +14 -7
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyproject.toml +1 -1
- python_hilo-2026.11.2/tests/test_climate.py +265 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/tests/test_device.py +107 -1
- python_hilo-2026.11.2/tests/test_graphql_value_mapper.py +126 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/uv.lock +38 -38
- python_hilo-2026.10.2/pyhilo/device/climate.py +0 -100
- python_hilo-2026.10.2/tests/test_graphql_value_mapper.py +0 -23
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.bandit.yaml +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.devcontainer/devcontainer-lock.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.devcontainer/devcontainer.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.editorconfig +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.gitattributes +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.github/ISSUE_TEMPLATE/issue.md +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.github/dependabot.yml +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.github/labels.yml +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.github/release-drafter.yml +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.github/workflows/labeler.yml +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.github/workflows/release-drafter.yml +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.gitignore +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/.pre-commit-config.yaml +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/AGENTS.md +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/LICENSE +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/README.md +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/README.md +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/graphql/onAnyDeviceUpdated-cee.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/graphql/onAnyDeviceUpdated-meter.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/postman/hilo-prod.postman_environment.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/postman/hilo.postman_collection.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/rest/location_weather.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/rest/season_events_ch_2025.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/rest/season_events_ch_2026_empty.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/rest/season_events_hilo_2024.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/rest/seasons_summary.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/ChallengeAdded.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/EventAdded.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/EventCHConsumptionUpdatedValuesReceived-during-event.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/EventCHConsumptionUpdatedValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/EventCHDetailsInitialValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/EventCHDetailsUpdatedValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/EventFlexConsumptionUpdatedValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/EventFlexDetailsInitialValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/EventFlexDetailsUpdatedValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/legacy-pre-2025/ChallengeAdded.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/legacy-pre-2025/ChallengeConsumptionUpdatedValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/legacy-pre-2025/ChallengeDetailsInitialValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/legacy-pre-2025/ChallengeListInitialValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/challengehub/legacy-pre-2025/ChallengeListUpdatedValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/doc/signalr/devicehub/DeviceListInitialValuesReceived.json +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/__init__.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/auth.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/device/light.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/device/sensor.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/device/switch.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/devices.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/event.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/exceptions.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/graphql.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/oauth2helper.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/signalr.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/util/__init__.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/pyhilo/util/state.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/tests/__init__.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/tests/test_api_auth.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/tests/test_signalr.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/tests/test_util.py +0 -0
- {python_hilo-2026.10.2 → python_hilo-2026.11.2}/tests/test_util_state.py +0 -0
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
|
25
25
|
|
|
26
26
|
- name: Install uv
|
|
27
|
-
uses: astral-sh/setup-uv@v10.3.
|
|
27
|
+
uses: astral-sh/setup-uv@v10.3.3
|
|
28
28
|
|
|
29
29
|
- name: Install dependencies
|
|
30
30
|
run: uv sync --group dev
|
|
@@ -51,7 +51,7 @@ jobs:
|
|
|
51
51
|
python-version: ${{ matrix.python-version }}
|
|
52
52
|
|
|
53
53
|
- name: Install uv
|
|
54
|
-
uses: astral-sh/setup-uv@v10.3.
|
|
54
|
+
uses: astral-sh/setup-uv@v10.3.3
|
|
55
55
|
|
|
56
56
|
- name: Install dependencies
|
|
57
57
|
run: uv sync --python ${{ matrix.python-version }} --group test
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": {
|
|
3
|
+
"onAnyDeviceUpdated": {
|
|
4
|
+
"deviceType": "tstat",
|
|
5
|
+
"locationHiloId": "urn:hilo:crm:00000-abcdef:0",
|
|
6
|
+
"transmissionTime": "2026-10-29T22:44:57.250Z",
|
|
7
|
+
"operationId": "00000000-0000-0000-0000-000000000003",
|
|
8
|
+
"status": "REPORT",
|
|
9
|
+
"device": {
|
|
10
|
+
"deviceType": "tstat",
|
|
11
|
+
"hiloId": "urn:hilo:philo:1a2b3c4d5e6f0001:0",
|
|
12
|
+
"physicalAddress": "1A2B3C4D5E6F0001",
|
|
13
|
+
"connectionStatus": "CONNECTED",
|
|
14
|
+
"ambientHumidity": 0,
|
|
15
|
+
"gDState": "EXCLUDED",
|
|
16
|
+
"version": "21",
|
|
17
|
+
"zigbeeVersion": "22",
|
|
18
|
+
"ambientTemperature": {
|
|
19
|
+
"value": 18,
|
|
20
|
+
"kind": "DEGREE_CELSIUS"
|
|
21
|
+
},
|
|
22
|
+
"ambientTempSetpoint": {
|
|
23
|
+
"value": 15,
|
|
24
|
+
"kind": "DEGREE_CELSIUS"
|
|
25
|
+
},
|
|
26
|
+
"maxAmbientTempSetpoint": {
|
|
27
|
+
"value": 30,
|
|
28
|
+
"kind": "DEGREE_CELSIUS"
|
|
29
|
+
},
|
|
30
|
+
"minAmbientTempSetpoint": {
|
|
31
|
+
"value": 5,
|
|
32
|
+
"kind": "DEGREE_CELSIUS"
|
|
33
|
+
},
|
|
34
|
+
"maxAmbientTempSetpointLimit": {
|
|
35
|
+
"value": 30,
|
|
36
|
+
"kind": "DEGREE_CELSIUS"
|
|
37
|
+
},
|
|
38
|
+
"minAmbientTempSetpointLimit": {
|
|
39
|
+
"value": 5,
|
|
40
|
+
"kind": "DEGREE_CELSIUS"
|
|
41
|
+
},
|
|
42
|
+
"heatDemand": 0,
|
|
43
|
+
"power": {
|
|
44
|
+
"value": null,
|
|
45
|
+
"kind": "WATT"
|
|
46
|
+
},
|
|
47
|
+
"mode": "UNKNOWN",
|
|
48
|
+
"allowedModes": []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -491,6 +491,12 @@ class API:
|
|
|
491
491
|
req: list[dict[str, Any]] = await self.async_request("get", url)
|
|
492
492
|
return (req[0]["id"], req[0]["locationHiloId"])
|
|
493
493
|
|
|
494
|
+
async def get_rate_plan(self, location_id: int) -> str:
|
|
495
|
+
"""Return the current rate plan name of a location (e.g. "Flex", "D", "CH")."""
|
|
496
|
+
url = f"{API_AUTOMATION_ENDPOINT}/Locations/{location_id}"
|
|
497
|
+
location: dict[str, Any] = await self.async_request("get", url)
|
|
498
|
+
return str(location["ratePlan"]["current"])
|
|
499
|
+
|
|
494
500
|
def set_device_cache(self, devices: list[dict[str, Any]]) -> None:
|
|
495
501
|
"""Store devices received from SignalR DeviceListInitialValuesReceived.
|
|
496
502
|
|
|
@@ -585,6 +591,18 @@ class API:
|
|
|
585
591
|
LOG.debug("Device Attribute URL is %s", url)
|
|
586
592
|
await self.async_request("put", url, json={key.hilo_attribute: value})
|
|
587
593
|
|
|
594
|
+
async def _set_device_attributes(
|
|
595
|
+
self,
|
|
596
|
+
device: HiloDevice,
|
|
597
|
+
attributes: dict[str, Union[str, float, int, None]],
|
|
598
|
+
) -> None:
|
|
599
|
+
"""Sets several device attributes in a single request."""
|
|
600
|
+
url = self._get_url(
|
|
601
|
+
f"Devices/{device.id}/Attributes", location_id=device.location_id
|
|
602
|
+
)
|
|
603
|
+
LOG.debug("Device Attributes URL is %s", url)
|
|
604
|
+
await self.async_request("put", url, json=attributes)
|
|
605
|
+
|
|
588
606
|
async def get_event_notifications(self, location_id: int) -> dict[str, Any]:
|
|
589
607
|
"""This will return events notifications
|
|
590
608
|
Event types:
|
|
@@ -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.
|
|
14
|
+
PYHILO_VERSION: Final = "2026.11.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"
|
|
@@ -219,15 +219,21 @@ HILO_READING_TYPES: Final = {
|
|
|
219
219
|
"BatteryPercent": "Percentage",
|
|
220
220
|
"Co2": "PPM",
|
|
221
221
|
"ColorTemperature": "Integer",
|
|
222
|
+
"CoolTemperatureSet": "Celsius",
|
|
223
|
+
"CurrentState": "null",
|
|
222
224
|
"CurrentTemperature": "Celsius",
|
|
223
225
|
"Disconnected": "null",
|
|
224
226
|
"DrmsState": "OnOff",
|
|
227
|
+
"FanMode": "null",
|
|
228
|
+
"FanSpeed": "null",
|
|
225
229
|
"firmwareVersion": "null",
|
|
226
230
|
"Heating": "Percentage",
|
|
227
231
|
"Hue": "Integer",
|
|
228
232
|
"Humidity": "Percentage",
|
|
229
233
|
"Intensity": "Percentage",
|
|
234
|
+
"MaxCoolSetpoint": "Celsius",
|
|
230
235
|
"MaxTempSetpoint": "Celsius",
|
|
236
|
+
"MinCoolSetpoint": "Celsius",
|
|
231
237
|
"MinTempSetpoint": "Celsius",
|
|
232
238
|
"Noise": "DB",
|
|
233
239
|
"onlineStatus": "null",
|
|
@@ -237,6 +243,9 @@ HILO_READING_TYPES: Final = {
|
|
|
237
243
|
"Saturation": "Integer",
|
|
238
244
|
"Status": "OnOff",
|
|
239
245
|
"TargetTemperature": "Celsius",
|
|
246
|
+
"Thermostat24VAllowedFanMode": "null",
|
|
247
|
+
"Thermostat24VAllowedMode": "null",
|
|
248
|
+
"Thermostat24VMode": "null",
|
|
240
249
|
"Unpaired": "null",
|
|
241
250
|
"WifiStatus": "Integer",
|
|
242
251
|
"zigBeePairingActivated": "OnOff",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
-
from datetime import datetime
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
7
|
from typing import TYPE_CHECKING, Any, Dict, Union, cast
|
|
8
8
|
|
|
9
9
|
from pyhilo.const import (
|
|
@@ -62,7 +62,7 @@ class HiloDevice:
|
|
|
62
62
|
"deviceId": self.id,
|
|
63
63
|
"hiloId": self.hilo_id,
|
|
64
64
|
"locationId": self.location_id,
|
|
65
|
-
"timeStampUTC": datetime.
|
|
65
|
+
"timeStampUTC": datetime.now(UTC).isoformat(),
|
|
66
66
|
"value": value,
|
|
67
67
|
"device_attribute": DeviceAttribute(orig_att, reading_att),
|
|
68
68
|
}
|
|
@@ -127,7 +127,7 @@ class HiloDevice:
|
|
|
127
127
|
**{
|
|
128
128
|
"deviceId": self.id,
|
|
129
129
|
"locationId": self.location_id,
|
|
130
|
-
"timeStampUTC": datetime.
|
|
130
|
+
"timeStampUTC": datetime.now(UTC).isoformat(),
|
|
131
131
|
"value": value,
|
|
132
132
|
"device_attribute": attribute,
|
|
133
133
|
} # type: ignore
|
|
@@ -136,6 +136,40 @@ class HiloDevice:
|
|
|
136
136
|
else:
|
|
137
137
|
LOG.warning("%s Invalid attribute %s for device", self._tag, attribute)
|
|
138
138
|
|
|
139
|
+
async def set_attributes(
|
|
140
|
+
self, attributes: Dict[str, Union[str, int, float, None]]
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Send several attributes to the device in a single request.
|
|
143
|
+
|
|
144
|
+
Some devices reject partial writes: 24 V thermostats require the mode
|
|
145
|
+
and both setpoints to be sent together, even when only one of them
|
|
146
|
+
changed.
|
|
147
|
+
|
|
148
|
+
This differs from set_attribute() on two deliberate points:
|
|
149
|
+
|
|
150
|
+
- it does not check settable_attributes. Hilo advertises only
|
|
151
|
+
['Disconnected'] as settable on those thermostats, while the API does
|
|
152
|
+
accept and apply the write.
|
|
153
|
+
- it does not update the readings optimistically. The new values come
|
|
154
|
+
back through the normal read path, which makes a read-back an actual
|
|
155
|
+
confirmation rather than an echo.
|
|
156
|
+
"""
|
|
157
|
+
payload: Dict[str, Union[str, int, float, None]] = {}
|
|
158
|
+
for attribute, value in attributes.items():
|
|
159
|
+
dev_attribute = self._api.dev_atts(attribute)
|
|
160
|
+
if not isinstance(dev_attribute, DeviceAttribute):
|
|
161
|
+
LOG.warning(
|
|
162
|
+
"%s Unable to set attribute %s: Unknown attribute",
|
|
163
|
+
self._tag,
|
|
164
|
+
attribute,
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
payload[dev_attribute.hilo_attribute] = value
|
|
168
|
+
if not payload:
|
|
169
|
+
return
|
|
170
|
+
LOG.debug("%s Setting attributes %s", self._tag, payload)
|
|
171
|
+
await self._api._set_device_attributes(self, payload)
|
|
172
|
+
|
|
139
173
|
def get_attribute(self, attribute: str) -> Union[DeviceReading, None]:
|
|
140
174
|
if dev_attribute := cast(DeviceAttribute, self._api.dev_atts(attribute)):
|
|
141
175
|
return self._get_attribute(dev_attribute)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Climate object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from pyhilo import API
|
|
8
|
+
from pyhilo.const import LOG, STATE_UNKNOWN
|
|
9
|
+
from pyhilo.device import HiloDevice
|
|
10
|
+
|
|
11
|
+
# Attributes reported only by low voltage (24 V) thermostats. Thermostat24VMode
|
|
12
|
+
# is emitted solely by GraphqlValueMapper._build_lowvoltage_thermostat(), which
|
|
13
|
+
# makes its presence an exact discriminator.
|
|
14
|
+
LOW_VOLTAGE_MODE = "thermostat24_v_mode"
|
|
15
|
+
LOW_VOLTAGE_ALLOWED_MODES = "thermostat24_v_allowed_mode"
|
|
16
|
+
LOW_VOLTAGE_ALLOWED_FAN_MODES = "thermostat24_v_allowed_fan_mode"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def as_list(value: Any) -> list[str]:
|
|
20
|
+
"""Return a list of strings from a Hilo list attribute.
|
|
21
|
+
|
|
22
|
+
Hilo sends those either as a real list or as a comma separated string
|
|
23
|
+
depending on the transport, so accept both.
|
|
24
|
+
"""
|
|
25
|
+
if value is None or value in ("", STATE_UNKNOWN):
|
|
26
|
+
return []
|
|
27
|
+
if isinstance(value, (list, tuple, set)):
|
|
28
|
+
return [str(v).strip() for v in value if str(v).strip()]
|
|
29
|
+
return [v.strip() for v in str(value).split(",") if v.strip()]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Climate(HiloDevice):
|
|
33
|
+
"""
|
|
34
|
+
Represents a climate device within the Hilo ecosystem.
|
|
35
|
+
|
|
36
|
+
This class provides methods to interact with and control climate-related
|
|
37
|
+
devices such as thermostats.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self, api: API, **kwargs: dict[str, str | int | dict[Any, Any]]
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize the Climate object.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
api: The Hilo API instance.
|
|
47
|
+
**kwargs: Keyword arguments containing device data.
|
|
48
|
+
"""
|
|
49
|
+
super().__init__(api, **kwargs)
|
|
50
|
+
LOG.debug("Setting up Climate device: %s", self.name)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def current_temperature(self) -> float:
|
|
54
|
+
"""
|
|
55
|
+
Gets the current temperature reported by the device.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
float: The current temperature.
|
|
59
|
+
"""
|
|
60
|
+
return cast(float, self.get_value("current_temperature", 0))
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def target_temperature(self) -> float:
|
|
64
|
+
"""
|
|
65
|
+
Gets the target temperature set for the device.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
float: The target temperature.
|
|
69
|
+
"""
|
|
70
|
+
return cast(float, self.get_value("target_temperature", 0))
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def max_temp(self) -> float:
|
|
74
|
+
"""
|
|
75
|
+
Gets the maximum temperature setpoint allowed for the device.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
float: The maximum temperature. Defaults to 36.0 if not defined.
|
|
79
|
+
"""
|
|
80
|
+
value = self.get_value("max_temp_setpoint", 0)
|
|
81
|
+
|
|
82
|
+
if value is None or value == 0:
|
|
83
|
+
return 36.0
|
|
84
|
+
return float(value)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def min_temp(self) -> float:
|
|
88
|
+
"""
|
|
89
|
+
Gets the minimum temperature setpoint allowed for the device.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
float: The minimum temperature. Defaults to 5.0 if not defined.
|
|
93
|
+
"""
|
|
94
|
+
value = self.get_value("min_temp_setpoint", 0)
|
|
95
|
+
|
|
96
|
+
if value is None or value == 0:
|
|
97
|
+
return 5.0
|
|
98
|
+
return float(value)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def hvac_action(self) -> str:
|
|
102
|
+
"""
|
|
103
|
+
Gets the current HVAC action of the device.
|
|
104
|
+
|
|
105
|
+
For 24 V thermostats the action comes from currentState ("HEATING",
|
|
106
|
+
"COOLING", "OFF"). For baseboard thermostats it is derived from the
|
|
107
|
+
reported heating power.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
str: one of 'heating', 'cooling', or 'idle'.
|
|
111
|
+
"""
|
|
112
|
+
if self.is_low_voltage:
|
|
113
|
+
state = self.current_state
|
|
114
|
+
if state == "HEATING":
|
|
115
|
+
return "heating"
|
|
116
|
+
if state == "COOLING":
|
|
117
|
+
return "cooling"
|
|
118
|
+
return "idle"
|
|
119
|
+
attr = self.get_value("heating", 0)
|
|
120
|
+
return "heating" if attr > 0 else "idle"
|
|
121
|
+
|
|
122
|
+
async def async_set_temperature(self, temperature: float) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Sets the target temperature of the device.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
temperature: The desired target temperature.
|
|
128
|
+
"""
|
|
129
|
+
if self.is_low_voltage:
|
|
130
|
+
await self.async_set_low_voltage_state(target_temperature=temperature)
|
|
131
|
+
return
|
|
132
|
+
if temperature != self.target_temperature:
|
|
133
|
+
LOG.info("%s Setting temperature to %s", self._tag, temperature)
|
|
134
|
+
await self.set_attribute("target_temperature", str(temperature))
|
|
135
|
+
|
|
136
|
+
def _optional_float(self, attribute: str) -> float | None:
|
|
137
|
+
"""Return a numeric reading, or None when absent or unusable."""
|
|
138
|
+
value = self.get_value(attribute, None)
|
|
139
|
+
if value is None or value == STATE_UNKNOWN:
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
return float(value)
|
|
143
|
+
except (TypeError, ValueError):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def _optional_str(self, attribute: str) -> str | None:
|
|
147
|
+
"""Return a textual reading, or None when absent or unusable."""
|
|
148
|
+
value = self.get_value(attribute, None)
|
|
149
|
+
if value is None or value == STATE_UNKNOWN:
|
|
150
|
+
return None
|
|
151
|
+
return str(value)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_low_voltage(self) -> bool:
|
|
155
|
+
"""Whether this is a low voltage (24 V) thermostat.
|
|
156
|
+
|
|
157
|
+
Baseboard thermostats report none of the 24 V attributes and therefore
|
|
158
|
+
keep their heat-only behaviour.
|
|
159
|
+
"""
|
|
160
|
+
return self.low_voltage_mode is not None
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def low_voltage_mode(self) -> str | None:
|
|
164
|
+
"""Operating mode reported by a 24 V thermostat, in Hilo's vocabulary."""
|
|
165
|
+
return self._optional_str(LOW_VOLTAGE_MODE)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def allowed_modes(self) -> list[str]:
|
|
169
|
+
"""Mode vocabulary advertised by a 24 V thermostat."""
|
|
170
|
+
return as_list(self.get_value(LOW_VOLTAGE_ALLOWED_MODES, None))
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def fan_mode(self) -> str | None:
|
|
174
|
+
"""Current fan mode, when the device reports one."""
|
|
175
|
+
return self._optional_str("fan_mode")
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def allowed_fan_modes(self) -> list[str]:
|
|
179
|
+
"""Fan mode vocabulary advertised by a 24 V thermostat."""
|
|
180
|
+
return as_list(self.get_value(LOW_VOLTAGE_ALLOWED_FAN_MODES, None))
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def current_state(self) -> str | None:
|
|
184
|
+
"""Raw running state reported by a 24 V thermostat ("HEATING", "COOLING", "OFF")."""
|
|
185
|
+
return self._optional_str("current_state")
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def cool_setpoint(self) -> float | None:
|
|
189
|
+
"""Cooling setpoint of a 24 V thermostat."""
|
|
190
|
+
return self._optional_float("cool_temperature_set")
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def min_cool_setpoint(self) -> float | None:
|
|
194
|
+
"""Lowest cooling setpoint allowed by a 24 V thermostat."""
|
|
195
|
+
return self._optional_float("min_cool_setpoint")
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def max_cool_setpoint(self) -> float | None:
|
|
199
|
+
"""Highest cooling setpoint allowed by a 24 V thermostat."""
|
|
200
|
+
return self._optional_float("max_cool_setpoint")
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def current_humidity(self) -> int | None:
|
|
204
|
+
"""Ambient humidity, rounded to the nearest percent.
|
|
205
|
+
|
|
206
|
+
GraphqlValueMapper reports 0 for a device without a humidity sensor,
|
|
207
|
+
not None, so a reading of 0 is a real (if degenerate) value rather
|
|
208
|
+
than an absent one.
|
|
209
|
+
"""
|
|
210
|
+
value = self._optional_float("humidity")
|
|
211
|
+
return None if value is None else round(value)
|
|
212
|
+
|
|
213
|
+
async def async_set_low_voltage_state(
|
|
214
|
+
self,
|
|
215
|
+
mode: str | None = None,
|
|
216
|
+
target_temperature: float | None = None,
|
|
217
|
+
cool_setpoint: float | None = None,
|
|
218
|
+
fan_mode: str | None = None,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Write the mode and both setpoints together.
|
|
221
|
+
|
|
222
|
+
24 V thermostats reject partial writes: the mode and both setpoints
|
|
223
|
+
have to be sent in one request even when only one of them changed.
|
|
224
|
+
Arguments left out keep their current value; values the device does
|
|
225
|
+
not report at all are omitted from the payload.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: The mode isn't given and the device hasn't reported
|
|
229
|
+
one either. Silently dropping Thermostat24VMode from the
|
|
230
|
+
payload in that case would send setpoints without a mode,
|
|
231
|
+
exactly the partial write this method exists to prevent.
|
|
232
|
+
"""
|
|
233
|
+
resolved_mode = mode if mode is not None else self.low_voltage_mode
|
|
234
|
+
if resolved_mode is None:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"{self._tag} Cannot set low voltage state: no mode was given "
|
|
237
|
+
"and the device has not reported one. Pass mode explicitly."
|
|
238
|
+
)
|
|
239
|
+
payload: dict[str, str | int | float | None] = {
|
|
240
|
+
"Thermostat24VMode": resolved_mode,
|
|
241
|
+
"TargetTemperature": (
|
|
242
|
+
target_temperature
|
|
243
|
+
if target_temperature is not None
|
|
244
|
+
else self._optional_float("target_temperature")
|
|
245
|
+
),
|
|
246
|
+
"CoolTemperatureSet": (
|
|
247
|
+
cool_setpoint if cool_setpoint is not None else self.cool_setpoint
|
|
248
|
+
),
|
|
249
|
+
}
|
|
250
|
+
if fan_mode is not None:
|
|
251
|
+
payload["FanMode"] = fan_mode
|
|
252
|
+
payload = {k: v for k, v in payload.items() if v is not None}
|
|
253
|
+
LOG.info("%s Setting low voltage state to %s", self._tag, payload)
|
|
254
|
+
await self.set_attributes(payload)
|
|
@@ -84,7 +84,9 @@ class GraphqlValueMapper:
|
|
|
84
84
|
)
|
|
85
85
|
attributes.append(
|
|
86
86
|
self.build_attribute(
|
|
87
|
-
device["hiloId"],
|
|
87
|
+
device["hiloId"],
|
|
88
|
+
"Disconnected",
|
|
89
|
+
device["connectionStatus"] == "DISCONNECTED",
|
|
88
90
|
),
|
|
89
91
|
)
|
|
90
92
|
if device.get("power") is not None:
|
|
@@ -105,7 +107,9 @@ class GraphqlValueMapper:
|
|
|
105
107
|
)
|
|
106
108
|
attributes.append(
|
|
107
109
|
self.build_attribute(
|
|
108
|
-
device["hiloId"],
|
|
110
|
+
device["hiloId"],
|
|
111
|
+
"Disconnected",
|
|
112
|
+
device["connectionStatus"] == "DISCONNECTED",
|
|
109
113
|
)
|
|
110
114
|
) # Offline
|
|
111
115
|
attributes.append(
|
|
@@ -174,11 +178,14 @@ class GraphqlValueMapper:
|
|
|
174
178
|
device["hiloId"], "ZigbeeVersion", device["zigbeeVersion"]
|
|
175
179
|
)
|
|
176
180
|
)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
# Some models (e.g. the HT402) have no humidity sensor and always
|
|
182
|
+
# report 0, so only keep a real, non-zero reading.
|
|
183
|
+
if device.get("ambientHumidity"):
|
|
184
|
+
attributes.append(
|
|
185
|
+
self.build_attribute(
|
|
186
|
+
device["hiloId"], "Humidity", device["ambientHumidity"]
|
|
187
|
+
)
|
|
180
188
|
)
|
|
181
|
-
)
|
|
182
189
|
if device.get("allowedModes") is not None:
|
|
183
190
|
attributes.append(
|
|
184
191
|
self.build_attribute(
|
|
@@ -448,7 +455,7 @@ class GraphqlValueMapper:
|
|
|
448
455
|
self.build_attribute(
|
|
449
456
|
device["hiloId"],
|
|
450
457
|
"Disconnected",
|
|
451
|
-
device["connectionStatus"] == "
|
|
458
|
+
device["connectionStatus"] == "DISCONNECTED",
|
|
452
459
|
),
|
|
453
460
|
]
|
|
454
461
|
|