|
45 | 45 | PowerCommand, |
46 | 46 | RGBColor, |
47 | 47 | SceneCommand, |
| 48 | + ToggleCommand, |
48 | 49 | ) |
| 50 | +from .models.device import INSTANCE_NIGHT_LIGHT |
49 | 51 | from .platforms.grouped_segment import GoveeGroupedSegmentEntity |
50 | 52 | from .platforms.segment import GoveeSegmentEntity |
51 | 53 |
|
@@ -81,6 +83,13 @@ async def async_setup_entry( |
81 | 83 | if device.is_light_device and device.supports_power: |
82 | 84 | entities.append(GoveeLightEntity(coordinator, device, enable_scenes)) |
83 | 85 |
|
| 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 | + |
84 | 93 | # Create segment entities for RGBIC devices based on per-device mode |
85 | 94 | if device.supports_segments and device.segment_count > 0: |
86 | 95 | # Use per-device mode if set, otherwise default to individual |
@@ -399,3 +408,160 @@ async def async_added_to_hass(self) -> None: |
399 | 408 | scenes = await self.coordinator.async_get_scenes(self._device_id) |
400 | 409 | if scenes: |
401 | 410 | 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) |
0 commit comments