Skip to content

Commit b7592ae

Browse files
author
Tom Lasswell
committed
feat: Temperature/humidity sensors for H5109 and friends (#62)
Adds sensor entities backed by the devices.capabilities.property sensorTemperature and sensorHumidity instances. Routing is capability- based, so H5109 (the issue reporter's device), H5179, and any other SKU in the same family pick up the entities without per-SKU logic. Both common API state shapes are parsed: a plain number under state.value, or the legacy {currentTemperature/currentHumidity} STRUCT under state.value. Leak-sensor support for H5054 is deferred — see the research doc; follow-up needs diagnostics from the reporter to identify the actual event capability instance.
1 parent a6e9b21 commit b7592ae

8 files changed

Lines changed: 374 additions & 2 deletions

File tree

custom_components/govee/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@
2424
"cryptography>=41.0.0"
2525
],
2626
"ssdp": [],
27-
"version": "2026.5.1",
27+
"version": "2026.5.2",
2828
"zeroconf": []
2929
}

custom_components/govee/models/device.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@
5959
INSTANCE_HUMIDITY = "humidity"
6060
INSTANCE_WATER_FULL_EVENT = "waterFullEvent"
6161

62+
# Read-only sensor property instances (devices.capabilities.property).
63+
# These appear on stand-alone sensors like H5179 (WiFi Thermometer) and
64+
# H5109 (Smart Temperature Sensor) — issue #62.
65+
INSTANCE_SENSOR_TEMPERATURE = "sensorTemperature"
66+
INSTANCE_SENSOR_HUMIDITY = "sensorHumidity"
67+
68+
# Device type for stand-alone temperature/humidity sensors.
69+
DEVICE_TYPE_THERMOMETER = "devices.types.thermometer"
70+
6271

6372
@dataclass(frozen=True)
6473
class ColorTempRange:
@@ -345,6 +354,30 @@ def supports_water_full_event(self) -> bool:
345354
for cap in self.capabilities
346355
)
347356

357+
@property
358+
def supports_temperature_sensor(self) -> bool:
359+
"""Check if device exposes a sensorTemperature property (e.g. H5109,
360+
H5179). The capability is read-only — surfaced as an HA sensor."""
361+
return any(
362+
cap.type == CAPABILITY_PROPERTY
363+
and cap.instance == INSTANCE_SENSOR_TEMPERATURE
364+
for cap in self.capabilities
365+
)
366+
367+
@property
368+
def supports_humidity_sensor(self) -> bool:
369+
"""Check if device exposes a sensorHumidity property."""
370+
return any(
371+
cap.type == CAPABILITY_PROPERTY
372+
and cap.instance == INSTANCE_SENSOR_HUMIDITY
373+
for cap in self.capabilities
374+
)
375+
376+
@property
377+
def is_thermometer(self) -> bool:
378+
"""Check if device is a stand-alone thermometer/hygrometer."""
379+
return self.device_type == DEVICE_TYPE_THERMOMETER
380+
348381
def get_humidity_range(self) -> tuple[int, int]:
349382
"""Extract target humidity range from the range.humidity capability.
350383

custom_components/govee/models/state.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ class GoveeDeviceState:
123123
# ``work_mode``. Only the event flag needs its own field.
124124
water_full: bool | None = None # Dehumidifier water-tank-full event
125125

126+
# Read-only sensor properties (devices.capabilities.property) for
127+
# stand-alone sensors like H5109/H5179. None until first poll lands.
128+
sensor_temperature: float | None = None # Celsius (always; entity converts)
129+
sensor_humidity: float | None = None # Relative humidity 0-100 %
130+
126131
# Last activated scene (for restoring after music mode off)
127132
last_scene_id: str | None = None
128133
last_scene_name: str | None = None
@@ -201,6 +206,34 @@ def update_from_api(self, data: dict[str, Any]) -> None:
201206
if instance == "hdmiSource":
202207
self.hdmi_source = int(value) if value is not None else None
203208

209+
elif cap_type == "devices.capabilities.property":
210+
# Read-only sensor properties on devices like H5109 and
211+
# H5179 (issue #62). The state shape varies by SKU — some
212+
# return a plain number under "value", others return a
213+
# STRUCT. Accept both, plus the legacy "currentX" field
214+
# naming used by older WiFi sensors.
215+
if instance in ("sensorTemperature", "sensorHumidity"):
216+
parsed: float | None = None
217+
if isinstance(value, (int, float)):
218+
parsed = float(value)
219+
elif isinstance(value, dict):
220+
for key in (
221+
"currentTemperature",
222+
"currentHumidity",
223+
"value",
224+
"temperature",
225+
"humidity",
226+
):
227+
sub = value.get(key)
228+
if isinstance(sub, (int, float)):
229+
parsed = float(sub)
230+
break
231+
if parsed is not None:
232+
if instance == "sensorTemperature":
233+
self.sensor_temperature = parsed
234+
else:
235+
self.sensor_humidity = parsed
236+
204237
elif cap_type == "devices.capabilities.event":
205238
# Event capabilities (e.g. waterFullEvent) report a boolean-
206239
# ish value when the event is active. Some backends also

custom_components/govee/sensor.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Provides sensor entities for:
44
- Rate limit remaining (diagnostic)
55
- MQTT connection status (diagnostic)
6+
- Temperature / humidity properties on stand-alone sensors (H5109, H5179)
67
"""
78

