Compare commits

...

12 Commits

Author SHA1 Message Date
Ludovic BOUÉ
550597310d Add calibration mode entries for various window covering devices 2025-12-20 22:31:51 +00:00
Ludovic BOUÉ
6f11e612d2 Add calibration mode string to Matter button configuration 2025-12-20 22:25:02 +00:00
Ludovic BOUÉ
efc2f7fe34 Add WindowCoveringCalibrationModeButton for calibration mode control 2025-12-20 22:23:14 +00:00
wollew
7dc9084f06 Velux action setup (#159502) 2025-12-20 19:49:15 +01:00
Svetoslav
39ba36d642 Fix syntax error in mute_volume method (#159458) 2025-12-20 19:45:02 +01:00
Álvaro Fernández Rojas
5009560f57 Update aioqsw to v0.4.2 (#159467) 2025-12-20 19:43:20 +01:00
Niracler
41e88573bb Enhance Sunricher DALI with stale-device cleanup (#156015)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-20 18:26:57 +01:00
Markus Jacobsen
27ee986b1b Add Beoremote One diagnostics to Bang & Olufsen (#159447) 2025-12-20 18:25:04 +01:00
Lukas
c9d21c1851 Pooldose: Add parallel updates (Silver Qly Scale) (#159479) 2025-12-20 18:23:25 +01:00
wollew
2afbdc5757 add gateway disconnect on unload of velux integration (#159497) 2025-12-20 18:16:58 +01:00
Joost Lekkerkerker
14cb8af9fe Add integration_type service to meteoclimatic (#159488) 2025-12-20 15:16:31 +01:00
Joost Lekkerkerker
74ae0f8297 Add integration_type service to metoffice (#159489) 2025-12-20 15:14:18 +01:00
27 changed files with 666 additions and 67 deletions

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er
from . import BeoConfigEntry
from .const import DOMAIN
from .util import get_device_buttons
from .util import get_device_buttons, get_remote_keys, get_remotes
async def async_get_config_entry_diagnostics(
@@ -53,4 +53,23 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context")
data[f"{device_button}_event"] = state_dict
# Get remotes
for remote in await get_remotes(config_entry.runtime_data.client):
# Get key Event entity states (if enabled)
for key_type in get_remote_keys():
if entity_id := entity_registry.async_get_entity_id(
EVENT_DOMAIN,
DOMAIN,
f"{remote.serial_number}_{config_entry.unique_id}_{key_type}",
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data[f"remote_{remote.serial_number}_{key_type}_event"] = state_dict
# Add remote Mozart model
data[f"remote_{remote.serial_number}"] = dict(remote)
return data

View File

@@ -16,11 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry
from .const import (
BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEY_EVENTS,
BEO_REMOTE_KEYS,
BEO_REMOTE_SUBMENU_CONTROL,
BEO_REMOTE_SUBMENU_LIGHT,
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DOMAIN,
@@ -29,7 +25,7 @@ from .const import (
WebsocketNotification,
)
from .entity import BeoEntity
from .util import get_device_buttons, get_remotes
from .util import get_device_buttons, get_remote_keys, get_remotes
PARALLEL_UPDATES = 0
@@ -40,38 +36,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Event entities from config entry."""
entities: list[BeoEvent] = []
async_add_entities(
entities: list[BeoEvent] = [
BeoButtonEvent(config_entry, button_type)
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
)
]
# Check for connected Beoremote One
remotes = await get_remotes(config_entry.runtime_data.client)
for remote in remotes:
# Add Light keys
entities.extend(
[
BeoRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
)
for key_type in BEO_REMOTE_KEYS
]
)
# Add Control keys
entities.extend(
[
BeoRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
)
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
BeoRemoteKeyEvent(config_entry, remote, key_type)
for key_type in get_remote_keys()
]
)

View File

@@ -11,7 +11,16 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DEVICE_BUTTONS, DOMAIN, BeoButtons, BeoModel
from .const import (
BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEYS,
BEO_REMOTE_SUBMENU_CONTROL,
BEO_REMOTE_SUBMENU_LIGHT,
DEVICE_BUTTONS,
DOMAIN,
BeoButtons,
BeoModel,
)
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
@@ -64,3 +73,14 @@ def get_device_buttons(model: BeoModel) -> list[str]:
buttons.remove(BeoButtons.BLUETOOTH)
return buttons
def get_remote_keys() -> list[str]:
"""Get remote keys for the Beoremote One. Formatted for Home Assistant use."""
return [
*[f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}" for key_type in BEO_REMOTE_KEYS],
*[
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}"
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
],
]

View File

@@ -70,6 +70,7 @@ MEDIA_MODES = {
"Favorites": "FAVORITES",
"Internet Radio": "IRADIO",
"USB/IPOD": "USB/IPOD",
"USB": "USB",
}
# Sub-modes of 'NET/USB'
@@ -279,7 +280,7 @@ class DenonDevice(MediaPlayerEntity):
def mute_volume(self, mute: bool) -> None:
"""Mute (true) or unmute (false) media player."""
mute_status = "ON" if mute else "OFF"
self.telnet_command(f"MU{mute_status})")
self.telnet_command(f"MU{mute_status}")
def media_play(self) -> None:
"""Play media player."""

View File

@@ -52,6 +52,34 @@ class MatterCommandButton(MatterEntity, ButtonEntity):
await self.send_device_command(self.entity_description.command())
class WindowCoveringCalibrationModeButton(MatterEntity, ButtonEntity):
"""Button to set WindowCovering calibration mode bit to 1."""
async def async_press(self) -> None:
"""Enable calibration mode by setting the CalibrationMode bit (bit 1)."""
# Prefer the defined bitmap constant; fallback to 0x2 (bit 1)
mode_bitmap = getattr(clusters.WindowCovering.Bitmaps, "Mode", None)
calibration_bit_obj = (
getattr(mode_bitmap, "kCalibrationMode", None)
if mode_bitmap is not None
else None
)
calibration_bit = (
int(calibration_bit_obj) if calibration_bit_obj is not None else 0x2
)
current_mode = self.get_matter_attribute_value(
clusters.WindowCovering.Attributes.Mode
)
if not isinstance(current_mode, int):
current_mode = 0
new_mode = current_mode | calibration_bit
await self.write_attribute(new_mode, clusters.WindowCovering.Attributes.Mode)
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
@@ -155,6 +183,16 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.SmokeCoAlarm.Attributes.AcceptedCommandList,),
value_contains=clusters.SmokeCoAlarm.Commands.SelfTestRequest.command_id,
),
MatterDiscoverySchema(
platform=Platform.BUTTON,
entity_description=MatterButtonEntityDescription(
key="WindowCoveringCalibrationMode",
translation_key="calibration_mode",
entity_category=EntityCategory.DIAGNOSTIC,
),
entity_class=WindowCoveringCalibrationModeButton,
required_attributes=(clusters.WindowCovering.Attributes.Mode,),
),
MatterDiscoverySchema(
platform=Platform.BUTTON,
entity_description=MatterButtonEntityDescription(

View File

@@ -115,6 +115,9 @@
}
},
"button": {
"calibration_mode": {
"name": "Calibration mode"
},
"cancel_boost": {
"name": "Cancel boost"
},

View File

@@ -4,6 +4,7 @@
"codeowners": ["@adrianmo"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/meteoclimatic",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["meteoclimatic"],
"requirements": ["pymeteoclimatic==0.1.0"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@MrHarcombe", "@avee87"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/metoffice",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["datapoint"],
"requirements": ["datapoint==0.12.1"]

View File

@@ -19,6 +19,8 @@ from .entity import PooldoseEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
BINARY_SENSOR_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(

View File

@@ -26,6 +26,8 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
NUMBER_DESCRIPTIONS: tuple[NumberEntityDescription, ...] = (
NumberEntityDescription(

View File

@@ -35,7 +35,7 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
parallel-updates: done
reauthentication-flow:
status: exempt
comment: This integration does not need authentication for the local API.

View File

@@ -20,6 +20,8 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class PooldoseSelectEntityDescription(SelectEntityDescription):

View File

@@ -27,6 +27,8 @@ from .entity import PooldoseEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class PooldoseSensorEntityDescription(SensorEntityDescription):

View File

@@ -18,6 +18,8 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
SWITCH_DESCRIPTIONS: tuple[SwitchEntityDescription, ...] = (
SwitchEntityDescription(

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/qnap_qsw",
"iot_class": "local_polling",
"loggers": ["aioqsw"],
"requirements": ["aioqsw==0.4.1"]
"requirements": ["aioqsw==0.4.2"]
}

View File

@@ -3,9 +3,10 @@
from __future__ import annotations
import asyncio
from collections.abc import Sequence
import logging
from PySrDaliGateway import DaliGateway
from PySrDaliGateway import DaliGateway, Device
from PySrDaliGateway.exceptions import DaliGatewayError
from homeassistant.const import (
@@ -28,6 +29,38 @@ _PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SCENE]
_LOGGER = logging.getLogger(__name__)
def _remove_missing_devices(
hass: HomeAssistant,
entry: DaliCenterConfigEntry,
devices: Sequence[Device],
gateway_identifier: tuple[str, str],
) -> None:
"""Detach devices that are no longer provided by the gateway."""
device_registry = dr.async_get(hass)
known_device_ids = {device.dev_id for device in devices}
for device_entry in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
if gateway_identifier in device_entry.identifiers:
continue
domain_device_ids = {
identifier[1]
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
}
if not domain_device_ids:
continue
if domain_device_ids.isdisjoint(known_device_ids):
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=entry.entry_id,
)
async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
"""Set up Sunricher DALI from a config entry."""
@@ -70,6 +103,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -
model="SR-GW-EDA",
serial_number=gw_sn,
)
_remove_missing_devices(hass, entry, devices, (DOMAIN, gw_sn))
entry.runtime_data = DaliCenterData(
gateway=gateway,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from pyvlx import PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
@@ -12,13 +12,54 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
issue_registry as ir,
)
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER, PLATFORMS
type VeluxConfigEntry = ConfigEntry[PyVLX]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Velux component."""
async def async_reboot_gateway(service_call: ServiceCall) -> None:
"""Reboot the gateway (deprecated - use button entity instead)."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_reboot_service",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_reboot_service",
breaks_in_ha_version="2026.6.0",
)
# Find a loaded config entry to get the PyVLX instance
# We assume only one gateway is set up or we just reboot the first one found
# (this is no change to the previous behavior, the alternative would be to reboot all)
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.state is ConfigEntryState.LOADED:
await entry.runtime_data.reboot_gateway()
return
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_gateway_loaded",
)
hass.services.async_register(DOMAIN, "reboot_gateway", async_reboot_gateway)
return True
async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Set up the velux component."""
@@ -67,27 +108,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
LOGGER.debug("Velux interface terminated")
await pyvlx.disconnect()
async def async_reboot_gateway(service_call: ServiceCall) -> None:
"""Reboot the gateway (deprecated - use button entity instead)."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_reboot_service",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_reboot_service",
breaks_in_ha_version="2026.6.0",
)
await pyvlx.reboot_gateway()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
)
hass.services.async_register(DOMAIN, "reboot_gateway", async_reboot_gateway)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -95,4 +119,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
# Disconnect from gateway only after platforms are successfully unloaded.
# Disconnecting will reboot the gateway in the pyvlx library, which is needed to allow new
# connections to be made later.
await entry.runtime_data.disconnect()
return unload_ok

View File

@@ -1,8 +1,6 @@
rules:
# Bronze
action-setup:
status: todo
comment: needs to move to async_setup
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
@@ -25,7 +23,7 @@ rules:
# Silver
action-exceptions: todo
config-entry-unloading: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo

View File

@@ -37,6 +37,9 @@
}
},
"exceptions": {
"no_gateway_loaded": {
"message": "No loaded Velux gateway found"
},
"reboot_failed": {
"message": "Failed to reboot gateway. Try again in a few moments or power cycle the device manually"
}

View File

@@ -3956,13 +3956,13 @@
},
"meteoclimatic": {
"name": "Meteoclimatic",
"integration_type": "hub",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"metoffice": {
"name": "Met Office",
"integration_type": "hub",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},

2
requirements_all.txt generated
View File

@@ -369,7 +369,7 @@ aiopvpc==4.2.2
aiopyarr==23.4.0
# homeassistant.components.qnap_qsw
aioqsw==0.4.1
aioqsw==0.4.2
# homeassistant.components.rainforest_raven
aioraven==0.7.1

View File

@@ -354,7 +354,7 @@ aiopvpc==4.2.2
aiopyarr==23.4.0
# homeassistant.components.qnap_qsw
aioqsw==0.4.1
aioqsw==0.4.2
# homeassistant.components.rainforest_raven
aioraven==0.7.1

View File

@@ -82,6 +82,30 @@
'entity_id': 'media_player.beosound_balance_11111111',
'state': 'playing',
}),
'remote_55555555': dict({
'address': '',
'app_version': '1.0.0',
'battery_level': 50,
'connected': True,
'db_version': None,
'last_seen': None,
'name': 'BEORC',
'serial_number': '55555555',
'updated': None,
}),
'remote_55555555_Control/Play_event': dict({
'attributes': dict({
'device_class': 'button',
'event_type': None,
'event_types': list([
'key_press',
'key_release',
]),
'friendly_name': 'Beoremote One-55555555-11111111 Control - Play',
}),
'entity_id': 'event.beoremote_one_55555555_11111111_control_play',
'state': 'unknown',
}),
'websocket_connected': False,
})
# ---

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
from .conftest import mock_websocket_connection
from .const import TEST_BUTTON_EVENT_ENTITY_ID
from .const import TEST_BUTTON_EVENT_ENTITY_ID, TEST_REMOTE_KEY_EVENT_ENTITY_ID
from tests.common import AsyncMock, MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
@@ -25,8 +25,11 @@ async def test_async_get_config_entry_diagnostics(
) -> None:
"""Test config entry diagnostics."""
# Enable an Event entity
# Enable a button and remote key Event entity
entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None)
entity_registry.async_update_entity(
TEST_REMOTE_KEY_EVENT_ENTITY_ID, disabled_by=None
)
hass.config_entries.async_schedule_reload(mock_config_entry.entry_id)
# Re-trigger WebSocket events after the reload

View File

@@ -1369,6 +1369,54 @@
'state': 'unknown',
})
# ---
# name: test_buttons[eve_shutter][button.eve_shutter_switch_20eci1701_calibration_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.eve_shutter_switch_20eci1701_calibration_mode',
'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': 'Calibration mode',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'calibration_mode',
'unique_id': '00000000000004D2-0000000000000094-MatterNodeDevice-1-WindowCoveringCalibrationMode-258-23',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[eve_shutter][button.eve_shutter_switch_20eci1701_calibration_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Eve Shutter Switch 20ECI1701 Calibration mode',
}),
'context': <ANY>,
'entity_id': 'button.eve_shutter_switch_20eci1701_calibration_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[eve_shutter][button.eve_shutter_switch_20eci1701_identify-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -3849,6 +3897,150 @@
'state': 'unknown',
})
# ---
# name: test_buttons[window_covering_full][button.mock_full_window_covering_calibration_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.mock_full_window_covering_calibration_mode',
'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': 'Calibration mode',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'calibration_mode',
'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-WindowCoveringCalibrationMode-258-23',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[window_covering_full][button.mock_full_window_covering_calibration_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Full Window Covering Calibration mode',
}),
'context': <ANY>,
'entity_id': 'button.mock_full_window_covering_calibration_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[window_covering_lift][button.mock_lift_window_covering_calibration_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.mock_lift_window_covering_calibration_mode',
'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': 'Calibration mode',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'calibration_mode',
'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-WindowCoveringCalibrationMode-258-23',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[window_covering_lift][button.mock_lift_window_covering_calibration_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Lift Window Covering Calibration mode',
}),
'context': <ANY>,
'entity_id': 'button.mock_lift_window_covering_calibration_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[window_covering_pa_lift][button.longan_link_wncv_da01_calibration_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.longan_link_wncv_da01_calibration_mode',
'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': 'Calibration mode',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'calibration_mode',
'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-WindowCoveringCalibrationMode-258-23',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[window_covering_pa_lift][button.longan_link_wncv_da01_calibration_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Longan link WNCV DA01 Calibration mode',
}),
'context': <ANY>,
'entity_id': 'button.longan_link_wncv_da01_calibration_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[window_covering_pa_lift][button.longan_link_wncv_da01_identify-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -3898,6 +4090,102 @@
'state': 'unknown',
})
# ---
# name: test_buttons[window_covering_pa_tilt][button.mock_pa_tilt_window_covering_calibration_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.mock_pa_tilt_window_covering_calibration_mode',
'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': 'Calibration mode',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'calibration_mode',
'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-WindowCoveringCalibrationMode-258-23',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[window_covering_pa_tilt][button.mock_pa_tilt_window_covering_calibration_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock PA Tilt Window Covering Calibration mode',
}),
'context': <ANY>,
'entity_id': 'button.mock_pa_tilt_window_covering_calibration_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[window_covering_tilt][button.mock_tilt_window_covering_calibration_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.mock_tilt_window_covering_calibration_mode',
'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': 'Calibration mode',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'calibration_mode',
'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-WindowCoveringCalibrationMode-258-23',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[window_covering_tilt][button.mock_tilt_window_covering_calibration_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Tilt Window Covering Calibration mode',
}),
'context': <ANY>,
'entity_id': 'button.mock_tilt_window_covering_calibration_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[yandex_smart_socket][button.yndx_00540_identify-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -3947,3 +4235,51 @@
'state': 'unknown',
})
# ---
# name: test_buttons[zemismart_mt25b][button.zemismart_mt25b_roller_motor_calibration_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.zemismart_mt25b_roller_motor_calibration_mode',
'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': 'Calibration mode',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'calibration_mode',
'unique_id': '00000000000004D2-000000000000007A-MatterNodeDevice-1-WindowCoveringCalibrationMode-258-23',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[zemismart_mt25b][button.zemismart_mt25b_roller_motor_calibration_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Zemismart MT25B Roller Motor Calibration mode',
}),
'context': <ANY>,
'entity_id': 'button.zemismart_mt25b_roller_motor_calibration_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -5,9 +5,10 @@ from unittest.mock import MagicMock
from PySrDaliGateway.exceptions import DaliGatewayError
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.sunricher_dali.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
@@ -63,6 +64,23 @@ async def test_setup_entry_connection_error(
mock_gateway.connect.assert_called_once()
async def test_setup_entry_discovery_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gateway: MagicMock,
) -> None:
"""Test setup fails when device discovery fails."""
mock_config_entry.add_to_hass(hass)
mock_gateway.discover_devices.side_effect = DaliGatewayError("Discovery 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()
mock_gateway.discover_devices.assert_called_once()
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -80,3 +98,40 @@ async def test_unload_entry(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_remove_stale_devices(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gateway: MagicMock,
mock_devices: list[MagicMock],
device_registry: dr.DeviceRegistry,
) -> None:
"""Test stale devices are removed when device list decreases."""
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()
devices_before = dr.async_entries_for_config_entry(
device_registry, mock_config_entry.entry_id
)
initial_count = len(devices_before)
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_gateway.discover_devices.return_value = mock_devices[:2]
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
devices_after = dr.async_entries_for_config_entry(
device_registry, mock_config_entry.entry_id
)
assert len(devices_after) < initial_count
gateway_device = device_registry.async_get_device(
identifiers={(DOMAIN, mock_gateway.gw_sn)}
)
assert gateway_device is not None
assert mock_config_entry.entry_id in gateway_device.config_entries

View File

@@ -2,16 +2,22 @@
These tests verify that setup retries (ConfigEntryNotReady) are triggered
when scene or node loading fails.
They also verify that unloading the integration properly disconnects.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from pyvlx.exception import PyVLXException
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.common import AsyncMock, ConfigEntry
from tests.common import AsyncMock, ConfigEntry, MockConfigEntry
async def test_setup_retry_on_nodes_failure(
@@ -53,3 +59,44 @@ async def test_setup_retry_on_oserror_during_scenes(
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
mock_pyvlx.load_scenes.assert_awaited_once()
mock_pyvlx.load_nodes.assert_not_called()
@pytest.fixture
def platform() -> Platform:
"""Fixture to specify platform to test."""
return Platform.COVER
@pytest.mark.usefixtures("setup_integration")
async def test_unload_calls_disconnect(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pyvlx
) -> None:
"""Test that unloading the config entry disconnects from the gateway."""
# Unload the entry
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify disconnect was called
mock_pyvlx.disconnect.assert_awaited_once()
@pytest.mark.usefixtures("setup_integration")
async def test_unload_does_not_disconnect_if_platform_unload_fails(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pyvlx
) -> None:
"""Test that disconnect is not called if platform unload fails."""
# Mock platform unload to fail
with patch(
"homeassistant.config_entries.ConfigEntries.async_unload_platforms",
return_value=False,
):
result = await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify unload failed
assert result is False
# Verify disconnect was NOT called since platform unload failed
mock_pyvlx.disconnect.assert_not_awaited()