1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 02:48:57 +00:00

Add DALI Center integration (#151479)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Niracler Li
2025-10-26 04:15:56 +08:00
committed by GitHub
parent 40c9e5356e
commit 27516dee6a
19 changed files with 1425 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -0,0 +1,5 @@
"""Constants for the DALI Center integration."""
DOMAIN = "sunricher_dali_center"
MANUFACTURER = "Sunricher"
CONF_SERIAL_NUMBER = "serial_number"

View File

@@ -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()

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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%]"
}
}
}

View File

@@ -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]

View File

@@ -641,6 +641,7 @@ FLOWS = {
"subaru",
"suez_water",
"sun",
"sunricher_dali_center",
"sunweg",
"surepetcare",
"swiss_public_transport",

View File

@@ -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",

3
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
"""Tests for the Sunricher DALI Center integration."""

View File

@@ -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

View File

@@ -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([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.cct_0000_03',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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([
<ColorMode.COLOR_TEMP: 'color_temp'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': None,
}),
'context': <ANY>,
'entity_id': 'light.cct_0000_03',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[light.dimmer_0000_02-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.dimmer_0000_02',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),
'supported_features': <LightEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'light.dimmer_0000_02',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[light.hs_color_light-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.HS: 'hs'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.hs_color_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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([
<ColorMode.HS: 'hs'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': None,
}),
'context': <ANY>,
'entity_id': 'light.hs_color_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[light.rgbw_light-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.RGBW: 'rgbw'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.rgbw_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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([
<ColorMode.RGBW: 'rgbw'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': None,
}),
'context': <ANY>,
'entity_id': 'light.rgbw_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -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"

View File

@@ -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

View File

@@ -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()