python-hilo 2026.10.2__tar.gz → 2026.11.1__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.
Files changed (75) hide show
  1. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.github/workflows/tests.yaml +2 -2
  2. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/PKG-INFO +1 -1
  3. python_hilo-2026.11.1/doc/graphql/onAnyDeviceUpdated-tstat.json +52 -0
  4. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/api.py +18 -0
  5. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/const.py +10 -1
  6. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/device/__init__.py +37 -3
  7. python_hilo-2026.11.1/pyhilo/device/climate.py +247 -0
  8. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/device/graphql_value_mapper.py +14 -7
  9. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyproject.toml +1 -1
  10. python_hilo-2026.11.1/tests/test_climate.py +238 -0
  11. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/tests/test_device.py +107 -1
  12. python_hilo-2026.11.1/tests/test_graphql_value_mapper.py +126 -0
  13. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/uv.lock +39 -29
  14. python_hilo-2026.10.2/pyhilo/device/climate.py +0 -100
  15. python_hilo-2026.10.2/tests/test_graphql_value_mapper.py +0 -23
  16. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.bandit.yaml +0 -0
  17. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.devcontainer/devcontainer-lock.json +0 -0
  18. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.devcontainer/devcontainer.json +0 -0
  19. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.editorconfig +0 -0
  20. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.gitattributes +0 -0
  21. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  22. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.github/ISSUE_TEMPLATE/issue.md +0 -0
  23. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.github/dependabot.yml +0 -0
  24. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.github/labels.yml +0 -0
  25. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.github/release-drafter.yml +0 -0
  26. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.github/workflows/labeler.yml +0 -0
  27. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.github/workflows/release-drafter.yml +0 -0
  28. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.gitignore +0 -0
  29. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/.pre-commit-config.yaml +0 -0
  30. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/AGENTS.md +0 -0
  31. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/LICENSE +0 -0
  32. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/README.md +0 -0
  33. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/README.md +0 -0
  34. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/graphql/onAnyDeviceUpdated-cee.json +0 -0
  35. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/graphql/onAnyDeviceUpdated-meter.json +0 -0
  36. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/postman/hilo-prod.postman_environment.json +0 -0
  37. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/postman/hilo.postman_collection.json +0 -0
  38. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/rest/location_weather.json +0 -0
  39. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/rest/season_events_ch_2025.json +0 -0
  40. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/rest/season_events_ch_2026_empty.json +0 -0
  41. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/rest/season_events_hilo_2024.json +0 -0
  42. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/rest/seasons_summary.json +0 -0
  43. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/ChallengeAdded.json +0 -0
  44. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/EventAdded.json +0 -0
  45. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/EventCHConsumptionUpdatedValuesReceived-during-event.json +0 -0
  46. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/EventCHConsumptionUpdatedValuesReceived.json +0 -0
  47. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/EventCHDetailsInitialValuesReceived.json +0 -0
  48. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/EventCHDetailsUpdatedValuesReceived.json +0 -0
  49. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/EventFlexConsumptionUpdatedValuesReceived.json +0 -0
  50. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/EventFlexDetailsInitialValuesReceived.json +0 -0
  51. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/EventFlexDetailsUpdatedValuesReceived.json +0 -0
  52. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/legacy-pre-2025/ChallengeAdded.json +0 -0
  53. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/legacy-pre-2025/ChallengeConsumptionUpdatedValuesReceived.json +0 -0
  54. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/legacy-pre-2025/ChallengeDetailsInitialValuesReceived.json +0 -0
  55. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/legacy-pre-2025/ChallengeListInitialValuesReceived.json +0 -0
  56. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/challengehub/legacy-pre-2025/ChallengeListUpdatedValuesReceived.json +0 -0
  57. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/doc/signalr/devicehub/DeviceListInitialValuesReceived.json +0 -0
  58. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/__init__.py +0 -0
  59. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/auth.py +0 -0
  60. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/device/light.py +0 -0
  61. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/device/sensor.py +0 -0
  62. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/device/switch.py +0 -0
  63. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/devices.py +0 -0
  64. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/event.py +0 -0
  65. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/exceptions.py +0 -0
  66. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/graphql.py +0 -0
  67. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/oauth2helper.py +0 -0
  68. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/signalr.py +0 -0
  69. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/util/__init__.py +0 -0
  70. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/pyhilo/util/state.py +0 -0
  71. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/tests/__init__.py +0 -0
  72. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/tests/test_api_auth.py +0 -0
  73. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/tests/test_signalr.py +0 -0
  74. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/tests/test_util.py +0 -0
  75. {python_hilo-2026.10.2 → python_hilo-2026.11.1}/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.1
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.1
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-hilo
3
- Version: 2026.10.2
3
+ Version: 2026.11.1
4
4
  Summary: A Python3, async interface to the Hilo API
