Add is_on state to LunatoneLineBroadcastLight of lunatone (#180897)

This commit is contained in:
MoonDevLT
2026-09-08 13:07:09 +02:00
committed by GitHub
parent 8c1cdca6c9
commit 6057639d21
7 changed files with 215 additions and 31 deletions
@@ -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]]):
@@ -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()
],
}
+49 -18
View File
@@ -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()
+9
View File
@@ -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,
),
]
@@ -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({
@@ -43,7 +43,6 @@
# name: test_setup[light.dali_line_0-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.ASSUMED_STATE: 'assumed_state'>: True,
<LightEntityStateAttribute.BRIGHTNESS: 'brightness'>: None,
<LightEntityStateAttribute.COLOR_MODE: 'color_mode'>: None,
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'DALI Line 0',
@@ -57,7 +56,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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({
<EntityStateAttribute.ASSUMED_STATE: 'assumed_state'>: True,
<LightEntityStateAttribute.BRIGHTNESS: 'brightness'>: None,
<LightEntityStateAttribute.COLOR_MODE: 'color_mode'>: None,
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'DALI Line 1',
@@ -118,7 +116,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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({
<LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES: 'supported_color_modes'>: list([
<ColorMode.ONOFF: 'onoff'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.device_6',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<LightEntityStateAttribute.COLOR_MODE: 'color_mode'>: None,
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Device 6',
<LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES: 'supported_color_modes'>: list([
<ColorMode.ONOFF: 'onoff'>,
]),
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <LightEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'light.device_6',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+25 -3
View File
@@ -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