From 1c045ab715ff42513e3ff861f2f2482b39eaa8a0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 25 May 2026 11:26:14 +0200 Subject: [PATCH 01/13] Add negative test to WS API test test_test_condition (#171427) --- .../components/websocket_api/test_commands.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index fbc3ce23c54baf..e2f76d330f12b8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2749,6 +2749,26 @@ async def test_test_condition( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test testing a condition.""" + await websocket_client.send_json_auto_id( + { + "type": "test_condition", + "condition": { + "condition": "state", + "entity_id": "hello.world", + "state": "paulus", + }, + "variables": {"hello": "world"}, + } + ) + + msg = await websocket_client.receive_json() + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"] == { + "code": "home_assistant_error", + "message": "In 'state':\n In 'state' condition: unknown entity hello.world", + } + hass.states.async_set("hello.world", "paulus") await websocket_client.send_json_auto_id( From 2f4abd6a25994ad33bb559beb3a1187ee09cbc58 Mon Sep 17 00:00:00 2001 From: johanzander Date: Mon, 25 May 2026 11:37:25 +0200 Subject: [PATCH 02/13] growatt_server: implement dynamic-devices and stale-devices Gold rules (#166081) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Joost Lekkerkerker --- .../components/growatt_server/__init__.py | 103 +++++++- .../components/growatt_server/const.py | 10 + .../components/growatt_server/coordinator.py | 76 +++++- .../components/growatt_server/number.py | 42 ++- .../growatt_server/quality_scale.yaml | 4 +- .../growatt_server/sensor/__init__.py | 87 ++++--- .../components/growatt_server/switch.py | 42 ++- tests/components/growatt_server/test_init.py | 244 +++++++++++++++++- 8 files changed, 550 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index e416b93c73dc7b..c0218ea31c51f0 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -25,6 +25,7 @@ """ from collections.abc import Mapping +import datetime from json import JSONDecodeError import logging @@ -34,7 +35,9 @@ from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import ( @@ -46,10 +49,13 @@ DEFAULT_PLANT_ID, DEFAULT_URL, DEPRECATED_URLS, + DEVICE_SCAN_INTERVAL, DOMAIN, LOGIN_INVALID_AUTH_CODE, PLATFORMS, + SUPPORTED_DEVICE_TYPES, V1_API_ERROR_NO_PRIVILEGE, + V1_DEVICE_TYPES, ) from .coordinator import GrowattConfigEntry, GrowattCoordinator from .models import GrowattRuntimeData @@ -241,9 +247,6 @@ def _login_classic_api( return login_response -V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"} - - def get_device_list_v1( api, config: Mapping[str, str] ) -> tuple[list[dict[str, str]], str]: @@ -353,7 +356,7 @@ async def async_setup_entry( hass, config_entry, device["deviceSn"], device["deviceType"], plant_id ) for device in devices - if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"] + if device["deviceType"] in SUPPORTED_DEVICE_TYPES } # Perform the first refresh for the total coordinator @@ -372,6 +375,96 @@ async def async_setup_entry( # Set up all the entities await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + async def _async_scan_for_new_devices(_now: datetime.datetime) -> None: + """Scan for new or removed devices and update HA accordingly.""" + # Fetch current config (in case it was updated via reauth or options) + current_plant_id = config_entry.data[CONF_PLANT_ID] + + total_coordinator = config_entry.runtime_data.total_coordinator + # Signal the coordinator to also fetch the device list on its next + # _sync_update_data run, then force an immediate refresh. This keeps + # the device_list call in the same executor thread as the existing + # login() + plant-overview call, so for Classic API there is no extra + # login and no thread-safety concern with the shared session. + total_coordinator.request_device_list_scan() + await total_coordinator.async_refresh() + + if not total_coordinator.last_update_success: + _LOGGER.debug("Coordinator refresh failed during device scan, skipping") + return + + current_devices = total_coordinator.device_list + if current_devices is None: + _LOGGER.debug( + "Device list not populated after coordinator refresh, skipping scan" + ) + return + + runtime_data = config_entry.runtime_data + current_device_sns = {device["deviceSn"] for device in current_devices} + + # Remove stale devices + device_registry = dr.async_get(hass) + for device_entry in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + device_domain_ids = { + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + } + if not device_domain_ids: + continue + # Skip the plant "total" device + if current_plant_id in device_domain_ids: + continue + if device_domain_ids.isdisjoint(current_device_sns): + for device_sn in device_domain_ids: + if coordinator := runtime_data.devices.pop(device_sn, None): + await coordinator.async_shutdown() + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry.entry_id, + ) + + # Add new devices + new_coordinators: list[GrowattCoordinator] = [] + for device in current_devices: + device_sn = device["deviceSn"] + device_type = device["deviceType"] + if device_sn in runtime_data.devices: + continue + if device_type not in SUPPORTED_DEVICE_TYPES: + _LOGGER.debug( + "New device %s with type %s is not supported, skipping", + device_sn, + device_type, + ) + continue + coordinator = GrowattCoordinator( + hass, config_entry, device_sn, device_type, current_plant_id + ) + await coordinator.async_refresh() + if not coordinator.last_update_success: + _LOGGER.debug("Failed to refresh new device %s, skipping", device_sn) + await coordinator.async_shutdown() + continue + runtime_data.devices[device_sn] = coordinator + new_coordinators.append(coordinator) + + if new_coordinators: + async_dispatcher_send( + hass, + f"{DOMAIN}_new_device_{config_entry.entry_id}", + new_coordinators, + ) + + config_entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL + ) + ) + return True diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 15040507920a7e..81c400f2407699 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -1,7 +1,11 @@ """Define constants for the Growatt Server component.""" +from datetime import timedelta + from homeassistant.const import Platform +DEVICE_SCAN_INTERVAL = timedelta(hours=1) + CONF_PLANT_ID = "plant_id" # Auth types for config flow @@ -62,3 +66,9 @@ # Used to pass logged-in session from async_migrate_entry to async_setup_entry # to avoid double login() calls that trigger API rate limiting CACHED_API_KEY = "_cached_api_" + +# Supported device types for coordinator creation +SUPPORTED_DEVICE_TYPES = ["inverter", "tlx", "storage", "mix", "min", "sph"] + +# Maps V1 API device type integers to coordinator device-type strings +V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"} diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index c9885f16dad56d..513f17d9ebeee3 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any import growattServer +from requests import RequestException from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntry @@ -27,6 +28,7 @@ DOMAIN, LOGIN_INVALID_AUTH_CODE, V1_API_ERROR_NO_PRIVILEGE, + V1_DEVICE_TYPES, ) from .models import GrowattRuntimeData @@ -60,6 +62,15 @@ def __init__( self.plant_id = plant_id self.previous_values: dict[str, Any] = {} self._pre_reset_values: dict[str, float] = {} + # Populated during _sync_update_data when request_device_list_scan() was called. + # Consumed by _async_scan_for_new_devices to avoid a separate executor job + # and the extra login() call that would otherwise be required (Classic API). + # Thread safety: written in the executor thread, read on the event loop after + # async_refresh() awaits the executor job — ordering guarantees safe access. + self.device_list: list[dict[str, str]] | None = None + # Flag set on the event loop (request_device_list_scan) and consumed in the + # executor thread (_sync_update_data). Bool assignment is atomic under CPython's GIL. + self._fetch_device_list: bool = False if self.api_version == "v1": self.username = None @@ -87,10 +98,58 @@ def __init__( config_entry=config_entry, ) + def _sync_fetch_device_list(self) -> None: + """Fetch the device list for the current plant.""" + if self.api_version == "v1": + try: + devices_dict = self.api.device_list(self.plant_id) + devices = devices_dict.get("devices", []) + self.device_list = [ + { + "deviceSn": device.get("device_sn", ""), + "deviceType": V1_DEVICE_TYPES[device.get("type")], + } + for device in devices + if device.get("type") in V1_DEVICE_TYPES + ] + except growattServer.GrowattV1ApiError as err: + if err.error_code == V1_API_ERROR_NO_PRIVILEGE: + raise ConfigEntryAuthFailed( + f"Authentication failed for Growatt API: {err.error_msg or str(err)}" + ) from err + _LOGGER.debug("Failed to fetch V1 device list during scan: %s", err) + self.device_list = None + else: + try: + # login() was already called above; reuse the same session. + devices = self.api.device_list(self.plant_id) + self.device_list = [ + { + "deviceSn": device["deviceSn"], + "deviceType": device["deviceType"], + } + for device in devices + ] + except ( + RequestException, + json.JSONDecodeError, + KeyError, + TypeError, + ) as err: + _LOGGER.debug( + "Failed to fetch Classic device list during scan: %s", err + ) + self.device_list = None + def _sync_update_data(self) -> dict[str, Any]: """Update data via library synchronously.""" _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type) + # Consume the scan flag immediately so it is cleared even if an exception + # is raised later in this method. + fetch_device_list = self._fetch_device_list + self._fetch_device_list = False + # login only required for classic API if self.api_version == "classic": login_response = self.api.login(self.username, self.password) @@ -132,12 +191,16 @@ def _sync_update_data(self) -> dict[str, Any]: total_info["totalEnergy"] = total_info["total_energy"] total_info["invTodayPpv"] = total_info["current_power"] else: - # Classic API: use plant_info as before - total_info = self.api.plant_info(self.device_id) + # Classic API: use plant_info as before. + # Copy the response to avoid mutating the dict returned by the library + # (important for test mocks, harmless in production). + total_info = dict(self.api.plant_info(self.device_id)) del total_info["deviceList"] plant_money_text, currency = total_info["plantMoneyText"].split("/") total_info["plantMoneyText"] = plant_money_text total_info["currency"] = currency + if fetch_device_list: + self._sync_fetch_device_list() _LOGGER.debug("Total info for plant %s: %r", self.plant_id, total_info) self.data = total_info elif self.device_type == "inverter": @@ -252,6 +315,15 @@ async def _async_update_data(self) -> dict[str, Any]: except json.decoder.JSONDecodeError as err: raise UpdateFailed(f"Error fetching data: {err}") from err + def request_device_list_scan(self) -> None: + """Request that the next _sync_update_data also fetches the device list. + + Setting this flag before async_refresh() keeps the device_list call in + the same executor thread as the existing login() + plant-overview fetch, + so no extra login is needed and there is no thread-safety concern. + """ + self._fetch_device_list = True + def get_currency(self): """Get the currency.""" return self.data.get("currency") diff --git a/homeassistant/components/growatt_server/number.py b/homeassistant/components/growatt_server/number.py index 7c8bd681c5f117..5356cc617eb8eb 100644 --- a/homeassistant/components/growatt_server/number.py +++ b/homeassistant/components/growatt_server/number.py @@ -7,9 +7,10 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -88,6 +89,17 @@ class GrowattNumberEntityDescription(NumberEntityDescription): ) +def _create_numbers_for_device( + coordinator: GrowattCoordinator, +) -> list[GrowattNumber]: + """Create number entities for a device coordinator.""" + if coordinator.device_type == "min" and coordinator.api_version == "v1": + return [ + GrowattNumber(coordinator, description) for description in MIN_NUMBER_TYPES + ] + return [] + + async def async_setup_entry( hass: HomeAssistant, entry: GrowattConfigEntry, @@ -96,15 +108,29 @@ async def async_setup_entry( """Set up Growatt number entities.""" runtime_data = entry.runtime_data - # Add number entities for each MIN device (only supported with V1 API) async_add_entities( - GrowattNumber(device_coordinator, description) - for device_coordinator in runtime_data.devices.values() - if ( - device_coordinator.device_type == "min" - and device_coordinator.api_version == "v1" + entity + for coordinator in runtime_data.devices.values() + for entity in _create_numbers_for_device(coordinator) + ) + + @callback + def _async_new_device(coordinators: list[GrowattCoordinator]) -> None: + """Add number entities for new devices.""" + new_entities = [ + entity + for coordinator in coordinators + for entity in _create_numbers_for_device(coordinator) + ] + if new_entities: + async_add_entities(new_entities) + + entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_new_device_{entry.entry_id}", + _async_new_device, ) - for description in MIN_NUMBER_TYPES ) diff --git a/homeassistant/components/growatt_server/quality_scale.yaml b/homeassistant/components/growatt_server/quality_scale.yaml index c6c503981c30bc..2d4bd5834f10b7 100644 --- a/homeassistant/components/growatt_server/quality_scale.yaml +++ b/homeassistant/components/growatt_server/quality_scale.yaml @@ -51,7 +51,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -62,7 +62,7 @@ rules: repair-issues: status: exempt comment: Integration does not raise repairable issues. - stale-devices: todo + stale-devices: done # Platinum async-dependency: todo diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index 420daf06661450..bb91e73a168faf 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -5,8 +5,9 @@ import logging from homeassistant.components.sensor import SensorEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -24,15 +25,46 @@ _LOGGER = logging.getLogger(__name__) +def _create_sensors_for_device( + coordinator: GrowattCoordinator, +) -> list[GrowattSensor]: + """Create sensor entities for a device coordinator.""" + if coordinator.device_type == "inverter": + sensor_descriptions = INVERTER_SENSOR_TYPES + elif coordinator.device_type in ("tlx", "min"): + sensor_descriptions = TLX_SENSOR_TYPES + elif coordinator.device_type == "storage": + sensor_descriptions = STORAGE_SENSOR_TYPES + elif coordinator.device_type == "mix": + sensor_descriptions = MIX_SENSOR_TYPES + elif coordinator.device_type == "sph": + sensor_descriptions = SPH_SENSOR_TYPES + else: + _LOGGER.debug( + "Device type %s was found but is not supported right now", + coordinator.device_type, + ) + return [] + device_sn = coordinator.device_id + return [ + GrowattSensor( + coordinator, + name=device_sn, + serial_id=device_sn, + unique_id=f"{device_sn}-{description.key}", + description=description, + ) + for description in sensor_descriptions + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: GrowattConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Growatt sensor.""" - # Use runtime_data instead of hass.data data = config_entry.runtime_data - entities: list[GrowattSensor] = [] # Add total sensors @@ -48,38 +80,29 @@ async def async_setup_entry( for description in TOTAL_SENSOR_TYPES ) - # Add sensors for each device - for device_sn, device_coordinator in data.devices.items(): - sensor_descriptions: list = [] - if device_coordinator.device_type == "inverter": - sensor_descriptions = list(INVERTER_SENSOR_TYPES) - elif device_coordinator.device_type in ("tlx", "min"): - sensor_descriptions = list(TLX_SENSOR_TYPES) - elif device_coordinator.device_type == "storage": - sensor_descriptions = list(STORAGE_SENSOR_TYPES) - elif device_coordinator.device_type == "mix": - sensor_descriptions = list(MIX_SENSOR_TYPES) - elif device_coordinator.device_type == "sph": - sensor_descriptions = list(SPH_SENSOR_TYPES) - else: - _LOGGER.debug( - "Device type %s was found but is not supported right now", - device_coordinator.device_type, - ) - - entities.extend( - GrowattSensor( - device_coordinator, - name=device_sn, - serial_id=device_sn, - unique_id=f"{device_sn}-{description.key}", - description=description, - ) - for description in sensor_descriptions - ) + # Add sensors for each existing device + for device_coordinator in data.devices.values(): + entities.extend(_create_sensors_for_device(device_coordinator)) async_add_entities(entities) + @callback + def _async_new_device(coordinators: list[GrowattCoordinator]) -> None: + """Add sensor entities for new devices.""" + new_entities: list[GrowattSensor] = [] + for coordinator in coordinators: + new_entities.extend(_create_sensors_for_device(coordinator)) + if new_entities: + async_add_entities(new_entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_new_device_{config_entry.entry_id}", + _async_new_device, + ) + ) + class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity): """Representation of a Growatt Sensor.""" diff --git a/homeassistant/components/growatt_server/switch.py b/homeassistant/components/growatt_server/switch.py index 2590acdb632c74..d5286bc5035e64 100644 --- a/homeassistant/components/growatt_server/switch.py +++ b/homeassistant/components/growatt_server/switch.py @@ -8,9 +8,10 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -45,6 +46,17 @@ class GrowattSwitchEntityDescription(SwitchEntityDescription): ) +def _create_switches_for_device( + coordinator: GrowattCoordinator, +) -> list[GrowattSwitch]: + """Create switch entities for a device coordinator.""" + if coordinator.device_type == "min" and coordinator.api_version == "v1": + return [ + GrowattSwitch(coordinator, description) for description in MIN_SWITCH_TYPES + ] + return [] + + async def async_setup_entry( hass: HomeAssistant, entry: GrowattConfigEntry, @@ -53,15 +65,29 @@ async def async_setup_entry( """Set up Growatt switch entities.""" runtime_data = entry.runtime_data - # Add switch entities for each MIN device (only supported with V1 API) async_add_entities( - GrowattSwitch(device_coordinator, description) - for device_coordinator in runtime_data.devices.values() - if ( - device_coordinator.device_type == "min" - and device_coordinator.api_version == "v1" + entity + for coordinator in runtime_data.devices.values() + for entity in _create_switches_for_device(coordinator) + ) + + @callback + def _async_new_device(coordinators: list[GrowattCoordinator]) -> None: + """Add switch entities for new devices.""" + new_entities = [ + entity + for coordinator in coordinators + for entity in _create_switches_for_device(coordinator) + ] + if new_entities: + async_add_entities(new_entities) + + entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_new_device_{entry.entry_id}", + _async_new_device, ) - for description in MIN_SWITCH_TYPES ) diff --git a/tests/components/growatt_server/test_init.py b/tests/components/growatt_server/test_init.py index b6a5f57f531eae..76a3ca7cdddb57 100644 --- a/tests/components/growatt_server/test_init.py +++ b/tests/components/growatt_server/test_init.py @@ -17,6 +17,7 @@ CONF_AUTH_TYPE, CONF_PLANT_ID, DEFAULT_PLANT_ID, + DEVICE_SCAN_INTERVAL, DOMAIN, LOGIN_INVALID_AUTH_CODE, V1_API_ERROR_WRONG_DOMAIN, @@ -31,7 +32,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration @@ -819,3 +820,244 @@ async def test_migrate_already_migrated( # Plant ID should remain unchanged assert mock_config_entry.data[CONF_PLANT_ID] == "specific_plant_123" + + +@pytest.mark.usefixtures("init_integration") +async def test_dynamic_device_added( + hass: HomeAssistant, + mock_growatt_v1_api, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that new devices are dynamically added when discovered during a scan.""" + # Initially only MIN123456 device exists + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + is not None + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, "NEW456789")}) is None + + # Mock a new device appearing in the device list + mock_growatt_v1_api.device_list.return_value = { + "devices": [ + {"device_sn": "MIN123456", "type": 7}, + {"device_sn": "NEW456789", "type": 7}, + ] + } + mock_growatt_v1_api.min_detail.return_value = { + "deviceSn": "NEW456789", + "acChargeEnable": 0, + } + + # Trigger the periodic device scan + freezer.tick(DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # New device should now be in the device registry + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "NEW456789")}) + is not None + ) + # New device should be in runtime_data + assert "NEW456789" in mock_config_entry.runtime_data.devices + # Entities for the new device should have been created via the dispatcher. + # Verify multiple entity types to confirm end-to-end dynamic device support + assert hass.states.get("switch.new456789_charge_from_grid") is not None + # Additional check: verify entities exist in the entity registry + entity_registry = er.async_get(hass) + new_device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "NEW456789")} + ) + new_device_entities = er.async_entries_for_device( + entity_registry, new_device_entry.id, include_disabled_entities=True + ) + assert len(new_device_entities) > 0, "No entities created for new device" + + +@pytest.mark.usefixtures("init_integration") +async def test_stale_device_removed( + hass: HomeAssistant, + mock_growatt_v1_api, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that stale devices are removed from the device registry during a scan.""" + # Initially MIN123456 device exists with entities in the state machine + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + is not None + ) + assert hass.states.get("switch.min123456_charge_from_grid") is not None + + # Mock the device disappearing from the API + mock_growatt_v1_api.device_list.return_value = {"devices": []} + + # Trigger the periodic device scan + freezer.tick(DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # The device should be removed from HA + assert device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) is None + # The coordinator should be removed from runtime_data + assert "MIN123456" not in mock_config_entry.runtime_data.devices + # Orphaned entities must also be gone from the entity registry and state machine + assert entity_registry.async_get("switch.min123456_charge_from_grid") is None + assert hass.states.get("switch.min123456_charge_from_grid") is None + + +@pytest.mark.usefixtures("init_integration") +async def test_device_scan_error_is_silent( + hass: HomeAssistant, + mock_growatt_v1_api, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that errors during device scan are handled gracefully without crashing.""" + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Simulate a scan failure + mock_growatt_v1_api.device_list.side_effect = growattServer.GrowattV1ApiError( + message="Temporary error", + error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED, + error_msg="Temporary error", + ) + + freezer.tick(DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Integration should remain loaded - scan errors are non-fatal + assert mock_config_entry.state is ConfigEntryState.LOADED + # Existing device should still be present + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + is not None + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_dynamic_device_refresh_fails_is_skipped( + hass: HomeAssistant, + mock_growatt_v1_api, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a new device whose first coordinator refresh fails is not added.""" + # Mock a new device appearing but its data endpoint failing + mock_growatt_v1_api.device_list.return_value = { + "devices": [ + {"device_sn": "MIN123456", "type": 7}, + {"device_sn": "NEW456789", "type": 7}, + ] + } + + def min_detail_side_effect(device_sn: str) -> dict: + if device_sn == "NEW456789": + raise growattServer.GrowattV1ApiError( + message="Device not reachable", + error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED, + error_msg="Device not reachable", + ) + return mock_growatt_v1_api.min_detail.return_value + + mock_growatt_v1_api.min_detail.side_effect = min_detail_side_effect + + freezer.tick(DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # New device should NOT be added — its refresh failed + assert device_registry.async_get_device(identifiers={(DOMAIN, "NEW456789")}) is None + assert "NEW456789" not in mock_config_entry.runtime_data.devices + # Existing device must be unaffected + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + is not None + ) + + +async def test_classic_api_device_scan( + hass: HomeAssistant, + mock_growatt_classic_api, + mock_config_entry_classic: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the periodic device scan works correctly for Classic API (password auth). + + The scan must reuse the total coordinator's authenticated session (no extra login). + """ + mock_growatt_classic_api.device_list.return_value = [ + {"deviceSn": "TLX123456", "deviceType": "tlx"} + ] + await setup_integration(hass, mock_config_entry_classic) + assert mock_config_entry_classic.state is ConfigEntryState.LOADED + + # Mock a new device appearing; reset device_list call count after setup + # so we can assert the scan calls it exactly once. + mock_growatt_classic_api.device_list.reset_mock() + mock_growatt_classic_api.device_list.return_value = [ + {"deviceSn": "TLX123456", "deviceType": "tlx"}, + {"deviceSn": "TLX999999", "deviceType": "tlx"}, + ] + mock_growatt_classic_api.tlx_detail.return_value = { + "data": {"deviceSn": "TLX999999"} + } + + freezer.tick(DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # New device should be added via the classic scan path + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "TLX999999")}) + is not None + ) + assert "TLX999999" in mock_config_entry_classic.runtime_data.devices + + # The scan reuses the coordinator's API session — device_list must have been + # called exactly once (by the scan itself, not a fresh login+list cycle). + mock_growatt_classic_api.device_list.assert_called_once() + + +async def test_classic_api_stale_device_removed( + hass: HomeAssistant, + mock_growatt_classic_api, + mock_config_entry_classic: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that stale devices are removed during a Classic API device scan.""" + mock_growatt_classic_api.device_list.return_value = [ + {"deviceSn": "TLX123456", "deviceType": "tlx"} + ] + await setup_integration(hass, mock_config_entry_classic) + assert mock_config_entry_classic.state is ConfigEntryState.LOADED + + # Verify device exists after setup + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "TLX123456")}) + is not None + ) + assert "TLX123456" in mock_config_entry_classic.runtime_data.devices + + # Mock the device disappearing from the API + mock_growatt_classic_api.device_list.return_value = [] + + # Trigger the periodic device scan + freezer.tick(DEVICE_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # The device should be removed from HA + assert device_registry.async_get_device(identifiers={(DOMAIN, "TLX123456")}) is None + # The coordinator should be removed from runtime_data + assert "TLX123456" not in mock_config_entry_classic.runtime_data.devices From 5801fdad143674bbbb6c39e34b9ac54a252d315a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 25 May 2026 12:22:45 +0200 Subject: [PATCH 03/13] Add property in_zones to TrackerEntity (#171765) --- .../components/device_tracker/config_entry.py | 34 +++- .../device_tracker/test_config_entry.py | 186 +++++++++++++++++- 2 files changed, 210 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index d71ee7ce96e8a2..0d5a541553194c 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -6,6 +6,7 @@ from propcache.api import cached_property from homeassistant.components import zone +from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -207,6 +208,7 @@ class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True): CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = { + "in_zones", "latitude", "location_accuracy", "location_name", @@ -220,6 +222,7 @@ class TrackerEntity( """Base class for a tracked device.""" entity_description: TrackerEntityDescription + _attr_in_zones: list[str] | None = None _attr_latitude: float | None = None _attr_location_accuracy: float = 0 _attr_location_name: str | None = None @@ -239,6 +242,16 @@ def force_update(self) -> bool: """All updates need to be written to the state machine if we're not polling.""" return not self.should_poll + @cached_property + def in_zones(self) -> list[str] | None: + """Return the entity_id of zones the device is currently in. + + The list may be in any order; the base class sorts it by zone radius + and discards zones which do not exist. Ignored if latitude and + longitude are both set. + """ + return self._attr_in_zones + @cached_property def location_accuracy(self) -> float: """Return the location accuracy of the device. @@ -269,6 +282,20 @@ def _async_write_ha_state(self) -> None: self.__active_zone, self.__in_zones = zone.async_in_zones( self.hass, self.latitude, self.longitude, self.location_accuracy ) + elif (zones := self.in_zones) is not None: + zone_states = sorted( + ( + zone_state + for entity_id in zones + if (zone_state := self.hass.states.get(entity_id)) is not None + ), + key=lambda z: z.attributes[ATTR_RADIUS], + ) + self.__active_zone = next( + (z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)), + None, + ) + self.__in_zones = [z.entity_id for z in zone_states] else: self.__active_zone = None self.__in_zones = None @@ -280,7 +307,9 @@ def state(self) -> str | None: if self.location_name is not None: return self.location_name - if self.latitude is not None and self.longitude is not None: + if ( + self.latitude is not None and self.longitude is not None + ) or self.__in_zones is not None: zone_state = self.__active_zone if zone_state is None: state = STATE_NOT_HOME @@ -296,11 +325,10 @@ def state(self) -> str | None: @property def state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" - attr: dict[str, Any] = {ATTR_IN_ZONES: []} + attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []} attr.update(super().state_attributes) if self.latitude is not None and self.longitude is not None: - attr[ATTR_IN_ZONES] = self.__in_zones or [] attr[ATTR_LATITUDE] = self.latitude attr[ATTR_LONGITUDE] = self.longitude attr[ATTR_GPS_ACCURACY] = self.location_accuracy diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index cfefae958b412a..bd38b4d593ed82 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -21,7 +21,7 @@ ScannerEntity, TrackerEntity, ) -from homeassistant.components.zone import ATTR_RADIUS +from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -146,6 +146,7 @@ class MockTrackerEntity(TrackerEntity): def __init__( self, battery_level: int | None = None, + in_zones: list[str] | None = None, location_name: str | None = None, latitude: float | None = None, longitude: float | None = None, @@ -153,6 +154,7 @@ def __init__( ) -> None: """Initialize entity.""" self._battery_level = battery_level + self._in_zones = in_zones self._location_name = location_name self._latitude = latitude self._longitude = longitude @@ -171,6 +173,11 @@ def source_type(self) -> SourceType: """Return the source type, eg gps or router, of the device.""" return SourceType.GPS + @property + def in_zones(self) -> list[str] | None: + """Return the entity_id of zones the device is currently in.""" + return self._in_zones + @property def location_name(self) -> str | None: """Return a location name for the current location of the device.""" @@ -198,6 +205,12 @@ def battery_level_fixture() -> int | None: return None +@pytest.fixture(name="in_zones") +def in_zones_fixture() -> list[str] | None: + """Return the in_zones value of the entity for the test.""" + return None + + @pytest.fixture(name="location_name") def location_name_fixture() -> str | None: """Return the location_name of the entity for the test.""" @@ -226,6 +239,7 @@ def accuracy_fixture() -> float: def tracker_entity_fixture( entity_id: str, battery_level: int | None, + in_zones: list[str] | None, location_name: str | None, latitude: float | None, longitude: float | None, @@ -234,6 +248,7 @@ def tracker_entity_fixture( """Create a test tracker entity.""" entity = MockTrackerEntity( battery_level=battery_level, + in_zones=in_zones, location_name=location_name, latitude=latitude, longitude=longitude, @@ -464,6 +479,7 @@ async def test_load_unload_entry_tracker( @pytest.mark.parametrize( ( "battery_level", + "in_zones", "location_name", "latitude", "longitude", @@ -471,7 +487,8 @@ async def test_load_unload_entry_tracker( "expected_attributes", ), [ - ( + pytest.param( + None, None, None, 1.0, @@ -484,8 +501,10 @@ async def test_load_unload_entry_tracker( ATTR_LATITUDE: 1.0, ATTR_LONGITUDE: 2.0, }, + id="lat_long_no_zone", ), - ( + pytest.param( + None, None, None, 50.0, @@ -498,8 +517,10 @@ async def test_load_unload_entry_tracker( ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, }, + id="lat_long_home", ), - ( + pytest.param( + None, None, None, -50.0, @@ -512,8 +533,10 @@ async def test_load_unload_entry_tracker( ATTR_LATITUDE: -50.0, ATTR_LONGITUDE: -60.0, }, + id="lat_long_other_zone", ), - ( + pytest.param( + None, None, "zen_zone", None, @@ -523,8 +546,10 @@ async def test_load_unload_entry_tracker( ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_IN_ZONES: [], }, + id="location_name", ), - ( + pytest.param( + None, None, None, None, @@ -534,18 +559,154 @@ async def test_load_unload_entry_tracker( ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_IN_ZONES: [], }, + id="no_location", ), - ( + pytest.param( 100, None, None, None, + None, STATE_UNKNOWN, { ATTR_BATTERY_LEVEL: 100, ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_IN_ZONES: [], }, + id="battery_only", + ), + pytest.param( + None, + ["zone.home"], + None, + None, + None, + STATE_HOME, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: ["zone.home"], + }, + id="in_zones_home", + ), + pytest.param( + None, + ["zone.other_zone"], + None, + None, + None, + "other zone", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: ["zone.other_zone"], + }, + id="in_zones_other_zone", + ), + pytest.param( + None, + ["zone.other_zone_larger", "zone.other_zone"], + None, + None, + None, + "other zone", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: ["zone.other_zone", "zone.other_zone_larger"], + }, + id="in_zones_multiple_sorted_by_radius", + ), + pytest.param( + None, + ["zone.does_not_exist", "zone.other_zone"], + None, + None, + None, + "other zone", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: ["zone.other_zone"], + }, + id="in_zones_filters_missing_zones", + ), + pytest.param( + None, + ["zone.does_not_exist"], + None, + None, + None, + STATE_NOT_HOME, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: [], + }, + id="in_zones_all_missing", + ), + pytest.param( + None, + ["zone.passive_small", "zone.other_zone"], + None, + None, + None, + "other zone", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: ["zone.passive_small", "zone.other_zone"], + }, + id="in_zones_skips_passive_for_state", + ), + pytest.param( + None, + ["zone.passive_small"], + None, + None, + None, + STATE_NOT_HOME, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: ["zone.passive_small"], + }, + id="in_zones_only_passive", + ), + pytest.param( + None, + [], + None, + None, + None, + STATE_NOT_HOME, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: [], + }, + id="in_zones_empty", + ), + pytest.param( + None, + ["zone.home"], + None, + 1.0, + 2.0, + STATE_NOT_HOME, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_GPS_ACCURACY: 0, + ATTR_IN_ZONES: [], + ATTR_LATITUDE: 1.0, + ATTR_LONGITUDE: 2.0, + }, + id="in_zones_ignored_when_lat_long_set", + ), + pytest.param( + None, + ["zone.home"], + "zen_zone", + None, + None, + "zen_zone", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: ["zone.home"], + }, + id="location_name_wins_over_in_zones", ), ], ) @@ -575,6 +736,16 @@ async def test_tracker_entity_state( "0", {ATTR_LATITUDE: -50.0, ATTR_LONGITUDE: -60.0, ATTR_RADIUS: 500}, ) + hass.states.async_set( + "zone.passive_small", + "0", + { + ATTR_LATITUDE: 10.0, + ATTR_LONGITUDE: 10.0, + ATTR_RADIUS: 50, + ATTR_PASSIVE: True, + }, + ) await hass.async_block_till_done() # Write state again to ensure the zone state is taken into account. tracker_entity.async_write_ha_state() @@ -674,6 +845,7 @@ def test_tracker_entity() -> None: """Test coverage for base TrackerEntity class.""" entity = TrackerEntity() assert entity.source_type is SourceType.GPS + assert entity.in_zones is None assert entity.latitude is None assert entity.longitude is None assert entity.location_name is None From e507a97d8bb8cc60d2b9033c17f8a7c1ae8e65a3 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Mon, 25 May 2026 12:44:25 +0200 Subject: [PATCH 04/13] Bump blebox_uniapi to v2.5.4 (#172130) --- homeassistant/components/blebox/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index eb301bd926c351..a9e79ee7d2a981 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], - "requirements": ["blebox-uniapi==2.5.3"], + "requirements": ["blebox-uniapi==2.5.4"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6611495a3fe306..e61241f5f76a44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -660,7 +660,7 @@ bleak-retry-connector==4.6.1 bleak==3.0.2 # homeassistant.components.blebox -blebox-uniapi==2.5.3 +blebox-uniapi==2.5.4 # homeassistant.components.blink blinkpy==0.25.2 From 3ce33b0ac6f22c009bffb6fdb29ce22b27168c06 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 25 May 2026 12:56:19 +0200 Subject: [PATCH 05/13] Proxmox fix duplicate const (#171352) --- homeassistant/components/proxmoxve/__init__.py | 2 +- homeassistant/components/proxmoxve/config_flow.py | 2 +- homeassistant/components/proxmoxve/const.py | 2 -- homeassistant/components/proxmoxve/coordinator.py | 2 +- tests/components/proxmoxve/test_config_flow.py | 9 +++++++-- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index d7958871b2419d..2f969fae5bed77 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -9,6 +9,7 @@ CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_TOKEN, CONF_USERNAME, CONF_VERIFY_SSL, Platform, @@ -31,7 +32,6 @@ CONF_NODE, CONF_NODES, CONF_REALM, - CONF_TOKEN, CONF_TOKEN_ID, CONF_TOKEN_SECRET, CONF_VMS, diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py index 26b591f23c9b50..5c01fea7c8bd5e 100644 --- a/homeassistant/components/proxmoxve/config_flow.py +++ b/homeassistant/components/proxmoxve/config_flow.py @@ -15,6 +15,7 @@ CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_TOKEN, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -35,7 +36,6 @@ CONF_NODE, CONF_NODES, CONF_REALM, - CONF_TOKEN, CONF_TOKEN_ID, CONF_TOKEN_SECRET, CONF_VMS, diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index cad6eac2127411..7bd5d979a785a5 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -7,8 +7,6 @@ CONF_REALM = "realm" CONF_NODE = "node" CONF_NODES = "nodes" -# pylint: disable-next=home-assistant-duplicate-const -CONF_TOKEN = "token" CONF_TOKEN_ID = "token_id" CONF_TOKEN_SECRET = "token_value" CONF_VMS = "vms" diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index b901475268e8ae..7424880fd2f4d5 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -16,6 +16,7 @@ CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_TOKEN, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -26,7 +27,6 @@ from .common import sanitize_config_entry from .const import ( CONF_NODE, - CONF_TOKEN, CONF_TOKEN_ID, CONF_TOKEN_SECRET, DEFAULT_VERIFY_SSL, diff --git a/tests/components/proxmoxve/test_config_flow.py b/tests/components/proxmoxve/test_config_flow.py index 71bc95db6996f5..877bf92610fa13 100644 --- a/tests/components/proxmoxve/test_config_flow.py +++ b/tests/components/proxmoxve/test_config_flow.py @@ -13,13 +13,18 @@ from homeassistant.components.proxmoxve.const import ( CONF_NODE, CONF_NODES, - CONF_TOKEN, CONF_TOKEN_ID, CONF_TOKEN_SECRET, DOMAIN, ) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PORT, + CONF_TOKEN, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType From a2a381924173dca3e9262ac7bc0b0288eb297cd5 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 25 May 2026 12:57:05 +0200 Subject: [PATCH 06/13] Extract ProxmoxVE TOKEN_ID from full token string (#172129) --- homeassistant/components/proxmoxve/common.py | 5 ++++- tests/components/proxmoxve/test_config_flow.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py index 790f5ffa891dfb..32131808af7c94 100644 --- a/homeassistant/components/proxmoxve/common.py +++ b/homeassistant/components/proxmoxve/common.py @@ -5,7 +5,7 @@ from homeassistant.const import CONF_USERNAME -from .const import AUTH_OTHER, CONF_AUTH_METHOD, CONF_REALM +from .const import AUTH_OTHER, CONF_AUTH_METHOD, CONF_REALM, CONF_TOKEN_ID def sanitize_config_entry(input_data: Mapping[str, Any]) -> dict[str, Any]: @@ -21,4 +21,7 @@ def sanitize_config_entry(input_data: Mapping[str, Any]) -> dict[str, Any]: data[CONF_REALM] = realm data[CONF_USERNAME] = f"{username}@{realm}" + if CONF_TOKEN_ID in data and "!" in data[CONF_TOKEN_ID]: + data[CONF_TOKEN_ID] = data[CONF_TOKEN_ID].split("!")[1] + return data diff --git a/tests/components/proxmoxve/test_config_flow.py b/tests/components/proxmoxve/test_config_flow.py index 877bf92610fa13..a11c6637e5717b 100644 --- a/tests/components/proxmoxve/test_config_flow.py +++ b/tests/components/proxmoxve/test_config_flow.py @@ -62,6 +62,11 @@ CONF_TOKEN_SECRET: "test_token_secret", } +MOCK_USER_AUTH_STEP_TOKEN_FULL_ID = { + CONF_TOKEN_ID: "test_user@pam!test_token_id", + CONF_TOKEN_SECRET: "test_token_secret", +} + # Other authentication method (e.g. LDAP) with realm MOCK_USER_STEP_OTHER = { **MOCK_USER_STEP, @@ -97,6 +102,11 @@ [ (MOCK_USER_STEP, MOCK_USER_AUTH_STEP_PASSWORD, MOCK_TEST_CONFIG), (MOCK_USER_STEP_TOKEN, MOCK_USER_AUTH_STEP_TOKEN, MOCK_TEST_TOKEN_CONFIG), + ( + MOCK_USER_STEP_TOKEN, + MOCK_USER_AUTH_STEP_TOKEN_FULL_ID, + MOCK_TEST_TOKEN_CONFIG, + ), (MOCK_USER_STEP_OTHER, MOCK_USER_AUTH_STEP_OTHER, MOCK_TEST_OTHER_CONFIG), ( MOCK_USER_STEP_OTHER_TOKEN, From b6f69f6b9907f32472811413b872249819b09452 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 25 May 2026 13:48:49 +0200 Subject: [PATCH 07/13] Clean up should_expose in google assistant (#171937) --- .../components/cloud/google_config.py | 23 ++++++--------- .../components/google_assistant/helpers.py | 8 +++--- .../components/google_assistant/http.py | 14 ++++------ .../google_assistant/report_state.py | 2 +- tests/components/cloud/test_client.py | 5 ++-- tests/components/cloud/test_google_config.py | 28 +++++++------------ tests/components/google_assistant/__init__.py | 4 +-- .../components/google_assistant/test_http.py | 22 +-------------- .../google_assistant/test_smart_home.py | 6 ++-- 9 files changed, 37 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 3f2c8503c142ae..56e4a133236db0 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -275,9 +275,13 @@ async def on_hass_start(hass: HomeAssistant) -> None: ) ) - def should_expose(self, state: State) -> bool: - """If a state object should be exposed.""" - return self._should_expose_entity_id(state.entity_id) + def should_expose(self, entity_id: str) -> bool: + """If an entity should be exposed.""" + entity_filter: EntityFilter = self._config[CONF_FILTER] + if not entity_filter.empty_filter: + return entity_filter(entity_id) + + return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) def _should_expose_legacy(self, entity_id: str) -> bool: """If an entity ID should be exposed.""" @@ -308,14 +312,6 @@ def _should_expose_legacy(self, entity_id: str) -> bool: and _supported_legacy(self.hass, entity_id) ) - def _should_expose_entity_id(self, entity_id: str) -> bool: - """If an entity should be exposed.""" - entity_filter: EntityFilter = self._config[CONF_FILTER] - if not entity_filter.empty_filter: - return entity_filter(entity_id) - - return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) - @property def agent_user_id(self) -> str: """Return Agent User Id to use for query responses.""" @@ -467,7 +463,7 @@ def _handle_entity_registry_updated( entity_id = event.data["entity_id"] - if not self._should_expose_entity_id(entity_id): + if not self.should_expose(entity_id): return self.async_schedule_google_sync_all() @@ -490,8 +486,7 @@ def _handle_device_registry_updated( # Check if any exposed entity uses the device area if not any( - entity_entry.area_id is None - and self._should_expose_entity_id(entity_entry.entity_id) + entity_entry.area_id is None and self.should_expose(entity_entry.entity_id) for entity_entry in er.async_entries_for_device( er.async_get(self.hass), event.data["device_id"] ) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index d6b7c9c08fe215..ad36b4e83f77da 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -185,7 +185,7 @@ def get_agent_user_id_from_webhook(self, webhook_id): """ @abstractmethod - def should_expose(self, state) -> bool: + def should_expose(self, entity_id: str) -> bool: """Return if entity should be exposed.""" @abstractmethod @@ -532,7 +532,7 @@ def __init__( def __repr__(self) -> str: """Return the representation.""" - return f"" + return f"" @callback def traits(self) -> list[trait._Trait]: @@ -549,7 +549,7 @@ def traits(self) -> list[trait._Trait]: @callback def should_expose(self): """If entity should be exposed.""" - return self.config.should_expose(self.state) + return self.config.should_expose(self.entity_id) @callback def should_expose_local(self) -> bool: @@ -733,7 +733,7 @@ async def execute(self, data, command_payload): if not executed: raise SmartHomeError( ERR_FUNCTION_NOT_SUPPORTED, - f"Unable to execute {command} for {self.state.entity_id}", + f"Unable to execute {command} for {self.entity_id}", ) @callback diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index b98f82acd3cf9e..7b7e3f15fce9b6 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -12,7 +12,7 @@ from homeassistant.components import webhook from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -157,17 +157,13 @@ def get_agent_user_id_from_webhook(self, webhook_id): return None - def should_expose(self, state) -> bool: + def should_expose(self, entity_id: str) -> bool: """Return if entity should be exposed.""" expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS) - if state.attributes.get("view") is not None: - # Ignore entities that are views - return False - entity_registry = er.async_get(self.hass) - registry_entry = entity_registry.async_get(state.entity_id) + registry_entry = entity_registry.async_get(entity_id) if registry_entry: auxiliary_entity = ( registry_entry.entity_category is not None @@ -176,10 +172,10 @@ def should_expose(self, state) -> bool: else: auxiliary_entity = False - explicit_expose = self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE) + explicit_expose = self.entity_config.get(entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = ( - expose_by_default and state.domain in exposed_domains + expose_by_default and split_entity_id(entity_id)[0] in exposed_domains ) # Expose an entity by default if the entity's domain is exposed by default diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index c95defaddef6b3..75602562847ecd 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -73,7 +73,7 @@ def _async_entity_state_filter(data: EventStateChangedData) -> bool: return bool( hass.is_running and (new_state := data["new_state"]) - and google_config.should_expose(new_state) + and google_config.should_expose(new_state.entity_id) and async_get_google_entity_if_supported_cached( hass, google_config, new_state ) diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 97db3cd1b0a7da..32c9f3845a178f 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -377,14 +377,13 @@ async def test_google_config_expose_entity( ) cloud_client = hass.data[DATA_CLOUD].client - state = State(entity_entry.entity_id, "on") gconf = await cloud_client.get_google_config() - assert gconf.should_expose(state) + assert gconf.should_expose(entity_entry.entity_id) async_expose_entity(hass, "cloud.google_assistant", entity_entry.entity_id, False) - assert not gconf.should_expose(state) + assert not gconf.should_expose(entity_entry.entity_id) @pytest.mark.usefixtures("mock_cloud_setup", "mock_cloud_login") diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 3083c24a6137ea..ae8d9305c8245e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -27,7 +27,7 @@ EVENT_HOMEASSISTANT_STARTED, EntityCategory, ) -from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -431,32 +431,24 @@ async def test_google_config_expose_entity_prefs( expose_new(hass, True) expose_entity(hass, entity_entry5.entity_id, False) - state = State("light.kitchen", "on") - state_config = State(entity_entry1.entity_id, "on") - state_diagnostic = State(entity_entry2.entity_id, "on") - state_hidden_integration = State(entity_entry3.entity_id, "on") - state_hidden_user = State(entity_entry4.entity_id, "on") - state_not_exposed = State(entity_entry5.entity_id, "on") - state_exposed_default = State(entity_entry6.entity_id, "on") - # an entity which is not in the entity registry can be exposed expose_entity(hass, "light.kitchen", True) - assert mock_conf.should_expose(state) + assert mock_conf.should_expose("light.kitchen") # categorized and hidden entities should not be exposed - assert not mock_conf.should_expose(state_config) - assert not mock_conf.should_expose(state_diagnostic) - assert not mock_conf.should_expose(state_hidden_integration) - assert not mock_conf.should_expose(state_hidden_user) + assert not mock_conf.should_expose(entity_entry1.entity_id) + assert not mock_conf.should_expose(entity_entry2.entity_id) + assert not mock_conf.should_expose(entity_entry3.entity_id) + assert not mock_conf.should_expose(entity_entry4.entity_id) # this has been hidden - assert not mock_conf.should_expose(state_not_exposed) + assert not mock_conf.should_expose(entity_entry5.entity_id) # exposed by default - assert mock_conf.should_expose(state_exposed_default) + assert mock_conf.should_expose(entity_entry6.entity_id) expose_entity(hass, entity_entry5.entity_id, True) - assert mock_conf.should_expose(state_not_exposed) + assert mock_conf.should_expose(entity_entry5.entity_id) expose_entity(hass, entity_entry5.entity_id, None) - assert not mock_conf.should_expose(state_not_exposed) + assert not mock_conf.should_expose(entity_entry5.entity_id) @pytest.mark.usefixtures("mock_expired_cloud_login") diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 015c20e8393e29..c29adcffd8e29e 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -60,9 +60,9 @@ def get_agent_user_id_from_context(self, context): """Get agent user ID making request.""" return context.user_id - def should_expose(self, state): + def should_expose(self, entity_id): """Expose it all.""" - return self._should_expose is None or self._should_expose(state) + return self._should_expose is None or self._should_expose(entity_id) @property def should_report_state(self): diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index a162bca440de20..8544cd3475943d 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -14,7 +14,6 @@ from homeassistant.components.google_assistant import GOOGLE_ASSISTANT_SCHEMA from homeassistant.components.google_assistant.const import ( - DOMAIN, EVENT_COMMAND_RECEIVED, HOMEGRAPH_TOKEN_URL, REPORT_STATE_BASE_URL, @@ -29,7 +28,7 @@ async_get_users, ) from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -344,25 +343,6 @@ async def test_secure_device_pin_config(hass: HomeAssistant) -> None: assert config.secure_devices_pin == secure_pin -async def test_should_expose(hass: HomeAssistant) -> None: - """Test the google config should expose method.""" - config = GoogleConfig(hass, DUMMY_CONFIG) - await config.async_initialize() - - with patch.object(config, "async_call_homegraph_api"): - # Wait for google_assistant.helpers.async_initialize.sync_google to be called - await hass.async_block_till_done() - - assert ( - config.should_expose(State(DOMAIN + ".mock", "mock", {"view": "not None"})) - is False - ) - - with patch.object(config, "async_call_homegraph_api"): - # Wait for google_assistant.helpers.async_initialize.sync_google to be called - await hass.async_block_till_done() - - async def test_missing_service_account(hass: HomeAssistant) -> None: """Test the google config _async_request_sync_devices.""" incorrect_config = GOOGLE_ASSISTANT_SCHEMA( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index cb778cbeb675a6..081e987f3e993e 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -84,7 +84,7 @@ def registries( async def test_async_handle_message(hass: HomeAssistant) -> None: """Test the async handle message method.""" config = MockConfig( - should_expose=lambda state: state.entity_id != "light.not_expose", + should_expose=lambda entity_id: entity_id != "light.not_expose", entity_config={ "light.demo_light": { const.CONF_ROOM_HINT: "Living Room", @@ -172,7 +172,7 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: hass.states.async_set("light.not_expose", "on") config = MockConfig( - should_expose=lambda state: state.entity_id != "light.not_expose", + should_expose=lambda entity_id: entity_id != "light.not_expose", entity_config={ "light.demo_light": { const.CONF_ROOM_HINT: "Living Room", @@ -1392,7 +1392,7 @@ async def test_reachable_devices(hass: HomeAssistant) -> None: hass.states.async_set("lock.has_2fa", "on") config = MockConfig( - should_expose=lambda state: state.entity_id != "light.not_expose", + should_expose=lambda entity_id: entity_id != "light.not_expose", ) user_agent_id = "mock-user-id" From 422ea1a9b1b3f84b1e71b5074554073ec854bcbc Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 25 May 2026 16:13:38 +0200 Subject: [PATCH 08/13] Bump wakeonlan to 3.3.0 (#172150) --- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/wake_on_lan/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 5cf0d90cc4212e..60d9abcb35ccce 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", - "wakeonlan==3.1.0", + "wakeonlan==3.3.0", "async-upnp-client==0.46.2" ], "ssdp": [ diff --git a/homeassistant/components/wake_on_lan/manifest.json b/homeassistant/components/wake_on_lan/manifest.json index 4643ea4a4ff82a..c9547419f79835 100644 --- a/homeassistant/components/wake_on_lan/manifest.json +++ b/homeassistant/components/wake_on_lan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wake_on_lan", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wakeonlan==3.1.0"] + "requirements": ["wakeonlan==3.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e61241f5f76a44..31a63dcf52e883 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3311,7 +3311,7 @@ vtjp==0.2.1 # homeassistant.components.samsungtv # homeassistant.components.wake_on_lan -wakeonlan==3.1.0 +wakeonlan==3.3.0 # homeassistant.components.wallbox wallbox==0.9.0 From ec5210dca8ee232f48a40676e9c4399086250d71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 09:35:11 -0500 Subject: [PATCH 09/13] Bump led-ble to 1.1.11 (#172154) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 86120dbe9bd536..6489e7711e3f09 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -36,5 +36,5 @@ "documentation": "https://www.home-assistant.io/integrations/led_ble", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.29.18", "led-ble==1.1.8"] + "requirements": ["bluetooth-data-tools==1.29.18", "led-ble==1.1.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 31a63dcf52e883..822338a734cbb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1447,7 +1447,7 @@ ld2410-ble==0.1.1 leaone-ble==0.3.0 # homeassistant.components.led_ble -led-ble==1.1.8 +led-ble==1.1.11 # homeassistant.components.lektrico lektricowifi==0.1 From f5835f849a29ba880b9d434298a8512fa39e997f Mon Sep 17 00:00:00 2001 From: Mattias Arrelid Date: Mon, 25 May 2026 16:36:53 +0200 Subject: [PATCH 10/13] Update anyio to 4.13.0 (#172138) --- homeassistant/components/mcp_server/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index ca07e22e6808f1..d54fc33ebc506e 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.26.0", "aiohttp_sse==2.2.0", "anyio==4.10.0"], + "requirements": ["mcp==1.26.0", "aiohttp_sse==2.2.0", "anyio==4.13.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f4566293e856ff..1ccd14f87c606d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -113,7 +113,7 @@ uuid==1000000000.0.0 # even newer versions seem to introduce new issues, it's useful # for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.10.0 +anyio==4.13.0 h11==0.16.0 httpcore==1.0.9 diff --git a/requirements_all.txt b/requirements_all.txt index 822338a734cbb3..c55903c11b831c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -522,7 +522,7 @@ anthemav==1.4.1 anthropic==0.96.0 # homeassistant.components.mcp_server -anyio==4.10.0 +anyio==4.13.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fde45407929ca2..70914076b5de48 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -97,7 +97,7 @@ # even newer versions seem to introduce new issues, it's useful # for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.10.0 +anyio==4.13.0 h11==0.16.0 httpcore==1.0.9 From cf52a7a509b14658b638ed9c166e273e748d9614 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 10:10:54 -0500 Subject: [PATCH 11/13] Bump bluetooth-adapters to 2.2.0 (#172120) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b25f71d620dc2e..8618b75044d4fd 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "bleak==3.0.2", "bleak-retry-connector==4.6.1", - "bluetooth-adapters==2.1.0", + "bluetooth-adapters==2.2.0", "bluetooth-auto-recovery==1.6.4", "bluetooth-data-tools==1.29.18", "dbus-fast==5.0.9", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1ccd14f87c606d..6a49181ee54a0b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ awesomeversion==25.8.0 bcrypt==5.0.0 bleak-retry-connector==4.6.1 bleak==3.0.2 -bluetooth-adapters==2.1.0 +bluetooth-adapters==2.2.0 bluetooth-auto-recovery==1.6.4 bluetooth-data-tools==1.29.18 cached-ipaddress==1.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index c55903c11b831c..3abe40ebf1f8cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -675,7 +675,7 @@ bluecurrent-api==1.3.2 bluemaestro-ble==0.4.1 # homeassistant.components.bluetooth -bluetooth-adapters==2.1.0 +bluetooth-adapters==2.2.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.6.4 From 2f33b4b7f9d0cabc439391df0f5244973c7bcee3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 May 2026 10:16:47 -0500 Subject: [PATCH 12/13] Bump aioharmony to 1.0.8 (#172116) --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 014d492c3ff499..1d01a215614e77 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==1.0.3"], + "requirements": ["aioharmony==1.0.8"], "ssdp": [ { "deviceType": "urn:myharmony-com:device:harmony:1", diff --git a/requirements_all.txt b/requirements_all.txt index 3abe40ebf1f8cb..c377fe7f494ac9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -279,7 +279,7 @@ aiogithubapi==26.0.0 aioguardian==2026.01.1 # homeassistant.components.harmony -aioharmony==1.0.3 +aioharmony==1.0.8 # homeassistant.components.hassio aiohasupervisor==0.4.3 From 80cefc74ecd6f8d059eb51b3080d55efb59f3cf9 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 25 May 2026 17:17:53 +0200 Subject: [PATCH 13/13] Update rf-protocols to 4.0.0 (#172131) --- .../components/novy_cooker_hood/commands.py | 7 ---- .../novy_cooker_hood/config_flow.py | 7 ++-- .../components/novy_cooker_hood/fan.py | 16 ++++----- .../components/novy_cooker_hood/light.py | 15 ++++----- .../components/radio_frequency/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- tests/components/novy_cooker_hood/conftest.py | 33 +------------------ .../novy_cooker_hood/test_config_flow.py | 28 ++++++++-------- .../components/novy_cooker_hood/test_light.py | 12 +++---- 10 files changed, 40 insertions(+), 84 deletions(-) delete mode 100644 homeassistant/components/novy_cooker_hood/commands.py diff --git a/homeassistant/components/novy_cooker_hood/commands.py b/homeassistant/components/novy_cooker_hood/commands.py deleted file mode 100644 index 6e422f9ac28b3c..00000000000000 --- a/homeassistant/components/novy_cooker_hood/commands.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Command names for the Novy Cooker Hood RF codes.""" - -from typing import Final - -COMMAND_LIGHT: Final = "light" -COMMAND_PLUS: Final = "plus" -COMMAND_MINUS: Final = "minus" diff --git a/homeassistant/components/novy_cooker_hood/config_flow.py b/homeassistant/components/novy_cooker_hood/config_flow.py index 503033e7e69642..4d7d9b919e198c 100644 --- a/homeassistant/components/novy_cooker_hood/config_flow.py +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -3,7 +3,7 @@ import asyncio from typing import Any -from rf_protocols.codes.novy.cooker_hood import get_codes_for_code +from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton import voluptuous as vol from homeassistant.components.radio_frequency import ( @@ -19,7 +19,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector -from .commands import COMMAND_LIGHT from .const import ( CODE_MAX, CODE_MIN, @@ -128,10 +127,8 @@ async def async_step_test_light( ) -> ConfigFlowResult: """Toggle the hood light on then off so it ends in its starting state.""" assert self._transmitter_entity_id is not None + command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code) try: - command = await get_codes_for_code(self._code).async_load_command( - COMMAND_LIGHT - ) await async_send_command(self.hass, self._transmitter_entity_id, command) await asyncio.sleep(_TOGGLE_GAP) await async_send_command(self.hass, self._transmitter_entity_id, command) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index a6ef15e013e2f1..3243f8a7113cb6 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -3,7 +3,8 @@ import math from typing import Any -from rf_protocols.codes.novy.cooker_hood import get_codes_for_code +from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton +from rf_protocols.commands.novy import NovyCookerHoodCommand from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature from homeassistant.components.radio_frequency import async_send_command @@ -17,7 +18,6 @@ ranged_value_to_percentage, ) -from .commands import COMMAND_MINUS, COMMAND_PLUS from .const import SPEED_COUNT from .entity import NovyCookerHoodEntity @@ -49,7 +49,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the fan.""" super().__init__(entry) - self._codes = get_codes_for_code(entry.data[CONF_CODE]) + self._code: int = entry.data[CONF_CODE] self._level = 0 self._attr_unique_id = entry.entry_id @@ -103,7 +103,7 @@ async def async_set_percentage(self, percentage: int) -> None: async def async_increase_speed(self, percentage_step: int | None = None) -> None: """Bump speed up by N hardware levels (no recalibration).""" steps = self._steps_from_percentage(percentage_step) - plus = await self._codes.async_load_command(COMMAND_PLUS) + plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) for _ in range(steps): await self._async_send(plus) self._level = min(SPEED_COUNT, self._level + steps) @@ -112,7 +112,7 @@ async def async_increase_speed(self, percentage_step: int | None = None) -> None async def async_decrease_speed(self, percentage_step: int | None = None) -> None: """Bump speed down by N hardware levels (no recalibration).""" steps = self._steps_from_percentage(percentage_step) - minus = await self._codes.async_load_command(COMMAND_MINUS) + minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) for _ in range(steps): await self._async_send(minus) self._level = max(0, self._level - steps) @@ -127,17 +127,17 @@ def _steps_from_percentage(percentage_step: int | None) -> int: async def _async_set_level(self, level: int) -> None: """Reset to off with `SPEED_COUNT` minus presses, then climb to level.""" - minus = await self._codes.async_load_command(COMMAND_MINUS) + minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) for _ in range(SPEED_COUNT): await self._async_send(minus) if level > 0: - plus = await self._codes.async_load_command(COMMAND_PLUS) + plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) for _ in range(level): await self._async_send(plus) self._level = level self.async_write_ha_state() - async def _async_send(self, command: Any) -> None: + async def _async_send(self, command: NovyCookerHoodCommand) -> None: """Send a single RF command via the configured transmitter.""" await async_send_command( self.hass, self._transmitter, command, context=self._context diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py index 17f5ca6f045e1c..db31075de8c68a 100644 --- a/homeassistant/components/novy_cooker_hood/light.py +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -2,7 +2,7 @@ from typing import Any -from rf_protocols.codes.novy.cooker_hood import get_codes_for_code +from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton from homeassistant.components.light import ColorMode, LightEntity from homeassistant.components.radio_frequency import async_send_command @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .commands import COMMAND_LIGHT from .entity import NovyCookerHoodEntity PARALLEL_UPDATES = 1 @@ -37,7 +36,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the light.""" super().__init__(entry) - self._codes = get_codes_for_code(entry.data[CONF_CODE]) + self._code = entry.data[CONF_CODE] self._attr_unique_id = entry.entry_id async def async_added_to_hass(self) -> None: @@ -48,19 +47,19 @@ async def async_added_to_hass(self) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on by sending the toggle command.""" - await self._async_send_command(COMMAND_LIGHT) + await self._async_send_light() self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off by sending the toggle command.""" - await self._async_send_command(COMMAND_LIGHT) + await self._async_send_light() self._attr_is_on = False self.async_write_ha_state() - async def _async_send_command(self, name: str) -> None: - """Load the named command and send it via the configured transmitter.""" - command = await self._codes.async_load_command(name) + async def _async_send_light(self) -> None: + """Send the light toggle command via the configured transmitter.""" + command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code) await async_send_command( self.hass, self._transmitter, command, context=self._context ) diff --git a/homeassistant/components/radio_frequency/manifest.json b/homeassistant/components/radio_frequency/manifest.json index ca1cb17cf5f5a7..ede2f718b4db60 100644 --- a/homeassistant/components/radio_frequency/manifest.json +++ b/homeassistant/components/radio_frequency/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/radio_frequency", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["rf-protocols==3.2.0"] + "requirements": ["rf-protocols==4.0.0"] } diff --git a/requirements.txt b/requirements.txt index e411de7039cef8..3be402f06d17cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.34.2 -rf-protocols==3.2.0 +rf-protocols==4.0.0 securetar==2026.4.1 SQLAlchemy==2.0.49 standard-aifc==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index c377fe7f494ac9..9ccaf4f77381ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2874,7 +2874,7 @@ renson-endura-delta==1.7.2 reolink-aio==0.20.0 # homeassistant.components.radio_frequency -rf-protocols==3.2.0 +rf-protocols==4.0.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/tests/components/novy_cooker_hood/conftest.py b/tests/components/novy_cooker_hood/conftest.py index 9c4a99434ca406..f9a0b62cf3b12c 100644 --- a/tests/components/novy_cooker_hood/conftest.py +++ b/tests/components/novy_cooker_hood/conftest.py @@ -1,10 +1,6 @@ """Common fixtures for the Novy Cooker Hood tests.""" -from collections.abc import Iterator -from unittest.mock import AsyncMock, MagicMock, patch - import pytest -from rf_protocols.loader import CodeCollection from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN from homeassistant.const import CONF_CODE @@ -12,38 +8,11 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -from tests.components.radio_frequency.common import ( - MockRadioFrequencyCommand, - MockRadioFrequencyEntity, -) +from tests.components.radio_frequency.common import MockRadioFrequencyEntity TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter" -@pytest.fixture(autouse=True) -def mock_get_codes() -> Iterator[MagicMock]: - """Patch the bundled-codes loader so tests don't hit the filesystem.""" - fake_collection = MagicMock(spec=CodeCollection) - fake_collection.async_load_command = AsyncMock( - side_effect=lambda name: MockRadioFrequencyCommand() - ) - with ( - patch( - "homeassistant.components.novy_cooker_hood.light.get_codes_for_code", - return_value=fake_collection, - ), - patch( - "homeassistant.components.novy_cooker_hood.fan.get_codes_for_code", - return_value=fake_collection, - ), - patch( - "homeassistant.components.novy_cooker_hood.config_flow.get_codes_for_code", - return_value=fake_collection, - ), - ): - yield fake_collection - - @pytest.fixture def mock_config_entry( mock_rf_entity: MockRadioFrequencyEntity, diff --git a/tests/components/novy_cooker_hood/test_config_flow.py b/tests/components/novy_cooker_hood/test_config_flow.py index d74dd55cf5d708..85bc94d2f9d91b 100644 --- a/tests/components/novy_cooker_hood/test_config_flow.py +++ b/tests/components/novy_cooker_hood/test_config_flow.py @@ -1,11 +1,11 @@ """Test the Novy Hood config flow.""" from collections.abc import Iterator -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest +from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton -from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -49,7 +49,6 @@ async def _start_user_flow(hass: HomeAssistant, code: str = "1") -> dict: async def test_user_flow_test_then_finish( hass: HomeAssistant, - mock_get_codes: MagicMock, mock_rf_entity: MockRadioFrequencyEntity, entity_registry: er.EntityRegistry, ) -> None: @@ -58,8 +57,10 @@ async def test_user_flow_test_then_finish( assert result["type"] is FlowResultType.MENU assert result["step_id"] == "test_light" - mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT) assert len(mock_rf_entity.send_command_calls) == 2 + sent = mock_rf_entity.send_command_calls[0].command + assert sent.key == NovyCookerHoodButton.LIGHT.code + assert sent.channel == 3 result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": "finish"} @@ -77,7 +78,6 @@ async def test_user_flow_test_then_finish( async def test_user_flow_retry_picks_different_code( hass: HomeAssistant, - mock_get_codes: MagicMock, mock_rf_entity: MockRadioFrequencyEntity, entity_registry: er.EntityRegistry, ) -> None: @@ -99,9 +99,13 @@ async def test_user_flow_retry_picks_different_code( }, ) assert result["type"] is FlowResultType.MENU - # One load per test x two tests; two sends per test x two tests. - assert mock_get_codes.async_load_command.await_count == 2 assert len(mock_rf_entity.send_command_calls) == 4 + assert [c.command.channel for c in mock_rf_entity.send_command_calls] == [ + 1, + 1, + 7, + 7, + ] result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": "finish"} @@ -127,7 +131,6 @@ async def test_user_flow_test_transmit_failure( async def test_recover_after_transmit_failure( hass: HomeAssistant, - mock_get_codes: MagicMock, mock_rf_entity: MockRadioFrequencyEntity, ) -> None: """The user can Retry from test_failed and complete the flow.""" @@ -183,7 +186,6 @@ async def test_unique_id_already_configured( async def test_same_transmitter_different_code_is_allowed( hass: HomeAssistant, - mock_get_codes: MagicMock, mock_config_entry: MockConfigEntry, mock_rf_entity: MockRadioFrequencyEntity, entity_registry: er.EntityRegistry, @@ -205,7 +207,6 @@ async def test_same_transmitter_different_code_is_allowed( async def test_reconfigure_updates_entry( hass: HomeAssistant, - mock_get_codes: MagicMock, init_novy_cooker_hood: MockConfigEntry, mock_rf_entity: MockRadioFrequencyEntity, entity_registry: er.EntityRegistry, @@ -224,7 +225,9 @@ async def test_reconfigure_updates_entry( ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "test_light" - mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT) + sent = mock_rf_entity.send_command_calls[-1].command + assert sent.key == NovyCookerHoodButton.LIGHT.code + assert sent.channel == 4 result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": "finish"} @@ -239,7 +242,6 @@ async def test_reconfigure_updates_entry( async def test_reconfigure_frees_old_unique_id( hass: HomeAssistant, - mock_get_codes: MagicMock, init_novy_cooker_hood: MockConfigEntry, mock_rf_entity: MockRadioFrequencyEntity, ) -> None: @@ -295,7 +297,6 @@ async def test_reconfigure_aborts_on_collision( async def test_reconfigure_retry_returns_to_picker( hass: HomeAssistant, - mock_get_codes: MagicMock, init_novy_cooker_hood: MockConfigEntry, mock_rf_entity: MockRadioFrequencyEntity, ) -> None: @@ -326,7 +327,6 @@ async def test_no_transmitters(hass: HomeAssistant) -> None: async def test_recover_after_no_transmitters( hass: HomeAssistant, - mock_get_codes: MagicMock, ) -> None: """User can re-init the flow after the radio_frequency integration loads.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/novy_cooker_hood/test_light.py b/tests/components/novy_cooker_hood/test_light.py index 6fc34e20c2f780..f117f76b1f2f14 100644 --- a/tests/components/novy_cooker_hood/test_light.py +++ b/tests/components/novy_cooker_hood/test_light.py @@ -1,13 +1,12 @@ """Tests for the Novy Hood light platform.""" -from unittest.mock import MagicMock, call +from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, @@ -28,7 +27,6 @@ async def test_turn_on_and_off_send_light_once_each( hass: HomeAssistant, - mock_get_codes: MagicMock, mock_rf_entity: MockRadioFrequencyEntity, init_novy_cooker_hood: MockConfigEntry, ) -> None: @@ -66,11 +64,11 @@ async def test_turn_on_and_off_send_light_once_each( state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_OFF - assert mock_get_codes.async_load_command.await_args_list == [ - call(COMMAND_LIGHT), - call(COMMAND_LIGHT), - ] assert len(mock_rf_entity.send_command_calls) == 2 + assert [c.command.key for c in mock_rf_entity.send_command_calls] == [ + NovyCookerHoodButton.LIGHT.code, + NovyCookerHoodButton.LIGHT.code, + ] async def test_restore_state(