Compare commits

...

9 Commits

Author SHA1 Message Date
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
24 changed files with 289 additions and 67 deletions

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er
from . import BeoConfigEntry from . import BeoConfigEntry
from .const import DOMAIN 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( async def async_get_config_entry_diagnostics(
@@ -53,4 +53,23 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context") state_dict.pop("context")
data[f"{device_button}_event"] = state_dict 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 return data

View File

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

View File

@@ -11,7 +11,16 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry 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: 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) buttons.remove(BeoButtons.BLUETOOTH)
return buttons 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", "Favorites": "FAVORITES",
"Internet Radio": "IRADIO", "Internet Radio": "IRADIO",
"USB/IPOD": "USB/IPOD", "USB/IPOD": "USB/IPOD",
"USB": "USB",
} }
# Sub-modes of 'NET/USB' # Sub-modes of 'NET/USB'
@@ -279,7 +280,7 @@ class DenonDevice(MediaPlayerEntity):
def mute_volume(self, mute: bool) -> None: def mute_volume(self, mute: bool) -> None:
"""Mute (true) or unmute (false) media player.""" """Mute (true) or unmute (false) media player."""
mute_status = "ON" if mute else "OFF" 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: def media_play(self) -> None:
"""Play media player.""" """Play media player."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Sequence
import logging import logging
from PySrDaliGateway import DaliGateway from PySrDaliGateway import DaliGateway, Device
from PySrDaliGateway.exceptions import DaliGatewayError from PySrDaliGateway.exceptions import DaliGatewayError
from homeassistant.const import ( from homeassistant.const import (
@@ -28,6 +29,38 @@ _PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SCENE]
_LOGGER = logging.getLogger(__name__) _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: async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
"""Set up Sunricher DALI from a config entry.""" """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", model="SR-GW-EDA",
serial_number=gw_sn, serial_number=gw_sn,
) )
_remove_missing_devices(hass, entry, devices, (DOMAIN, gw_sn))
entry.runtime_data = DaliCenterData( entry.runtime_data = DaliCenterData(
gateway=gateway, gateway=gateway,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from pyvlx import PyVLX, PyVLXException from pyvlx import PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
CONF_MAC, CONF_MAC,
@@ -12,13 +12,54 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
) )
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
from homeassistant.helpers import device_registry as dr, issue_registry as ir 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 from .const import DOMAIN, LOGGER, PLATFORMS
type VeluxConfigEntry = ConfigEntry[PyVLX] 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: async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Set up the velux component.""" """Set up the velux component."""
@@ -67,27 +108,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
LOGGER.debug("Velux interface terminated") LOGGER.debug("Velux interface terminated")
await pyvlx.disconnect() 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( entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True 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: async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Unload a config entry.""" """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: rules:
# Bronze # Bronze
action-setup: action-setup: done
status: todo
comment: needs to move to async_setup
appropriate-polling: done appropriate-polling: done
brands: done brands: done
common-modules: done common-modules: done
@@ -25,7 +23,7 @@ rules:
# Silver # Silver
action-exceptions: todo action-exceptions: todo
config-entry-unloading: todo config-entry-unloading: done
docs-configuration-parameters: todo docs-configuration-parameters: todo
docs-installation-parameters: todo docs-installation-parameters: todo
entity-unavailable: todo entity-unavailable: todo

View File

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

View File

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

2
requirements_all.txt generated
View File

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

View File

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

View File

@@ -82,6 +82,30 @@
'entity_id': 'media_player.beosound_balance_11111111', 'entity_id': 'media_player.beosound_balance_11111111',
'state': 'playing', '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, 'websocket_connected': False,
}) })
# --- # ---

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.helpers.entity_registry import EntityRegistry
from .conftest import mock_websocket_connection 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.common import AsyncMock, MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.components.diagnostics import get_diagnostics_for_config_entry
@@ -25,8 +25,11 @@ async def test_async_get_config_entry_diagnostics(
) -> None: ) -> None:
"""Test config entry diagnostics.""" """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_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) hass.config_entries.async_schedule_reload(mock_config_entry.entry_id)
# Re-trigger WebSocket events after the reload # Re-trigger WebSocket events after the reload

View File

@@ -5,9 +5,10 @@ from unittest.mock import MagicMock
from PySrDaliGateway.exceptions import DaliGatewayError from PySrDaliGateway.exceptions import DaliGatewayError
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.sunricher_dali.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant 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 from tests.common import MockConfigEntry
@@ -63,6 +64,23 @@ async def test_setup_entry_connection_error(
mock_gateway.connect.assert_called_once() 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( async def test_unload_entry(
hass: HomeAssistant, hass: HomeAssistant,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
@@ -80,3 +98,40 @@ async def test_unload_entry(
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED 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 These tests verify that setup retries (ConfigEntryNotReady) are triggered
when scene or node loading fails. when scene or node loading fails.
They also verify that unloading the integration properly disconnects.
""" """
from __future__ import annotations from __future__ import annotations
from unittest.mock import patch
import pytest
from pyvlx.exception import PyVLXException from pyvlx.exception import PyVLXException
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant 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( 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 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
mock_pyvlx.load_scenes.assert_awaited_once() mock_pyvlx.load_scenes.assert_awaited_once()
mock_pyvlx.load_nodes.assert_not_called() 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()