diff --git a/.strict-typing b/.strict-typing index cacab1a4151..e950da8d25d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -326,6 +326,7 @@ homeassistant.components.london_underground.* homeassistant.components.lookin.* homeassistant.components.lovelace.* homeassistant.components.luftdaten.* +homeassistant.components.lunatone.* homeassistant.components.madvr.* homeassistant.components.manual.* homeassistant.components.mastodon.* diff --git a/CODEOWNERS b/CODEOWNERS index 5b1c185bbf7..ccd8cbadb6b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -910,6 +910,8 @@ build.json @home-assistant/supervisor /homeassistant/components/luci/ @mzdrale /homeassistant/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck +/homeassistant/components/lunatone/ @MoonDevLT +/tests/components/lunatone/ @MoonDevLT /homeassistant/components/lupusec/ @majuss @suaveolent /tests/components/lupusec/ @majuss @suaveolent /homeassistant/components/lutron/ @cdheiser @wilburCForce diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py new file mode 100644 index 00000000000..d507f91a4f3 --- /dev/null +++ b/homeassistant/components/lunatone/__init__.py @@ -0,0 +1,64 @@ +"""The Lunatone integration.""" + +from typing import Final + +from lunatone_rest_api_client import Auth, Devices, Info + +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import ( + LunatoneConfigEntry, + LunatoneData, + LunatoneDevicesDataUpdateCoordinator, + LunatoneInfoDataUpdateCoordinator, +) + +PLATFORMS: Final[list[Platform]] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool: + """Set up Lunatone from a config entry.""" + auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL]) + info_api = Info(auth_api) + devices_api = Devices(auth_api) + + coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api) + await coordinator_info.async_config_entry_first_refresh() + + if info_api.serial_number is None: + raise ConfigEntryError( + translation_domain=DOMAIN, translation_key="missing_device_info" + ) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, str(info_api.serial_number))}, + name=info_api.name, + manufacturer="Lunatone", + sw_version=info_api.version, + hw_version=info_api.data.device.pcb, + configuration_url=entry.data[CONF_URL], + serial_number=str(info_api.serial_number), + model_id=( + f"{info_api.data.device.article_number}{info_api.data.device.article_info}" + ), + ) + + coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api) + await coordinator_devices.async_config_entry_first_refresh() + + entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py new file mode 100644 index 00000000000..4dc5d8c03ec --- /dev/null +++ b/homeassistant/components/lunatone/config_flow.py @@ -0,0 +1,83 @@ +"""Config flow for Lunatone.""" + +from typing import Any, Final + +import aiohttp +from lunatone_rest_api_client import Auth, Info +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_URL +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +DATA_SCHEMA: Final[vol.Schema] = vol.Schema( + {vol.Required(CONF_URL, default="http://"): cv.string}, +) + + +def compose_title(name: str | None, serial_number: int) -> str: + """Compose a title string from a given name and serial number.""" + return f"{name or 'DALI Gateway'} {serial_number}" + + +class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN): + """Lunatone config flow.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + url = user_input[CONF_URL] + data = {CONF_URL: url} + self._async_abort_entries_match(data) + auth_api = Auth( + session=async_get_clientsession(self.hass), + base_url=url, + ) + info_api = Info(auth_api) + try: + await info_api.async_update() + except aiohttp.InvalidUrlClientError: + errors["base"] = "invalid_url" + except aiohttp.ClientConnectionError: + errors["base"] = "cannot_connect" + else: + if info_api.data is None or info_api.serial_number is None: + errors["base"] = "missing_device_info" + else: + await self.async_set_unique_id(str(info_api.serial_number)) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=data, + title=compose_title(info_api.name, info_api.serial_number), + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=compose_title(info_api.name, info_api.serial_number), + data={CONF_URL: url}, + ) + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/lunatone/const.py b/homeassistant/components/lunatone/const.py new file mode 100644 index 00000000000..ad7eb57affa --- /dev/null +++ b/homeassistant/components/lunatone/const.py @@ -0,0 +1,5 @@ +"""Constants for the Lunatone integration.""" + +from typing import Final + +DOMAIN: Final = "lunatone" diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py new file mode 100644 index 00000000000..f9f15ed4629 --- /dev/null +++ b/homeassistant/components/lunatone/coordinator.py @@ -0,0 +1,101 @@ +"""Coordinator for handling data fetching and updates.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +import aiohttp +from lunatone_rest_api_client import Device, Devices, Info +from lunatone_rest_api_client.models import InfoData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10) + + +@dataclass +class LunatoneData: + """Data for Lunatone integration.""" + + coordinator_info: LunatoneInfoDataUpdateCoordinator + coordinator_devices: LunatoneDevicesDataUpdateCoordinator + + +type LunatoneConfigEntry = ConfigEntry[LunatoneData] + + +class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]): + """Data update coordinator for Lunatone info.""" + + config_entry: LunatoneConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: LunatoneConfigEntry, info_api: Info + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}-info", + always_update=False, + ) + self.info_api = info_api + + async def _async_update_data(self) -> InfoData: + """Update info data.""" + try: + await self.info_api.async_update() + except aiohttp.ClientConnectionError as ex: + raise UpdateFailed( + "Unable to retrieve info data from Lunatone REST API" + ) from ex + + if self.info_api.data is None: + raise UpdateFailed("Did not receive info data from Lunatone REST API") + return self.info_api.data + + +class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Device]]): + """Data update coordinator for Lunatone devices.""" + + config_entry: LunatoneConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: LunatoneConfigEntry, + devices_api: Devices, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}-devices", + always_update=False, + update_interval=DEFAULT_DEVICES_SCAN_INTERVAL, + ) + self.devices_api = devices_api + + async def _async_update_data(self) -> dict[int, Device]: + """Update devices data.""" + try: + await self.devices_api.async_update() + except aiohttp.ClientConnectionError as ex: + raise UpdateFailed( + "Unable to retrieve devices data from Lunatone REST API" + ) from ex + + if self.devices_api.data is None: + raise UpdateFailed("Did not receive devices data from Lunatone REST API") + + return {device.id: device for device in self.devices_api.devices} diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py new file mode 100644 index 00000000000..416412aea6e --- /dev/null +++ b/homeassistant/components/lunatone/light.py @@ -0,0 +1,103 @@ +"""Platform for Lunatone light integration.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant, callback +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, LunatoneDevicesDataUpdateCoordinator + +PARALLEL_UPDATES = 0 +STATUS_UPDATE_DELAY = 0.04 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LunatoneConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Lunatone Light platform.""" + coordinator_info = config_entry.runtime_data.coordinator_info + coordinator_devices = config_entry.runtime_data.coordinator_devices + + async_add_entities( + [ + LunatoneLight( + coordinator_devices, device_id, coordinator_info.data.device.serial + ) + for device_id in coordinator_devices.data + ] + ) + + +class LunatoneLight( + CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity +): + """Representation of a Lunatone light.""" + + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + + def __init__( + self, + coordinator: LunatoneDevicesDataUpdateCoordinator, + device_id: int, + interface_serial_number: int, + ) -> None: + """Initialize a LunatoneLight.""" + super().__init__(coordinator=coordinator) + self._device_id = device_id + self._interface_serial_number = interface_serial_number + self._device = self.coordinator.data.get(self._device_id) + self._attr_unique_id = f"{interface_serial_number}-device{device_id}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + assert self.unique_id + name = self._device.name if self._device is not None else None + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=name, + via_device=(DOMAIN, str(self._interface_serial_number)), + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._device is not None + + @property + def is_on(self) -> bool: + """Return True if light is on.""" + return self._device is not None and self._device.is_on + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._device = self.coordinator.data.get(self._device_id) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on.""" + assert self._device + await self._device.switch_on() + await asyncio.sleep(STATUS_UPDATE_DELAY) + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + assert self._device + await self._device.switch_off() + await asyncio.sleep(STATUS_UPDATE_DELAY) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json new file mode 100644 index 00000000000..8db658869d5 --- /dev/null +++ b/homeassistant/components/lunatone/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lunatone", + "name": "Lunatone", + "codeowners": ["@MoonDevLT"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lunatone", + "integration_type": "hub", + "iot_class": "local_polling", + "quality_scale": "silver", + "requirements": ["lunatone-rest-api-client==0.4.8"] +} diff --git a/homeassistant/components/lunatone/quality_scale.yaml b/homeassistant/components/lunatone/quality_scale.yaml new file mode 100644 index 00000000000..c118c210d53 --- /dev/null +++ b/homeassistant/components/lunatone/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: + status: exempt + comment: | + This integration has only one platform which uses a coordinator. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: todo + comment: Discovery not yet supported + discovery: + status: todo + comment: Discovery not yet supported + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json new file mode 100644 index 00000000000..71f4b23b058 --- /dev/null +++ b/homeassistant/components/lunatone/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "user": { + "description": "Connect to the API of your Lunatone DALI IoT Gateway.", + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The URL of the Lunatone gateway device." + } + }, + "reconfigure": { + "description": "Update the URL.", + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "[%key:component::lunatone::config::step::user::data_description::url%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_url": "Failed to connect. Check the URL and if the device is connected to power", + "missing_device_info": "Failed to read device information. Check the network connection of the device" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1d2c6fc21a7..8c162a7f10f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -370,6 +370,7 @@ FLOWS = { "lookin", "loqed", "luftdaten", + "lunatone", "lupusec", "lutron", "lutron_caseta", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 71c3ee23c81..08f08b24d59 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3727,6 +3727,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "lunatone": { + "name": "Lunatone", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "lupusec": { "name": "Lupus Electronics LUPUSEC", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index c05ec7019b2..1813576cf23 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3016,6 +3016,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lunatone.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.madvr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 137a7e60198..bd7d10b0b12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1399,6 +1399,9 @@ loqedAPI==2.1.10 # homeassistant.components.luftdaten luftdaten==0.7.4 +# homeassistant.components.lunatone +lunatone-rest-api-client==0.4.8 + # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c845c3a3a8d..41fee2f799b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1200,6 +1200,9 @@ loqedAPI==2.1.10 # homeassistant.components.luftdaten luftdaten==0.7.4 +# homeassistant.components.lunatone +lunatone-rest-api-client==0.4.8 + # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py new file mode 100644 index 00000000000..bc9e44d2e09 --- /dev/null +++ b/tests/components/lunatone/__init__.py @@ -0,0 +1,76 @@ +"""Tests for the Lunatone integration.""" + +from typing import Final + +from lunatone_rest_api_client.models import ( + DeviceData, + DeviceInfoData, + DevicesData, + FeaturesStatus, + InfoData, +) +from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status +from lunatone_rest_api_client.models.devices import DeviceStatus + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +BASE_URL: Final = "http://10.0.0.131" +SERIAL_NUMBER: Final = 12345 +VERSION: Final = "v1.14.1/1.4.3" + +DEVICE_DATA_LIST: Final[list[DeviceData]] = [ + DeviceData( + id=1, + name="Device 1", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorKelvin=Status[int](status=1000), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)), + ), + address=0, + line=0, + ), + DeviceData( + id=2, + name="Device 2", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorKelvin=Status[int](status=1000), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)), + ), + address=1, + line=0, + ), +] +DEVICES_DATA: Final[DevicesData] = DevicesData(devices=DEVICE_DATA_LIST) +INFO_DATA: Final[InfoData] = InfoData( + name="Test", + version=VERSION, + device=DeviceInfoData( + serial=SERIAL_NUMBER, + gtin=192837465, + pcb="2a", + articleNumber=87654321, + productionYear=20, + productionWeek=1, + ), +) + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Lunatone integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py new file mode 100644 index 00000000000..5f60d084788 --- /dev/null +++ b/tests/components/lunatone/conftest.py @@ -0,0 +1,82 @@ +"""Fixtures for Lunatone tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, PropertyMock, patch + +from lunatone_rest_api_client import Device, Devices +import pytest + +from homeassistant.components.lunatone.const import DOMAIN +from homeassistant.const import CONF_URL + +from . import BASE_URL, DEVICES_DATA, INFO_DATA, SERIAL_NUMBER + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lunatone.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_lunatone_devices() -> Generator[AsyncMock]: + """Mock a Lunatone devices object.""" + + def build_devices_mock(devices: Devices): + device_list = [] + for device_data in devices.data.devices: + device = AsyncMock(spec=Device) + device.data = device_data + device.id = device.data.id + device.name = device.data.name + device.is_on = device.data.features.switchable.status + device_list.append(device) + return device_list + + with patch( + "homeassistant.components.lunatone.Devices", autospec=True + ) as mock_devices: + devices = mock_devices.return_value + devices.data = DEVICES_DATA + type(devices).devices = PropertyMock( + side_effect=lambda d=devices: build_devices_mock(d) + ) + yield devices + + +@pytest.fixture +def mock_lunatone_info() -> Generator[AsyncMock]: + """Mock a Lunatone info object.""" + with ( + patch( + "homeassistant.components.lunatone.Info", + autospec=True, + ) as mock_info, + patch( + "homeassistant.components.lunatone.config_flow.Info", + new=mock_info, + ), + ): + info = mock_info.return_value + info.data = INFO_DATA + info.name = info.data.name + info.version = info.data.version + info.serial_number = info.data.device.serial + yield info + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=f"Lunatone {SERIAL_NUMBER}", + domain=DOMAIN, + data={CONF_URL: BASE_URL}, + unique_id=str(SERIAL_NUMBER), + ) diff --git a/tests/components/lunatone/snapshots/test_light.ambr b/tests/components/lunatone/snapshots/test_light.ambr new file mode 100644 index 00000000000..b2762be4540 --- /dev/null +++ b/tests/components/lunatone/snapshots/test_light.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_setup[light.device_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': 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': '12345-device1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Device 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.device_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[light.device_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': 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': '12345-device2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Device 2', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.device_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lunatone/test_config_flow.py b/tests/components/lunatone/test_config_flow.py new file mode 100644 index 00000000000..56bae075a19 --- /dev/null +++ b/tests/components/lunatone/test_config_flow.py @@ -0,0 +1,184 @@ +"""Define tests for the Lunatone config flow.""" + +from unittest.mock import AsyncMock + +import aiohttp +import pytest + +from homeassistant.components.lunatone.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import BASE_URL, SERIAL_NUMBER + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, mock_lunatone_info: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test full user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Test {SERIAL_NUMBER}" + assert result["data"] == {CONF_URL: BASE_URL} + + +async def test_full_flow_fail_because_of_missing_device_infos( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, +) -> None: + """Test full flow.""" + mock_lunatone_info.data = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "missing_device_info"} + + +async def test_device_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that the flow is aborted when the device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: BASE_URL}, + ) + + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"), + (aiohttp.ClientConnectionError(), "cannot_connect"), + ], +) +async def test_user_step_fail_with_error( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test user step with an error.""" + mock_lunatone_info.async_update.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_lunatone_info.async_update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Test {SERIAL_NUMBER}" + assert result["data"] == {CONF_URL: BASE_URL} + + +async def test_reconfigure( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + url = "http://10.0.0.100" + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_URL: url} + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"), + (aiohttp.ClientConnectionError(), "cannot_connect"), + ], +) +async def test_reconfigure_fail_with_error( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_error: str, +) -> None: + """Test reconfigure flow with an error.""" + url = "http://10.0.0.100" + + mock_lunatone_info.async_update.side_effect = exception + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_lunatone_info.async_update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_URL: url} diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py new file mode 100644 index 00000000000..0e063b25adb --- /dev/null +++ b/tests/components/lunatone/test_init.py @@ -0,0 +1,133 @@ +"""Tests for the Lunatone integration.""" + +from unittest.mock import AsyncMock + +import aiohttp + +from homeassistant.components.lunatone.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import BASE_URL, VERSION, setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the Lunatone configuration entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.manufacturer == "Lunatone" + assert device_entry.sw_version == VERSION + assert device_entry.configuration_url == BASE_URL + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready_info_api_fail( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to a failure in the info API.""" + mock_lunatone_info.async_update.side_effect = aiohttp.ClientConnectionError() + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_lunatone_info.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() + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready_devices_api_fail( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to a failure in the devices API.""" + mock_lunatone_devices.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() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_lunatone_devices.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() + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready_no_info_data( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to missing info data.""" + mock_lunatone_info.data = None + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_not_ready_no_devices_data( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to missing devices data.""" + mock_lunatone_devices.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() + 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, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to a missing serial number.""" + mock_lunatone_info.serial_number = None + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/lunatone/test_light.py b/tests/components/lunatone/test_light.py new file mode 100644 index 00000000000..64262ad497b --- /dev/null +++ b/tests/components/lunatone/test_light.py @@ -0,0 +1,79 @@ +"""Tests for the Lunatone integration.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_ENTITY_ID = "light.device_1" + + +async def test_setup( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Lunatone configuration entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + entities = hass.states.async_all(Platform.LIGHT) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + 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_turn_on_off( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the light can be turned on and off.""" + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + device = mock_lunatone_devices.data.devices[0] + device.features.switchable.status = not device.features.switchable.status + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF