diff --git a/CODEOWNERS b/CODEOWNERS index 696b7a0f2f0..218d4a47def 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1543,6 +1543,8 @@ build.json @home-assistant/supervisor /tests/components/suez_water/ @ooii @jb101010-2 /homeassistant/components/sun/ @home-assistant/core /tests/components/sun/ @home-assistant/core +/homeassistant/components/sunricher_dali_center/ @niracler +/tests/components/sunricher_dali_center/ @niracler /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen diff --git a/homeassistant/components/sunricher_dali_center/__init__.py b/homeassistant/components/sunricher_dali_center/__init__.py new file mode 100644 index 00000000000..75b08a16d21 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/__init__.py @@ -0,0 +1,88 @@ +"""The DALI Center integration.""" + +from __future__ import annotations + +import logging + +from PySrDaliGateway import DaliGateway +from PySrDaliGateway.exceptions import DaliGatewayError + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER +from .types import DaliCenterConfigEntry, DaliCenterData + +_PLATFORMS: list[Platform] = [Platform.LIGHT] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool: + """Set up DALI Center from a config entry.""" + + gateway = DaliGateway( + entry.data[CONF_SERIAL_NUMBER], + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + name=entry.data[CONF_NAME], + ) + gw_sn = gateway.gw_sn + + try: + await gateway.connect() + except DaliGatewayError as exc: + raise ConfigEntryNotReady( + "You can try to delete the gateway and add it again" + ) from exc + + def on_online_status(dev_id: str, available: bool) -> None: + signal = f"{DOMAIN}_update_available_{dev_id}" + hass.add_job(async_dispatcher_send, hass, signal, available) + + gateway.on_online_status = on_online_status + + try: + devices = await gateway.discover_devices() + except DaliGatewayError as exc: + raise ConfigEntryNotReady( + "Unable to discover devices from the gateway" + ) from exc + + _LOGGER.debug("Discovered %d devices on gateway %s", len(devices), gw_sn) + + dev_reg = dr.async_get(hass) + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, gw_sn)}, + manufacturer=MANUFACTURER, + name=gateway.name, + model="SR-GW-EDA", + serial_number=gw_sn, + ) + + entry.runtime_data = DaliCenterData( + gateway=gateway, + devices=devices, + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, _PLATFORMS): + await entry.runtime_data.gateway.disconnect() + return unload_ok diff --git a/homeassistant/components/sunricher_dali_center/config_flow.py b/homeassistant/components/sunricher_dali_center/config_flow.py new file mode 100644 index 00000000000..84b1c692920 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow for the DALI Center integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from PySrDaliGateway import DaliGateway +from PySrDaliGateway.discovery import DaliGatewayDiscovery +from PySrDaliGateway.exceptions import DaliGatewayError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) + +from .const import CONF_SERIAL_NUMBER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DaliCenterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for DALI Center.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_gateways: dict[str, DaliGateway] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is not None: + return await self.async_step_select_gateway() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({}), + ) + + async def async_step_select_gateway( + self, discovery_info: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle gateway discovery.""" + errors: dict[str, str] = {} + + if discovery_info and "selected_gateway" in discovery_info: + selected_sn = discovery_info["selected_gateway"] + selected_gateway = self._discovered_gateways[selected_sn] + + await self.async_set_unique_id(selected_gateway.gw_sn) + self._abort_if_unique_id_configured() + + try: + await selected_gateway.connect() + except DaliGatewayError as err: + _LOGGER.debug( + "Failed to connect to gateway %s during config flow", + selected_gateway.gw_sn, + exc_info=err, + ) + errors["base"] = "cannot_connect" + else: + await selected_gateway.disconnect() + return self.async_create_entry( + title=selected_gateway.name, + data={ + CONF_SERIAL_NUMBER: selected_gateway.gw_sn, + CONF_HOST: selected_gateway.gw_ip, + CONF_PORT: selected_gateway.port, + CONF_NAME: selected_gateway.name, + CONF_USERNAME: selected_gateway.username, + CONF_PASSWORD: selected_gateway.passwd, + }, + ) + + if not self._discovered_gateways: + _LOGGER.debug("Starting gateway discovery") + discovery = DaliGatewayDiscovery() + try: + discovered = await discovery.discover_gateways() + except DaliGatewayError as err: + _LOGGER.debug("Gateway discovery failed", exc_info=err) + errors["base"] = "discovery_failed" + else: + configured_gateways = { + entry.data[CONF_SERIAL_NUMBER] + for entry in self.hass.config_entries.async_entries(DOMAIN) + } + + self._discovered_gateways = { + gw.gw_sn: gw + for gw in discovered + if gw.gw_sn not in configured_gateways + } + + if not self._discovered_gateways: + return self.async_show_form( + step_id="select_gateway", + errors=errors if errors else {"base": "no_devices_found"}, + data_schema=vol.Schema({}), + ) + + gateway_options = [ + SelectOptionDict( + value=sn, + label=f"{gateway.name} [SN {sn}, IP {gateway.gw_ip}]", + ) + for sn, gateway in self._discovered_gateways.items() + ] + + return self.async_show_form( + step_id="select_gateway", + data_schema=vol.Schema( + { + vol.Optional("selected_gateway"): SelectSelector( + SelectSelectorConfig(options=gateway_options, sort=True) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/sunricher_dali_center/const.py b/homeassistant/components/sunricher_dali_center/const.py new file mode 100644 index 00000000000..7e78b441d52 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/const.py @@ -0,0 +1,5 @@ +"""Constants for the DALI Center integration.""" + +DOMAIN = "sunricher_dali_center" +MANUFACTURER = "Sunricher" +CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/sunricher_dali_center/light.py b/homeassistant/components/sunricher_dali_center/light.py new file mode 100644 index 00000000000..a73422ca334 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/light.py @@ -0,0 +1,190 @@ +"""Platform for light integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from PySrDaliGateway import Device +from PySrDaliGateway.helper import is_light_device +from PySrDaliGateway.types import LightStatus + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ATTR_RGBW_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, MANUFACTURER +from .types import DaliCenterConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DaliCenterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up DALI Center light entities from config entry.""" + runtime_data = entry.runtime_data + gateway = runtime_data.gateway + devices = runtime_data.devices + + def _on_light_status(dev_id: str, status: LightStatus) -> None: + signal = f"{DOMAIN}_update_{dev_id}" + hass.add_job(async_dispatcher_send, hass, signal, status) + + gateway.on_light_status = _on_light_status + + async_add_entities( + DaliCenterLight(device) + for device in devices + if is_light_device(device.dev_type) + ) + + +class DaliCenterLight(LightEntity): + """Representation of a DALI Center Light.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_is_on: bool | None = None + _attr_brightness: int | None = None + _white_level: int | None = None + _attr_color_mode: ColorMode | str | None = None + _attr_color_temp_kelvin: int | None = None + _attr_hs_color: tuple[float, float] | None = None + _attr_rgbw_color: tuple[int, int, int, int] | None = None + + def __init__(self, light: Device) -> None: + """Initialize the light entity.""" + + self._light = light + self._unavailable_logged = False + self._attr_unique_id = light.unique_id + self._attr_available = light.status == "online" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, light.dev_id)}, + name=light.name, + manufacturer=MANUFACTURER, + model=light.model, + via_device=(DOMAIN, light.gw_sn), + ) + self._attr_min_color_temp_kelvin = 1000 + self._attr_max_color_temp_kelvin = 8000 + + self._determine_features() + + def _determine_features(self) -> None: + supported_modes: set[ColorMode] = set() + color_mode = self._light.color_mode + color_mode_map: dict[str, ColorMode] = { + "color_temp": ColorMode.COLOR_TEMP, + "hs": ColorMode.HS, + "rgbw": ColorMode.RGBW, + } + self._attr_color_mode = color_mode_map.get(color_mode, ColorMode.BRIGHTNESS) + supported_modes.add(self._attr_color_mode) + self._attr_supported_color_modes = supported_modes + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + _LOGGER.debug( + "Turning on light %s with kwargs: %s", self._attr_unique_id, kwargs + ) + brightness = kwargs.get(ATTR_BRIGHTNESS) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + hs_color = kwargs.get(ATTR_HS_COLOR) + rgbw_color = kwargs.get(ATTR_RGBW_COLOR) + self._light.turn_on( + brightness=brightness, + color_temp_kelvin=color_temp_kelvin, + hs_color=hs_color, + rgbw_color=rgbw_color, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + self._light.turn_off() + + async def async_added_to_hass(self) -> None: + """Handle entity addition to Home Assistant.""" + + signal = f"{DOMAIN}_update_{self._attr_unique_id}" + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._handle_device_update) + ) + + signal = f"{DOMAIN}_update_available_{self._attr_unique_id}" + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._handle_availability) + ) + + # read_status() only queues a request on the gateway and relies on the + # current event loop via call_later, so it must run in the loop thread. + self._light.read_status() + + @callback + def _handle_availability(self, available: bool) -> None: + self._attr_available = available + if not available and not self._unavailable_logged: + _LOGGER.info("Light %s became unavailable", self._attr_unique_id) + self._unavailable_logged = True + elif available and self._unavailable_logged: + _LOGGER.info("Light %s is back online", self._attr_unique_id) + self._unavailable_logged = False + self.schedule_update_ha_state() + + @callback + def _handle_device_update(self, status: LightStatus) -> None: + if status.get("is_on") is not None: + self._attr_is_on = status["is_on"] + + if status.get("brightness") is not None: + self._attr_brightness = status["brightness"] + + if status.get("white_level") is not None: + self._white_level = status["white_level"] + if self._attr_rgbw_color is not None and self._white_level is not None: + self._attr_rgbw_color = ( + self._attr_rgbw_color[0], + self._attr_rgbw_color[1], + self._attr_rgbw_color[2], + self._white_level, + ) + + if ( + status.get("color_temp_kelvin") is not None + and self._attr_supported_color_modes + and ColorMode.COLOR_TEMP in self._attr_supported_color_modes + ): + self._attr_color_temp_kelvin = status["color_temp_kelvin"] + + if ( + status.get("hs_color") is not None + and self._attr_supported_color_modes + and ColorMode.HS in self._attr_supported_color_modes + ): + self._attr_hs_color = status["hs_color"] + + if ( + status.get("rgbw_color") is not None + and self._attr_supported_color_modes + and ColorMode.RGBW in self._attr_supported_color_modes + ): + self._attr_rgbw_color = status["rgbw_color"] + + self.async_write_ha_state() diff --git a/homeassistant/components/sunricher_dali_center/manifest.json b/homeassistant/components/sunricher_dali_center/manifest.json new file mode 100644 index 00000000000..1f68d484f78 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sunricher_dali_center", + "name": "DALI Center", + "codeowners": ["@niracler"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sunricher_dali_center", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["PySrDaliGateway==0.13.1"] +} diff --git a/homeassistant/components/sunricher_dali_center/quality_scale.yaml b/homeassistant/components/sunricher_dali_center/quality_scale.yaml new file mode 100644 index 00000000000..dd4e2fff823 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + 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: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: todo + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + 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: + status: exempt + comment: Integration exposes only primary light entities. + entity-device-class: + status: exempt + comment: Light entities do not support device classes. + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/sunricher_dali_center/strings.json b/homeassistant/components/sunricher_dali_center/strings.json new file mode 100644 index 00000000000..c9fe7aaeeaf --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up DALI Center gateway", + "description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press." + }, + "select_gateway": { + "title": "Select DALI gateway", + "description": "Select the gateway to configure.", + "data": { + "selected_gateway": "Gateway" + }, + "data_description": { + "selected_gateway": "Each option shows the gateway name, serial number, and IP address." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "discovery_failed": "Failed to discover DALI gateways on the network", + "no_devices_found": "No DALI gateways found on the network", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/sunricher_dali_center/types.py b/homeassistant/components/sunricher_dali_center/types.py new file mode 100644 index 00000000000..8307360f62c --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/types.py @@ -0,0 +1,18 @@ +"""Type definitions for the DALI Center integration.""" + +from dataclasses import dataclass + +from PySrDaliGateway import DaliGateway, Device + +from homeassistant.config_entries import ConfigEntry + + +@dataclass +class DaliCenterData: + """Runtime data for the DALI Center integration.""" + + gateway: DaliGateway + devices: list[Device] + + +type DaliCenterConfigEntry = ConfigEntry[DaliCenterData] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f3d23183f55..9a924853c4e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -641,6 +641,7 @@ FLOWS = { "subaru", "suez_water", "sun", + "sunricher_dali_center", "sunweg", "surepetcare", "swiss_public_transport", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7b933a735bf..730a77bccd9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6489,6 +6489,12 @@ "iot_class": "calculated", "single_config_entry": true }, + "sunricher_dali_center": { + "name": "DALI Center", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "sunweg": { "name": "Sun WEG", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 111290c0da9..a365a8727dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -83,6 +83,9 @@ PyQRCode==1.2.1 # homeassistant.components.rmvtransport PyRMVtransport==0.3.3 +# homeassistant.components.sunricher_dali_center +PySrDaliGateway==0.13.1 + # homeassistant.components.switchbot PySwitchbot==0.72.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b706a433b27..9c87033b288 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -80,6 +80,9 @@ PyQRCode==1.2.1 # homeassistant.components.rmvtransport PyRMVtransport==0.3.3 +# homeassistant.components.sunricher_dali_center +PySrDaliGateway==0.13.1 + # homeassistant.components.switchbot PySwitchbot==0.72.0 diff --git a/tests/components/sunricher_dali_center/__init__.py b/tests/components/sunricher_dali_center/__init__.py new file mode 100644 index 00000000000..5ff4c7ca609 --- /dev/null +++ b/tests/components/sunricher_dali_center/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sunricher DALI Center integration.""" diff --git a/tests/components/sunricher_dali_center/conftest.py b/tests/components/sunricher_dali_center/conftest.py new file mode 100644 index 00000000000..b533a5e8ded --- /dev/null +++ b/tests/components/sunricher_dali_center/conftest.py @@ -0,0 +1,150 @@ +"""Common fixtures for the Dali Center tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.sunricher_dali_center.const import ( + CONF_SERIAL_NUMBER, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SERIAL_NUMBER: "6A242121110E", + CONF_HOST: "192.168.1.100", + CONF_PORT: 1883, + CONF_NAME: "Test Gateway", + CONF_USERNAME: "gateway_user", + CONF_PASSWORD: "gateway_pass", + }, + unique_id="6A242121110E", + title="Test Gateway", + ) + + +def _create_mock_device( + dev_id: str, + dev_type: str, + name: str, + model: str, + color_mode: str, + gw_sn: str = "6A242121110E", +) -> MagicMock: + """Create a mock device with standard attributes.""" + device = MagicMock() + device.dev_id = dev_id + device.unique_id = dev_id + device.status = "online" + device.dev_type = dev_type + device.name = name + device.model = model + device.gw_sn = gw_sn + device.color_mode = color_mode + device.turn_on = MagicMock() + device.turn_off = MagicMock() + device.read_status = MagicMock() + return device + + +@pytest.fixture +def mock_devices() -> list[MagicMock]: + """Return mocked Device objects.""" + return [ + _create_mock_device( + "01010000026A242121110E", + "0101", + "Dimmer 0000-02", + "DALI DT6 Dimmable Driver", + "brightness", + ), + _create_mock_device( + "01020000036A242121110E", + "0102", + "CCT 0000-03", + "DALI DT8 Tc Dimmable Driver", + "color_temp", + ), + _create_mock_device( + "01030000046A242121110E", + "0103", + "HS Color Light", + "DALI HS Color Driver", + "hs", + ), + _create_mock_device( + "01040000056A242121110E", + "0104", + "RGBW Light", + "DALI RGBW Driver", + "rgbw", + ), + _create_mock_device( + "01010000026A242121110E", + "0101", + "Duplicate Dimmer", + "DALI DT6 Dimmable Driver", + "brightness", + ), + ] + + +@pytest.fixture +def mock_discovery(mock_gateway: MagicMock) -> Generator[MagicMock]: + """Mock DaliGatewayDiscovery.""" + with patch( + "homeassistant.components.sunricher_dali_center.config_flow.DaliGatewayDiscovery" + ) as mock_discovery_class: + mock_discovery = mock_discovery_class.return_value + mock_discovery.discover_gateways = AsyncMock(return_value=[mock_gateway]) + yield mock_discovery + + +@pytest.fixture +def mock_gateway(mock_devices: list[MagicMock]) -> Generator[MagicMock]: + """Return a mocked DaliGateway.""" + with ( + patch( + "homeassistant.components.sunricher_dali_center.DaliGateway", autospec=True + ) as mock_gateway_class, + patch( + "homeassistant.components.sunricher_dali_center.config_flow.DaliGateway", + new=mock_gateway_class, + ), + ): + mock_gateway = mock_gateway_class.return_value + mock_gateway.gw_sn = "6A242121110E" + mock_gateway.gw_ip = "192.168.1.100" + mock_gateway.port = 1883 + mock_gateway.name = "Test Gateway" + mock_gateway.username = "gateway_user" + mock_gateway.passwd = "gateway_pass" + mock_gateway.connect = AsyncMock() + mock_gateway.disconnect = AsyncMock() + mock_gateway.discover_devices = AsyncMock(return_value=mock_devices) + yield mock_gateway + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sunricher_dali_center.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sunricher_dali_center/snapshots/test_light.ambr b/tests/components/sunricher_dali_center/snapshots/test_light.ambr new file mode 100644 index 00000000000..75ee3145121 --- /dev/null +++ b/tests/components/sunricher_dali_center/snapshots/test_light.ambr @@ -0,0 +1,253 @@ +# serializer version: 1 +# name: test_entities[light.cct_0000_03-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 8000, + 'max_mireds': 1000, + 'min_color_temp_kelvin': 1000, + 'min_mireds': 125, + '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.cct_0000_03', + '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': 'sunricher_dali_center', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01020000036A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.cct_0000_03-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'CCT 0000-03', + 'hs_color': None, + 'max_color_temp_kelvin': 8000, + 'max_mireds': 1000, + 'min_color_temp_kelvin': 1000, + 'min_mireds': 125, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.cct_0000_03', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[light.dimmer_0000_02-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.dimmer_0000_02', + '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': 'sunricher_dali_center', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01010000026A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.dimmer_0000_02-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer 0000-02', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_0000_02', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[light.hs_color_light-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.hs_color_light', + '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': 'sunricher_dali_center', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01030000046A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.hs_color_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'HS Color Light', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.hs_color_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[light.rgbw_light-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.rgbw_light', + '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': 'sunricher_dali_center', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01040000056A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.rgbw_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'RGBW Light', + 'hs_color': None, + 'rgb_color': None, + 'rgbw_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.rgbw_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sunricher_dali_center/test_config_flow.py b/tests/components/sunricher_dali_center/test_config_flow.py new file mode 100644 index 00000000000..dbad0c11f5e --- /dev/null +++ b/tests/components/sunricher_dali_center/test_config_flow.py @@ -0,0 +1,224 @@ +"""Test the DALI Center config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from PySrDaliGateway.exceptions import DaliGatewayError + +from homeassistant.components.sunricher_dali_center.const import ( + CONF_SERIAL_NUMBER, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_discovery_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovery: MagicMock, + mock_gateway: MagicMock, +) -> None: + """Test a successful discovery flow.""" + 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" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == mock_gateway.name + assert result.get("data") == { + CONF_SERIAL_NUMBER: mock_gateway.gw_sn, + CONF_HOST: mock_gateway.gw_ip, + CONF_PORT: mock_gateway.port, + CONF_NAME: mock_gateway.name, + CONF_USERNAME: mock_gateway.username, + CONF_PASSWORD: mock_gateway.passwd, + } + result_entry = result.get("result") + assert result_entry is not None + assert result_entry.unique_id == mock_gateway.gw_sn + mock_setup_entry.assert_called_once() + mock_gateway.connect.assert_awaited_once() + mock_gateway.disconnect.assert_awaited_once() + + +async def test_discovery_no_gateways_found( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_gateway: MagicMock, +) -> None: + """Test discovery step when no gateways are found.""" + mock_discovery.discover_gateways.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + errors = result.get("errors") + assert errors is not None + assert errors["base"] == "no_devices_found" + + mock_discovery.discover_gateways.return_value = [mock_gateway] + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_discovery_gateway_error( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_gateway: MagicMock, +) -> None: + """Test discovery error handling when gateway search fails.""" + mock_discovery.discover_gateways.side_effect = DaliGatewayError("failure") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + errors = result.get("errors") + assert errors is not None + assert errors["base"] == "discovery_failed" + + mock_discovery.discover_gateways.side_effect = None + mock_discovery.discover_gateways.return_value = [mock_gateway] + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_discovery_connection_failure( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_gateway: MagicMock, +) -> None: + """Test connection failure when validating the selected gateway.""" + mock_gateway.connect.side_effect = DaliGatewayError("failure") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + errors = result.get("errors") + assert errors is not None + assert errors["base"] == "cannot_connect" + mock_gateway.connect.assert_awaited_once() + mock_gateway.disconnect.assert_not_awaited() + + mock_gateway.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_discovery_duplicate_filtered( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test that already configured gateways are filtered out.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + errors = result.get("errors") + assert errors is not None + assert errors["base"] == "no_devices_found" + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_discovery_unique_id_already_configured( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test duplicate protection when the entry appears during the flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/sunricher_dali_center/test_init.py b/tests/components/sunricher_dali_center/test_init.py new file mode 100644 index 00000000000..20ff81ce059 --- /dev/null +++ b/tests/components/sunricher_dali_center/test_init.py @@ -0,0 +1,61 @@ +"""Test the Dali Center integration initialization.""" + +from unittest.mock import MagicMock + +from PySrDaliGateway.exceptions import DaliGatewayError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test successful setup of config entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_gateway.connect.assert_called_once() + + +async def test_setup_entry_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test setup fails when gateway connection fails.""" + mock_config_entry.add_to_hass(hass) + mock_gateway.connect.side_effect = DaliGatewayError("Connection failed") + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_gateway.connect.assert_called_once() + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test successful unloading of config entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/sunricher_dali_center/test_light.py b/tests/components/sunricher_dali_center/test_light.py new file mode 100644 index 00000000000..999c3f8d6df --- /dev/null +++ b/tests/components/sunricher_dali_center/test_light.py @@ -0,0 +1,177 @@ +"""Test the Dali Center light platform.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform + +TEST_DIMMER_ENTITY_ID = "light.dimmer_0000_02" +TEST_DIMMER_DEVICE_ID = "01010000026A242121110E" +TEST_CCT_DEVICE_ID = "01020000036A242121110E" +TEST_HS_DEVICE_ID = "01030000046A242121110E" +TEST_RGBW_DEVICE_ID = "01040000056A242121110E" + + +def _dispatch_status( + gateway: MagicMock, device_id: str, status: dict[str, Any] +) -> None: + """Invoke the status callback registered on the gateway mock.""" + callback = gateway.on_light_status + assert callable(callback) + callback(device_id, status) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify which platforms to test.""" + return [Platform.LIGHT] + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, + mock_devices: list[MagicMock], + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.sunricher_dali_center._PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the light entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 5 + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id is not None + + +async def test_turn_on_light( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_devices: list[MagicMock], +) -> None: + """Test turning on a light.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_DIMMER_ENTITY_ID}, + blocking=True, + ) + + mock_devices[0].turn_on.assert_called_once() + + +async def test_turn_off_light( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_devices: list[MagicMock], +) -> None: + """Test turning off a light.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_DIMMER_ENTITY_ID}, + blocking=True, + ) + + mock_devices[0].turn_off.assert_called_once() + + +async def test_turn_on_with_brightness( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_devices: list[MagicMock], +) -> None: + """Test turning on light with brightness.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_DIMMER_ENTITY_ID, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + mock_devices[0].turn_on.assert_called_once_with( + brightness=128, + color_temp_kelvin=None, + hs_color=None, + rgbw_color=None, + ) + + +async def test_dispatcher_connection( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_devices: list[MagicMock], + mock_gateway: MagicMock, +) -> None: + """Test that dispatcher signals are properly connected.""" + entity_entry = er.async_get(hass).async_get(TEST_DIMMER_ENTITY_ID) + assert entity_entry is not None + + state = hass.states.get(TEST_DIMMER_ENTITY_ID) + assert state is not None + + status_update: dict[str, Any] = {"is_on": True, "brightness": 128} + + _dispatch_status(mock_gateway, TEST_DIMMER_DEVICE_ID, status_update) + await hass.async_block_till_done() + + state_after = hass.states.get(TEST_DIMMER_ENTITY_ID) + assert state_after is not None + + +@pytest.mark.parametrize( + ("device_id", "status_update"), + [ + (TEST_CCT_DEVICE_ID, {"color_temp_kelvin": 3000}), + (TEST_HS_DEVICE_ID, {"hs_color": (120.0, 50.0)}), + (TEST_RGBW_DEVICE_ID, {"rgbw_color": (255, 128, 64, 32)}), + (TEST_RGBW_DEVICE_ID, {"white_level": 200}), + ], + ids=["cct_color_temp", "hs_color", "rgbw_color", "rgbw_white_level"], +) +async def test_status_updates( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_gateway: MagicMock, + device_id: str, + status_update: dict[str, Any], +) -> None: + """Test various status updates for different device types.""" + _dispatch_status(mock_gateway, device_id, status_update) + await hass.async_block_till_done()