Skip to content

Commit 56db117

Browse files
author
Tom Lasswell
committed
feat: nightlight controls for outlet extenders & purifiers (#114 Phase C)
Adds a dedicated nightlight light entity + nightlight-scene select for appliances whose only light is the nightlight (H5089 outlet extender, H7124 air purifier): - GoveeNightLightEntity: on/off via nightlightToggle (NOT the outlet's powerSwitch), brightness + RGB from the shared range/colour capabilities - GoveeNightlightSceneSelectEntity: named nightlightScene modes (Forest/Ocean/ ... read per-SKU from the capability, since H5089 is 0-based and H7124 1-based) - New device helpers has_nightlight_light / supports_nightlight_scene, state fields nightlight_scene + nightlightToggle (via generic toggles dict) BREAKING (H5089 outlet extender): its colour/brightness belong to the nightlight, not the outlet, so the previously conflated main light entity (whose on/off toggled the outlet's powerSwitch — refines #59) is replaced by the nightlight light entity, and the separate night-light on/off switch is removed for devices that get the richer light entity. Real RGB lights with a nightlightToggle are unaffected (main light kept, nightlight stays a switch). Adds 20 tests (53 total for #114).
1 parent 5991672 commit 56db117

9 files changed

Lines changed: 572 additions & 4 deletions

File tree

custom_components/govee/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,4 @@ def descale_centi_temperature(sku: str, value: float) -> float:
156156
SUFFIX_HEATER_AUTO_STOP: Final = "_heater_auto_stop"
157157
SUFFIX_PURIFIER_MODE_SELECT: Final = "_purifier_mode_select"
158158
SUFFIX_PRESET_SCENE_SELECT: Final = "_preset_scene_select"
159+
SUFFIX_NIGHTLIGHT_SCENE_SELECT: Final = "_nightlight_scene_select"

custom_components/govee/light.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@
4545
PowerCommand,
4646
RGBColor,
4747
SceneCommand,
48+
ToggleCommand,
4849
)
50+
from .models.device import INSTANCE_NIGHT_LIGHT
4951
from .platforms.grouped_segment import GoveeGroupedSegmentEntity
5052
from .platforms.segment import GoveeSegmentEntity
5153

@@ -81,6 +83,13 @@ async def async_setup_entry(
8183
if device.is_light_device and device.supports_power:
8284
entities.append(GoveeLightEntity(coordinator, device, enable_scenes))
8385

86+
# Appliances whose only light is the nightlight (e.g. H5089 outlet
87+
# extender, H7124 purifier) get a dedicated nightlight light entity —
88+
# on/off via nightlightToggle, brightness + colour from the shared
89+
# range/colour capabilities (issue #114).
90+
if device.has_nightlight_light and not device.is_group:
91+
entities.append(GoveeNightLightEntity(coordinator, device))
92+
8493
# Create segment entities for RGBIC devices based on per-device mode
8594
if device.supports_segments and device.segment_count > 0:
8695
# Use per-device mode if set, otherwise default to individual
@@ -399,3 +408,160 @@ async def async_added_to_hass(self) -> None:
399408
scenes = await self.coordinator.async_get_scenes(self._device_id)
400409
if scenes:
401410
self._build_effect_mapping(scenes)
411+
412+
413+
class GoveeNightLightEntity(GoveeEntity, LightEntity):
414+
"""Dedicated light entity for an appliance's nightlight (issue #114).
415+
416+
Used for appliances whose only light is the nightlight — the H5089 outlet
417+
extender and H7124 air purifier. On/off uses the ``nightlightToggle``
418+
capability (NOT the device's ``powerSwitch``, which is the outlet/appliance),
419+
while brightness and RGB colour come from the shared
420+
``range::brightness`` / ``color_setting::colorRgb`` capabilities that, on
421+
these appliances, belong to the nightlight. The named nightlightScene modes
422+
are surfaced by a separate select entity.
423+
"""
424+
425+
_attr_translation_key = "govee_nightlight"
426+
_attr_icon = "mdi:lightbulb-night"
427+
428+
def __init__(
429+
self,
430+
coordinator: GoveeCoordinator,
431+
device: GoveeDevice,
432+
) -> None:
433+
"""Initialize the nightlight entity."""
434+
super().__init__(coordinator, device)
435+
self._attr_unique_id = f"{device.device_id}_nightlight"
436+
437+
modes: set[ColorMode] = set()
438+
if device.supports_rgb:
439+
modes.add(ColorMode.RGB)
440+
if device.supports_color_temp:
441+
modes.add(ColorMode.COLOR_TEMP)
442+
if not modes and device.supports_brightness:
443+
modes.add(ColorMode.BRIGHTNESS)
444+
if not modes:
445+
modes.add(ColorMode.ONOFF)
446+
self._attr_supported_color_modes = modes
447+
448+
self._brightness_min, self._brightness_max = device.brightness_range
449+
450+
def _ha_to_device_brightness(self, ha_brightness: int) -> int:
451+
ratio = ha_brightness / HA_BRIGHTNESS_MAX
452+
result = int(
453+
self._brightness_min + ratio * (self._brightness_max - self._brightness_min)
454+
)
455+
return max(self._brightness_min, min(self._brightness_max, result))
456+
457+
def _device_to_ha_brightness(self, device_brightness: int) -> int:
458+
device_range = self._brightness_max - self._brightness_min
459+
if device_range <= 0:
460+
return 0
461+
result = int(
462+
(device_brightness - self._brightness_min)
463+
/ device_range
464+
* HA_BRIGHTNESS_MAX
465+
)
466+
return max(0, min(HA_BRIGHTNESS_MAX, result))
467+
468+
@property
469+
def color_mode(self) -> ColorMode:
470+
"""Return current colour mode (always within supported_color_modes)."""
471+
state = self.device_state
472+
modes = self.supported_color_modes or {ColorMode.ONOFF}
473+
if state and state.color_temp_kelvin is not None and ColorMode.COLOR_TEMP in modes:
474+
return ColorMode.COLOR_TEMP
475+
if state and state.color is not None and ColorMode.RGB in modes:
476+
return ColorMode.RGB
477+
if ColorMode.RGB in modes:
478+
return ColorMode.RGB
479+
if ColorMode.BRIGHTNESS in modes:
480+
return ColorMode.BRIGHTNESS
481+
if ColorMode.COLOR_TEMP in modes:
482+
return ColorMode.COLOR_TEMP
483+
return ColorMode(next(iter(modes)))
484+
485+
@property
486+
def is_on(self) -> bool | None:
487+
"""Return True if the nightlight is on (live nightlightToggle state)."""
488+
state = self.device_state
489+
if state is None:
490+
return None
491+
return state.toggles.get(INSTANCE_NIGHT_LIGHT)
492+
493+
@property
494+
def brightness(self) -> int | None:
495+
"""Return nightlight brightness (0-255)."""
496+
state = self.device_state
497+
if state is None:
498+
return None
499+
return self._device_to_ha_brightness(state.brightness)
500+
501+
@property
502+
def rgb_color(self) -> tuple[int, int, int] | None:
503+
"""Return nightlight RGB colour."""
504+
state = self.device_state
505+
if state and state.color:
506+
return state.color.as_tuple
507+
return None
508+
509+
@property
510+
def color_temp_kelvin(self) -> int | None:
511+
"""Return nightlight colour temperature in Kelvin."""
512+
state = self.device_state
513+
return state.color_temp_kelvin if state and state.color_temp_kelvin else None
514+
515+
@property
516+
def min_color_temp_kelvin(self) -> int:
517+
"""Return minimum colour temperature in Kelvin."""
518+
temp_range = self._device.color_temp_range
519+
return temp_range.min_kelvin if temp_range else 2000
520+
521+
@property
522+
def max_color_temp_kelvin(self) -> int:
523+
"""Return maximum colour temperature in Kelvin."""
524+
temp_range = self._device.color_temp_range
525+
return temp_range.max_kelvin if temp_range else 9000
526+
527+
async def _set_toggle(self, enabled: bool) -> bool:
528+
success = await self.coordinator.async_control_device(
529+
self._device_id,
530+
ToggleCommand(toggle_instance=INSTANCE_NIGHT_LIGHT, enabled=enabled),
531+
)
532+
if success:
533+
state = self.device_state
534+
if state is not None:
535+
state.toggles[INSTANCE_NIGHT_LIGHT] = enabled
536+
self.async_write_ha_state()
537+
return success
538+
539+
async def async_turn_on(self, **kwargs: Any) -> None:
540+
"""Turn the nightlight on with optional brightness/colour."""
541+
if ATTR_BRIGHTNESS in kwargs:
542+
device_brightness = self._ha_to_device_brightness(kwargs[ATTR_BRIGHTNESS])
543+
await self.coordinator.async_control_device(
544+
self._device_id, BrightnessCommand(brightness=device_brightness)
545+
)
546+
547+
if ATTR_RGB_COLOR in kwargs:
548+
r, g, b = kwargs[ATTR_RGB_COLOR]
549+
await self.coordinator.async_control_device(
550+
self._device_id, ColorCommand(color=RGBColor(r=r, g=g, b=b))
551+
)
552+
553+
if ATTR_COLOR_TEMP_KELVIN in kwargs:
554+
await self.coordinator.async_control_device(
555+
self._device_id, ColorTempCommand(kelvin=kwargs[ATTR_COLOR_TEMP_KELVIN])
556+
)
557+
558+
has_attribute = any(
559+
k in kwargs
560+
for k in (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_COLOR_TEMP_KELVIN)
561+
)
562+
if not has_attribute or not self.is_on:
563+
await self._set_toggle(True)
564+
565+
async def async_turn_off(self, **kwargs: Any) -> None:
566+
"""Turn the nightlight off."""
567+
await self._set_toggle(False)

custom_components/govee/models/device.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@
6666
INSTANCE_SCENE = "lightScene"
6767
INSTANCE_DIY = "diyScene"
6868
INSTANCE_NIGHT_LIGHT = "nightlightToggle"
69+
# Named light+colour scenes for an appliance's nightlight (H5089/H7124) — a
70+
# devices.capabilities.mode whose options carry the localized name + integer id
71+
# the control payload uses (issue #114).
72+
INSTANCE_NIGHTLIGHT_SCENE = "nightlightScene"
6973
INSTANCE_GRADUAL_ON = "gradientToggle"
7074
INSTANCE_TIMER = "timer"
7175
INSTANCE_OSCILLATION = "oscillationToggle"
@@ -933,6 +937,12 @@ def is_light_device(self) -> bool:
933937
or self.is_aroma_diffuser
934938
):
935939
return False
940+
# An outlet extender whose colour belongs to a nightlight (it also
941+
# exposes nightlightToggle) is represented by a dedicated nightlight
942+
# light entity, not a conflated main light on the outlet's powerSwitch
943+
# (issue #114, refines #59).
944+
if self.is_plug and self.supports_night_light:
945+
return False
936946
if self.is_plug and not (self.supports_rgb or self.supports_color_temp):
937947
return False
938948
return (
@@ -941,6 +951,38 @@ def is_light_device(self) -> bool:
941951
or self.supports_color_temp
942952
)
943953

954+
@property
955+
def has_nightlight_light(self) -> bool:
956+
"""Whether the device's nightlight warrants a full light entity (#114).
957+
958+
True for appliances whose only light is the nightlight (e.g. the H5089
959+
outlet extender, H7124 purifier): the nightlight has on/off
960+
(nightlightToggle) plus brightness/colour, so it maps onto a light
961+
entity. A real light (is_light_device) owns brightness/colour for its
962+
main light, so its nightlight stays a simple on/off switch.
963+
"""
964+
if self.is_light_device:
965+
return False
966+
return self.supports_night_light and (
967+
self.supports_brightness or self.supports_rgb or self.supports_color_temp
968+
)
969+
970+
@property
971+
def supports_nightlight_scene(self) -> bool:
972+
"""Check if device exposes a nightlightScene mode capability (#114)."""
973+
return any(
974+
cap.type == CAPABILITY_MODE and cap.instance == INSTANCE_NIGHTLIGHT_SCENE
975+
for cap in self.capabilities
976+
)
977+
978+
def get_nightlight_scene_options(self) -> list[dict[str, Any]]:
979+
"""Extract nightlightScene options as {"name", "value"} dicts (#114)."""
980+
for cap in self.capabilities:
981+
if cap.type == CAPABILITY_MODE and cap.instance == INSTANCE_NIGHTLIGHT_SCENE:
982+
options: list[dict[str, Any]] = cap.parameters.get("options", [])
983+
return options
984+
return []
985+
944986
@property
945987
def brightness_range(self) -> tuple[int, int]:
946988
"""Get brightness range from capability. Default (0, 100)."""

custom_components/govee/models/state.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ class GoveeDeviceState:
181181
# named scene. Often "" on poll, so the select is optimistic like HDMI/scene.
182182
preset_scene: int | None = None
183183

184+
# Appliance nightlight scene (H5089/H7124, issue #114): integer id of the
185+
# active named nightlightScene mode option.
186+
nightlight_scene: int | None = None
187+
184188
# DreamView (Movie Mode) state
185189
dreamview_enabled: bool | None = None # DreamView on/off
186190

@@ -323,6 +327,11 @@ def update_from_api(self, data: dict[str, Any]) -> None:
323327
self.hdmi_source = _coerce_int(value)
324328
elif instance == "presetScene":
325329
self.preset_scene = _coerce_int(value)
330+
elif instance == "nightlightScene":
331+
# Appliance nightlight named scene (H5089/H7124, #114).
332+
parsed_ns = _coerce_int(value)
333+
if parsed_ns is not None:
334+
self.nightlight_scene = parsed_ns
326335

327336
elif cap_type == "devices.capabilities.property":
328337
# Read-only sensor properties on devices like H5109 and

custom_components/govee/select.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
SUFFIX_HDMI_SOURCE_SELECT,
2626
SUFFIX_HEATER_FAN_SPEED,
2727
SUFFIX_MUSIC_MODE_SELECT,
28+
SUFFIX_NIGHTLIGHT_SCENE_SELECT,
2829
SUFFIX_PRESET_SCENE_SELECT,
2930
SUFFIX_PURIFIER_MODE_SELECT,
3031
SUFFIX_SCENE_SELECT,
@@ -40,6 +41,7 @@
4041
)
4142
from .models.device import (
4243
INSTANCE_HDMI_SOURCE,
44+
INSTANCE_NIGHTLIGHT_SCENE,
4345
INSTANCE_PRESET_SCENE,
4446
INSTANCE_PURIFIER_MODE,
4547
)
@@ -216,6 +218,24 @@ async def async_setup_entry(
216218
len(preset_scene_options),
217219
)
218220

221+
# Nightlight scene selector for appliances with a nightlight (H5089
222+
# outlet extender, H7124 purifier) — issue #114.
223+
if device.supports_nightlight_scene:
224+
nightlight_scene_options = device.get_nightlight_scene_options()
225+
if nightlight_scene_options:
226+
entities.append(
227+
GoveeNightlightSceneSelectEntity(
228+
coordinator=coordinator,
229+
device=device,
230+
options=nightlight_scene_options,
231+
)
232+
)
233+
_LOGGER.debug(
234+
"Created nightlight scene select entity for %s with %d scenes",
235+
device.name,
236+
len(nightlight_scene_options),
237+
)
238+
219239
async_add_entities(entities)
220240
_LOGGER.debug("Set up %d Govee scene select entities", len(entities))
221241

@@ -635,6 +655,71 @@ async def async_select_option(self, option: str) -> None:
635655
)
636656

637657

658+
class GoveeNightlightSceneSelectEntity(GoveeEntity, SelectEntity):
659+
"""Govee nightlight scene select entity (issue #114).
660+
661+
Dropdown of the named ``nightlightScene`` modes (e.g. Forest, Ocean) on
662+
appliances with a nightlight — the H5089 outlet extender and H7124 air
663+
purifier.
664+
"""
665+
666+
_attr_translation_key = "govee_nightlight_scene_select"
667+
_attr_icon = "mdi:weather-night"
668+
669+
def __init__(
670+
self,
671+
coordinator: GoveeCoordinator,
672+
device: GoveeDevice,
673+
options: list[dict[str, Any]],
674+
) -> None:
675+
"""Initialize the nightlight scene select entity."""
676+
super().__init__(coordinator, device)
677+
678+
self._option_map: dict[str, int] = {}
679+
option_names: list[str] = []
680+
for opt in options:
681+
name = opt.get("name", "")
682+
value = opt.get("value")
683+
if name and value is not None:
684+
self._option_map[name] = value
685+
option_names.append(name)
686+
687+
self._attr_options = option_names
688+
self._attr_unique_id = (
689+
f"{device.device_id}{SUFFIX_NIGHTLIGHT_SCENE_SELECT}"
690+
)
691+
692+
@property
693+
def current_option(self) -> str | None:
694+
"""Return the active nightlight scene name from state."""
695+
state = self.coordinator.get_state(self._device_id)
696+
if state and state.nightlight_scene is not None:
697+
for name, value in self._option_map.items():
698+
if value == state.nightlight_scene:
699+
return name
700+
return self._attr_options[0] if self._attr_options else None
701+
702+
async def async_select_option(self, option: str) -> None:
703+
"""Handle nightlight scene selection."""
704+
value = self._option_map.get(option)
705+
if value is None:
706+
_LOGGER.warning("Unknown nightlight scene option: %s", option)
707+
return
708+
709+
success = await self.coordinator.async_control_device(
710+
self._device_id,
711+
ModeCommand(mode_instance=INSTANCE_NIGHTLIGHT_SCENE, value=value),
712+
)
713+
if success:
714+
self.async_write_ha_state()
715+
_LOGGER.debug(
716+
"Set nightlight scene '%s' (value=%d) on %s",
717+
option,
718+
value,
719+
self._device.name,
720+
)
721+
722+
638723
class GoveeMusicModeSelectEntity(GoveeEntity, SelectEntity):
639724
"""Govee music mode select entity.
640725

0 commit comments

Comments
 (0)