89
from __future__ import annotations
@@ -15,14 +16,20 @@
1516
SensorStateClass,
1617
)
1718
from homeassistant.config_entries import ConfigEntry
18-
from homeassistant.const import EntityCategory
19+
from homeassistant.const import (
20+
EntityCategory,
21+
PERCENTAGE,
22+
UnitOfTemperature,
23+
)
1924
from homeassistant.core import HomeAssistant
2025
from homeassistant.helpers.entity import DeviceInfo # type: ignore[attr-defined]
2126
from homeassistant.helpers.entity_platform import AddEntitiesCallback
2227
from homeassistant.helpers.update_coordinator import CoordinatorEntity
2328

2429
from .const import DOMAIN
2530
from .coordinator import GoveeCoordinator
31+
from .entity import GoveeEntity
32+
from .models import GoveeDevice
2633

2734
_LOGGER = logging.getLogger(__name__)
2835

@@ -43,6 +50,18 @@ async def async_setup_entry(
4350
if coordinator.mqtt_client is not None:
4451
entities.append(GoveeMqttStatusSensor(coordinator, entry.entry_id))
4552

53+
# Per-device temperature / humidity sensors for stand-alone sensors
54+
# like H5109 and H5179 (issue #62). Anything that exposes the
55+
# corresponding `property` capability gets the entity, regardless of
56+
# device_type — the integration shouldn't have to know about every SKU.
57+
for device in coordinator.devices.values():
58+
if device.is_group:
59+
continue
60+
if device.supports_temperature_sensor:
61+
entities.append(GoveeTemperatureSensor(coordinator, device))
62+
if device.supports_humidity_sensor:
63+
entities.append(GoveeHumiditySensor(coordinator, device))
64+
4665
async_add_entities(entities)
4766
_LOGGER.debug("Set up %d Govee sensor entities", len(entities))
4867

@@ -134,3 +153,54 @@ def native_value(self) -> str:
134153
if mqtt_client is None:
135154
return "unavailable"
136155
return "connected" if mqtt_client.connected else "disconnected"
156+
157+
158+
class GoveeTemperatureSensor(GoveeEntity, SensorEntity):
159+
"""Read-only temperature reading from devices like H5109 and H5179.
160+
161+
Backed by the ``devices.capabilities.property`` / ``sensorTemperature``
162+
capability. Values are pushed through the standard coordinator state
163+
flow so MQTT updates and API polls both feed it.
164+
"""
165+
166+
_attr_has_entity_name = True
167+
_attr_translation_key = "sensor_temperature"
168+
_attr_device_class = SensorDeviceClass.TEMPERATURE
169+
_attr_state_class = SensorStateClass.MEASUREMENT
170+
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
171+
172+
def __init__(
173+
self,
174+
coordinator: GoveeCoordinator,
175+
device: GoveeDevice,
176+
) -> None:
177+
super().__init__(coordinator, device)
178+
self._attr_unique_id = f"{device.device_id}_temperature"
179+
180+
@property
181+
def native_value(self) -> float | None:
182+
state = self.device_state
183+
return state.sensor_temperature if state else None
184+
185+
186+
class GoveeHumiditySensor(GoveeEntity, SensorEntity):
187+
"""Read-only humidity reading from devices like H5109 and H5179."""
188+
189+
_attr_has_entity_name = True
190+
_attr_translation_key = "sensor_humidity"
191+
_attr_device_class = SensorDeviceClass.HUMIDITY
192+
_attr_state_class = SensorStateClass.MEASUREMENT
193+
_attr_native_unit_of_measurement = PERCENTAGE
194+
195+
def __init__(
196+
self,
197+
coordinator: GoveeCoordinator,
198+
device: GoveeDevice,
199+
) -> None:
200+
super().__init__(coordinator, device)
201+
self._attr_unique_id = f"{device.device_id}_humidity"
202+
203+
@property
204+
def native_value(self) -> float | None:
205+
state = self.device_state
206+
return state.sensor_humidity if state else None

custom_components/govee/strings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@
161161
},
162162
"mqtt_status": {
163163
"name": "MQTT Status"
164+
},
165+
"sensor_temperature": {
166+
"name": "Temperature"
167+
},
168+
"sensor_humidity": {
169+
"name": "Humidity"
164170
}
165171
},
166172
"binary_sensor": {

custom_components/govee/translations/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@
161161
},
162162
"mqtt_status": {
163163
"name": "MQTT Status"
164+
},
165+
"sensor_temperature": {
166+
"name": "Temperature"
167+
},
168+
"sensor_humidity": {
169+
"name": "Humidity"
164170
}
165171
},
166172
"binary_sensor": {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Issue #62 — Temperature/humidity + leak sensor support
2+
3+
**Date**: 2026-05-01
4+
**Type**: Feature implementation (partial) + open follow-up
5+
**Issue**: [#62](https://github.com/lasswellt/govee-homeassistant/issues/62)
6+
**Reporter**: steamer70 — wants H5054 (water detector) and H5109 (smart temperature sensor) support.
7+
8+
---
9+
10+
## Summary
11+
12+
Two devices, two different capability shapes. Implemented the well-documented half (temperature/humidity via the standard `devices.capabilities.property` capability — covers H5109, H5179, and any future SKU that exposes the same instances). Deferred the leak-detector half (H5054) pending diagnostics from the reporter — without ground truth on what the cloud API returns, the right binary-sensor instance name is a guess, and PR #56 is already negotiating the related but distinct hub-LoRa H5058 path.
13+
14+
## H5109 — implemented
15+
16+
Per `docs/govee-protocol-reference.md` §8.5 the canonical thermometer SKU is H5179, which exposes:
17+
18+
```json
19+
{"capabilities": [
20+
{"type": "devices.capabilities.property", "instance": "sensorTemperature"},
21+
{"type": "devices.capabilities.property", "instance": "sensorHumidity"}
22+
]}
23+
```
24+
25+
H5109 is the "Smart Temperature Sensor" in the same family and is expected to expose the same capability shape. The integration now:
26+
27+
- Adds `INSTANCE_SENSOR_TEMPERATURE` / `INSTANCE_SENSOR_HUMIDITY` constants (`models/device.py`).
28+
- Adds `DEVICE_TYPE_THERMOMETER` and `is_thermometer` for completeness, but routing is **capability-based, not SKU-based** — anything that exposes those property instances picks up the entities, regardless of `device_type`.
29+
- Adds `supports_temperature_sensor` / `supports_humidity_sensor` on `GoveeDevice`.
30+
- Adds `sensor_temperature` / `sensor_humidity` fields on `GoveeDeviceState` and parses them in `update_from_api`. The parser handles both shapes seen in the wild — `state.value` as a plain number, and the legacy `state.value.currentTemperature` / `state.value.currentHumidity` STRUCT.
31+
- Adds `GoveeTemperatureSensor` / `GoveeHumiditySensor` entities in `sensor.py`, each gated on the matching `supports_*` predicate so they don't appear on light devices that happen to share the platform.
32+
- Translation strings updated in `strings.json` and `translations/en.json`.
33+
- Tests in `tests/test_thermometer.py` exercise both the H5109 and H5179 device shapes plus both API state-payload variants.
34+
35+
Because the gating is capability-based, this also covers any other Govee SKU that exposes the same instances — H5074, H5075, H5101, H5102, etc. — for free.
36+
37+
## H5054 — deferred
38+
39+
The H5054 is a stand-alone water sensor with a different lineage from H5058. PR #56 hardcodes `LEAK_SENSOR_SKUS = {"H5058", "H5054", "H5055"}` but routes everything through the H5043 hub's LoRa multiSync MQTT path — that's correct for H5058 but speculative for H5054, which (per Govee's product pages) does not advertise as a hub-paired sensor.
40+
41+
Without a diagnostics dump from the reporter we don't know:
42+
- The actual `device_type` returned by `/device/list/v1`.
43+
- Whether the leak signal is exposed as `devices.capabilities.event` (and under which instance name — `leakEvent`? `waterDetectionEvent`? something else) or as a `property`.
44+
- Whether battery is exposed at all.
45+
46+
Action: comment on the issue asking for diagnostics, then add a generic event-driven leak binary sensor once we have a real payload. The temperature/humidity work shipping today is decoupled and doesn't block this follow-up.
47+
48+
## Tests
49+
50+
- `tests/test_thermometer.py` (8 tests) — capability detection, state parsing both shapes, missing-value safety, light device must not pick up sensors.
51+
52+
Full suite: 697 → 705 passing. Mypy clean. Flake8 clean.

0 commit comments

Comments
 (0)