From 6057639d2198ad6cb3792c90f77a381c37126d2a Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:07:09 +0200 Subject: [PATCH] Add `is_on` state to `LunatoneLineBroadcastLight` of lunatone (#180897) --- .../components/lunatone/coordinator.py | 13 +++- .../components/lunatone/diagnostics.py | 9 ++- homeassistant/components/lunatone/light.py | 67 ++++++++++++++----- tests/components/lunatone/__init__.py | 9 +++ .../lunatone/snapshots/test_diagnostics.ambr | 55 +++++++++++++++ .../lunatone/snapshots/test_light.ambr | 65 ++++++++++++++++-- tests/components/lunatone/test_light.py | 28 +++++++- 7 files changed, 215 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py index 0583e3511a41..cf321bb95055 100644 --- a/homeassistant/components/lunatone/coordinator.py +++ b/homeassistant/components/lunatone/coordinator.py @@ -1,5 +1,6 @@ """Coordinator for handling data fetching and updates.""" +from collections import defaultdict from dataclasses import dataclass from datetime import timedelta import logging @@ -79,7 +80,9 @@ class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]): return self.info_api.data -class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Device]]): +class LunatoneDevicesDataUpdateCoordinator( + DataUpdateCoordinator[dict[int, dict[int, Device]]] +): """Data update coordinator for Lunatone devices.""" config_entry: LunatoneConfigEntry @@ -102,7 +105,7 @@ class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Devic self.devices_api = devices_api @override - async def _async_update_data(self) -> dict[int, Device]: + async def _async_update_data(self) -> dict[int, dict[int, Device]]: """Update devices data.""" try: await self.devices_api.async_update() @@ -113,7 +116,11 @@ class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Devic if self.devices_api.data is None: raise UpdateFailed("Did not receive devices data from Lunatone REST API") - return {device.data.id: device for device in self.devices_api.devices} + + data: dict[int, dict[int, Device]] = defaultdict(dict) + for device in self.devices_api.devices: + data[device.data.line].update({device.data.id: device}) + return dict(data) class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Sensor]]): diff --git a/homeassistant/components/lunatone/diagnostics.py b/homeassistant/components/lunatone/diagnostics.py index 64df87c8f63e..1516ae5465eb 100644 --- a/homeassistant/components/lunatone/diagnostics.py +++ b/homeassistant/components/lunatone/diagnostics.py @@ -11,10 +11,13 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: LunatoneConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" + info_data = entry.runtime_data.coordinator_info.data + devices_data = entry.runtime_data.coordinator_devices.data return { - "info": entry.runtime_data.coordinator_info.data.model_dump(), + "info": info_data.model_dump(), "devices": [ - v.data.model_dump() - for v in entry.runtime_data.coordinator_devices.data.values() + device.data.model_dump() + for devices in devices_data.values() + for device in devices.values() ], } diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index fefa30226646..7c1d3d6a9ecc 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -45,18 +45,17 @@ async def async_setup_entry( entities: list[LightEntity] = [ LunatoneLineBroadcastLight( - coordinator_info, coordinator_devices, + coordinator_info, dali_line_broadcast, config_entry.unique_id, ) for dali_line_broadcast in dali_line_broadcasts ] entities.extend( - [ - LunatoneLight(coordinator_devices, device_id, config_entry.unique_id) - for device_id in coordinator_devices.data - ] + LunatoneLight(coordinator_devices, line_id, device_id, config_entry.unique_id) + for line_id, devices in coordinator_devices.data.items() + for device_id in devices ) async_add_entities(entities) @@ -80,14 +79,17 @@ class LunatoneLight( def __init__( self, coordinator: LunatoneDevicesDataUpdateCoordinator, + line_id: int, device_id: int, config_entry_unique_id: str, ) -> None: """Initialize a Lunatone light.""" super().__init__(coordinator) + self._line_id = line_id self._device_id = device_id self._config_entry_unique_id = config_entry_unique_id - self._device = self.coordinator.data[device_id] + self._device = self.coordinator.data[line_id][device_id] + self._attr_unique_id = f"{config_entry_unique_id}-device{device_id}" @property @@ -183,7 +185,7 @@ class LunatoneLight( @override def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._device = self.coordinator.data[self._device_id] + self._device = self.coordinator.data[self._line_id][self._device_id] self.async_write_ha_state() @override @@ -225,13 +227,12 @@ class LunatoneLight( class LunatoneLineBroadcastLight( - CoordinatorEntity[LunatoneInfoDataUpdateCoordinator], LightEntity + CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity ): """Representation of a Lunatone line broadcast light.""" BRIGHTNESS_SCALE = (1, 100) - _attr_assumed_state = True _attr_color_mode = ColorMode.BRIGHTNESS _attr_has_entity_name = True _attr_name = None @@ -239,23 +240,23 @@ class LunatoneLineBroadcastLight( def __init__( self, - coordinator_info: LunatoneInfoDataUpdateCoordinator, coordinator_devices: LunatoneDevicesDataUpdateCoordinator, + coordinator_info: LunatoneInfoDataUpdateCoordinator, broadcast: DALIBroadcast, config_entry_unique_id: str, ) -> None: """Initialize a Lunatone line broadcast light.""" - super().__init__(coordinator_info) - self._coordinator_devices = coordinator_devices + super().__init__(coordinator_devices) + self._coordinator_info = coordinator_info self._broadcast = broadcast line = broadcast.line self._attr_unique_id = f"{config_entry_unique_id}-line{line}" - line_device = self.coordinator.data.lines[str(line)].device + line_device = self._coordinator_info.data.lines[str(line)].device extra_info: dict = {} - if line_device.serial != coordinator_info.data.device.serial: + if line_device.serial != self._coordinator_info.data.device.serial: extra_info.update( serial_number=str(line_device.serial), hw_version=line_device.pcb, @@ -274,12 +275,42 @@ class LunatoneLineBroadcastLight( **extra_info, ) + @override + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + self.async_on_remove( + self._coordinator_info.async_add_listener(self._handle_info_update) + ) + + @callback + def _handle_info_update(self) -> None: + self.async_write_ha_state() + @property @override def available(self) -> bool: """Return True if entity is available.""" - line_status = self.coordinator.data.lines[str(self._broadcast.line)].line_status - return super().available and line_status == LineStatus.OK + info_data = self._coordinator_info.data + line_id = self._broadcast.line + return ( + super().available + and self._coordinator_info.last_update_success + and line_id is not None + and str(line_id) in info_data.lines + and info_data.lines[str(line_id)].line_status == LineStatus.OK + ) + + @property + @override + def is_on(self) -> bool: + """Return True if light is on.""" + line_id = self._broadcast.line + return ( + any(device.is_on for device in self.coordinator.data[line_id].values()) + if line_id is not None and line_id in self.coordinator.data + else False + ) @override async def async_turn_on(self, **kwargs: Any) -> None: @@ -287,10 +318,10 @@ class LunatoneLineBroadcastLight( await self._broadcast.fade_to_brightness( brightness_to_value(self.BRIGHTNESS_SCALE, kwargs.get(ATTR_BRIGHTNESS, 255)) ) - await self._coordinator_devices.async_refresh() + await self.coordinator.async_refresh() @override async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the line to turn off.""" await self._broadcast.fade_to_brightness(0) - await self._coordinator_devices.async_refresh() + await self.coordinator.async_refresh() diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index 98d0d302dde9..43a4fb918f9e 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -189,6 +189,15 @@ def build_device_data_list() -> list[DeviceData]: address=4, line=0, ), + DeviceData( + id=6, + name="Device 6", + available=True, + status=DeviceStatus(), + features=FeaturesStatus(switchable=Status[bool](status=False)), + address=0, + line=1, + ), ] diff --git a/tests/components/lunatone/snapshots/test_diagnostics.ambr b/tests/components/lunatone/snapshots/test_diagnostics.ambr index 4ffe5e96c820..a8f7eac22737 100644 --- a/tests/components/lunatone/snapshots/test_diagnostics.ambr +++ b/tests/components/lunatone/snapshots/test_diagnostics.ambr @@ -305,6 +305,61 @@ 'time_signature': None, 'type': 'default', }), + dict({ + 'address': 0, + 'available': True, + 'dali_types': list([ + ]), + 'features': dict({ + 'color_kelvin': None, + 'color_kelvin_with_fade': None, + 'color_rgb': None, + 'color_rgb_with_fade': None, + 'color_waf': None, + 'color_waf_with_fade': None, + 'color_xy': None, + 'color_xy_with_fade': None, + 'dali_cmd16': None, + 'dim_down': None, + 'dim_up': None, + 'dimmable': None, + 'dimmable_kelvin': None, + 'dimmable_rgb': None, + 'dimmable_waf': None, + 'dimmable_with_fade': None, + 'dimmable_xy': None, + 'fade_rate': None, + 'fade_time': None, + 'goto_last_active': None, + 'goto_last_active_with_fade': None, + 'save_to_scene': None, + 'scene': None, + 'scene_with_fade': None, + 'switchable': dict({ + 'status': False, + }), + }), + 'groups': list([ + ]), + 'id': 6, + 'line': 1, + 'name': 'Device 6', + 'scenes': list([ + ]), + 'status': dict({ + 'control_gear_failure': False, + 'fade_running': False, + 'is_unaddressed': False, + 'lamp_failure': False, + 'lamp_on': False, + 'limit_error': False, + 'power_cycle_see': False, + 'raw': 0, + 'reset_state': False, + }), + 'time_signature': None, + 'type': 'default', + }), ]), 'info': dict({ 'descriptor': dict({ diff --git a/tests/components/lunatone/snapshots/test_light.ambr b/tests/components/lunatone/snapshots/test_light.ambr index 76c573f8fb3b..e29eea596155 100644 --- a/tests/components/lunatone/snapshots/test_light.ambr +++ b/tests/components/lunatone/snapshots/test_light.ambr @@ -43,7 +43,6 @@ # name: test_setup[light.dali_line_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : True, : None, : None, : 'DALI Line 0', @@ -57,7 +56,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_setup[light.dali_line_1-entry] @@ -104,7 +103,6 @@ # name: test_setup[light.dali_line_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : True, : None, : None, : 'DALI Line 1', @@ -118,7 +116,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_setup[light.device_1-entry] @@ -435,3 +433,62 @@ 'state': 'off', }) # --- +# name: test_setup[light.device_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device6', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'Device 6', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.device_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lunatone/test_light.py b/tests/components/lunatone/test_light.py index 0b88287f49eb..9705fdb3104c 100644 --- a/tests/components/lunatone/test_light.py +++ b/tests/components/lunatone/test_light.py @@ -3,6 +3,7 @@ import copy from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from lunatone_rest_api_client.models import LineStatus import pytest from syrupy.assertion import SnapshotAssertion @@ -166,10 +167,21 @@ async def test_turn_on_off_broadcast( mock_config_entry: MockConfigEntry, ) -> None: """Test the broadcast light can be turned on and off.""" - entity_id = f"light.dali_line_{mock_lunatone_dali_broadcast.line}" + line_id = mock_lunatone_dali_broadcast.line + entity_id = f"light.dali_line_{line_id}" + light_status = iter((True, True, False)) await setup_integration(hass, mock_config_entry) + async def fake_update(): + status = next(light_status) + for device in mock_lunatone_devices.data.devices: + device.features.switchable.status = ( + status if device.line == line_id else True + ) + + mock_lunatone_devices.async_update.side_effect = fake_update + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -180,6 +192,10 @@ async def test_turn_on_off_broadcast( assert mock_lunatone_dali_broadcast.fade_to_brightness.await_count == 1 mock_lunatone_dali_broadcast.fade_to_brightness.assert_awaited() + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -200,6 +216,10 @@ async def test_turn_on_off_broadcast( assert mock_lunatone_dali_broadcast.fade_to_brightness.await_count == 3 mock_lunatone_dali_broadcast.fade_to_brightness.assert_awaited() + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + async def test_line_broadcast_available_status( hass: HomeAssistant, @@ -209,15 +229,17 @@ async def test_line_broadcast_available_status( mock_lunatone_scan: AsyncMock, mock_lunatone_dali_broadcast: AsyncMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test if the broadcast light is available.""" - entity_id = f"light.dali_line_{mock_lunatone_dali_broadcast.line}" + line_id = str(mock_lunatone_dali_broadcast.line) + entity_id = f"light.dali_line_{line_id}" await setup_integration(hass, mock_config_entry) async def fake_update(): info_data = copy.deepcopy(mock_lunatone_info.data) - info_data.lines["0"].line_status = LineStatus.NOT_REACHABLE + info_data.lines[line_id].line_status = LineStatus.NOT_REACHABLE mock_lunatone_info.data = info_data mock_lunatone_info.async_update.side_effect = fake_update