mirror of
https://github.com/home-assistant/core.git
synced 2025-12-21 23:37:58 +00:00
Compare commits
17 Commits
knx-text-u
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
966209e4b6 | ||
|
|
a09ac94db9 | ||
|
|
0710cf3e6b | ||
|
|
a81f2a63c0 | ||
|
|
6ef2d0d0a3 | ||
|
|
911ea67a6d | ||
|
|
28dc32d5dc | ||
|
|
c95416cb48 | ||
|
|
7dc9084f06 | ||
|
|
39ba36d642 | ||
|
|
5009560f57 | ||
|
|
41e88573bb | ||
|
|
27ee986b1b | ||
|
|
c9d21c1851 | ||
|
|
2afbdc5757 | ||
|
|
14cb8af9fe | ||
|
|
74ae0f8297 |
@@ -136,6 +136,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"scene",
|
||||
"siren",
|
||||
"switch",
|
||||
"text",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
],
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.3.0",
|
||||
"aioesphomeapi==43.4.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/playstation_network",
|
||||
"integration_type": "service",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["PSNAWP==3.0.1", "pyrate-limiter==3.9.0"]
|
||||
|
||||
@@ -19,6 +19,8 @@ from .entity import PooldoseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
BINARY_SENSOR_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
|
||||
BinarySensorEntityDescription(
|
||||
|
||||
@@ -26,6 +26,8 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
NUMBER_DESCRIPTIONS: tuple[NumberEntityDescription, ...] = (
|
||||
NumberEntityDescription(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -20,6 +20,8 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PooldoseSelectEntityDescription(SelectEntityDescription):
|
||||
|
||||
@@ -27,6 +27,8 @@ from .entity import PooldoseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PooldoseSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
@@ -18,6 +18,8 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
SWITCH_DESCRIPTIONS: tuple[SwitchEntityDescription, ...] = (
|
||||
SwitchEntityDescription(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,22 @@ from .coordinator import RoborockConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"]
|
||||
TO_REDACT_CONFIG = [
|
||||
"token",
|
||||
"sn",
|
||||
"rruid",
|
||||
CONF_UNIQUE_ID,
|
||||
"username",
|
||||
"uid",
|
||||
"h",
|
||||
"k",
|
||||
"s",
|
||||
"u",
|
||||
"avatarurl",
|
||||
"nickname",
|
||||
"tuyaUuid",
|
||||
"extra",
|
||||
]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
|
||||
@@ -20,5 +20,10 @@
|
||||
"turn_on": {
|
||||
"service": "mdi:power"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"activated": {
|
||||
"trigger": "mdi:palette"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,5 +59,11 @@
|
||||
"name": "Activate"
|
||||
}
|
||||
},
|
||||
"title": "Scene"
|
||||
"title": "Scene",
|
||||
"triggers": {
|
||||
"activated": {
|
||||
"description": "Triggers when a scene was activated",
|
||||
"name": "Scene activated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
homeassistant/components/scene/trigger.py
Normal file
42
homeassistant/components/scene/trigger.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Provides triggers for scenes."""
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
)
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
|
||||
class SceneActivatedTrigger(EntityTriggerBase):
|
||||
"""Trigger for scene entity activations."""
|
||||
|
||||
_domain = DOMAIN
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state is valid and different from the current state."""
|
||||
|
||||
# UNKNOWN is a valid from_state, otherwise the first time the scene is activated
|
||||
# it would not trigger
|
||||
if from_state.state == STATE_UNAVAILABLE:
|
||||
return False
|
||||
|
||||
return from_state.state != to_state.state
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state is not invalid."""
|
||||
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"activated": SceneActivatedTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for scenes."""
|
||||
return TRIGGERS
|
||||
4
homeassistant/components/scene/triggers.yaml
Normal file
4
homeassistant/components/scene/triggers.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
activated:
|
||||
target:
|
||||
entity:
|
||||
domain: scene
|
||||
@@ -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,
|
||||
|
||||
@@ -30,7 +30,7 @@ class VelbusEntity(Entity):
|
||||
def __init__(self, channel: VelbusChannel) -> None:
|
||||
"""Initialize a Velbus entity."""
|
||||
self._channel = channel
|
||||
self._module_adress = str(channel.get_module_address())
|
||||
self._module_address = str(channel.get_module_address())
|
||||
self._attr_name = channel.get_name()
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
@@ -46,16 +46,16 @@ class VelbusEntity(Entity):
|
||||
if self._channel.is_sub_device():
|
||||
self._attr_device_info["via_device"] = (
|
||||
DOMAIN,
|
||||
self._module_adress,
|
||||
self._module_address,
|
||||
)
|
||||
serial = channel.get_module_serial() or self._module_adress
|
||||
serial = channel.get_module_serial() or self._module_address
|
||||
self._attr_unique_id = f"{serial}-{channel.get_channel_number()}"
|
||||
|
||||
def _get_identifier(self) -> str:
|
||||
"""Return the identifier of the entity."""
|
||||
if not self._channel.is_sub_device():
|
||||
return self._module_adress
|
||||
return f"{self._module_adress}-{self._channel.get_channel_number()}"
|
||||
return self._module_address
|
||||
return f"{self._module_address}-{self._channel.get_channel_number()}"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add listener for state changes."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/xbox",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["python-xbox==0.1.2"],
|
||||
"ssdp": [
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -6350,7 +6350,7 @@
|
||||
"name": "Sony Songpal"
|
||||
},
|
||||
"playstation_network": {
|
||||
"integration_type": "service",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"name": "PlayStation Network"
|
||||
|
||||
@@ -30,7 +30,6 @@ from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import entity, event
|
||||
from .debounce import Debouncer
|
||||
from .frame import report_usage
|
||||
from .typing import UNDEFINED, UndefinedType
|
||||
|
||||
REQUEST_REFRESH_DEFAULT_COOLDOWN = 10
|
||||
@@ -333,11 +332,9 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
|
||||
self.config_entry.state
|
||||
is not config_entries.ConfigEntryState.SETUP_IN_PROGRESS
|
||||
):
|
||||
report_usage(
|
||||
"uses `async_config_entry_first_refresh`, which is only supported "
|
||||
f"when entry state is {config_entries.ConfigEntryState.SETUP_IN_PROGRESS}, "
|
||||
f"but it is in state {self.config_entry.state}",
|
||||
breaks_in_ha_version="2025.11",
|
||||
raise ConfigEntryError(
|
||||
f"`async_config_entry_first_refresh` called when config entry state is {self.config_entry.state}, "
|
||||
f"but should only be called in state {config_entries.ConfigEntryState.SETUP_IN_PROGRESS}"
|
||||
)
|
||||
if await self.__wrap_async_setup():
|
||||
await self._async_refresh(
|
||||
|
||||
31
requirements.txt
generated
31
requirements.txt
generated
@@ -5,38 +5,45 @@
|
||||
# Home Assistant Core
|
||||
aiodns==3.6.1
|
||||
aiohasupervisor==0.3.3
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
aiohttp-fast-zlib==0.3.0
|
||||
aiohttp==3.13.2
|
||||
aiohttp_cors==0.8.1
|
||||
aiohttp-fast-zlib==0.3.0
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
aiozoneinfo==0.2.3
|
||||
annotatedyaml==1.0.2
|
||||
astral==2.2
|
||||
async-interrupt==1.2.2
|
||||
attrs==25.4.0
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==25.4.0
|
||||
audioop-lts==0.2.1
|
||||
awesomeversion==25.8.0
|
||||
bcrypt==5.0.0
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==46.0.2
|
||||
fnv-hash-fast==1.6.0
|
||||
ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==1.7.0
|
||||
httpx==0.28.1
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-intents==2025.12.2
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
lru-dict==1.3.0
|
||||
PyJWT==2.10.1
|
||||
cryptography==46.0.2
|
||||
Pillow==12.0.0
|
||||
propcache==0.4.1
|
||||
pyOpenSSL==25.3.0
|
||||
mutagen==1.47.0
|
||||
orjson==3.11.3
|
||||
packaging>=23.1
|
||||
Pillow==12.0.0
|
||||
propcache==0.4.1
|
||||
psutil-home-assistant==0.0.1
|
||||
PyJWT==2.10.1
|
||||
pyOpenSSL==25.3.0
|
||||
pysilero-vad==3.0.1
|
||||
pyspeex-noise==1.0.2
|
||||
python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.0
|
||||
PyYAML==6.0.3
|
||||
requests==2.32.5
|
||||
securetar==2025.2.1
|
||||
@@ -47,9 +54,9 @@ typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==1.5.2
|
||||
urllib3>=2.0
|
||||
uv==0.9.17
|
||||
voluptuous==0.15.2
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous-openapi==0.1.0
|
||||
yarl==1.22.0
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
webrtc-models==0.3.0
|
||||
yarl==1.22.0
|
||||
zeroconf==0.148.0
|
||||
|
||||
4
requirements_all.txt
generated
4
requirements_all.txt
generated
@@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==43.3.0
|
||||
aioesphomeapi==43.4.0
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -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
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==43.3.0
|
||||
aioesphomeapi==43.4.0
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -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
|
||||
|
||||
@@ -350,6 +350,24 @@ def gather_modules() -> dict[str, list[str]] | None:
|
||||
return reqs
|
||||
|
||||
|
||||
def gather_entity_platform_requirements() -> set[str]:
|
||||
"""Gather all of the requirements from manifests for entity platforms."""
|
||||
config = _get_hassfest_config()
|
||||
integrations = Integration.load_dir(config.core_integrations_path, config)
|
||||
reqs = set()
|
||||
for domain in sorted(integrations):
|
||||
integration = integrations[domain]
|
||||
|
||||
if integration.disabled:
|
||||
continue
|
||||
|
||||
if integration.integration_type != "entity":
|
||||
continue
|
||||
|
||||
reqs.update(gather_recursive_requirements(integration.domain))
|
||||
return reqs
|
||||
|
||||
|
||||
def gather_requirements_from_manifests(
|
||||
errors: list[str], reqs: dict[str, list[str]]
|
||||
) -> None:
|
||||
@@ -432,7 +450,12 @@ def requirements_output() -> str:
|
||||
"\n",
|
||||
"# Home Assistant Core\n",
|
||||
]
|
||||
output.append("\n".join(core_requirements()))
|
||||
|
||||
requirements = set()
|
||||
requirements.update(core_requirements())
|
||||
requirements.update(gather_entity_platform_requirements())
|
||||
|
||||
output.append("\n".join(sorted(requirements, key=lambda key: key.lower())))
|
||||
output.append("\n")
|
||||
|
||||
return "".join(output)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,22 +4,22 @@
|
||||
'config_entry': dict({
|
||||
'base_url': 'https://usiot.roborock.com',
|
||||
'user_data': dict({
|
||||
'avatarurl': 'https://files.roborock.com/iottest/default_avatar.png',
|
||||
'avatarurl': '**REDACTED**',
|
||||
'country': 'US',
|
||||
'countrycode': '1',
|
||||
'nickname': 'user_nickname',
|
||||
'nickname': '**REDACTED**',
|
||||
'region': 'us',
|
||||
'rriot': dict({
|
||||
'h': 'abc123',
|
||||
'k': 'abc123',
|
||||
'h': '**REDACTED**',
|
||||
'k': '**REDACTED**',
|
||||
'r': dict({
|
||||
'a': 'https://api-us.roborock.com',
|
||||
'l': 'https://wood-us.roborock.com',
|
||||
'm': 'ssl://mqtt-us-2.roborock.com:8883',
|
||||
'r': 'US',
|
||||
}),
|
||||
's': 'abc123',
|
||||
'u': 'abc123',
|
||||
's': '**REDACTED**',
|
||||
'u': '**REDACTED**',
|
||||
}),
|
||||
'rruid': '**REDACTED**',
|
||||
'token': '**REDACTED**',
|
||||
|
||||
192
tests/components/scene/test_trigger.py
Normal file
192
tests/components/scene/test_trigger.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Test scene trigger."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ENTITY_ID,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture(name="enable_experimental_triggers_conditions")
|
||||
def enable_experimental_triggers_conditions() -> Generator[None]:
|
||||
"""Enable experimental triggers and conditions."""
|
||||
with patch(
|
||||
"homeassistant.components.labs.async_is_preview_feature_enabled",
|
||||
return_value=True,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_scenes(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple scene entities associated with different targets."""
|
||||
return (await target_entities(hass, "scene"))["included"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trigger_key", ["scene.activated"])
|
||||
async def test_scene_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the scene triggers are gated by the labs flag."""
|
||||
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
|
||||
assert (
|
||||
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
|
||||
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
|
||||
"feature to be enabled in Home Assistant Labs settings (feature flag: "
|
||||
"'new_triggers_conditions')"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("scene"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
(
|
||||
"scene.activated",
|
||||
[
|
||||
{"included": {"state": None, "attributes": {}}, "count": 0},
|
||||
{
|
||||
"included": {
|
||||
"state": "2021-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"state": "2022-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"scene.activated",
|
||||
[
|
||||
{"included": {"state": "foo", "attributes": {}}, "count": 0},
|
||||
{
|
||||
"included": {
|
||||
"state": "2021-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"state": "2022-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"scene.activated",
|
||||
[
|
||||
{
|
||||
"included": {"state": STATE_UNAVAILABLE, "attributes": {}},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"state": "2021-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 0,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"state": "2022-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included": {"state": STATE_UNAVAILABLE, "attributes": {}},
|
||||
"count": 0,
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"scene.activated",
|
||||
[
|
||||
{"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0},
|
||||
{
|
||||
"included": {
|
||||
"state": "2021-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"included": {
|
||||
"state": "2022-01-01T23:59:59+00:00",
|
||||
"attributes": {},
|
||||
},
|
||||
"count": 1,
|
||||
},
|
||||
{"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0},
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_scene_state_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_scenes: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the scene state trigger fires when any scene state changes to a specific state."""
|
||||
other_entity_ids = set(target_scenes) - {entity_id}
|
||||
|
||||
# Set all scenes, including the tested scene, to the initial state
|
||||
for eid in target_scenes:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, None, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Check if changing other scenes also triggers
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == (entities_in_target - 1) * state["count"]
|
||||
service_calls.clear()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -720,13 +720,14 @@ async def test_async_config_entry_first_refresh_invalid_state(
|
||||
crd = get_crd(hass, DEFAULT_UPDATE_INTERVAL, entry)
|
||||
crd.setup_method = AsyncMock()
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Detected code that uses `async_config_entry_first_refresh`, which "
|
||||
"is only supported when entry state is ConfigEntryState.SETUP_IN_PROGRESS, "
|
||||
"but it is in state ConfigEntryState.NOT_LOADED. Please report this issue",
|
||||
config_entries.ConfigEntryError,
|
||||
match="`async_config_entry_first_refresh` called when config entry state is ConfigEntryState.NOT_LOADED, "
|
||||
"but should only be called in state ConfigEntryState.SETUP_IN_PROGRESS",
|
||||
):
|
||||
await crd.async_config_entry_first_refresh()
|
||||
|
||||
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
|
||||
|
||||
assert crd.last_update_success is True
|
||||
crd.setup_method.assert_not_called()
|
||||
|
||||
@@ -735,21 +736,20 @@ async def test_async_config_entry_first_refresh_invalid_state(
|
||||
async def test_async_config_entry_first_refresh_invalid_state_in_integration(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test first refresh successfully, despite wrong state."""
|
||||
"""Test first refresh fails, because of wrong state."""
|
||||
entry = MockConfigEntry()
|
||||
crd = get_crd(hass, DEFAULT_UPDATE_INTERVAL, entry)
|
||||
crd.setup_method = AsyncMock()
|
||||
|
||||
with pytest.raises(
|
||||
config_entries.ConfigEntryError,
|
||||
match="`async_config_entry_first_refresh` called when config entry state is ConfigEntryState.NOT_LOADED, "
|
||||
"but should only be called in state ConfigEntryState.SETUP_IN_PROGRESS",
|
||||
):
|
||||
await crd.async_config_entry_first_refresh()
|
||||
|
||||
assert crd.last_update_success is True
|
||||
crd.setup_method.assert_called()
|
||||
assert (
|
||||
"Detected that integration 'hue' uses `async_config_entry_first_refresh`, which "
|
||||
"is only supported when entry state is ConfigEntryState.SETUP_IN_PROGRESS, "
|
||||
"but it is in state ConfigEntryState.NOT_LOADED at "
|
||||
"homeassistant/components/hue/light.py, line 23: self.light.is_on. "
|
||||
"This will stop working in Home Assistant 2025.11"
|
||||
) in caplog.text
|
||||
crd.setup_method.assert_not_called()
|
||||
|
||||
|
||||
async def test_async_config_entry_first_refresh_no_entry(hass: HomeAssistant) -> None:
|
||||
|
||||
Reference in New Issue
Block a user