Skip to content

Commit 46e0eb9

Browse files
author
Tom Lasswell
committed
fix: Recognize devices.types.air_purifier as fan+purifier (H7126) (#37)
The DEVICE_TYPE_PURIFIER constant held the wrong string — "devices.types.purifier" is not a real Govee API v2.0 value. Four independent primary sources (Govee Developer API docs, wez/govee2mqtt DeviceType enum, Mavrrick's Hubitat driver, and two independent H7126 fixtures from disforw/goveelife and CanDuru4/govee) all confirm the canonical string is "devices.types.air_purifier". The purifier code path was unreachable until now — no device ever matched the old constant, so no user ever got a working purifier entity. Replace the string rather than adding a second alias constant, and expand is_fan to also match purifiers so H7126-class devices produce a fan entity with the workMode gearMode speeds (Sleep/Low/High) and Auto preset that the existing fan platform already handles. Add test coverage for is_fan, is_purifier, is_light_device, and nested get_purifier_mode_options() on a mock H7126 shaped after the disforw fixture. is_purifier had zero test coverage previously — that is how the wrong string shipped undetected. Credit: kami587 diagnosed the issue in #37. This commit closes that PR with a tighter fix that removes the dead string instead of aliasing around it. See docs/_research/2026-04-08_pr-37-validation.md for full validation.
1 parent e072383 commit 46e0eb9

3 files changed

Lines changed: 104 additions & 3 deletions

File tree

custom_components/govee/models/device.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
DEVICE_TYPE_HEATER = "devices.types.heater"
3131
DEVICE_TYPE_HUMIDIFIER = "devices.types.humidifier"
3232
DEVICE_TYPE_FAN = "devices.types.fan"
33-
DEVICE_TYPE_PURIFIER = "devices.types.purifier"
33+
DEVICE_TYPE_PURIFIER = "devices.types.air_purifier"
3434

3535
# Instance constants
3636
INSTANCE_POWER = "powerSwitch"
@@ -290,8 +290,13 @@ def is_plug(self) -> bool:
290290

291291
@property
292292
def is_fan(self) -> bool:
293-
"""Check if device is a fan."""
294-
return self.device_type == DEVICE_TYPE_FAN
293+
"""Check if device is a fan or air purifier.
294+
295+
Air purifiers (devices.types.air_purifier, e.g. H7126) expose the same
296+
workMode capability shape as fans — gearMode speeds (Sleep/Low/High)
297+
plus an Auto preset — so they map onto the Home Assistant fan entity.
298+
"""
299+
return self.device_type in (DEVICE_TYPE_FAN, DEVICE_TYPE_PURIFIER)
295300

296301
@property
297302
def is_heater(self) -> bool:

tests/conftest.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
DEVICE_TYPE_LIGHT = "devices.types.light"
4343
DEVICE_TYPE_PLUG = "devices.types.socket"
4444
DEVICE_TYPE_FAN = "devices.types.fan"
45+
DEVICE_TYPE_AIR_PURIFIER = "devices.types.air_purifier"
4546

4647

4748
@pytest.fixture
@@ -437,6 +438,72 @@ def mock_fan_device(fan_capabilities) -> GoveeDevice:
437438
)
438439

439440

441+
@pytest.fixture
442+
def air_purifier_capabilities() -> tuple[GoveeCapability, ...]:
443+
"""Create capabilities for an H7126 air purifier (nested gearMode shape).
444+
445+
Mirrors the real API response captured in disforw/goveelife's H7126 fixture:
446+
workMode options = [gearMode, Custom, Auto]; modeValue.gearMode options =
447+
[Sleep, Low, High].
448+
"""
449+
return (
450+
GoveeCapability(
451+
type=CAPABILITY_ON_OFF,
452+
instance=INSTANCE_POWER,
453+
parameters={},
454+
),
455+
GoveeCapability(
456+
type=CAPABILITY_WORK_MODE,
457+
instance=INSTANCE_WORK_MODE,
458+
parameters={
459+
"dataType": "STRUCT",
460+
"fields": [
461+
{
462+
"fieldName": "workMode",
463+
"dataType": "ENUM",
464+
"options": [
465+
{"name": "gearMode", "value": 1},
466+
{"name": "Custom", "value": 2},
467+
{"name": "Auto", "value": 3},
468+
],
469+
},
470+
{
471+
"fieldName": "modeValue",
472+
"dataType": "ENUM",
473+
"options": [
474+
{"name": "gearMode", "options": [
475+
{"name": "Sleep", "value": 1},
476+
{"name": "Low", "value": 2},
477+
{"name": "High", "value": 3},
478+
]},
479+
{"defaultValue": 0, "name": "Custom"},
480+
{"defaultValue": 0, "name": "Auto"},
481+
],
482+
},
483+
],
484+
},
485+
),
486+
)
487+
488+
489+
@pytest.fixture
490+
def mock_air_purifier_device(air_purifier_capabilities) -> GoveeDevice:
491+
"""Create a mock H7126 air purifier device.
492+
493+
Uses the canonical ``devices.types.air_purifier`` device type as reported
494+
by the real Govee API v2.0 (confirmed against multiple independent fixtures
495+
and the official Govee Developer API docs).
496+
"""
497+
return GoveeDevice(
498+
device_id="AA:BB:CC:DD:EE:FF:00:66",
499+
sku="H7126",
500+
name="Bedroom Air Purifier",
501+
device_type=DEVICE_TYPE_AIR_PURIFIER,
502+
capabilities=air_purifier_capabilities,
503+
is_group=False,
504+
)
505+
506+
440507
@pytest.fixture
441508
def mock_fan_device_state() -> GoveeDeviceState:
442509
"""Create a mock fan device state."""

tests/test_models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,35 @@ def test_fan_not_light(self, mock_fan_device):
269269
assert mock_fan_device.is_light_device is False
270270
assert mock_fan_device.supports_power is True
271271

272+
def test_air_purifier_is_purifier(self, mock_air_purifier_device):
273+
"""H7126 (devices.types.air_purifier) must match is_purifier."""
274+
assert mock_air_purifier_device.is_purifier is True
275+
276+
def test_air_purifier_is_fan(self, mock_air_purifier_device):
277+
"""Air purifiers should also match is_fan so a fan entity gets created.
278+
279+
Air purifiers expose the same workMode/gearMode capability shape as
280+
fans, so they are represented in Home Assistant via the fan platform.
281+
"""
282+
assert mock_air_purifier_device.is_fan is True
283+
284+
def test_air_purifier_not_light(self, mock_air_purifier_device):
285+
"""Air purifiers must not be detected as light devices."""
286+
assert mock_air_purifier_device.is_light_device is False
287+
assert mock_air_purifier_device.is_plug is False
288+
289+
def test_air_purifier_mode_options(self, mock_air_purifier_device):
290+
"""get_purifier_mode_options should extract nested gearMode options."""
291+
options = mock_air_purifier_device.get_purifier_mode_options()
292+
names = [o.get("name") for o in options]
293+
assert "Sleep" in names
294+
assert "Low" in names
295+
assert "High" in names
296+
297+
def test_fan_is_not_purifier(self, mock_fan_device):
298+
"""A plain fan (devices.types.fan) must not match is_purifier."""
299+
assert mock_fan_device.is_purifier is False
300+
272301
def test_supports_hdmi_source(self, mock_hdmi_device):
273302
"""Test HDMI source support detection."""
274303
assert mock_hdmi_device.supports_hdmi_source is True

0 commit comments

Comments
 (0)