From 47ec8b7f12eca1f1b47eb6abb3823509dbfba116 Mon Sep 17 00:00:00 2001 From: Jan Gutowski Date: Mon, 15 Sep 2025 11:41:44 +0200 Subject: [PATCH 01/11] Bump nibe to 2.18.0 (#152353) --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index a8441fb90d830e..c1160e389d6fea 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.17.0"] + "requirements": ["nibe==2.18.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e483dfaf4272b4..83d8ef5e5bc811 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1530,7 +1530,7 @@ nextdns==4.1.0 nhc==0.4.12 # homeassistant.components.nibe_heatpump -nibe==2.17.0 +nibe==2.18.0 # homeassistant.components.nice_go nice-go==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b02e37d9d7374a..7526b77fd212e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ nextdns==4.1.0 nhc==0.4.12 # homeassistant.components.nibe_heatpump -nibe==2.17.0 +nibe==2.18.0 # homeassistant.components.nice_go nice-go==1.0.1 From b01be9403484b40148a30dd20a2abebb73364ccb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 15 Sep 2025 11:52:08 +0200 Subject: [PATCH 02/11] Update "Find my iPhone" to "Find My" in `icloud` (#152354) --- homeassistant/components/icloud/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index 83c45f10b0519d..b2e1b6dc450a13 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -6,7 +6,7 @@ "description": "Enter your credentials", "data": { "username": "[%key:common::config_flow::data::email%]", - "password": "Main Password (MFA)", + "password": "Main password (MFA)", "with_family": "With family" } }, @@ -40,7 +40,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "no_device": "None of your devices have \"Find my iPhone\" activated", + "no_device": "None of your devices have \"Find My\" activated", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, From 71749da3a3d076f25e2ed3dd64c0163f7f9a9141 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 15 Sep 2025 11:52:24 +0200 Subject: [PATCH 03/11] Rename MQTT tag and device_automation setup helpers (#152344) --- homeassistant/components/mqtt/device_automation.py | 4 +++- homeassistant/components/mqtt/tag.py | 4 +++- homeassistant/components/mqtt/util.py | 10 ++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index 366f2f13ad40b1..2738332bb1534c 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -25,7 +25,9 @@ ) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_setup_mqtt_device_automation_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Set up MQTT device automation dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_automation, hass, config_entry=config_entry) diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 9a05d1896f7cc7..0615e0e7e6c7f8 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -53,7 +53,9 @@ ) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_setup_mqtt_tag_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Set up MQTT tag scanner dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_tag, hass, config_entry=config_entry) diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 1bf743d3da721f..3aea554e46088d 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -166,13 +166,19 @@ async def async_forward_entry_setup_and_setup_discovery( from . import device_automation # noqa: PLC0415 tasks.append( - create_eager_task(device_automation.async_setup_entry(hass, config_entry)) + create_eager_task( + device_automation.async_setup_mqtt_device_automation_entry( + hass, config_entry + ) + ) ) if "tag" in new_platforms: # Local import to avoid circular dependencies from . import tag # noqa: PLC0415 - tasks.append(create_eager_task(tag.async_setup_entry(hass, config_entry))) + tasks.append( + create_eager_task(tag.async_setup_mqtt_tag_entry(hass, config_entry)) + ) if new_entity_platforms := (new_platforms - {"tag", "device_automation"}): tasks.append( create_eager_task( From c0af0159e32721ac607b7a164e9ff37604c90ee9 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 15 Sep 2025 14:10:25 +0300 Subject: [PATCH 04/11] Use Entity Description in Shelly light platform (#137118) --- homeassistant/components/shelly/light.py | 147 ++++++++++++++--------- tests/components/shelly/test_light.py | 1 + 2 files changed, 90 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index f5cffe37d5a3bb..12ca25916b867b 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, cast +from dataclasses import dataclass +from typing import Any, Final, cast from aioshelly.block_device import Block from aioshelly.const import MODEL_BULB, RPC_GENERATIONS @@ -17,6 +18,7 @@ DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntity, + LightEntityDescription, LightEntityFeature, brightness_supported, ) @@ -37,13 +39,17 @@ STANDARD_RGB_EFFECTS, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyBlockEntity, ShellyRpcEntity +from .entity import ( + RpcEntityDescription, + ShellyBlockEntity, + ShellyRpcAttributeEntity, + async_setup_entry_rpc, +) from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, brightness_to_percentage, get_device_entry_gen, - get_rpc_key_ids, is_block_channel_type_light, is_rpc_channel_type_light, percentage_to_brightness, @@ -94,53 +100,6 @@ def async_setup_block_entry( async_add_entities(BlockShellyLight(coordinator, block) for block in blocks) -@callback -def async_setup_rpc_entry( - hass: HomeAssistant, - config_entry: ShellyConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up entities for RPC device.""" - coordinator = config_entry.runtime_data.rpc - assert coordinator - switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch") - - switch_ids = [] - for id_ in switch_key_ids: - if not is_rpc_channel_type_light(coordinator.device.config, id_): - continue - - switch_ids.append(id_) - unique_id = f"{coordinator.mac}-switch:{id_}" - async_remove_shelly_entity(hass, "switch", unique_id) - - if switch_ids: - async_add_entities( - RpcShellySwitchAsLight(coordinator, id_) for id_ in switch_ids - ) - return - - entities: list[RpcShellyLightBase] = [] - if light_key_ids := get_rpc_key_ids(coordinator.device.status, "light"): - entities.extend(RpcShellyLight(coordinator, id_) for id_ in light_key_ids) - if cct_key_ids := get_rpc_key_ids(coordinator.device.status, "cct"): - entities.extend(RpcShellyCctLight(coordinator, id_) for id_ in cct_key_ids) - if rgb_key_ids := get_rpc_key_ids(coordinator.device.status, "rgb"): - entities.extend(RpcShellyRgbLight(coordinator, id_) for id_ in rgb_key_ids) - if rgbw_key_ids := get_rpc_key_ids(coordinator.device.status, "rgbw"): - entities.extend(RpcShellyRgbwLight(coordinator, id_) for id_ in rgbw_key_ids) - - async_add_entities(entities) - - async_remove_orphaned_entities( - hass, - config_entry.entry_id, - coordinator.mac, - LIGHT_DOMAIN, - coordinator.device.status, - ) - - class BlockShellyLight(ShellyBlockEntity, LightEntity): """Entity that controls a light on block based Shelly devices.""" @@ -386,15 +345,27 @@ def _update_callback(self) -> None: super()._update_callback() -class RpcShellyLightBase(ShellyRpcEntity, LightEntity): +@dataclass(frozen=True, kw_only=True) +class RpcLightDescription(RpcEntityDescription, LightEntityDescription): + """Description for a Shelly RPC number entity.""" + + +class RpcShellyLightBase(ShellyRpcAttributeEntity, LightEntity): """Base Entity for RPC based Shelly devices.""" + entity_description: RpcLightDescription _component: str = "Light" - def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + ) -> None: """Initialize light.""" - super().__init__(coordinator, f"{self._component.lower()}:{id_}") - self._id = id_ + super().__init__(coordinator, key, attribute, description) + self._attr_unique_id = f"{coordinator.mac}-{key}" @property def is_on(self) -> bool: @@ -480,14 +451,19 @@ class RpcShellyCctLight(RpcShellyLightBase): _attr_supported_color_modes = {ColorMode.COLOR_TEMP} _attr_supported_features = LightEntityFeature.TRANSITION - def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + ) -> None: """Initialize light.""" - color_temp_range = coordinator.device.config[f"cct:{id_}"]["ct_range"] + super().__init__(coordinator, key, attribute, description) + color_temp_range = coordinator.device.config[f"cct:{self._id}"]["ct_range"] self._attr_min_color_temp_kelvin = color_temp_range[0] self._attr_max_color_temp_kelvin = color_temp_range[1] - super().__init__(coordinator, id_) - @property def color_temp_kelvin(self) -> int: """Return the CT color value in Kelvin.""" @@ -512,3 +488,58 @@ class RpcShellyRgbwLight(RpcShellyLightBase): _attr_color_mode = ColorMode.RGBW _attr_supported_color_modes = {ColorMode.RGBW} _attr_supported_features = LightEntityFeature.TRANSITION + + +LIGHTS: Final = { + "switch": RpcEntityDescription( + key="switch", + sub_key="output", + removal_condition=lambda config, _status, key: not is_rpc_channel_type_light( + config, int(key.split(":")[-1]) + ), + entity_class=RpcShellySwitchAsLight, + ), + "light": RpcEntityDescription( + key="light", + sub_key="output", + entity_class=RpcShellyLight, + ), + "cct": RpcEntityDescription( + key="cct", + sub_key="output", + entity_class=RpcShellyCctLight, + ), + "rgb": RpcEntityDescription( + key="rgb", + sub_key="output", + entity_class=RpcShellyRgbLight, + ), + "rgbw": RpcEntityDescription( + key="rgbw", + sub_key="output", + entity_class=RpcShellyRgbwLight, + ), +} + + +@callback +def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ShellyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + coordinator = config_entry.runtime_data.rpc + assert coordinator + + async_setup_entry_rpc( + hass, config_entry, async_add_entities, LIGHTS, RpcShellyLight + ) + + async_remove_orphaned_entities( + hass, + config_entry.entry_id, + coordinator.mac, + LIGHT_DOMAIN, + coordinator.device.status, + ) diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 9c79cf5d9880bd..bd39e45746d5a6 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -459,6 +459,7 @@ async def test_rpc_device_switch_type_lights_mode( monkeypatch.setitem( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") await init_integration(hass, 2) await hass.services.async_call( From 99fb64af9b8fda7845cd7fdd1d62183913091a74 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 15 Sep 2025 07:12:57 -0400 Subject: [PATCH 05/11] Add new USB drives to Synology DSM without reloading integration (#146829) Co-authored-by: Erik Montnemery --- .../components/synology_dsm/sensor.py | 78 ++++---- tests/components/synology_dsm/common.py | 167 ++++++++++++++++++ tests/components/synology_dsm/test_sensor.py | 163 +++++++++++------ 3 files changed, 316 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 613938f078f9ed..a9f66e4762ea7c 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -6,7 +6,10 @@ from datetime import datetime, timedelta from typing import cast -from synology_dsm.api.core.external_usb import SynoCoreExternalUSB +from synology_dsm.api.core.external_usb import ( + SynoCoreExternalUSB, + SynoCoreExternalUSBDevice, +) from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.storage.storage import SynoStorage @@ -343,14 +346,42 @@ async def async_setup_entry( coordinator = data.coordinator_central storage = api.storage assert storage is not None - external_usb = api.external_usb - - entities: list[ - SynoDSMUtilSensor - | SynoDSMStorageSensor - | SynoDSMInfoSensor - | SynoDSMExternalUSBSensor - ] = [ + known_usb_devices: set[str] = set() + + def _check_usb_devices() -> None: + """Check for new USB devices during and after initial setup.""" + if api.external_usb is not None and api.external_usb.get_devices: + current_usb_devices: set[str] = { + device.device_name for device in api.external_usb.get_devices.values() + } + new_usb_devices = current_usb_devices - known_usb_devices + if new_usb_devices: + known_usb_devices.update(new_usb_devices) + external_devices: list[SynoCoreExternalUSBDevice] = [ + device + for device in api.external_usb.get_devices.values() + if device.device_name in new_usb_devices + ] + new_usb_entities: list[SynoDSMExternalUSBSensor] = [ + SynoDSMExternalUSBSensor( + api, coordinator, description, device.device_name + ) + for device in entry.data.get(CONF_DEVICES, external_devices) + for description in EXTERNAL_USB_DISK_SENSORS + ] + new_usb_entities.extend( + [ + SynoDSMExternalUSBSensor( + api, coordinator, description, partition.partition_title + ) + for device in entry.data.get(CONF_DEVICES, external_devices) + for partition in device.device_partitions.values() + for description in EXTERNAL_USB_PARTITION_SENSORS + ] + ) + async_add_entities(new_usb_entities) + + entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ SynoDSMUtilSensor(api, coordinator, description) for description in UTILISATION_SENSORS ] @@ -375,32 +406,6 @@ async def async_setup_entry( ] ) - # Handle all external usb - if external_usb is not None and external_usb.get_devices: - entities.extend( - [ - SynoDSMExternalUSBSensor( - api, coordinator, description, device.device_name - ) - for device in entry.data.get( - CONF_DEVICES, external_usb.get_devices.values() - ) - for description in EXTERNAL_USB_DISK_SENSORS - ] - ) - entities.extend( - [ - SynoDSMExternalUSBSensor( - api, coordinator, description, partition.partition_title - ) - for device in entry.data.get( - CONF_DEVICES, external_usb.get_devices.values() - ) - for partition in device.device_partitions.values() - for description in EXTERNAL_USB_PARTITION_SENSORS - ] - ) - entities.extend( [ SynoDSMInfoSensor(api, coordinator, description) @@ -408,6 +413,9 @@ async def async_setup_entry( ] ) + _check_usb_devices() + entry.async_on_unload(coordinator.async_add_listener(_check_usb_devices)) + async_add_entities(entities) diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py index 3b069d04ebe2fc..a9d05ce941e126 100644 --- a/tests/components/synology_dsm/common.py +++ b/tests/components/synology_dsm/common.py @@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, Mock from awesomeversion import AwesomeVersion +from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice +from synology_dsm.api.storage.storage import SynoStorageDisk, SynoStorageVolume from .consts import SERIAL @@ -30,3 +32,168 @@ def mock_dsm_information( temperature=temperature, uptime=uptime, ) + + +def mock_dsm_storage_get_volume(volume_id: str) -> SynoStorageVolume: + """Mock SynologyDSM storage volume information for a specific volume.""" + volumes = mock_dsm_storage_volumes() + for volume in volumes: + if volume.get("id") == volume_id: + return volume + raise ValueError(f"Volume with id {volume_id} not found in mock data.") + + +def mock_dsm_storage_volumes() -> list[SynoStorageVolume]: + """Mock SynologyDSM storage volume information.""" + volumes_data = { + "volume_1": { + "id": "volume_1", + "device_type": "btrfs", + "size": { + "free_inode": "1000000", + "total": "24000277250048", + "total_device": "24000277250048", + "total_inode": "2000000", + "used": "12000138625024", + }, + "status": "normal", + "fs_type": "btrfs", + }, + } + return [SynoStorageVolume(**volume_info) for volume_info in volumes_data.values()] + + +def mock_dsm_storage_get_disk(disk_id: str) -> SynoStorageDisk: + """Mock SynologyDSM storage disk information for a specific disk.""" + disks = mock_dsm_storage_disks() + for disk in disks: + if disk.get("id") == disk_id: + return disk + raise ValueError(f"Disk with id {disk_id} not found in mock data.") + + +def mock_dsm_storage_disks() -> list[SynoStorageDisk]: + """Mock SynologyDSM storage disk information.""" + disks_data = { + "sata1": { + "id": "sata1", + "name": "Drive 1", + "vendor": "Seagate", + "model": "ST24000NT002-3N1101", + "device": "/dev/sata1", + "temp": 32, + "size_total": "24000277250048", + "firm": "EN01", + "diskType": "SATA", + }, + "sata2": { + "id": "sata2", + "name": "Drive 2", + "vendor": "Seagate", + "model": "ST24000NT002-3N1101", + "device": "/dev/sata2", + "temp": 32, + "size_total": "24000277250048", + "firm": "EN01", + "diskType": "SATA", + }, + "sata3": { + "id": "sata3", + "name": "Drive 3", + "vendor": "Seagate", + "model": "ST24000NT002-3N1101", + "device": "/dev/sata3", + "temp": 32, + "size_total": "24000277250048", + "firm": "EN01", + "diskType": "SATA", + }, + } + return [SynoStorageDisk(**disk_info) for disk_info in disks_data.values()] + + +def mock_dsm_external_usb_devices_usb1() -> dict[str, SynoCoreExternalUSBDevice]: + """Mock SynologyDSM external USB device with USB Disk 1.""" + return { + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare2", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ), + } + + +def mock_dsm_external_usb_devices_usb2() -> dict[str, SynoCoreExternalUSBDevice]: + """Mock SynologyDSM external USB device with USB Disk 1 and USB Disk 2.""" + return { + "usb1": SynoCoreExternalUSBDevice( + { + "dev_id": "usb1", + "dev_type": "usbDisk", + "dev_title": "USB Disk 1", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb1p1", + "partition_title": "USB Disk 1 Partition 1", + "share_name": "usbshare2", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ), + "usb2": SynoCoreExternalUSBDevice( + { + "dev_id": "usb2", + "dev_type": "usbDisk", + "dev_title": "USB Disk 2", + "producer": "Western Digital Technologies, Inc.", + "product": "easystore 264D", + "formatable": True, + "progress": "", + "status": "normal", + "total_size_mb": 15259648, + "partitions": [ + { + "dev_fstype": "ntfs", + "filesystem": "ntfs", + "name_id": "usb2p1", + "partition_title": "USB Disk 2 Partition 1", + "share_name": "usbshare2", + "status": "normal", + "total_size_mb": 15259646, + "used_size_mb": 5942441, + } + ], + } + ), + } diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py index 654cade2462643..a02728dcc4c787 100644 --- a/tests/components/synology_dsm/test_sensor.py +++ b/tests/components/synology_dsm/test_sensor.py @@ -1,9 +1,9 @@ """Tests for Synology DSM USB.""" +from itertools import chain from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from synology_dsm.api.core.external_usb import SynoCoreExternalUSBDevice from homeassistant.components.synology_dsm.const import DOMAIN from homeassistant.const import ( @@ -17,7 +17,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import mock_dsm_information +from .common import ( + mock_dsm_external_usb_devices_usb1, + mock_dsm_external_usb_devices_usb2, + mock_dsm_information, + mock_dsm_storage_get_disk, + mock_dsm_storage_get_volume, +) from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry @@ -31,70 +37,33 @@ def mock_dsm_with_usb(): dsm.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) - dsm.upgrade.update = AsyncMock(return_value=True) + dsm.upgrade = Mock( + available_version=None, + available_version_details=None, + update=AsyncMock(return_value=True), + ) dsm.network = Mock( update=AsyncMock(return_value=True), macs=MACS, hostname=HOST ) dsm.information = mock_dsm_information() + dsm.storage = Mock( + get_disk=mock_dsm_storage_get_disk, + disk_temp=Mock(return_value=32), + disks_ids=["sata1", "sata2", "sata3"], + get_volume=mock_dsm_storage_get_volume, + volume_disk_temp_avg=Mock(return_value=32), + volume_size_used=Mock(return_value=12000138625024), + volume_percentage_used=Mock(return_value=38), + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) dsm.file = Mock(get_shared_folders=AsyncMock(return_value=None)) dsm.external_usb = Mock( update=AsyncMock(return_value=True), - get_device=Mock( - return_value=SynoCoreExternalUSBDevice( - { - "dev_id": "usb1", - "dev_type": "usbDisk", - "dev_title": "USB Disk 1", - "producer": "Western Digital Technologies, Inc.", - "product": "easystore 264D", - "formatable": True, - "progress": "", - "status": "normal", - "total_size_mb": 15259648, - "partitions": [ - { - "dev_fstype": "ntfs", - "filesystem": "ntfs", - "name_id": "usb1p1", - "partition_title": "USB Disk 1 Partition 1", - "share_name": "usbshare1", - "status": "normal", - "total_size_mb": 15259646, - "used_size_mb": 5942441, - } - ], - } - ) - ), - get_devices={ - "usb1": SynoCoreExternalUSBDevice( - { - "dev_id": "usb1", - "dev_type": "usbDisk", - "dev_title": "USB Disk 1", - "producer": "Western Digital Technologies, Inc.", - "product": "easystore 264D", - "formatable": True, - "progress": "", - "status": "normal", - "total_size_mb": 15259648, - "partitions": [ - { - "dev_fstype": "ntfs", - "filesystem": "ntfs", - "name_id": "usb1p1", - "partition_title": "USB Disk 1 Partition 1", - "share_name": "usbshare1", - "status": "normal", - "total_size_mb": 15259646, - "used_size_mb": 5942441, - } - ], - } - ) - }, + get_devices=mock_dsm_external_usb_devices_usb1(), ) dsm.logout = AsyncMock(return_value=True) + dsm.mock_entry = MockConfigEntry() yield dsm @@ -142,6 +111,8 @@ async def setup_dsm_with_usb( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + mock_dsm_with_usb.mock_entry = entry + yield mock_dsm_with_usb @@ -233,6 +204,84 @@ async def test_external_usb( assert sensor.attributes["attribution"] == "Data provided by Synology" +async def test_external_usb_new_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test Synology DSM USB adding new device.""" + + expected_sensors_disk_1 = { + "sensor.nas_meontheinternet_com_usb_disk_1_status": ("normal", {}), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size": ( + "14901.998046875", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space": ( + "5803.1650390625", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used": ( + "38.9", + {}, + ), + } + expected_sensors_disk_2 = { + "sensor.nas_meontheinternet_com_usb_disk_2_status": ("normal", {}), + "sensor.nas_meontheinternet_com_usb_disk_2_partition_1_partition_size": ( + "14901.998046875", + { + "device_class": "data_size", + "state_class": "measurement", + "unit_of_measurement": "GiB", + "attribution": "Data provided by Synology", + }, + ), + "sensor.nas_meontheinternet_com_usb_disk_2_partition_1_partition_used_space": ( + "5803.1650390625", + { + "device_class": "data_size", + "state_class": "measurement", + "unit_of_measurement": "GiB", + "attribution": "Data provided by Synology", + }, + ), + "sensor.nas_meontheinternet_com_usb_disk_2_partition_1_partition_used": ( + "38.9", + { + "state_class": "measurement", + "unit_of_measurement": "%", + "attribution": "Data provided by Synology", + }, + ), + } + + # Initial check of existing sensors + for sensor_id, (expected_state, expected_attrs) in expected_sensors_disk_1.items(): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + for sensor_id in expected_sensors_disk_2: + assert hass.states.get(sensor_id) is None + + # Mock the get_devices method to simulate a USB disk being added + setup_dsm_with_usb.external_usb.get_devices = mock_dsm_external_usb_devices_usb2() + # Coordinator refresh + await setup_dsm_with_usb.mock_entry.runtime_data.coordinator_central.async_request_refresh() + await hass.async_block_till_done() + + for sensor_id, (expected_state, expected_attrs) in chain( + expected_sensors_disk_1.items(), expected_sensors_disk_2.items() + ): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + + async def test_no_external_usb( hass: HomeAssistant, setup_dsm_without_usb: MagicMock, From f1bf28df18c3bd9853856e2c773794ea0801844e Mon Sep 17 00:00:00 2001 From: virtualbitzz <47397720+virtualbitzz@users.noreply.github.com> Date: Mon, 15 Sep 2025 04:28:40 -0700 Subject: [PATCH 06/11] Add Matter climate running state heat fan and cool fan (#151535) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/matter/climate.py | 34 ++++++++++------------ tests/components/matter/test_climate.py | 20 ++++++++++++- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index df57da4ded3afd..c15dd42d62b672 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -296,24 +296,22 @@ def _update_from_device(self) -> None: if running_state_value := self.get_matter_attribute_value( clusters.Thermostat.Attributes.ThermostatRunningState ): - match running_state_value: - case ( - ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2 - ): - self._attr_hvac_action = HVACAction.HEATING - case ( - ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2 - ): - self._attr_hvac_action = HVACAction.COOLING - case ( - ThermostatRunningState.Fan - | ThermostatRunningState.FanStage2 - | ThermostatRunningState.FanStage3 - ): - self._attr_hvac_action = HVACAction.FAN - case _: - self._attr_hvac_action = HVACAction.OFF - + if running_state_value & ( + ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2 + ): + self._attr_hvac_action = HVACAction.HEATING + elif running_state_value & ( + ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2 + ): + self._attr_hvac_action = HVACAction.COOLING + elif running_state_value & ( + ThermostatRunningState.Fan + | ThermostatRunningState.FanStage2 + | ThermostatRunningState.FanStage3 + ): + self._attr_hvac_action = HVACAction.FAN + else: + self._attr_hvac_action = HVACAction.OFF # update target temperature high/low supports_range = ( self._attr_supported_features diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 7761d5d27da7c5..a887ce1b5df6ec 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -85,6 +85,12 @@ async def test_thermostat_base( assert state assert state.attributes["hvac_action"] == HVACAction.HEATING + set_node_attribute(matter_node, 1, 513, 41, 5) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["hvac_action"] == HVACAction.HEATING + set_node_attribute(matter_node, 1, 513, 41, 8) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") @@ -97,12 +103,24 @@ async def test_thermostat_base( assert state assert state.attributes["hvac_action"] == HVACAction.COOLING + set_node_attribute(matter_node, 1, 513, 41, 6) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["hvac_action"] == HVACAction.COOLING + set_node_attribute(matter_node, 1, 513, 41, 16) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state assert state.attributes["hvac_action"] == HVACAction.COOLING + set_node_attribute(matter_node, 1, 513, 41, 66) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["hvac_action"] == HVACAction.COOLING + set_node_attribute(matter_node, 1, 513, 41, 4) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") @@ -121,7 +139,7 @@ async def test_thermostat_base( assert state assert state.attributes["hvac_action"] == HVACAction.FAN - set_node_attribute(matter_node, 1, 513, 41, 66) + set_node_attribute(matter_node, 1, 513, 41, 128) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state From 410c3df6dd9797d3a835ce4fea1ce6a92d77c409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 15 Sep 2025 13:52:26 +0200 Subject: [PATCH 07/11] Add Matter service actions for vacuum area (#151467) Co-authored-by: Norbert Rittel --- homeassistant/components/matter/const.py | 5 + homeassistant/components/matter/icons.json | 11 + homeassistant/components/matter/services.yaml | 24 + homeassistant/components/matter/strings.json | 24 + homeassistant/components/matter/vacuum.py | 212 ++++++++- tests/components/matter/conftest.py | 1 + .../matter/fixtures/nodes/switchbot_K11.json | 440 ++++++++++++++++++ .../matter/snapshots/test_select.ambr | 63 +++ .../matter/snapshots/test_sensor.ambr | 68 +++ .../matter/snapshots/test_vacuum.ambr | 58 +++ tests/components/matter/test_vacuum.py | 166 +++++++ 11 files changed, 1070 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/matter/services.yaml create mode 100644 tests/components/matter/fixtures/nodes/switchbot_K11.json diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index 8018d5e09edf7e..4c4679b0042ed0 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -15,3 +15,8 @@ ID_TYPE_SERIAL = "serial" FEATUREMAP_ATTRIBUTE_ID = 65532 + +# vacuum entity service actions +SERVICE_GET_AREAS = "get_areas" # get SupportedAreas and SupportedMaps +SERVICE_SELECT_AREAS = "select_areas" # call SelectAreas Matter command +SERVICE_CLEAN_AREAS = "clean_areas" # call SelectAreas Matter command and start RVC diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index dc1fbc25181f3c..a19b476914d869 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -150,5 +150,16 @@ "default": "mdi:ev-station" } } + }, + "services": { + "clean_areas": { + "service": "mdi:robot-vacuum" + }, + "get_areas": { + "service": "mdi:map" + }, + "select_areas": { + "service": "mdi:map" + } } } diff --git a/homeassistant/components/matter/services.yaml b/homeassistant/components/matter/services.yaml new file mode 100644 index 00000000000000..d0e5767315916f --- /dev/null +++ b/homeassistant/components/matter/services.yaml @@ -0,0 +1,24 @@ +# Service descriptions for Matter integration + +get_areas: + target: + entity: + domain: vacuum + +select_areas: + target: + entity: + domain: vacuum + fields: + areas: + required: true + example: [1, 3] + +clean_areas: + target: + entity: + domain: vacuum + fields: + areas: + required: true + example: [1, 3] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 7dae7638d8d623..ff3b52bf473c60 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -548,6 +548,30 @@ "description": "The Matter device to add to the other Matter network." } } + }, + "get_areas": { + "name": "Get areas", + "description": "Returns a list of available areas and maps for robot vacuum cleaners." + }, + "select_areas": { + "name": "Select areas", + "description": "Selects the specified areas for cleaning. The areas must be specified as a list of area IDs.", + "fields": { + "areas": { + "name": "Areas", + "description": "A list of area IDs to select." + } + } + }, + "clean_areas": { + "name": "Clean areas", + "description": "Instructs the Matter vacuum cleaner to clean the specified areas.", + "fields": { + "areas": { + "name": "Areas", + "description": "A list of area IDs to clean." + } + } } } } diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index cf9f26adecb4d7..c9c56df9894c9e 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -3,10 +3,12 @@ from __future__ import annotations from enum import IntEnum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models import device_types +import voluptuous as vol from homeassistant.components.vacuum import ( StateVacuumEntity, @@ -16,14 +18,25 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import ( + HomeAssistant, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import SERVICE_CLEAN_AREAS, SERVICE_GET_AREAS, SERVICE_SELECT_AREAS from .entity import MatterEntity from .helpers import get_matter from .models import MatterDiscoverySchema +ATTR_CURRENT_AREA = "current_area" +ATTR_CURRENT_AREA_NAME = "current_area_name" +ATTR_SELECTED_AREAS = "selected_areas" + class OperationalState(IntEnum): """Operational State of the vacuum cleaner. @@ -56,6 +69,33 @@ async def async_setup_entry( """Set up Matter vacuum platform from Config Entry.""" matter = get_matter(hass) matter.register_platform_handler(Platform.VACUUM, async_add_entities) + platform = entity_platform.async_get_current_platform() + + # This will call Entity.async_handle_get_areas + platform.async_register_entity_service( + SERVICE_GET_AREAS, + schema=None, + func="async_handle_get_areas", + supports_response=SupportsResponse.ONLY, + ) + # This will call Entity.async_handle_clean_areas + platform.async_register_entity_service( + SERVICE_CLEAN_AREAS, + schema={ + vol.Required("areas"): vol.All(cv.ensure_list, [cv.positive_int]), + }, + func="async_handle_clean_areas", + supports_response=SupportsResponse.ONLY, + ) + # This will call Entity.async_handle_select_areas + platform.async_register_entity_service( + SERVICE_SELECT_AREAS, + schema={ + vol.Required("areas"): vol.All(cv.ensure_list, [cv.positive_int]), + }, + func="async_handle_select_areas", + supports_response=SupportsResponse.ONLY, + ) class MatterVacuum(MatterEntity, StateVacuumEntity): @@ -65,9 +105,23 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): _supported_run_modes: ( dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None ) = None + _attr_matter_areas: dict[str, Any] | None = None + _attr_current_area: int | None = None + _attr_current_area_name: str | None = None + _attr_selected_areas: list[int] | None = None + _attr_supported_maps: list[dict[str, Any]] | None = None entity_description: StateVacuumEntityDescription _platform_translation_key = "vacuum" + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes of the entity.""" + return { + ATTR_CURRENT_AREA: self._attr_current_area, + ATTR_CURRENT_AREA_NAME: self._attr_current_area_name, + ATTR_SELECTED_AREAS: self._attr_selected_areas, + } + def _get_run_mode_by_tag( self, tag: ModeTag ) -> clusters.RvcRunMode.Structs.ModeOptionStruct | None: @@ -136,10 +190,160 @@ async def async_pause(self) -> None: """Pause the cleaning task.""" await self.send_device_command(clusters.RvcOperationalState.Commands.Pause()) + def async_get_areas(self, **kwargs: Any) -> dict[str, Any]: + """Get available area and map IDs from vacuum appliance.""" + + supported_areas = self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.SupportedAreas + ) + if not supported_areas: + raise HomeAssistantError("Can't get areas from the device.") + + # Group by area_id: {area_id: {"map_id": ..., "name": ...}} + areas = {} + for area in supported_areas: + area_id = getattr(area, "areaID", None) + map_id = getattr(area, "mapID", None) + location_name = None + area_info = getattr(area, "areaInfo", None) + if area_info is not None: + location_info = getattr(area_info, "locationInfo", None) + if location_info is not None: + location_name = getattr(location_info, "locationName", None) + if area_id is not None: + areas[area_id] = {"map_id": map_id, "name": location_name} + + # Optionally, also extract supported maps if available + supported_maps = self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.SupportedMaps + ) + maps = [] + if supported_maps: + maps = [ + { + "map_id": getattr(m, "mapID", None), + "name": getattr(m, "name", None), + } + for m in supported_maps + ] + + return { + "areas": areas, + "maps": maps, + } + + async def async_handle_get_areas(self, **kwargs: Any) -> ServiceResponse: + """Get available area and map IDs from vacuum appliance.""" + # Group by area_id: {area_id: {"map_id": ..., "name": ...}} + areas = {} + if self._attr_matter_areas is not None: + for area in self._attr_matter_areas: + area_id = getattr(area, "areaID", None) + map_id = getattr(area, "mapID", None) + location_name = None + area_info = getattr(area, "areaInfo", None) + if area_info is not None: + location_info = getattr(area_info, "locationInfo", None) + if location_info is not None: + location_name = getattr(location_info, "locationName", None) + if area_id is not None: + if map_id is NullValue: + areas[area_id] = {"name": location_name} + else: + areas[area_id] = {"map_id": map_id, "name": location_name} + + # Optionally, also extract supported maps if available + supported_maps = self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.SupportedMaps + ) + maps = [] + if supported_maps != NullValue: # chip.clusters.Types.Nullable + maps = [ + { + "map_id": getattr(m, "mapID", None) + if getattr(m, "mapID", None) != NullValue + else None, + "name": getattr(m, "name", None), + } + for m in supported_maps + ] + + return cast( + ServiceResponse, + { + "areas": areas, + "maps": maps, + }, + ) + return None + + async def async_handle_select_areas( + self, areas: list[int], **kwargs: Any + ) -> ServiceResponse: + """Select areas to clean.""" + selected_areas = areas + # Matter command to the vacuum cleaner to select the areas. + await self.send_device_command( + clusters.ServiceArea.Commands.SelectAreas(newAreas=selected_areas) + ) + # Return response indicating selected areas. + return cast( + ServiceResponse, {"status": "areas selected", "areas": selected_areas} + ) + + async def async_handle_clean_areas( + self, areas: list[int], **kwargs: Any + ) -> ServiceResponse: + """Start cleaning the specified areas.""" + # Matter command to the vacuum cleaner to select the areas. + await self.send_device_command( + clusters.ServiceArea.Commands.SelectAreas(newAreas=areas) + ) + # Start the vacuum cleaner after selecting areas. + await self.async_start() + # Return response indicating selected areas. + return cast( + ServiceResponse, {"status": "cleaning areas selected", "areas": areas} + ) + @callback def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() + # ServiceArea: get areas from the device + self._attr_matter_areas = self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.SupportedAreas + ) + # optional CurrentArea attribute + # pylint: disable=too-many-nested-blocks + if self.get_matter_attribute_value(clusters.ServiceArea.Attributes.CurrentArea): + current_area = self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.CurrentArea + ) + # get areaInfo.locationInfo.locationName for current_area in SupportedAreas list + area_name = None + if self._attr_matter_areas: + for area in self._attr_matter_areas: + if getattr(area, "areaID", None) == current_area: + area_info = getattr(area, "areaInfo", None) + if area_info is not None: + location_info = getattr(area_info, "locationInfo", None) + if location_info is not None: + area_name = getattr(location_info, "locationName", None) + break + self._attr_current_area = current_area + self._attr_current_area_name = area_name + else: + self._attr_current_area = None + self._attr_current_area_name = None + + # optional SelectedAreas attribute + if self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.SelectedAreas + ): + self._attr_selected_areas = self.get_matter_attribute_value( + clusters.ServiceArea.Attributes.SelectedAreas + ) # derive state from the run mode + operational state run_mode_raw: int = self.get_matter_attribute_value( clusters.RvcRunMode.Attributes.CurrentMode @@ -220,6 +424,10 @@ def _calculate_features(self) -> None: clusters.RvcRunMode.Attributes.CurrentMode, clusters.RvcOperationalState.Attributes.OperationalState, ), + optional_attributes=( + clusters.ServiceArea.Attributes.SelectedAreas, + clusters.ServiceArea.Attributes.CurrentArea, + ), device_type=(device_types.RoboticVacuumCleaner,), allow_none_value=True, ), diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index dca29cd7abd632..255e2e9a017e63 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -121,6 +121,7 @@ async def integration_fixture( "smoke_detector", "solar_power", "switch_unit", + "switchbot_K11", "temperature_sensor", "thermostat", "vacuum_cleaner", diff --git a/tests/components/matter/fixtures/nodes/switchbot_K11.json b/tests/components/matter/fixtures/nodes/switchbot_K11.json new file mode 100644 index 00000000000000..615979117e0f8a --- /dev/null +++ b/tests/components/matter/fixtures/nodes/switchbot_K11.json @@ -0,0 +1,440 @@ +{ + "node_id": 97, + "date_commissioned": "2025-08-21T16:38:31.165712", + "last_interview": "2025-08-21T16:38:31.165730", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 1, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "SwitchBot", + "0/40/2": 5015, + "0/40/3": "K11+", + "0/40/4": 2043, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 8, + "0/40/8": "8", + "0/40/9": 2, + "0/40/10": "2.0", + "0/40/11": "20200101", + "0/40/15": "SY612505261610300E", + "0/40/16": false, + "0/40/18": "5E441F48C89E75F4", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17039616, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 4, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 15, 16, 18, 19, 21, 22, 65528, + 65529, 65531, 65532, 65533 + ], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 2, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "wlan0", + "1": true, + "2": null, + "3": null, + "4": "sOn+hWUk", + "5": ["wKgBow=="], + "6": ["KgEOCgKzOZBGmN+UianfsA==", "/oAAAAAAAACEb4xWVmm9jw=="], + "7": 1 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 8, + "0/51/2": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRYRgkBwEkCAEwCUEED3gG83T4fgQ8mJi4UtxYHdce62io4H76mdpHCQluYUJ3zb4ahgxgT9tz7eNDwOooSPo985+iv5hDEEYsuVUu1TcKNQEoARgkAgE2AwQCBAEYMAQUGDYBbm6GdsqVhw7HwYXe2fWNMXIwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0DuruGO/yh7HLCuMeBxe6kBbjeStJ+VJAdWHiXBEyE1x2LZPcgX1LXpIwjshY5ACCNFRTuwtIH9GwSt9iVKZc7/GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "***********", + "2": 4939, + "3": 2, + "4": 97, + "5": "SSID", + "254": 3 + } + ], + "0/62/2": 16, + "0/62/3": 5, + "0/62/4": ["***********"], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 116, + "1": 1 + } + ], + "1/29/1": [3, 29, 84, 85, 97, 336], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/84/0": [ + { + "0": "Idle", + "1": 0, + "2": [ + { + "1": 16384 + } + ] + }, + { + "0": "Cleaning", + "1": 1, + "2": [ + { + "1": 16385 + } + ] + }, + { + "0": "Mapping", + "1": 2, + "2": [ + { + "1": 16386 + } + ] + }, + { + "0": "Pause", + "1": 3, + "2": [ + { + "1": 32769 + }, + { + "1": 0 + } + ] + }, + { + "0": "Resume", + "1": 4, + "2": [ + { + "1": 32770 + }, + { + "1": 0 + } + ] + }, + { + "0": "Docking", + "1": 5, + "2": [ + { + "1": 32771 + }, + { + "1": 0 + } + ] + } + ], + "1/84/1": 0, + "1/84/65532": 0, + "1/84/65533": 3, + "1/84/65528": [1], + "1/84/65529": [0], + "1/84/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/85/0": [ + { + "0": "Quick", + "1": 0, + "2": [ + { + "1": 16385 + }, + { + "1": 1 + } + ] + }, + { + "0": "Auto", + "1": 1, + "2": [ + { + "1": 16385 + }, + { + "1": 0 + } + ] + }, + { + "0": "Deep Clean", + "1": 2, + "2": [ + { + "1": 16385 + }, + { + "1": 16384 + } + ] + }, + { + "0": "Quiet", + "1": 3, + "2": [ + { + "1": 16385 + }, + { + "1": 2 + } + ] + }, + { + "0": "Max Vac", + "1": 4, + "2": [ + { + "1": 16385 + }, + { + "1": 7 + } + ] + } + ], + "1/85/1": 0, + "1/85/65532": 0, + "1/85/65533": 3, + "1/85/65528": [1], + "1/85/65529": [0], + "1/85/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/97/0": null, + "1/97/1": null, + "1/97/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 2 + }, + { + "0": 3 + }, + { + "0": 64 + }, + { + "0": 65 + }, + { + "0": 66 + } + ], + "1/97/4": 0, + "1/97/5": { + "0": 0 + }, + "1/97/65532": 0, + "1/97/65533": 2, + "1/97/65528": [4], + "1/97/65529": [0, 3, 128], + "1/97/65531": [0, 1, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/336/0": [ + { + "0": 1, + "1": null, + "2": { + "0": { + "0": "Bedroom #3", + "1": null, + "2": null + }, + "1": null + } + }, + { + "0": 2, + "1": null, + "2": { + "0": { + "0": "Stairs", + "1": null, + "2": null + }, + "1": null + } + }, + { + "0": 3, + "1": null, + "2": { + "0": { + "0": "Bedroom #1", + "1": null, + "2": null + }, + "1": null + } + }, + { + "0": 4, + "1": null, + "2": { + "0": { + "0": "Bedroom #2", + "1": null, + "2": null + }, + "1": null + } + }, + { + "0": 5, + "1": null, + "2": { + "0": { + "0": "Corridor", + "1": null, + "2": null + }, + "1": null + } + }, + { + "0": 6, + "1": null, + "2": { + "0": { + "0": "Bathroom", + "1": null, + "2": null + }, + "1": null + } + } + ], + "1/336/1": [], + "1/336/2": [4, 3], + "1/336/3": null, + "1/336/4": null, + "1/336/5": [], + "1/336/65532": 6, + "1/336/65533": 1, + "1/336/65528": [1, 3], + "1/336/65529": [0, 2], + "1/336/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index aab3d5f7ccec35..99228281971d32 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -2676,6 +2676,69 @@ 'state': 'previous', }) # --- +# name: test_selects[switchbot_K11][select.k11_clean_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Quick', + 'Auto', + 'Deep Clean', + 'Quiet', + 'Max Vac', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.k11_clean_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clean mode', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'clean_mode', + 'unique_id': '00000000000004D2-0000000000000061-MatterNodeDevice-1-MatterRvcCleanMode-85-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[switchbot_K11][select.k11_clean_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'K11+ Clean mode', + 'options': list([ + 'Quick', + 'Auto', + 'Deep Clean', + 'Quiet', + 'Max Vac', + ]), + }), + 'context': , + 'entity_id': 'select.k11_clean_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Quick', + }) +# --- # name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 2567ce2e936b78..09be27dcc15d5c 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -6791,6 +6791,74 @@ 'state': '234.899', }) # --- +# name: test_sensors[switchbot_K11][sensor.k11_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.k11_operational_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-0000000000000061-MatterNodeDevice-1-RvcOperationalState-97-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[switchbot_K11][sensor.k11_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'K11+ Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'context': , + 'entity_id': 'sensor.k11_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- # name: test_sensors[temperature_sensor][sensor.mock_temperature_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index 71e0f75614ddd8..78d90b00dcdfd9 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -1,4 +1,59 @@ # serializer version: 1 +# name: test_vacuum[switchbot_K11][vacuum.k11-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.k11', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000061-MatterNodeDevice-1-MatterVacuumCleaner-84-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum[switchbot_K11][vacuum.k11-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_area': None, + 'current_area_name': None, + 'friendly_name': 'K11+', + 'selected_areas': list([ + 4, + 3, + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.k11', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- # name: test_vacuum[vacuum_cleaner][vacuum.mock_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -37,7 +92,10 @@ # name: test_vacuum[vacuum_cleaner][vacuum.mock_vacuum-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'current_area': 7, + 'current_area_name': 'My Location A', 'friendly_name': 'Mock Vacuum', + 'selected_areas': None, 'supported_features': , }), 'context': , diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index cba4b9b59ebf10..36a4b5275df731 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -7,6 +7,16 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.matter.const import ( + SERVICE_CLEAN_AREAS, + SERVICE_GET_AREAS, + SERVICE_SELECT_AREAS, +) +from homeassistant.components.matter.vacuum import ( + ATTR_CURRENT_AREA, + ATTR_CURRENT_AREA_NAME, + ATTR_SELECTED_AREAS, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -137,6 +147,162 @@ async def test_vacuum_actions( matter_client.send_device_command.reset_mock() +@pytest.mark.parametrize("node_fixture", ["switchbot_K11"]) +async def test_k11_vacuum_actions( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Matter ServiceArea cluster actions.""" + # Fetch translations + await async_setup_component(hass, "homeassistant", {}) + entity_id = "vacuum.k11" + state = hass.states.get(entity_id) + # test selected_areas action + assert state + + selected_areas = [1, 2, 3] + await hass.services.async_call( + "matter", + SERVICE_SELECT_AREAS, + { + "entity_id": entity_id, + "areas": selected_areas, + }, + blocking=True, + return_response=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.ServiceArea.Commands.SelectAreas(newAreas=selected_areas), + ) + matter_client.send_device_command.reset_mock() + + # test clean_areasss action + assert state + + selected_areas = [1, 2, 3] + await hass.services.async_call( + "matter", + SERVICE_CLEAN_AREAS, + { + "entity_id": entity_id, + "areas": selected_areas, + }, + blocking=True, + return_response=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args_list[0] == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.ServiceArea.Commands.SelectAreas(newAreas=selected_areas), + ) + assert matter_client.send_device_command.call_args_list[1] == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=1), + ) + matter_client.send_device_command.reset_mock() + + # test get_areas action + response = await hass.services.async_call( + "matter", + SERVICE_GET_AREAS, + { + "entity_id": entity_id, + }, + blocking=True, + return_response=True, + ) + # check the response data + expected_data = { + "vacuum.k11": { + "areas": { + 1: {"name": "Bedroom #3"}, + 2: {"name": "Stairs"}, + 3: {"name": "Bedroom #1"}, + 4: {"name": "Bedroom #2"}, + 5: {"name": "Corridor"}, + 6: {"name": "Bathroom"}, + }, + "maps": [], + } + } + assert response == expected_data + + +@pytest.mark.parametrize("node_fixture", ["switchbot_K11"]) +async def test_k11_vacuum_service_area( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Matter ServiceArea cluster attributes.""" + # Fetch translations + await async_setup_component(hass, "homeassistant", {}) + entity_id = "vacuum.k11" + state = hass.states.get(entity_id) + # SupportedAreas attribute ID is 2 (1/336/0) + supported_areas = [ + { + "0": 1, + "1": None, + "2": { + "0": { + "0": "Bedroom #1", + "1": None, + "2": None, + }, + "1": None, + }, + }, + { + "0": 3, + "1": None, + "2": { + "0": { + "0": "Bedroom #2", + "1": None, + "2": None, + }, + "1": None, + }, + }, + { + "0": 4, + "1": None, + "2": { + "0": { + "0": "Bedroom #3", + "1": None, + "2": None, + }, + "1": None, + }, + }, + ] + set_node_attribute(matter_node, 1, 336, 0, supported_areas) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + + selected_areas = [1, 3] + set_node_attribute(matter_node, 1, 336, 2, selected_areas) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_SELECTED_AREAS] == selected_areas + + # ServiceArea.Attributes.CurrentArea (1/336/3) + set_node_attribute(matter_node, 1, 336, 3, 4) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_AREA] == 4 + assert state.attributes[ATTR_CURRENT_AREA_NAME] == "Bedroom #3" + + @pytest.mark.parametrize("node_fixture", ["vacuum_cleaner"]) async def test_vacuum_updates( hass: HomeAssistant, From b503f792b56e7e2d58a6c4f526441e35235b4063 Mon Sep 17 00:00:00 2001 From: Heindrich Paul Date: Mon, 15 Sep 2025 15:13:43 +0200 Subject: [PATCH 08/11] Add config flow to NS (#151567) Signed-off-by: Heindrich Paul Co-authored-by: Norbert Rittel Co-authored-by: Franck Nijhof Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- CODEOWNERS | 3 +- .../nederlandse_spoorwegen/__init__.py | 57 ++- .../nederlandse_spoorwegen/config_flow.py | 176 +++++++++ .../nederlandse_spoorwegen/const.py | 17 + .../nederlandse_spoorwegen/manifest.json | 4 +- .../nederlandse_spoorwegen/sensor.py | 125 ++++--- .../nederlandse_spoorwegen/strings.json | 74 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- requirements_test_all.txt | 3 + .../nederlandse_spoorwegen/__init__.py | 1 + .../nederlandse_spoorwegen/conftest.py | 68 ++++ .../nederlandse_spoorwegen/const.py | 3 + .../fixtures/stations.json | 262 ++++++++++++++ .../test_config_flow.py | 333 ++++++++++++++++++ .../nederlandse_spoorwegen/test_sensor.py | 53 +++ 16 files changed, 1133 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/nederlandse_spoorwegen/config_flow.py create mode 100644 homeassistant/components/nederlandse_spoorwegen/const.py create mode 100644 homeassistant/components/nederlandse_spoorwegen/strings.json create mode 100644 tests/components/nederlandse_spoorwegen/__init__.py create mode 100644 tests/components/nederlandse_spoorwegen/conftest.py create mode 100644 tests/components/nederlandse_spoorwegen/const.py create mode 100644 tests/components/nederlandse_spoorwegen/fixtures/stations.json create mode 100644 tests/components/nederlandse_spoorwegen/test_config_flow.py create mode 100644 tests/components/nederlandse_spoorwegen/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 80ab4744d5229c..67436a81addb86 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1017,7 +1017,8 @@ build.json @home-assistant/supervisor /tests/components/nanoleaf/ @milanmeu @joostlek /homeassistant/components/nasweb/ @nasWebio /tests/components/nasweb/ @nasWebio -/homeassistant/components/nederlandse_spoorwegen/ @YarmoM +/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul +/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /homeassistant/components/ness_alarm/ @nickw444 /tests/components/ness_alarm/ @nickw444 /homeassistant/components/nest/ @allenporter diff --git a/homeassistant/components/nederlandse_spoorwegen/__init__.py b/homeassistant/components/nederlandse_spoorwegen/__init__.py index b052df36e34008..9f7177f7432686 100644 --- a/homeassistant/components/nederlandse_spoorwegen/__init__.py +++ b/homeassistant/components/nederlandse_spoorwegen/__init__.py @@ -1 +1,56 @@ -"""The nederlandse_spoorwegen component.""" +"""The Nederlandse Spoorwegen integration.""" + +from __future__ import annotations + +import logging + +from ns_api import NSAPI, RequestParametersError +import requests + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +_LOGGER = logging.getLogger(__name__) + + +type NSConfigEntry = ConfigEntry[NSAPI] + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool: + """Set up Nederlandse Spoorwegen from a config entry.""" + api_key = entry.data[CONF_API_KEY] + + client = NSAPI(api_key) + + try: + await hass.async_add_executor_job(client.get_stations) + except ( + requests.exceptions.ConnectionError, + requests.exceptions.HTTPError, + ) as error: + _LOGGER.error("Could not connect to the internet: %s", error) + raise ConfigEntryNotReady from error + except RequestParametersError as error: + _LOGGER.error("Could not fetch stations, please check configuration: %s", error) + raise ConfigEntryNotReady from error + + entry.runtime_data = client + + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_reload_entry(hass: HomeAssistant, entry: NSConfigEntry) -> None: + """Reload NS integration when options are updated.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nederlandse_spoorwegen/config_flow.py b/homeassistant/components/nederlandse_spoorwegen/config_flow.py new file mode 100644 index 00000000000000..f614e41a9593b1 --- /dev/null +++ b/homeassistant/components/nederlandse_spoorwegen/config_flow.py @@ -0,0 +1,176 @@ +"""Config flow for Nederlandse Spoorwegen integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ns_api import NSAPI, Station +from requests.exceptions import ( + ConnectionError as RequestsConnectionError, + HTTPError, + Timeout, +) +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryData, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + TimeSelector, +) + +from .const import ( + CONF_FROM, + CONF_NAME, + CONF_ROUTES, + CONF_TIME, + CONF_TO, + CONF_VIA, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class NSConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nederlandse Spoorwegen.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step of the config flow (API key).""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + client = NSAPI(user_input[CONF_API_KEY]) + try: + await self.hass.async_add_executor_job(client.get_stations) + except HTTPError: + errors["base"] = "invalid_auth" + except (RequestsConnectionError, Timeout): + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception validating API key") + errors["base"] = "unknown" + if not errors: + return self.async_create_entry( + title="Nederlandse Spoorwegen", + data={CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Handle import from YAML configuration.""" + self._async_abort_entries_match({CONF_API_KEY: import_data[CONF_API_KEY]}) + + client = NSAPI(import_data[CONF_API_KEY]) + try: + stations = await self.hass.async_add_executor_job(client.get_stations) + except HTTPError: + return self.async_abort(reason="invalid_auth") + except (RequestsConnectionError, Timeout): + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception validating API key") + return self.async_abort(reason="unknown") + + station_codes = {station.code for station in stations} + + subentries: list[ConfigSubentryData] = [] + for route in import_data.get(CONF_ROUTES, []): + # Convert station codes to uppercase for consistency with UI routes + for key in (CONF_FROM, CONF_TO, CONF_VIA): + if key in route: + route[key] = route[key].upper() + if route[key] not in station_codes: + return self.async_abort(reason="invalid_station") + + subentries.append( + ConfigSubentryData( + title=route[CONF_NAME], + subentry_type="route", + data=route, + unique_id=None, + ) + ) + + return self.async_create_entry( + title="Nederlandse Spoorwegen", + data={CONF_API_KEY: import_data[CONF_API_KEY]}, + subentries=subentries, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"route": RouteSubentryFlowHandler} + + +class RouteSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding and modifying routes.""" + + def __init__(self) -> None: + """Initialize route subentry flow.""" + self.stations: dict[str, Station] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a new route subentry.""" + if user_input is not None: + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + client = NSAPI(self._get_entry().data[CONF_API_KEY]) + if not self.stations: + try: + self.stations = { + station.code: station + for station in await self.hass.async_add_executor_job( + client.get_stations + ) + } + except (RequestsConnectionError, Timeout, HTTPError, ValueError): + return self.async_abort(reason="cannot_connect") + + options = [ + SelectOptionDict(label=station.names["long"], value=code) + for code, station in self.stations.items() + ] + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_FROM): SelectSelector( + SelectSelectorConfig(options=options, sort=True), + ), + vol.Required(CONF_TO): SelectSelector( + SelectSelectorConfig(options=options, sort=True), + ), + vol.Optional(CONF_VIA): SelectSelector( + SelectSelectorConfig(options=options, sort=True), + ), + vol.Optional(CONF_TIME): TimeSelector(), + } + ), + ) diff --git a/homeassistant/components/nederlandse_spoorwegen/const.py b/homeassistant/components/nederlandse_spoorwegen/const.py new file mode 100644 index 00000000000000..3c350ed22ae90b --- /dev/null +++ b/homeassistant/components/nederlandse_spoorwegen/const.py @@ -0,0 +1,17 @@ +"""Constants for the Nederlandse Spoorwegen integration.""" + +DOMAIN = "nederlandse_spoorwegen" + +CONF_ROUTES = "routes" +CONF_FROM = "from" +CONF_TO = "to" +CONF_VIA = "via" +CONF_TIME = "time" +CONF_NAME = "name" + +# Attribute and schema keys +ATTR_ROUTE = "route" +ATTR_TRIPS = "trips" +ATTR_FIRST_TRIP = "first_trip" +ATTR_NEXT_TRIP = "next_trip" +ATTR_ROUTES = "routes" diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json index 0ef9d8d86f3aef..1f415dc695d69e 100644 --- a/homeassistant/components/nederlandse_spoorwegen/manifest.json +++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json @@ -1,8 +1,10 @@ { "domain": "nederlandse_spoorwegen", "name": "Nederlandse Spoorwegen (NS)", - "codeowners": ["@YarmoM"], + "codeowners": ["@YarmoM", "@heindrichpaul"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen", + "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "legacy", "requirements": ["nsapi==3.1.2"] diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 1e7fc54f4f7c5b..b5737c57c94987 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -5,8 +5,6 @@ from datetime import datetime, timedelta import logging -import ns_api -from ns_api import RequestParametersError import requests import voluptuous as vol @@ -14,13 +12,21 @@ PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_API_KEY, CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle, dt as dt_util +from homeassistant.util.dt import parse_time + +from . import NSConfigEntry +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -50,57 +56,84 @@ ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the departure sensor.""" - nsapi = ns_api.NSAPI(config[CONF_API_KEY]) - - try: - stations = nsapi.get_stations() - except ( - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - ) as error: - _LOGGER.error("Could not connect to the internet: %s", error) - raise PlatformNotReady from error - except RequestParametersError as error: - _LOGGER.error("Could not fetch stations, please check configuration: %s", error) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result.get('reason')}", + breaks_in_ha_version="2026.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Nederlandse Spoorwegen", + }, + ) return - sensors = [] - for departure in config.get(CONF_ROUTES, {}): - if not valid_stations( - stations, - [departure.get(CONF_FROM), departure.get(CONF_VIA), departure.get(CONF_TO)], - ): - continue - sensors.append( - NSDepartureSensor( - nsapi, - departure.get(CONF_NAME), - departure.get(CONF_FROM), - departure.get(CONF_TO), - departure.get(CONF_VIA), - departure.get(CONF_TIME), - ) - ) - add_entities(sensors, True) + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2026.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Nederlandse Spoorwegen", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the departure sensor from a config entry.""" + client = config_entry.runtime_data -def valid_stations(stations, given_stations): - """Verify the existence of the given station codes.""" - for station in given_stations: - if station is None: + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "route": continue - if not any(s.code == station.upper() for s in stations): - _LOGGER.warning("Station '%s' is not a valid station", station) - return False - return True + + async_add_entities( + [ + NSDepartureSensor( + client, + subentry.data[CONF_NAME], + subentry.data[CONF_FROM], + subentry.data[CONF_TO], + subentry.data.get(CONF_VIA), + parse_time(subentry.data[CONF_TIME]) + if CONF_TIME in subentry.data + else None, + ) + ], + config_subentry_id=subentry.subentry_id, + update_before_add=True, + ) class NSDepartureSensor(SensorEntity): diff --git a/homeassistant/components/nederlandse_spoorwegen/strings.json b/homeassistant/components/nederlandse_spoorwegen/strings.json new file mode 100644 index 00000000000000..0da1fd1ccc22ea --- /dev/null +++ b/homeassistant/components/nederlandse_spoorwegen/strings.json @@ -0,0 +1,74 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up your Nederlandse Spoorwegen integration.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your NS API key." + } + } + }, + "error": { + "cannot_connect": "Could not connect to NS API. Check your API key.", + "invalid_auth": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "config_subentries": { + "route": { + "step": { + "user": { + "description": "Select your departure and destination stations from the dropdown lists.", + "data": { + "name": "Route name", + "from": "Departure station", + "to": "Destination station", + "via": "Via station", + "time": "Departure time" + }, + "data_description": { + "name": "A name for this route", + "from": "The station to depart from", + "to": "The station to arrive at", + "via": "An optional intermediate station", + "time": "Optional planned departure time" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "initiate_flow": { + "user": "Add route" + }, + "entry_type": "Route" + } + }, + "issues": { + "deprecated_yaml_import_issue_invalid_auth": { + "title": "Nederlandse Spoorwegen YAML configuration deprecated", + "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an invalid API key was found. Please update your YAML configuration, or remove the existing YAML configuration and set the integration up via the UI." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "[%key:component::nederlandse_spoorwegen::issues::deprecated_yaml_import_issue_invalid_auth::title%]", + "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, Home Assistant could not connect to the NS API. Please check your internet connection and the status of the NS API, then restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "[%key:component::nederlandse_spoorwegen::issues::deprecated_yaml_import_issue_invalid_auth::title%]", + "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an unknown error occurred. Please restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI." + }, + "deprecated_yaml_import_issue_invalid_station": { + "title": "[%key:component::nederlandse_spoorwegen::issues::deprecated_yaml_import_issue_invalid_auth::title%]", + "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration an invalid station was found. Please update your YAML configuration, or remove the existing YAML configuration and set the integration up via the UI." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 99cbbbde73a9f9..e82915e03a13dc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -418,6 +418,7 @@ "nanoleaf", "nasweb", "neato", + "nederlandse_spoorwegen", "nest", "netatmo", "netgear", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 04f71cac6f2697..4dd81fa6adc9e2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4280,8 +4280,8 @@ }, "nederlandse_spoorwegen": { "name": "Nederlandse Spoorwegen (NS)", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "cloud_polling" }, "neff": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7526b77fd212e0..f0f5728f11d59f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1324,6 +1324,9 @@ notifications-android-tv==0.1.5 # homeassistant.components.notify_events notify-events==1.0.4 +# homeassistant.components.nederlandse_spoorwegen +nsapi==3.1.2 + # homeassistant.components.nsw_fuel_station nsw-fuel-api-client==1.1.0 diff --git a/tests/components/nederlandse_spoorwegen/__init__.py b/tests/components/nederlandse_spoorwegen/__init__.py new file mode 100644 index 00000000000000..a6b27df6185ffa --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nederlandse Spoorwegen integration.""" diff --git a/tests/components/nederlandse_spoorwegen/conftest.py b/tests/components/nederlandse_spoorwegen/conftest.py new file mode 100644 index 00000000000000..6e58a2e483eff6 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/conftest.py @@ -0,0 +1,68 @@ +"""Fixtures for Nederlandse Spoorwegen tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from ns_api import Station +import pytest + +from homeassistant.components.nederlandse_spoorwegen.const import ( + CONF_FROM, + CONF_TO, + CONF_VIA, + DOMAIN, +) +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_NAME + +from .const import API_KEY + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nederlandse_spoorwegen.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nsapi() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nederlandse_spoorwegen.config_flow.NSAPI", + autospec=True, + ) as mock_nsapi: + client = mock_nsapi.return_value + stations = load_json_object_fixture("stations.json", DOMAIN) + client.get_stations.return_value = [ + Station(station) for station in stations["payload"] + ] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + title="Nederlandse Spoorwegen", + data={CONF_API_KEY: API_KEY}, + domain=DOMAIN, + subentries_data=[ + ConfigSubentryData( + data={ + CONF_NAME: "To work", + CONF_FROM: "Ams", + CONF_TO: "Rot", + CONF_VIA: "Ht", + }, + subentry_type="route", + title="Test Route", + unique_id=None, + ), + ], + ) diff --git a/tests/components/nederlandse_spoorwegen/const.py b/tests/components/nederlandse_spoorwegen/const.py new file mode 100644 index 00000000000000..92c2a6e58f9088 --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/const.py @@ -0,0 +1,3 @@ +"""Constants for the Nederlandse Spoorwegen integration tests.""" + +API_KEY = "abc1234567" diff --git a/tests/components/nederlandse_spoorwegen/fixtures/stations.json b/tests/components/nederlandse_spoorwegen/fixtures/stations.json new file mode 100644 index 00000000000000..207c8cda8781bd --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/fixtures/stations.json @@ -0,0 +1,262 @@ +{ + "payload": [ + { + "EVACode": "8400058", + "UICCode": "8400058", + "UICCdCode": "118400058", + "cdCode": 58, + "code": "ASD", + "ingangsDatum": "2025-06-03", + "heeftFaciliteiten": true, + "heeftReisassistentie": true, + "heeftVertrektijden": true, + "land": "NL", + "lat": 52.3788871765137, + "lng": 4.90027761459351, + "radius": 525, + "naderenRadius": 1200, + "namen": { + "lang": "Amsterdam Centraal", + "middel": "Amsterdam C.", + "kort": "Amsterdm C" + }, + "synoniemen": ["Amsterdam CS", "Amsterdam"], + "nearbyMeLocationId": { + "value": "ASD", + "type": "stationV2" + }, + "sporen": [ + { + "spoorNummer": "1" + }, + { + "spoorNummer": "2" + }, + { + "spoorNummer": "2a" + }, + { + "spoorNummer": "2b" + }, + { + "spoorNummer": "4" + }, + { + "spoorNummer": "4a" + }, + { + "spoorNummer": "4b" + }, + { + "spoorNummer": "5" + }, + { + "spoorNummer": "5a" + }, + { + "spoorNummer": "5b" + }, + { + "spoorNummer": "7" + }, + { + "spoorNummer": "7a" + }, + { + "spoorNummer": "7b" + }, + { + "spoorNummer": "8" + }, + { + "spoorNummer": "8a" + }, + { + "spoorNummer": "8b" + }, + { + "spoorNummer": "10" + }, + { + "spoorNummer": "10a" + }, + { + "spoorNummer": "10b" + }, + { + "spoorNummer": "11" + }, + { + "spoorNummer": "11a" + }, + { + "spoorNummer": "11b" + }, + { + "spoorNummer": "13" + }, + { + "spoorNummer": "13a" + }, + { + "spoorNummer": "13b" + }, + { + "spoorNummer": "14" + }, + { + "spoorNummer": "14a" + }, + { + "spoorNummer": "14b" + }, + { + "spoorNummer": "15" + }, + { + "spoorNummer": "15a" + }, + { + "spoorNummer": "15b" + } + ], + "stationType": "MEGA_STATION" + }, + { + "EVACode": "8400319", + "UICCode": "8400319", + "UICCdCode": "118400319", + "cdCode": 319, + "code": "HT", + "ingangsDatum": "2025-06-03", + "heeftFaciliteiten": true, + "heeftReisassistentie": true, + "heeftVertrektijden": true, + "land": "NL", + "lat": 51.69048, + "lng": 5.29362, + "radius": 525, + "naderenRadius": 1200, + "namen": { + "lang": "'s-Hertogenbosch", + "middel": "'s-Hertogenbosch", + "kort": "Den Bosch" + }, + "synoniemen": ["Den Bosch", "Hertogenbosch ('s)"], + "nearbyMeLocationId": { + "value": "HT", + "type": "stationV2" + }, + "sporen": [ + { + "spoorNummer": "1" + }, + { + "spoorNummer": "3" + }, + { + "spoorNummer": "3a" + }, + { + "spoorNummer": "3b" + }, + { + "spoorNummer": "4" + }, + { + "spoorNummer": "4a" + }, + { + "spoorNummer": "4b" + }, + { + "spoorNummer": "6" + }, + { + "spoorNummer": "6a" + }, + { + "spoorNummer": "6b" + }, + { + "spoorNummer": "7" + }, + { + "spoorNummer": "7a" + }, + { + "spoorNummer": "7b" + } + ], + "stationType": "KNOOPPUNT_INTERCITY_STATION" + }, + { + "EVACode": "8400530", + "UICCode": "8400530", + "UICCdCode": "118400530", + "cdCode": 530, + "code": "RTD", + "ingangsDatum": "2017-02-01", + "heeftFaciliteiten": true, + "heeftReisassistentie": true, + "heeftVertrektijden": true, + "land": "NL", + "lat": 51.9249992370605, + "lng": 4.46888875961304, + "radius": 525, + "naderenRadius": 1000, + "namen": { + "lang": "Rotterdam Centraal", + "middel": "Rotterdam C.", + "kort": "Rotterdm C" + }, + "synoniemen": ["Rotterdam CS", "Rotterdam"], + "nearbyMeLocationId": { + "value": "RTD", + "type": "stationV2" + }, + "sporen": [ + { + "spoorNummer": "2" + }, + { + "spoorNummer": "3" + }, + { + "spoorNummer": "4" + }, + { + "spoorNummer": "6" + }, + { + "spoorNummer": "7" + }, + { + "spoorNummer": "8" + }, + { + "spoorNummer": "9" + }, + { + "spoorNummer": "11" + }, + { + "spoorNummer": "12" + }, + { + "spoorNummer": "13" + }, + { + "spoorNummer": "14" + }, + { + "spoorNummer": "15" + }, + { + "spoorNummer": "16" + } + ], + "stationType": "MEGA_STATION" + } + ] +} diff --git a/tests/components/nederlandse_spoorwegen/test_config_flow.py b/tests/components/nederlandse_spoorwegen/test_config_flow.py new file mode 100644 index 00000000000000..8d0c8e2b451abd --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/test_config_flow.py @@ -0,0 +1,333 @@ +"""Test config flow for Nederlandse Spoorwegen integration.""" + +from datetime import time +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from requests import ConnectionError as RequestsConnectionError, HTTPError, Timeout + +from homeassistant.components.nederlandse_spoorwegen.const import ( + CONF_FROM, + CONF_ROUTES, + CONF_TIME, + CONF_TO, + CONF_VIA, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import API_KEY + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, mock_nsapi: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test successful user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: API_KEY} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nederlandse Spoorwegen" + assert result["data"] == {CONF_API_KEY: API_KEY} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_creating_route( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a route after setting up the main config entry.""" + mock_config_entry.add_to_hass(hass) + assert len(mock_config_entry.subentries) == 1 + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "route"), context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_NAME: "Home to Work", + CONF_TIME: "08:30", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Home to Work" + assert result["data"] == { + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_NAME: "Home to Work", + CONF_TIME: "08:30", + } + assert len(mock_config_entry.subentries) == 2 + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (HTTPError("Invalid API key"), "invalid_auth"), + (Timeout("Cannot connect"), "cannot_connect"), + (RequestsConnectionError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_flow_exceptions( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test config flow handling different exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + mock_nsapi.get_stations.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: API_KEY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + mock_nsapi.get_stations.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: API_KEY} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nederlandse Spoorwegen" + assert result["data"] == {CONF_API_KEY: API_KEY} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_fetching_stations_failed( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a route after setting up the main config entry.""" + mock_config_entry.add_to_hass(hass) + assert len(mock_config_entry.subentries) == 1 + mock_nsapi.get_stations.side_effect = RequestsConnectionError("Unexpected error") + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "route"), context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config flow aborts if already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_config_flow_import_success( + hass: HomeAssistant, mock_nsapi: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test successful import flow from YAML configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: API_KEY}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nederlandse Spoorwegen" + assert result["data"] == {CONF_API_KEY: API_KEY} + assert not result["result"].subentries + + +@pytest.mark.parametrize( + ("routes_data", "expected_routes_data"), + [ + ( + # Test with uppercase station codes (UI behavior) + [ + { + CONF_NAME: "Home to Work", + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_TIME: time(hour=8, minute=30), + } + ], + [ + { + CONF_NAME: "Home to Work", + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_TIME: time(hour=8, minute=30), + } + ], + ), + ( + # Test with lowercase station codes (converted to uppercase) + [ + { + CONF_NAME: "Rotterdam-Amsterdam", + CONF_FROM: "rtd", # lowercase input + CONF_TO: "asd", # lowercase input + }, + { + CONF_NAME: "Amsterdam-Haarlem", + CONF_FROM: "asd", # lowercase input + CONF_TO: "ht", # lowercase input + CONF_VIA: "rtd", # lowercase input + }, + ], + [ + { + CONF_NAME: "Rotterdam-Amsterdam", + CONF_FROM: "RTD", # converted to uppercase + CONF_TO: "ASD", # converted to uppercase + }, + { + CONF_NAME: "Amsterdam-Haarlem", + CONF_FROM: "ASD", # converted to uppercase + CONF_TO: "HT", # converted to uppercase + CONF_VIA: "RTD", # converted to uppercase + }, + ], + ), + ], +) +async def test_config_flow_import_with_routes( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_setup_entry: AsyncMock, + routes_data: list[dict[str, Any]], + expected_routes_data: list[dict[str, Any]], +) -> None: + """Test import flow with routes from YAML configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_API_KEY: API_KEY, + CONF_ROUTES: routes_data, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nederlandse Spoorwegen" + assert result["data"] == {CONF_API_KEY: API_KEY} + assert len(result["result"].subentries) == len(expected_routes_data) + + subentries = list(result["result"].subentries.values()) + for expected_route in expected_routes_data: + route_entry = next( + entry for entry in subentries if entry.title == expected_route[CONF_NAME] + ) + assert route_entry.data == expected_route + assert route_entry.subentry_type == "route" + + +async def test_config_flow_import_with_unknown_station( + hass: HomeAssistant, mock_nsapi: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test import flow aborts with unknown station in routes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_API_KEY: API_KEY, + CONF_ROUTES: [ + { + CONF_NAME: "Home to Work", + CONF_FROM: "HRM", + CONF_TO: "RTD", + CONF_VIA: "HT", + CONF_TIME: time(hour=8, minute=30), + } + ], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_station" + + +async def test_config_flow_import_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test import flow when integration is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: API_KEY}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (HTTPError("Invalid API key"), "invalid_auth"), + (Timeout("Cannot connect"), "cannot_connect"), + (RequestsConnectionError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_import_flow_exceptions( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test config flow handling different exceptions.""" + mock_nsapi.get_stations.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_API_KEY: API_KEY} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error diff --git a/tests/components/nederlandse_spoorwegen/test_sensor.py b/tests/components/nederlandse_spoorwegen/test_sensor.py new file mode 100644 index 00000000000000..c748e126948d8e --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/test_sensor.py @@ -0,0 +1,53 @@ +"""Test the Nederlandse Spoorwegen sensor.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.nederlandse_spoorwegen.const import ( + CONF_FROM, + CONF_ROUTES, + CONF_TO, + CONF_VIA, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component + +from .const import API_KEY + + +async def test_config_import( + hass: HomeAssistant, + mock_nsapi, + mock_setup_entry: AsyncMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test sensor initialization.""" + await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: [ + { + CONF_PLATFORM: DOMAIN, + CONF_API_KEY: API_KEY, + CONF_ROUTES: [ + { + CONF_NAME: "Spoorwegen Nederlande Station", + CONF_FROM: "ASD", + CONF_TO: "RTD", + CONF_VIA: "HT", + } + ], + } + ] + }, + ) + + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml") in issue_registry.issues + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 From 75597ac98dd2249b2cc0e4a821e6817c9e266dc8 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 15 Sep 2025 16:15:15 +0300 Subject: [PATCH 09/11] Add Shelly removal condition for virtual components (#152312) --- homeassistant/components/shelly/binary_sensor.py | 4 ++++ homeassistant/components/shelly/number.py | 4 ++++ homeassistant/components/shelly/select.py | 4 ++++ homeassistant/components/shelly/sensor.py | 10 ++++++++++ homeassistant/components/shelly/switch.py | 4 ++++ homeassistant/components/shelly/text.py | 4 ++++ homeassistant/components/shelly/utils.py | 7 +++++++ tests/components/shelly/conftest.py | 13 +++++++++++++ tests/components/shelly/test_binary_sensor.py | 1 + tests/components/shelly/test_number.py | 1 + tests/components/shelly/test_select.py | 1 + tests/components/shelly/test_sensor.py | 3 +++ tests/components/shelly/test_switch.py | 2 ++ tests/components/shelly/test_text.py | 1 + 14 files changed, 59 insertions(+) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 24093ee1562d15..8ed6c37a9bee59 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -40,6 +40,7 @@ get_virtual_component_ids, is_block_momentary_input, is_rpc_momentary_input, + is_view_for_platform, ) PARALLEL_UPDATES = 0 @@ -273,6 +274,9 @@ def __init__( "boolean": RpcBinarySensorDescription( key="boolean", sub_key="value", + removal_condition=lambda config, _status, key: not is_view_for_platform( + config, key, BINARY_SENSOR_PLATFORM + ), ), "calibration": RpcBinarySensorDescription( key="blutrv", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 0f3080d53c37d5..f77db143c85069 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -41,6 +41,7 @@ get_device_entry_gen, get_virtual_component_ids, get_virtual_component_unit, + is_view_for_platform, ) PARALLEL_UPDATES = 0 @@ -185,6 +186,9 @@ async def async_set_native_value(self, value: float) -> None: "number": RpcNumberDescription( key="number", sub_key="value", + removal_condition=lambda config, _status, key: not is_view_for_platform( + config, key, NUMBER_PLATFORM + ), max_fn=lambda config: config["max"], min_fn=lambda config: config["min"], mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index 0e367a9df37c7f..c0838482b94746 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -26,6 +26,7 @@ async_remove_orphaned_entities, get_device_entry_gen, get_virtual_component_ids, + is_view_for_platform, ) PARALLEL_UPDATES = 0 @@ -40,6 +41,9 @@ class RpcSelectDescription(RpcEntityDescription, SelectEntityDescription): "enum": RpcSelectDescription( key="enum", sub_key="value", + removal_condition=lambda config, _status, key: not is_view_for_platform( + config, key, SELECT_PLATFORM + ), ), } diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index e69e2e76b3dd9f..dfe566b424c48a 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -63,6 +63,7 @@ get_virtual_component_ids, get_virtual_component_unit, is_rpc_wifi_stations_disabled, + is_view_for_platform, ) PARALLEL_UPDATES = 0 @@ -1385,10 +1386,16 @@ def __init__( "text": RpcSensorDescription( key="text", sub_key="value", + removal_condition=lambda config, _status, key: not is_view_for_platform( + config, key, SENSOR_PLATFORM + ), ), "number": RpcSensorDescription( key="number", sub_key="value", + removal_condition=lambda config, _status, key: not is_view_for_platform( + config, key, SENSOR_PLATFORM + ), unit=get_virtual_component_unit, device_class_fn=lambda config: ROLE_TO_DEVICE_CLASS_MAP.get(config["role"]) if "role" in config @@ -1397,6 +1404,9 @@ def __init__( "enum": RpcSensorDescription( key="enum", sub_key="value", + removal_condition=lambda config, _status, key: not is_view_for_platform( + config, key, SENSOR_PLATFORM + ), options_fn=lambda config: config["options"], device_class=SensorDeviceClass.ENUM, ), diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 1c184d260f87d8..0518858868df18 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -37,6 +37,7 @@ get_virtual_component_ids, is_block_exclude_from_relay, is_rpc_exclude_from_relay, + is_view_for_platform, ) PARALLEL_UPDATES = 0 @@ -89,6 +90,9 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): "boolean": RpcSwitchDescription( key="boolean", sub_key="value", + removal_condition=lambda config, _status, key: not is_view_for_platform( + config, key, SWITCH_PLATFORM + ), is_on=lambda status: bool(status["value"]), method_on="Boolean.Set", method_off="Boolean.Set", diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index d89531e2338634..5a514771a3f107 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -26,6 +26,7 @@ async_remove_orphaned_entities, get_device_entry_gen, get_virtual_component_ids, + is_view_for_platform, ) PARALLEL_UPDATES = 0 @@ -40,6 +41,9 @@ class RpcTextDescription(RpcEntityDescription, TextEntityDescription): "text": RpcTextDescription( key="text", sub_key="value", + removal_condition=lambda config, _status, key: not is_view_for_platform( + config, key, TEXT_PLATFORM + ), ), } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 075040cb92996a..2179620a6ea097 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -654,6 +654,13 @@ def get_virtual_component_ids(config: dict[str, Any], platform: str) -> list[str return ids +def is_view_for_platform(config: dict[str, Any], key: str, platform: str) -> bool: + """Return true if the virtual component view match the platform.""" + component = VIRTUAL_COMPONENTS_MAP[platform] + view = config[key]["meta"]["ui"]["view"] + return view in component["modes"] + + def get_virtual_component_unit(config: dict[str, Any]) -> str | None: """Return the unit of a virtual component. diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index a801caafdba900..7402d835ad1472 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -762,3 +762,16 @@ def mock_setup() -> Generator[AsyncMock]: "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup: yield mock_setup + + +@pytest.fixture +def disable_async_remove_shelly_rpc_entities() -> Generator[None]: + """Patch out async_remove_shelly_rpc_entities. + + This is used by virtual components tests that should not create entities, + without it async_remove_shelly_rpc_entities will clean up the entities. + """ + with patch( + "homeassistant.components.shelly.utils.async_remove_shelly_rpc_entities" + ): + yield diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index af7d3d14b7daee..5aa4f59781e990 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -435,6 +435,7 @@ async def test_rpc_device_virtual_binary_sensor( assert state.state == STATE_OFF +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index e33b04721cc0bc..9f7e85f8f05e4e 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -340,6 +340,7 @@ async def test_rpc_device_virtual_number( assert state.state == "56.7" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_number_when_mode_label( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/shelly/test_select.py b/tests/components/shelly/test_select.py index bb68edd19619a8..4586da344dbf2b 100644 --- a/tests/components/shelly/test_select.py +++ b/tests/components/shelly/test_select.py @@ -92,6 +92,7 @@ async def test_rpc_device_virtual_enum( assert state.state == "Title 1" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_enum_when_mode_label( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 6ab342b2cf81ab..dd43cbce3c4461 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1077,6 +1077,7 @@ async def test_rpc_device_virtual_text_sensor( assert state.state == "dolor sit amet" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_text_virtual_sensor_when_mode_field( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -1181,6 +1182,7 @@ async def test_rpc_device_virtual_number_sensor( assert state.state == "56.7" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_number_virtual_sensor_when_mode_field( hass: HomeAssistant, entity_registry: EntityRegistry, @@ -1294,6 +1296,7 @@ async def test_rpc_device_virtual_enum_sensor( assert state.state == "two" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index fd449570f310a0..2cb807236ec4a0 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -659,6 +659,7 @@ async def test_rpc_device_virtual_switch( assert state.state == STATE_ON +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_device_virtual_binary_sensor( hass: HomeAssistant, mock_rpc_device: Mock, @@ -680,6 +681,7 @@ async def test_rpc_device_virtual_binary_sensor( assert hass.states.get(entity_id) is None +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_switch_when_mode_label( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/shelly/test_text.py b/tests/components/shelly/test_text.py index 165272313cbd37..3190fabfbea475 100644 --- a/tests/components/shelly/test_text.py +++ b/tests/components/shelly/test_text.py @@ -77,6 +77,7 @@ async def test_rpc_device_virtual_text( assert state.state == "sed do eiusmod" +@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") async def test_rpc_remove_virtual_text_when_mode_label( hass: HomeAssistant, entity_registry: EntityRegistry, From 5dc509cba0f813e3bca05dca5fc6fbc8ea210fb6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 15 Sep 2025 16:19:39 +0200 Subject: [PATCH 10/11] Add typing to Nederlandse Spoorwegen (#152367) --- .../nederlandse_spoorwegen/sensor.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index b5737c57c94987..67dc43cfa25d41 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -2,9 +2,12 @@ from __future__ import annotations +import datetime as dt from datetime import datetime, timedelta import logging +from typing import Any +from ns_api import NSAPI, Trip import requests import voluptuous as vol @@ -142,7 +145,15 @@ class NSDepartureSensor(SensorEntity): _attr_attribution = "Data provided by NS" _attr_icon = "mdi:train" - def __init__(self, nsapi, name, departure, heading, via, time): + def __init__( + self, + nsapi: NSAPI, + name: str, + departure: str, + heading: str, + via: str | None, + time: dt.time | None, + ) -> None: """Initialize the sensor.""" self._nsapi = nsapi self._name = name @@ -150,23 +161,23 @@ def __init__(self, nsapi, name, departure, heading, via, time): self._via = via self._heading = heading self._time = time - self._state = None - self._trips = None - self._first_trip = None - self._next_trip = None + self._state: str | None = None + self._trips: list[Trip] | None = None + self._first_trip: Trip | None = None + self._next_trip: Trip | None = None @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> str | None: """Return the next departure time.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if not self._trips or self._first_trip is None: return None @@ -236,6 +247,7 @@ def extra_state_attributes(self): ): attributes["arrival_delay"] = True + assert self._next_trip is not None # Next attributes if self._next_trip.departure_time_actual is not None: attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M") From f4f99e015cd8868556cc94d68c52aa6fc4633c64 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 15 Sep 2025 17:14:41 +0200 Subject: [PATCH 11/11] Clarify "discovery_requires_supervisor" message in `zwave_js` (#152345) --- homeassistant/components/zwave_js/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index e02dff8e04a2f5..cf2d644da1b2fe 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -14,7 +14,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.", "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", - "discovery_requires_supervisor": "Discovery requires the supervisor.", + "discovery_requires_supervisor": "Discovery requires the Home Assistant Supervisor.", "migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the non-volatile memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.", "migration_successful": "Migration successful.", "not_zwave_device": "Discovered device is not a Z-Wave device.",