5
5
  Project-URL: Repository, https://github.com/dvd-dev/python-hilo
6
6
  Author-email: David Vallee Delisle <me@dvd.dev>
@@ -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.10.02"
14
+ PYHILO_VERSION: Final = "2026.11.01"
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.utcnow().isoformat(),
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.utcnow().isoformat(),
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,247 @@
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
+ Returns:
106
+ str: 'heating' if heating is active, 'idle' otherwise.
107
+ """
108
+ attr = self.get_value("heating", 0)
109
+ return "heating" if attr > 0 else "idle"
110
+
111
+ async def async_set_temperature(self, temperature: float) -> None:
112
+ """
113
+ Sets the target temperature of the device.
114
+
115
+ Args:
116
+ temperature: The desired target temperature.
117
+ """
118
+ if self.is_low_voltage:
119
+ await self.async_set_low_voltage_state(target_temperature=temperature)
120
+ return
121
+ if temperature != self.target_temperature:
122
+ LOG.info("%s Setting temperature to %s", self._tag, temperature)
123
+ await self.set_attribute("target_temperature", str(temperature))
124
+
125
+ def _optional_float(self, attribute: str) -> float | None:
126
+ """Return a numeric reading, or None when absent or unusable."""
127
+ value = self.get_value(attribute, None)
128
+ if value is None or value == STATE_UNKNOWN:
129
+ return None
130
+ try:
131
+ return float(value)
132
+ except (TypeError, ValueError):
133
+ return None
134
+
135
+ def _optional_str(self, attribute: str) -> str | None:
136
+ """Return a textual reading, or None when absent or unusable."""
137
+ value = self.get_value(attribute, None)
138
+ if value is None or value == STATE_UNKNOWN:
139
+ return None
140
+ return str(value)
141
+
142
+ @property
143
+ def is_low_voltage(self) -> bool:
144
+ """Whether this is a low voltage (24 V) thermostat.
145
+
146
+ Baseboard thermostats report none of the 24 V attributes and therefore
147
+ keep their heat-only behaviour.
148
+ """
149
+ return self.low_voltage_mode is not None
150
+
151
+ @property
152
+ def low_voltage_mode(self) -> str | None:
153
+ """Operating mode reported by a 24 V thermostat, in Hilo's vocabulary."""
154
+ return self._optional_str(LOW_VOLTAGE_MODE)
155
+
156
+ @property
157
+ def allowed_modes(self) -> list[str]:
158
+ """Mode vocabulary advertised by a 24 V thermostat."""
159
+ return as_list(self.get_value(LOW_VOLTAGE_ALLOWED_MODES, None))
160
+
161
+ @property
162
+ def fan_mode(self) -> str | None:
163
+ """Current fan mode, when the device reports one."""
164
+ return self._optional_str("fan_mode")
165
+
166
+ @property
167
+ def allowed_fan_modes(self) -> list[str]:
168
+ """Fan mode vocabulary advertised by a 24 V thermostat."""
169
+ return as_list(self.get_value(LOW_VOLTAGE_ALLOWED_FAN_MODES, None))
170
+
171
+ @property
172
+ def current_state(self) -> str | None:
173
+ """Raw running state reported by a 24 V thermostat.
174
+
175
+ Exposed for consumers, but deliberately not mapped to hvac_action yet:
176
+ its vocabulary has not been observed in enough operating conditions.
177
+ """
178
+ return self._optional_str("current_state")
179
+
180
+ @property
181
+ def cool_setpoint(self) -> float | None:
182
+ """Cooling setpoint of a 24 V thermostat."""
183
+ return self._optional_float("cool_temperature_set")
184
+
185
+ @property
186
+ def min_cool_setpoint(self) -> float | None:
187
+ """Lowest cooling setpoint allowed by a 24 V thermostat."""
188
+ return self._optional_float("min_cool_setpoint")
189
+
190
+ @property
191
+ def max_cool_setpoint(self) -> float | None:
192
+ """Highest cooling setpoint allowed by a 24 V thermostat."""
193
+ return self._optional_float("max_cool_setpoint")
194
+
195
+ @property
196
+ def current_humidity(self) -> int | None:
197
+ """Ambient humidity, rounded to the nearest percent.
198
+
199
+ GraphqlValueMapper reports 0 for a device without a humidity sensor,
200
+ not None, so a reading of 0 is a real (if degenerate) value rather
201
+ than an absent one.
202
+ """
203
+ value = self._optional_float("humidity")
204
+ return None if value is None else round(value)
205
+
206
+ async def async_set_low_voltage_state(
207
+ self,
208
+ mode: str | None = None,
209
+ target_temperature: float | None = None,
210
+ cool_setpoint: float | None = None,
211
+ fan_mode: str | None = None,
212
+ ) -> None:
213
+ """Write the mode and both setpoints together.
214
+
215
+ 24 V thermostats reject partial writes: the mode and both setpoints
216
+ have to be sent in one request even when only one of them changed.
217
+ Arguments left out keep their current value; values the device does
218
+ not report at all are omitted from the payload.
219
+
220
+ Raises:
221
+ ValueError: The mode isn't given and the device hasn't reported
222
+ one either. Silently dropping Thermostat24VMode from the
223
+ payload in that case would send setpoints without a mode,
224
+ exactly the partial write this method exists to prevent.
225
+ """
226
+ resolved_mode = mode if mode is not None else self.low_voltage_mode
227
+ if resolved_mode is None:
228
+ raise ValueError(
229
+ f"{self._tag} Cannot set low voltage state: no mode was given "
230
+ "and the device has not reported one. Pass mode explicitly."
231
+ )
232
+ payload: dict[str, str | int | float | None] = {
233
+ "Thermostat24VMode": resolved_mode,
234
+ "TargetTemperature": (
235
+ target_temperature
236
+ if target_temperature is not None
237
+ else self._optional_float("target_temperature")
238
+ ),
239
+ "CoolTemperatureSet": (
240
+ cool_setpoint if cool_setpoint is not None else self.cool_setpoint
241
+ ),
242
+ }
243
+ if fan_mode is not None:
244
+ payload["FanMode"] = fan_mode
245
+ payload = {k: v for k, v in payload.items() if v is not None}
246
+ LOG.info("%s Setting low voltage state to %s", self._tag, payload)
247
+ 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"], "Disconnected", device["connectionStatus"] == 2
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"], "Disconnected", device["connectionStatus"] == 2
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
- attributes.append(
178
- self.build_attribute(
179
- device["hiloId"], "Humidity", device.get("ambientHumidity") or 0
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"] == "Disconnected",
458
+ device["connectionStatus"] == "DISCONNECTED",
452
459
  ),
453
460
  ]
454
461
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-hilo"
3
- version = "2026.10.2"
3
+ version = "2026.11.1"
4
4
  description = "A Python3, async interface to the Hilo API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"