Skip to content

Commit 5991672

Browse files
author
Tom Lasswell
committed
feat: multi-SKU feature support — sensors, outlets, fan modes, dehumidifier (#114)
Phase A (read-only sensors + dehumidifier setpoint): - Air-quality index sensor via property::airQuality (H5106/H7124/H7126) - Filter remaining-life % sensor via property::filterLifeTime (H7124/H7126) - H7152 dehumidifier: read the real setpoint from range::humidity (was showing 0% because it read the Auto modeValue, which the H7152 pins to 80); set humidity via RangeCommand. H7150 (Auto-modeValue setpoint) path unchanged — gated on GoveeDevice.auto_mode_value_is_setpoint() - H7152 dehumidifier: expose the Medium gear mode (gearMode Low/Medium/High) Phase B (new controls): - H5089 per-outlet switches via socketToggle{N} (live state on poll) - H1310/H1370 separate Main/Background light toggles via main/backgroundLight Toggle (optimistic + RestoreEntity; API returns '' on poll) - H7124 fan presets: derive Sleep/Auto/Turbo from the work_mode capability instead of the hardcoded [Normal, Auto] (backward compatible) Adds GoveeDeviceState fields (air_quality, filter_life, configured_humidity, toggles) + parsing, device-model helpers, translations, and 33 tests. Deferred to follow-ups: nightlight brightness/colour/scene controls, dynamic_scene::snapshot, PM2.5 (not in the Developer API).
1 parent 4e6ddca commit 5991672

10 files changed

Lines changed: 1088 additions & 17 deletions

File tree

custom_components/govee/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ def descale_centi_temperature(sku: str, value: float) -> float:
145145
SUFFIX_REFRESH_SCENES: Final = "_refresh_scenes"
146146
SUFFIX_NIGHT_LIGHT: Final = "_night_light"
147147
SUFFIX_LIGHT_ZONE: Final = "_light_zone_"
148+
SUFFIX_SOCKET: Final = "_socket_"
149+
SUFFIX_MAIN_LIGHT: Final = "_main_light"
150+
SUFFIX_BACKGROUND_LIGHT: Final = "_background_light"
148151
SUFFIX_MUSIC_MODE: Final = "_music_mode"
149152
SUFFIX_MUSIC_SENSITIVITY: Final = "_music_sensitivity"
150153
SUFFIX_DREAMVIEW: Final = "_dreamview"

custom_components/govee/fan.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,28 @@ def __init__(
133133
# Build supported features based on device capabilities
134134
features = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
135135

136+
# Map preset name -> work_mode value for non-gear, non-Auto top-level
137+
# work modes (e.g. H7124's Sleep/Turbo, H7126's Custom). Normal
138+
# (gearMode) and Auto are always offered for backward compatibility.
139+
self._preset_work_modes: dict[str, int] = {}
136140
if device.supports_work_mode:
137141
features |= FanEntityFeature.SET_SPEED
138142
features |= FanEntityFeature.PRESET_MODE
139-
self._attr_preset_modes = FAN_PRESET_MODES
143+
extra_presets: list[str] = []
144+
for opt in device.get_fan_speed_options():
145+
wm = opt.get("work_mode")
146+
name = str(opt.get("name", ""))
147+
if wm is None or wm in (WORK_MODE_GEAR, WORK_MODE_AUTO) or not name:
148+
continue
149+
if name not in self._preset_work_modes:
150+
self._preset_work_modes[name] = int(wm)
151+
extra_presets.append(name)
152+
self._attr_preset_modes = FAN_PRESET_MODES + extra_presets
153+
154+
# Reverse lookup work_mode value -> preset name (issue #114).
155+
self._work_mode_to_preset: dict[int, str] = {
156+
wm: name for name, wm in self._preset_work_modes.items()
157+
}
140158

141159
if device.supports_oscillation:
142160
features |= FanEntityFeature.OSCILLATE
@@ -178,13 +196,17 @@ def preset_mode(self) -> str | None:
178196
Maps work_mode to preset:
179197
- 1 (gearMode) -> Normal
180198
- 3 (Auto) -> Auto
199+
- other top-level work modes (Sleep/Turbo/Custom) -> their own preset
200+
(issue #114)
181201
"""
182202
state = self.device_state
183203
if state is None or state.work_mode is None:
184204
return None
185205

186206
if state.work_mode == WORK_MODE_AUTO:
187207
return PRESET_MODE_AUTO
208+
if state.work_mode in self._work_mode_to_preset:
209+
return self._work_mode_to_preset[state.work_mode]
188210
return PRESET_MODE_NORMAL
189211

190212
@property
@@ -247,6 +269,11 @@ async def async_set_preset_mode(self, preset_mode: str) -> None:
247269
if preset_mode == PRESET_MODE_AUTO:
248270
work_mode = WORK_MODE_AUTO
249271
mode_value = 0 # Not used in auto mode
272+
elif preset_mode in self._preset_work_modes:
273+
# Sleep / Turbo / Custom etc. — top-level work mode, no speed value
274+
# (issue #114).
275+
work_mode = self._preset_work_modes[preset_mode]
276+
mode_value = 0
250277
else:
251278
# Normal mode - use current speed or default to medium
252279
work_mode = WORK_MODE_GEAR

custom_components/govee/humidifier.py

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828

2929
from .coordinator import GoveeCoordinator
3030
from .entity import GoveeEntity
31-
from .models import GoveeDevice, PowerCommand, WorkModeCommand
31+
from .models import GoveeDevice, PowerCommand, RangeCommand, WorkModeCommand
32+
from .models.device import INSTANCE_HUMIDITY
3233

3334
_LOGGER = logging.getLogger(__name__)
3435

@@ -37,16 +38,21 @@
3738
# Canonical mode names surfaced to Home Assistant. The set of modes offered
3839
# by a given device is intersected with these at entity-construction time.
3940
MODE_LOW = "low"
41+
MODE_MEDIUM = "medium"
4042
MODE_HIGH = "high"
4143
MODE_AUTO = "auto"
4244
MODE_DRYER = "dryer"
4345

46+
# gearMode sub-option names map onto these canonical HA modes.
47+
_GEAR_MODES = (MODE_LOW, MODE_MEDIUM, MODE_HIGH)
48+
4449
# Map canonical HA mode name -> (govee_mode_name, fallback_mode_value).
4550
# govee_mode_name is matched case-insensitively against the device's
4651
# work_mode and gear options. fallback_mode_value is used when the device's
4752
# capability doesn't specify a value (e.g. Dryer always sends 0).
4853
_MODE_ALIASES: dict[str, tuple[str, int]] = {
4954
MODE_LOW: ("low", 1),
55+
MODE_MEDIUM: ("medium", 2),
5056
MODE_HIGH: ("high", 3),
5157
MODE_AUTO: ("auto", 0),
5258
MODE_DRYER: ("dryer", 0),
@@ -100,6 +106,12 @@ def __init__(
100106
self._attr_min_humidity = min_h
101107
self._attr_max_humidity = max_h
102108

109+
# H7150 carries the target in the Auto modeValue; H7152 pins Auto to a
110+
# fixed point and carries the setpoint in a separate range::humidity
111+
# capability instead. Pick the right read/write path per device (#114).
112+
self._auto_modevalue_is_setpoint = device.auto_mode_value_is_setpoint()
113+
self._has_humidity_range = device.supports_humidity_range
114+
103115
# Build per-device maps from the capability so the entity honours
104116
# whatever the device actually advertises (values may vary by SKU).
105117
self._mode_to_work_mode: dict[str, int] = {}
@@ -113,7 +125,7 @@ def __init__(
113125
if value is None:
114126
continue
115127
for ha_mode, (alias, _default) in _MODE_ALIASES.items():
116-
if name == alias and ha_mode not in (MODE_LOW, MODE_HIGH):
128+
if name == alias and ha_mode not in _GEAR_MODES:
117129
self._mode_to_work_mode[ha_mode] = int(value)
118130
self._work_mode_to_mode[int(value)] = ha_mode
119131

@@ -134,7 +146,7 @@ def __init__(
134146
value = gear.get("value")
135147
if value is None:
136148
continue
137-
if name in (MODE_LOW, MODE_HIGH):
149+
if name in _GEAR_MODES:
138150
self._mode_to_work_mode[name] = gear_work_mode
139151
self._mode_to_mode_value[name] = int(value)
140152
self._gear_mode_values[name] = int(value)
@@ -145,7 +157,7 @@ def __init__(
145157
self._mode_to_mode_value.setdefault(ha_mode, _MODE_ALIASES[ha_mode][1])
146158

147159
# Final mode list, ordered for a consistent UI.
148-
ordered = [MODE_LOW, MODE_HIGH, MODE_AUTO, MODE_DRYER]
160+
ordered = [MODE_LOW, MODE_MEDIUM, MODE_HIGH, MODE_AUTO, MODE_DRYER]
149161
self._attr_available_modes = [
150162
m for m in ordered if m in self._mode_to_work_mode
151163
]
@@ -178,18 +190,26 @@ def mode(self) -> str | None:
178190

179191
@property
180192
def target_humidity(self) -> int | None:
181-
"""Return the target humidity percentage (Auto mode only)."""
193+
"""Return the target humidity percentage.
194+
195+
For H7150-style devices the setpoint lives in the Auto-mode modeValue
196+
and is only meaningful while in Auto. For H7152-style devices it lives
197+
in the persistent ``range::humidity`` capability and applies regardless
198+
of mode (issue #114).
199+
"""
182200
state = self.device_state
183201
if state is None:
184202
return None
185-
auto_work_mode = self._mode_to_work_mode.get(MODE_AUTO)
186-
if (
187-
auto_work_mode is not None
188-
and state.work_mode == auto_work_mode
189-
and state.mode_value is not None
190-
):
191-
return int(state.mode_value)
192-
return None
203+
if self._auto_modevalue_is_setpoint:
204+
auto_work_mode = self._mode_to_work_mode.get(MODE_AUTO)
205+
if (
206+
auto_work_mode is not None
207+
and state.work_mode == auto_work_mode
208+
and state.mode_value is not None
209+
):
210+
return int(state.mode_value)
211+
return None
212+
return state.configured_humidity
193213

194214
# --------------------------------------------------------------------- #
195215
# Commands
@@ -232,14 +252,27 @@ async def async_set_mode(self, mode: str) -> None:
232252
)
233253

234254
async def async_set_humidity(self, humidity: int) -> None:
235-
"""Set the target humidity — switches the device into Auto mode."""
255+
"""Set the target humidity.
256+
257+
H7150-style devices set it via the Auto-mode modeValue (WorkModeCommand);
258+
H7152-style devices set it via the dedicated ``range::humidity``
259+
capability (RangeCommand) — issue #114.
260+
"""
261+
clamped = max(self._attr_min_humidity, min(self._attr_max_humidity, humidity))
262+
263+
if not self._auto_modevalue_is_setpoint and self._has_humidity_range:
264+
await self.coordinator.async_control_device(
265+
self._device_id,
266+
RangeCommand(range_instance=INSTANCE_HUMIDITY, value=int(clamped)),
267+
)
268+
return
269+
236270
auto_work_mode = self._mode_to_work_mode.get(MODE_AUTO)
237271
if auto_work_mode is None:
238272
raise ValueError(
239273
f"{self._device.sku} does not support target-humidity (Auto) mode"
240274
)
241275

242-
clamped = max(self._attr_min_humidity, min(self._attr_max_humidity, humidity))
243276
await self.coordinator.async_control_device(
244277
self._device_id,
245278
WorkModeCommand(work_mode=auto_work_mode, mode_value=int(clamped)),

custom_components/govee/models/device.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@
108108
INSTANCE_SENSOR_TEMPERATURE = "sensorTemperature"
109109
INSTANCE_SENSOR_HUMIDITY = "sensorHumidity"
110110

111+
# Air-quality index + filter remaining-life, exposed as read-only sensors on
112+
# air-quality monitors (H5106) and air purifiers (H7124/H7126) — issue #114.
113+
INSTANCE_AIR_QUALITY = "airQuality"
114+
INSTANCE_FILTER_LIFE = "filterLifeTime"
115+
116+
# Separate main/background light toggles on ceiling-fan lights (H1310/H1370).
117+
# Distinct from the numeric ``light{N}Toggle`` zone toggles — issue #114.
118+
INSTANCE_MAIN_LIGHT_TOGGLE = "mainLightToggle"
119+
INSTANCE_BACKGROUND_LIGHT_TOGGLE = "backgroundLightToggle"
120+
111121
# Device type for stand-alone temperature/humidity sensors.
112122
DEVICE_TYPE_THERMOMETER = "devices.types.thermometer"
113123

@@ -336,6 +346,43 @@ def light_toggle_instances(self) -> list[str]:
336346
matches.append((int(m.group(1)), cap.instance))
337347
return [instance for _, instance in sorted(matches)]
338348

349+
@property
350+
def socket_toggle_instances(self) -> list[str]:
351+
"""Independently switchable outlets on multi-socket plugs (issue #114).
352+
353+
Outlet extenders like the H5089 expose each physical socket as a
354+
``socketToggle{N}`` ``devices.capabilities.toggle``. Unlike the
355+
main/background light toggles, the API returns a live 0/1 value for
356+
these on poll, so the switch reads real state. Returns instance names
357+
sorted by socket number, e.g. ``["socketToggle1", "socketToggle2"]``.
358+
"""
359+
pattern = re.compile(r"socketToggle(\d+)")
360+
matches: list[tuple[int, str]] = []
361+
for cap in self.capabilities:
362+
if cap.type != CAPABILITY_TOGGLE:
363+
continue
364+
m = pattern.fullmatch(cap.instance)
365+
if m:
366+
matches.append((int(m.group(1)), cap.instance))
367+
return [instance for _, instance in sorted(matches)]
368+
369+
@property
370+
def supports_main_light_toggle(self) -> bool:
371+
"""Check if device exposes a separate main-light toggle (H1310/H1370)."""
372+
return any(
373+
cap.type == CAPABILITY_TOGGLE and cap.instance == INSTANCE_MAIN_LIGHT_TOGGLE
374+
for cap in self.capabilities
375+
)
376+
377+
@property
378+
def supports_background_light_toggle(self) -> bool:
379+
"""Check if device exposes a separate background-light toggle (#114)."""
380+
return any(
381+
cap.type == CAPABILITY_TOGGLE
382+
and cap.instance == INSTANCE_BACKGROUND_LIGHT_TOGGLE
383+
for cap in self.capabilities
384+
)
385+
339386
@property
340387
def supports_scenes(self) -> bool:
341388
"""Check if device supports dynamic scenes."""
@@ -459,6 +506,28 @@ def supports_humidity_sensor(self) -> bool:
459506
for cap in self.capabilities
460507
)
461508

509+
@property
510+
def supports_air_quality(self) -> bool:
511+
"""Check if device exposes an airQuality property (H5106, H7124/H7126).
512+
513+
Read-only index surfaced as an HA sensor — issue #114.
514+
"""
515+
return any(
516+
cap.type == CAPABILITY_PROPERTY and cap.instance == INSTANCE_AIR_QUALITY
517+
for cap in self.capabilities
518+
)
519+
520+
@property
521+
def supports_filter_life(self) -> bool:
522+
"""Check if device exposes a filterLifeTime property (air purifiers).
523+
524+
Read-only remaining-life percentage surfaced as an HA sensor — #114.
525+
"""
526+
return any(
527+
cap.type == CAPABILITY_PROPERTY and cap.instance == INSTANCE_FILTER_LIFE
528+
for cap in self.capabilities
529+
)
530+
462531
@property
463532
def is_thermometer(self) -> bool:
464533
"""Check if device is a stand-alone thermometer/hygrometer."""
@@ -478,6 +547,38 @@ def get_humidity_range(self) -> tuple[int, int]:
478547
)
479548
return (30, 80)
480549

550+
@property
551+
def supports_humidity_range(self) -> bool:
552+
"""Check if device exposes a range::humidity setpoint capability (#114)."""
553+
return any(
554+
cap.type == CAPABILITY_RANGE and cap.instance == INSTANCE_HUMIDITY
555+
for cap in self.capabilities
556+
)
557+
558+
def auto_mode_value_is_setpoint(self) -> bool:
559+
"""Whether the Auto work-mode's modeValue carries the humidity setpoint.
560+
561+
The H7150 advertises Auto ``modeValue`` as a real range (e.g. 30–80) —
562+
the target humidity lives there. The H7152 pins Auto ``modeValue`` to a
563+
single point (80/80) and carries the real setpoint in the separate
564+
``range::humidity`` capability instead, so for it the modeValue is NOT
565+
the setpoint (issue #114). Returns False when there is no Auto modeValue
566+
range to read.
567+
"""
568+
for cap in self.capabilities:
569+
if cap.type != CAPABILITY_WORK_MODE or cap.instance != INSTANCE_WORK_MODE:
570+
continue
571+
for f in cap.parameters.get("fields", []):
572+
if f.get("fieldName") != "modeValue":
573+
continue
574+
for opt in f.get("options", []):
575+
if str(opt.get("name", "")).strip().lower() == "auto":
576+
rng = opt.get("range") or {}
577+
mn, mx = rng.get("min"), rng.get("max")
578+
if mn is not None and mx is not None:
579+
return bool(mn != mx)
580+
return False
581+
481582
def get_humidifier_work_mode_options(self) -> list[dict[str, Any]]:
482583
"""Extract top-level work mode options for humidifier/dehumidifier.
483584

0 commit comments

Comments
 (0)