Add DALI scan binary sensor to lunatone (#179068)

This commit is contained in:
MoonDevLT
2026-08-18 22:42:36 +02:00
committed by GitHub
parent f46e0414db
commit e327acb884
12 changed files with 366 additions and 10 deletions
+19 -2
View File
@@ -3,7 +3,14 @@
import logging
from typing import Final
from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info, Sensors
from lunatone_rest_api_client import (
Auth,
DALIBroadcast,
DALIScan,
Devices,
Info,
Sensors,
)
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
@@ -18,11 +25,16 @@ from .coordinator import (
LunatoneData,
LunatoneDevicesDataUpdateCoordinator,
LunatoneInfoDataUpdateCoordinator,
LunatoneScanDataUpdateCoordinator,
LunatoneSensorsDataUpdateCoordinator,
)
_LOGGER = logging.getLogger(__name__)
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT, Platform.SENSOR]
PLATFORMS: Final[list[Platform]] = [
Platform.BINARY_SENSOR,
Platform.LIGHT,
Platform.SENSOR,
]
async def _update_unique_id(
@@ -70,6 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
"""Set up Lunatone from a config entry."""
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
info_api = Info(auth_api)
dali_scan_api = DALIScan(auth_api)
devices_api = Devices(info_api)
sensors_api = Sensors(auth_api)
@@ -110,6 +123,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
coordinator_sensors = LunatoneSensorsDataUpdateCoordinator(hass, entry, sensors_api)
await coordinator_sensors.async_config_entry_first_refresh()
coordinator_scan = LunatoneScanDataUpdateCoordinator(hass, entry, dali_scan_api)
await coordinator_scan.async_config_entry_first_refresh()
dali_line_broadcasts = [
DALIBroadcast(auth_api, int(line)) for line in coordinator_info.data.lines
]
@@ -118,6 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
coordinator_info,
coordinator_devices,
coordinator_sensors,
coordinator_scan,
dali_line_broadcasts,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -0,0 +1,65 @@
"""Platform for Lunatone binary sensor integration."""
from typing import override
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LunatoneConfigEntry, LunatoneScanDataUpdateCoordinator
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lunatone binary sensors from the config entry."""
coordinator_scan = config_entry.runtime_data.coordinator_scan
assert config_entry.unique_id is not None
async_add_entities(
[LunatoneDALIScanStatus(coordinator_scan, config_entry.unique_id)]
)
class LunatoneDALIScanStatus(
CoordinatorEntity[LunatoneScanDataUpdateCoordinator], BinarySensorEntity
):
"""Representation of a Lunatone DALI scan status."""
_attr_device_class = BinarySensorDeviceClass.RUNNING
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_has_entity_name = True
_attr_translation_key = "scan_status"
def __init__(
self,
coordinator: LunatoneScanDataUpdateCoordinator,
config_entry_unique_id: str,
) -> None:
"""Initialize a Lunatone DALI scan status."""
super().__init__(coordinator)
self._config_entry_unique_id = config_entry_unique_id
self._attr_unique_id = f"{config_entry_unique_id}-scan-progress"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._config_entry_unique_id)},
)
@property
@override
def is_on(self) -> bool:
"""Return true if the DALI scan is on."""
return self.coordinator.dali_scan_api.is_busy
@@ -8,13 +8,14 @@ from typing import override
import aiohttp
from lunatone_rest_api_client import (
DALIBroadcast,
DALIScan,
Device,
Devices,
Info,
Sensor,
Sensors,
)
from lunatone_rest_api_client.models import InfoData
from lunatone_rest_api_client.models import InfoData, ScanData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -24,9 +25,10 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_INFO_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
DEFAULT_SENSORS_SCAN_INTERVAL = timedelta(seconds=30)
DEFAULT_INFO_UPDATE_INTERVAL = timedelta(seconds=60)
DEFAULT_DEVICES_UPDATE_INTERVAL = timedelta(seconds=10)
DEFAULT_SENSORS_UPDATE_INTERVAL = timedelta(seconds=30)
DEFAULT_SCAN_UPDATE_INTERVAL = timedelta(seconds=10)
@dataclass
@@ -36,6 +38,7 @@ class LunatoneData:
coordinator_info: LunatoneInfoDataUpdateCoordinator
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
coordinator_sensors: LunatoneSensorsDataUpdateCoordinator
coordinator_scan: LunatoneScanDataUpdateCoordinator
dali_line_broadcasts: list[DALIBroadcast]
@@ -57,7 +60,7 @@ class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]):
config_entry=config_entry,
name=f"{DOMAIN}-info",
always_update=False,
update_interval=DEFAULT_INFO_SCAN_INTERVAL,
update_interval=DEFAULT_INFO_UPDATE_INTERVAL,
)
self.info_api = info_api
@@ -94,7 +97,7 @@ class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Devic
config_entry=config_entry,
name=f"{DOMAIN}-devices",
always_update=False,
update_interval=DEFAULT_DEVICES_SCAN_INTERVAL,
update_interval=DEFAULT_DEVICES_UPDATE_INTERVAL,
)
self.devices_api = devices_api
@@ -131,7 +134,7 @@ class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Senso
config_entry=config_entry,
name=f"{DOMAIN}-sensors",
always_update=False,
update_interval=DEFAULT_SENSORS_SCAN_INTERVAL,
update_interval=DEFAULT_SENSORS_UPDATE_INTERVAL,
)
self.sensors_api = sensors_api
@@ -149,3 +152,46 @@ class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Senso
if self.sensors_api.data is None:
raise UpdateFailed("Did not receive sensors data from Lunatone REST API")
return {sensor.id: sensor for sensor in self.sensors_api.sensors}
class LunatoneScanDataUpdateCoordinator(DataUpdateCoordinator[ScanData]):
"""Data update coordinator for Lunatone scan."""
config_entry: LunatoneConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
dali_scan_api: DALIScan,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-scan",
always_update=False,
update_interval=DEFAULT_SCAN_UPDATE_INTERVAL,
)
self.dali_scan_api = dali_scan_api
@override
async def _async_update_data(self) -> ScanData:
"""Update scan data."""
try:
await self.dali_scan_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve scan data from Lunatone REST API"
) from ex
if self.dali_scan_api.data is None:
raise UpdateFailed("Did not receive scan data from Lunatone REST API")
update_interval = DEFAULT_SCAN_UPDATE_INTERVAL
if self.dali_scan_api.is_busy:
update_interval = timedelta(seconds=1)
self.update_interval = update_interval
return self.dali_scan_api.data
@@ -37,6 +37,13 @@
}
}
},
"entity": {
"binary_sensor": {
"scan_status": {
"name": "DALI scan"
}
}
},
"exceptions": {
"missing_device_info": {
"message": "Unable to read device information. Please verify the device's network connection."
+24 -1
View File
@@ -4,7 +4,7 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, PropertyMock, patch
from lunatone_rest_api_client import Device, Devices, Info, Sensor, Sensors
from lunatone_rest_api_client.models import InfoData, SensorsData
from lunatone_rest_api_client.models import InfoData, ScanData, ScanState, SensorsData
import pytest
from homeassistant.components.lunatone.config_flow import LunatoneConfigFlow
@@ -161,6 +161,29 @@ def mock_lunatone_sensors() -> Generator[AsyncMock]:
yield sensors
@pytest.fixture
def mock_lunatone_scan() -> Generator[AsyncMock]:
"""Mock a Lunatone DALI scan object."""
with (
patch(
"homeassistant.components.lunatone.DALIScan",
autospec=True,
) as mock_dali_scan,
patch(
"homeassistant.components.lunatone.coordinator.DALIScan",
new=mock_dali_scan,
),
):
scan = mock_dali_scan.return_value
scan.data = ScanData()
type(scan).is_busy = PropertyMock(
side_effect=lambda: (
scan.data.status in {ScanState.ADDRESSING, ScanState.IN_PROGRESS}
)
)
yield scan
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
@@ -0,0 +1,52 @@
# serializer version: 1
# name: test_setup[binary_sensor.test_dali_scan-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.test_dali_scan',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DALI scan',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'DALI scan',
'platform': 'lunatone',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'scan_status',
'unique_id': 'be37ca9c47c24498a38bc62c7c711840-scan-progress',
'unit_of_measurement': None,
})
# ---
# name: test_setup[binary_sensor.test_dali_scan-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'running',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test DALI scan',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.test_dali_scan',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
@@ -0,0 +1,76 @@
"""Tests for the binary sensors provided by the Lunatone integration."""
from datetime import timedelta
from unittest.mock import AsyncMock
from lunatone_rest_api_client.models import ScanData, ScanState
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry
async def test_setup(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the Lunatone binary sensor setup."""
await setup_integration(hass, mock_config_entry)
entities = hass.states.async_all(Platform.BINARY_SENSOR)
for entity_state in entities:
entity_entry = entity_registry.async_get(entity_state.entity_id)
assert entity_entry
assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")
assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state")
async def test_sensor_value_update(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Lunatone DALI scan status value update."""
scan_states = iter((ScanState.ADDRESSING, ScanState.DONE))
await setup_integration(hass, mock_config_entry)
coordinator = mock_config_entry.runtime_data.coordinator_scan
async def fake_update():
scan_state = next(scan_states)
mock_lunatone_scan.data = ScanData(status=scan_state)
mock_lunatone_scan.async_update.side_effect = fake_update
entities = hass.states.async_all(Platform.BINARY_SENSOR)
assert entities[0].state == "off"
assert coordinator.update_interval == timedelta(seconds=10)
await coordinator.async_refresh()
await hass.async_block_till_done()
entities = hass.states.async_all(Platform.BINARY_SENSOR)
assert entities[0].state == "on"
assert coordinator.update_interval == timedelta(seconds=1)
await coordinator.async_refresh()
await hass.async_block_till_done()
entities = hass.states.async_all(Platform.BINARY_SENSOR)
assert entities[0].state == "off"
assert coordinator.update_interval == timedelta(seconds=10)
@@ -158,6 +158,7 @@ async def test_zeroconf_flow(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
) -> None:
"""Test zeroconf flow."""
result = await hass.config_entries.flow.async_init(
@@ -180,6 +181,7 @@ async def test_zeroconf_flow_abort_duplicate(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test zeroconf flow aborts with duplicate."""
@@ -19,6 +19,7 @@ async def test_config_entry_diagnostics(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
+56
View File
@@ -20,6 +20,7 @@ async def test_load_unload_config_entry(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
@@ -50,6 +51,7 @@ async def test_config_entry_not_ready_info_api_fail(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config entry not ready due to info API failure."""
@@ -74,6 +76,7 @@ async def test_config_entry_not_ready_devices_api_fail(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config entry not ready due to devices API failure."""
@@ -100,6 +103,7 @@ async def test_config_entry_not_ready_sensors_api_fail(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config entry not ready due to sensors API failure."""
@@ -123,6 +127,37 @@ async def test_config_entry_not_ready_sensors_api_fail(
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_config_entry_not_ready_scan_api_fail(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config entry not ready due to sensors API failure."""
mock_lunatone_scan.async_update.side_effect = aiohttp.ClientConnectionError()
await setup_integration(hass, mock_config_entry)
mock_lunatone_info.async_update.assert_called_once()
mock_lunatone_devices.async_update.assert_called_once()
mock_lunatone_sensors.async_update.assert_called_once()
mock_lunatone_scan.async_update.assert_called_once()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
mock_lunatone_scan.async_update.side_effect = None
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_lunatone_info.async_update.assert_called()
mock_lunatone_devices.async_update.assert_called()
mock_lunatone_sensors.async_update.assert_called()
mock_lunatone_scan.async_update.assert_called()
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_config_entry_not_ready_no_info_data(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
@@ -172,6 +207,26 @@ async def test_config_entry_not_ready_no_sensors_data(
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_config_entry_not_ready_no_dali_scan_data(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Lunatone configuration entry not ready due to missing DALI scan data."""
mock_lunatone_scan.data = None
await setup_integration(hass, mock_config_entry)
mock_lunatone_info.async_update.assert_called_once()
mock_lunatone_devices.async_update.assert_called_once()
mock_lunatone_sensors.async_update.assert_called_once()
mock_lunatone_scan.async_update.assert_called_once()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_config_entry_not_ready_no_serial_number(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
@@ -192,6 +247,7 @@ async def test_config_entry_unique_id_update(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
+9
View File
@@ -35,6 +35,7 @@ async def test_setup(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
@@ -56,6 +57,7 @@ async def test_turn_on_off(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the light can be turned on and off."""
@@ -98,6 +100,7 @@ async def test_turn_on_off_with_brightness(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the light can be turned on with brightness."""
@@ -158,6 +161,7 @@ async def test_turn_on_off_broadcast(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_lunatone_dali_broadcast: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
@@ -202,6 +206,7 @@ async def test_line_broadcast_available_status(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_lunatone_dali_broadcast: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
@@ -234,6 +239,7 @@ async def test_line_broadcast_line_present(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_lunatone_dali_broadcast: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
@@ -254,6 +260,7 @@ async def test_turn_on_with_color_temperature(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
color_temp_kelvin: int,
) -> None:
@@ -294,6 +301,7 @@ async def test_turn_on_with_rgb_color(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
rgb_color: tuple[int, int, int],
) -> None:
@@ -336,6 +344,7 @@ async def test_turn_on_with_rgbw_color(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
rgbw_color: tuple[int, int, int, int],
) -> None:
+2
View File
@@ -21,6 +21,7 @@ async def test_setup(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
@@ -41,6 +42,7 @@ async def test_sensor_value_update(
mock_lunatone_info: AsyncMock,
mock_lunatone_devices: AsyncMock,
mock_lunatone_sensors: AsyncMock,
mock_lunatone_scan: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None: