Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions homeassistant/components/lunatone/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,11 +25,16 @@
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(
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
]
Expand All @@ -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)
Expand Down
65 changes: 65 additions & 0 deletions homeassistant/components/lunatone/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -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)]
)
Comment on lines +31 to +33


class LunatoneDALIScanStatus(
CoordinatorEntity[LunatoneScanDataUpdateCoordinator], BinarySensorEntity
):
"""Representation of a Lunatone DALI scan status."""

_attr_device_class = BinarySensorDeviceClass.RUNNING
_attr_has_entity_name = True

def __init__(
self,
coordinator: LunatoneScanDataUpdateCoordinator,
config_entry_unique_id: str,
) -> None:
"""Initialize a Lunatone DALI scan status."""
super().__init__(coordinator)
self.entity_category = EntityCategory.DIAGNOSTIC

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)},
)
self._attr_translation_key = "scan_status"
Comment on lines +51 to +59

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

entity category and translation key can be set outside of the constructor


@property
@override
def is_on(self) -> bool | None:
"""Return true if the DALI scan is on."""
return self.coordinator.dali_scan_api.is_busy
60 changes: 53 additions & 7 deletions homeassistant/components/lunatone/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
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
Expand All @@ -24,9 +25,10 @@

_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
Expand All @@ -36,6 +38,7 @@ class LunatoneData:
coordinator_info: LunatoneInfoDataUpdateCoordinator
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
coordinator_sensors: LunatoneSensorsDataUpdateCoordinator
coordinator_scan: LunatoneScanDataUpdateCoordinator
dali_line_broadcasts: list[DALIBroadcast]


Expand All @@ -57,7 +60,7 @@ def __init__(
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

Expand Down Expand Up @@ -94,7 +97,7 @@ def __init__(
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

Expand Down Expand Up @@ -131,7 +134,7 @@ def __init__(
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

Expand All @@ -149,3 +152,46 @@ async def _async_update_data(self) -> dict[int, Sensor]:
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
Comment on lines +192 to +195

return self.dali_scan_api.data
7 changes: 7 additions & 0 deletions homeassistant/components/lunatone/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
11 changes: 11 additions & 0 deletions tests/components/lunatone/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,17 @@ def _set_data(data: SensorsData) -> None:
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:
scan = mock_dali_scan.return_value
yield scan


@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
Expand Down
2 changes: 2 additions & 0 deletions tests/components/lunatone/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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."""
Expand Down
1 change: 1 addition & 0 deletions tests/components/lunatone/test_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions tests/components/lunatone/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading