Compare commits

...

25 Commits

Author SHA1 Message Date
Ville Skyttä
0a954953a9 Make Nord Pool action config entry optional
If there is only one configured, take it, otherwise error out.
2025-12-22 12:55:59 +02:00
Magnus
fd9064376a Bump melissa to 3.0.3 (#159557) 2025-12-22 09:08:03 +01:00
dependabot[bot]
9eb5d452cf Bump docker/setup-buildx-action from 3.11.1 to 3.12.0 (#159577)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-22 09:04:01 +01:00
J. Nick Koston
966209e4b6 Bump aioesphomeapi to 43.4.0 (#159524) 2025-12-21 21:25:23 +01:00
Frank
a09ac94db9 Correct spelling of property (#159549) 2025-12-21 21:22:28 +01:00
Allen Porter
0710cf3e6b Redact additional unnecessary diagnostic fields (#159546) 2025-12-21 09:50:51 -08:00
Joakim Plate
a81f2a63c0 Ensure all base component dependencies are added (#157428) 2025-12-21 15:24:56 +01:00
Manu
6ef2d0d0a3 Add integration type hub to Xbox (#159528) 2025-12-21 07:59:03 +01:00
Manu
911ea67a6d Change integration type to hub in PlayStation Network (#159529) 2025-12-21 07:58:49 +01:00
Josef Zweck
28dc32d5dc Follow through with deprecation in async_config_entry_first_refresh (#158775) 2025-12-21 07:56:35 +01:00
Abílio Costa
c95416cb48 Add scene activated trigger (#159226)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-12-21 01:07:00 +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
Paul Tarjan
3050a5c896 Support NVR Hikvision devices (#159253) 2025-12-20 10:08:48 +01:00
Raphael Hehl
9f886e66c7 Update UniFi Protect select entities to use snake_case state values with proper translations (#159284)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-12-20 10:07:21 +01:00
Tom Harris
3c752d4516 Bump insteon panel to 0.6.0 to fix dialog button issues (#159449) 2025-12-20 10:05:03 +01:00
Raphael Hehl
e4bfdf5b30 Add quality scale configuration for UniFi Protect integration (#157568)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-20 10:03:02 +01:00
Artur Pragacz
3e43424a73 Add myself as codeowner to intent script (#159454) 2025-12-20 10:00:58 +01:00
61 changed files with 844 additions and 175 deletions

View File

@@ -197,7 +197,7 @@ jobs:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build variables
id: vars
@@ -405,7 +405,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'

2
CODEOWNERS generated
View File

@@ -794,6 +794,8 @@ build.json @home-assistant/supervisor
/tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intent_script/ @arturpragacz
/tests/components/intent_script/ @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs

View File

@@ -136,6 +136,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"scene",
"siren",
"switch",
"text",

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

@@ -110,7 +110,7 @@ async def async_register_dynalite_frontend(hass: HomeAssistant):
frontend_url_path=DOMAIN,
config_panel_domain=DOMAIN,
webcomponent_name="dynalite-panel",
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
module_url=f"{URL_BASE}/entrypoint.{build_id}.js",
embed_iframe=True,
require_admin=True,
)

View File

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

View File

@@ -70,6 +70,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
device_type=device_type,
)
# For NVRs or devices with no detected events, try to fetch events from ISAPI
if device_type == "NVR" or not camera.current_event_states:
def fetch_and_inject_nvr_events() -> None:
"""Fetch and inject NVR events in a single executor job."""
if nvr_events := camera.get_event_triggers(None):
camera.inject_events(nvr_events)
await hass.async_add_executor_job(fetch_and_inject_nvr_events)
# Start the event stream
await hass.async_add_executor_job(camera.start_stream)

View File

@@ -107,7 +107,7 @@ async def async_register_insteon_frontend(hass: HomeAssistant):
frontend_url_path=DOMAIN,
webcomponent_name="insteon-frontend",
config_panel_domain=DOMAIN,
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
module_url=f"{URL_BASE}/entrypoint.{build_id}.js",
embed_iframe=True,
require_admin=True,
)

View File

@@ -282,6 +282,8 @@ async def websocket_reset_properties(
notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND)
return
for prop in device.configuration.values():
prop.new_value = None
for prop in device.operating_flags:
device.operating_flags[prop].new_value = None
for prop in device.properties:

View File

@@ -19,7 +19,7 @@
"loggers": ["pyinsteon", "pypubsub"],
"requirements": [
"pyinsteon==1.6.4",
"insteon-frontend-home-assistant==0.5.0"
"insteon-frontend-home-assistant==0.6.0"
],
"single_config_entry": true,
"usb": [

View File

@@ -1,7 +1,7 @@
{
"domain": "intent_script",
"name": "Intent Script",
"codeowners": [],
"codeowners": ["@arturpragacz"],
"documentation": "https://www.home-assistant.io/integrations/intent_script",
"quality_scale": "internal"
}

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["melissa"],
"quality_scale": "legacy",
"requirements": ["py-melissa-climate==3.0.2"]
"requirements": ["py-melissa-climate==3.0.3"]
}

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

@@ -48,7 +48,7 @@ SERVICE_GET_PRICES_FOR_DATE = "get_prices_for_date"
SERVICE_GET_PRICE_INDICES_FOR_DATE = "get_price_indices_for_date"
SERVICE_GET_PRICES_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Required(ATTR_DATE): cv.date,
vol.Optional(ATTR_AREAS): vol.All(vol.In(list(AREAS)), cv.ensure_list, [str]),
vol.Optional(ATTR_CURRENCY): vol.All(
@@ -65,9 +65,20 @@ SERVICE_GET_PRICE_INDICES_SCHEMA = SERVICE_GET_PRICES_SCHEMA.extend(
)
def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry:
def get_config_entry(hass: HomeAssistant, entry_id: str | None) -> NordPoolConfigEntry:
"""Return config entry."""
if not (entry := hass.config_entries.async_get_entry(entry_id)):
if entry_id is not None:
entry = hass.config_entries.async_get_entry(entry_id)
elif entries := hass.config_entries.async_entries(DOMAIN):
if len(entries) > 1:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="multiple_entries",
)
entry = entries[0]
else:
entry = None
if not entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
@@ -88,7 +99,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
call: ServiceCall,
) -> tuple[NordPoolClient, date, str, list[str], int]:
"""Return the parameters for the service."""
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
entry = get_config_entry(hass, call.data.get(ATTR_CONFIG_ENTRY))
client = entry.runtime_data.client
asked_date: date = call.data[ATTR_DATE]

View File

@@ -1,7 +1,6 @@
get_prices_for_date:
fields:
config_entry:
required: true
selector:
config_entry:
integration: nordpool
@@ -49,7 +48,6 @@ get_prices_for_date:
get_price_indices_for_date:
fields:
config_entry:
required: true
selector:
config_entry:
integration: nordpool

View File

@@ -111,6 +111,9 @@
"initial_update_failed": {
"message": "Initial update failed on startup with error {error}"
},
"multiple_entries": {
"message": "There are multiple Nord Pool configuration entries in Home Assistant, must specify entry to use."
},
"no_day_data": {
"message": "Data for current day is missing"
}
@@ -150,7 +153,7 @@
"name": "Areas"
},
"config_entry": {
"description": "The Nord Pool configuration entry for this action.",
"description": "The Nord Pool configuration entry for this action. Optional if there is exactly one configured.",
"name": "Config entry"
},
"currency": {

View File

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

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

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

View File

@@ -20,5 +20,10 @@
"turn_on": {
"service": "mdi:power"
}
},
"triggers": {
"activated": {
"trigger": "mdi:palette"
}
}
}

View File

@@ -59,5 +59,11 @@
"name": "Activate"
}
},
"title": "Scene"
"title": "Scene",
"triggers": {
"activated": {
"description": "Triggers when a scene was activated",
"name": "Scene activated"
}
}
}

View 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

View File

@@ -0,0 +1,4 @@
activated:
target:
entity:
domain: scene

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

@@ -40,6 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
"requirements": ["uiprotect==7.33.2", "unifi-discovery==1.2.0"],
"ssdp": [
{

View File

@@ -0,0 +1,68 @@
rules:
# Bronze
action-setup: done
appropriate-polling:
status: exempt
comment: Integration is push-based using WebSockets (iot_class local_push).
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: done
comment: Diagnostics tests could use snapshot testing.
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class:
status: done
comment: "Planned improvement: remove doorbell occupancy binary sensor and keep the event sensor after a solution for https://github.com/home-assistant/core/issues/145941 is available."
entity-disabled-by-default: done
entity-translations:
status: done
comment: "Planned improvement: camera insecure is not translated but will be dropped soon with public api migration"
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues: done
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -48,37 +48,37 @@ _KEY_LIGHT_MOTION = "light_motion"
PARALLEL_UPDATES = 0
HDR_MODES = [
{"id": "always", "name": "Always On"},
{"id": "off", "name": "Always Off"},
{"id": "auto", "name": "Auto"},
{"id": "always", "name": "always"},
{"id": "off", "name": "off"},
{"id": "auto", "name": "auto"},
]
INFRARED_MODES = [
{"id": IRLEDMode.AUTO.value, "name": "Auto"},
{"id": IRLEDMode.ON.value, "name": "Always Enable"},
{"id": IRLEDMode.AUTO_NO_LED.value, "name": "Auto (Filter Only, no LED's)"},
{"id": IRLEDMode.CUSTOM.value, "name": "Auto (Custom Lux)"},
{"id": IRLEDMode.OFF.value, "name": "Always Disable"},
{"id": IRLEDMode.AUTO.value, "name": "auto"},
{"id": IRLEDMode.ON.value, "name": "on"},
{"id": IRLEDMode.AUTO_NO_LED.value, "name": "auto_filter_only"},
{"id": IRLEDMode.CUSTOM.value, "name": "custom"},
{"id": IRLEDMode.OFF.value, "name": "off"},
]
CHIME_TYPES = [
{"id": ChimeType.NONE.value, "name": "None"},
{"id": ChimeType.MECHANICAL.value, "name": "Mechanical"},
{"id": ChimeType.DIGITAL.value, "name": "Digital"},
{"id": ChimeType.NONE.value, "name": "none"},
{"id": ChimeType.MECHANICAL.value, "name": "mechanical"},
{"id": ChimeType.DIGITAL.value, "name": "digital"},
]
MOUNT_TYPES = [
{"id": MountType.NONE.value, "name": "None"},
{"id": MountType.DOOR.value, "name": "Door"},
{"id": MountType.WINDOW.value, "name": "Window"},
{"id": MountType.GARAGE.value, "name": "Garage"},
{"id": MountType.LEAK.value, "name": "Leak"},
{"id": MountType.NONE.value, "name": MountType.NONE.value},
{"id": MountType.DOOR.value, "name": MountType.DOOR.value},
{"id": MountType.WINDOW.value, "name": MountType.WINDOW.value},
{"id": MountType.GARAGE.value, "name": MountType.GARAGE.value},
{"id": MountType.LEAK.value, "name": MountType.LEAK.value},
]
LIGHT_MODE_MOTION = "On Motion - Always"
LIGHT_MODE_MOTION_DARK = "On Motion - When Dark"
LIGHT_MODE_DARK = "When Dark"
LIGHT_MODE_OFF = "Manual"
LIGHT_MODE_MOTION = "motion"
LIGHT_MODE_MOTION_DARK = "motion_dark"
LIGHT_MODE_DARK = "when_dark"
LIGHT_MODE_OFF = "manual"
LIGHT_MODES = [LIGHT_MODE_MOTION, LIGHT_MODE_DARK, LIGHT_MODE_OFF]
LIGHT_MODE_TO_SETTINGS = {
@@ -93,13 +93,13 @@ LIGHT_MODE_TO_SETTINGS = {
MOTION_MODE_TO_LIGHT_MODE = [
{"id": LightModeType.MOTION.value, "name": LIGHT_MODE_MOTION},
{"id": f"{LightModeType.MOTION.value}Dark", "name": LIGHT_MODE_MOTION_DARK},
{"id": f"{LightModeType.MOTION.value}_dark", "name": LIGHT_MODE_MOTION_DARK},
{"id": LightModeType.WHEN_DARK.value, "name": LIGHT_MODE_DARK},
{"id": LightModeType.MANUAL.value, "name": LIGHT_MODE_OFF},
]
DEVICE_RECORDING_MODES = [
{"id": mode.value, "name": mode.value.title()} for mode in list(RecordingMode)
{"id": mode.value, "name": mode.value} for mode in list(RecordingMode)
]

View File

@@ -350,31 +350,68 @@
},
"select": {
"chime_type": {
"name": "Chime type"
"name": "Chime type",
"state": {
"digital": "Digital",
"mechanical": "Mechanical",
"none": "[%key:common::state::off%]"
}
},
"doorbell_text": {
"name": "Doorbell text"
},
"hdr_mode": {
"name": "[%key:component::unifiprotect::entity::binary_sensor::hdr_mode::name%]"
"name": "[%key:component::unifiprotect::entity::binary_sensor::hdr_mode::name%]",
"state": {
"always": "Always on",
"auto": "Auto",
"off": "Always off"
}
},
"infrared_mode": {
"name": "Infrared mode"
"name": "Infrared mode",
"state": {
"auto": "Auto",
"auto_filter_only": "Auto (filter only, no LEDs)",
"custom": "Auto (custom lux)",
"off": "Always disable",
"on": "Always enable"
}
},
"light_mode": {
"name": "Light mode"
"name": "Light mode",
"state": {
"manual": "Manual",
"motion": "On motion - always",
"motion_dark": "On motion - when dark",
"when_dark": "When dark"
}
},
"liveview": {
"name": "Liveview"
},
"mount_type": {
"name": "Mount type"
"name": "Mount type",
"state": {
"door": "Door",
"garage": "Garage",
"leak": "Leak",
"none": "[%key:common::state::off%]",
"window": "Window"
}
},
"paired_camera": {
"name": "Paired camera"
},
"recording_mode": {
"name": "Recording mode"
"name": "Recording mode",
"state": {
"adaptive": "Adaptive",
"always": "Always",
"detections": "Detections",
"never": "Never",
"schedule": "Schedule"
}
}
},
"sensor": {

View File

@@ -104,7 +104,7 @@ def async_get_light_motion_current(obj: Light) -> str:
obj.light_mode_settings.mode is LightModeType.MOTION
and obj.light_mode_settings.enable_at is LightModeEnableType.DARK
):
return f"{LightModeType.MOTION.value}Dark"
return f"{LightModeType.MOTION.value}_dark"
return obj.light_mode_settings.mode.value

View File

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

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

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

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

View File

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

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

8
requirements_all.txt generated
View File

@@ -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
@@ -1291,7 +1291,7 @@ influxdb==5.3.1
inkbird-ble==1.1.1
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.5.0
insteon-frontend-home-assistant==0.6.0
# homeassistant.components.intellifire
intellifire4py==4.2.1
@@ -1816,7 +1816,7 @@ py-improv-ble-client==2.0.1
py-madvr2==1.6.40
# homeassistant.components.melissa
py-melissa-climate==3.0.2
py-melissa-climate==3.0.3
# homeassistant.components.nextbus
py-nextbusnext==2.3.0

View File

@@ -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
@@ -1137,7 +1137,7 @@ influxdb==5.3.1
inkbird-ble==1.1.1
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.5.0
insteon-frontend-home-assistant==0.6.0
# homeassistant.components.intellifire
intellifire4py==4.2.1
@@ -1556,7 +1556,7 @@ py-improv-ble-client==2.0.1
py-madvr2==1.6.40
# homeassistant.components.melissa
py-melissa-climate==3.0.2
py-melissa-climate==3.0.3
# homeassistant.components.nextbus
py-nextbusnext==2.3.0

View File

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

View File

@@ -1008,7 +1008,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"unifi",
"unifi_direct",
"unifiled",
"unifiprotect",
"universal",
"upb",
"upc_connect",
@@ -2032,7 +2031,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"unifi",
"unifi_direct",
"unifiled",
"unifiprotect",
"universal",
"upb",
"upc_connect",

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

@@ -82,9 +82,20 @@ def mock_hikcamera() -> Generator[MagicMock]:
None,
"2024-01-01T00:00:00Z",
)
camera.get_event_triggers.return_value = {}
yield hikcamera_mock
@pytest.fixture
def mock_hik_nvr(mock_hikcamera: MagicMock) -> MagicMock:
"""Return a mocked HikCamera configured as an NVR."""
camera = mock_hikcamera.return_value
camera.get_type = "NVR"
camera.current_event_states = {}
camera.get_event_triggers.return_value = {"Motion": [1, 2]}
return mock_hikcamera
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hikcamera: MagicMock

View File

@@ -89,3 +89,16 @@ async def test_setup_entry_no_device_id(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_entry_nvr_fetches_events(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_hik_nvr: MagicMock,
) -> None:
"""Test setup fetches NVR events for NVR devices."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
mock_hik_nvr.return_value.inject_events.assert_called_once()

View File

@@ -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**',

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

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

@@ -95,7 +95,7 @@ async def test_select_setup_light(
await init_entry(hass, ufp, [light])
assert_entity_counts(hass, Platform.SELECT, 2, 2)
expected_values = ("On Motion - When Dark", "Not Paired")
expected_values = ("motion_dark", "Not Paired")
for index, description in enumerate(LIGHT_SELECTS):
unique_id, entity_id = await ids_from_device_description(
@@ -153,11 +153,11 @@ async def test_select_setup_camera_all(
assert_entity_counts(hass, Platform.SELECT, 5, 5)
expected_values = (
"Always",
"Auto",
"always",
"auto",
"Default Message (Welcome)",
"None",
"Always Off",
"none",
"off",
)
for index, description in enumerate(CAMERA_SELECTS):
@@ -186,7 +186,7 @@ async def test_select_setup_camera_none(
await init_entry(hass, ufp, [camera])
assert_entity_counts(hass, Platform.SELECT, 2, 2)
expected_values = ("Always", "Auto", "Default Message (Welcome)")
expected_values = ("always", "auto", "Default Message (Welcome)")
for index, description in enumerate(CAMERA_SELECTS):
if index == 2:
@@ -403,7 +403,7 @@ async def test_select_set_option_camera_recording(
await hass.services.async_call(
"select",
"select_option",
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Never"},
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "never"},
blocking=True,
)
@@ -428,7 +428,7 @@ async def test_select_set_option_camera_ir(
await hass.services.async_call(
"select",
"select_option",
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Always Enable"},
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "on"},
blocking=True,
)

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

View File

@@ -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()
await crd.async_config_entry_first_refresh()
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: