From 1ecbc44368344c13c3b441ec1f40b25581140b0d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 25 Mar 2026 19:47:57 +0100 Subject: [PATCH 0001/1707] Improve KNX tests and avoid dns lookups (#166508) --- tests/components/knx/test_config_flow.py | 53 +++++++++++++++--------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 6457d099eb26f1..a4171e73be2d21 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1,9 +1,10 @@ """Test the KNX config flow.""" from contextlib import contextmanager -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from xknx.exceptions import XKNXException from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.io.gateway_scanner import GatewayDescriptor @@ -60,6 +61,12 @@ GATEWAY_INDIVIDUAL_ADDRESS = IndividualAddress("1.0.0") +async def _mock_validate_ip_for_invalid_local(ip_address: str) -> str: + if ip_address in {"no_local_ip", "asdf"}: + raise XKNXException + return ip_address + + @pytest.fixture(name="knx_setup") def fixture_knx_setup(): """Mock KNX entry setup.""" @@ -238,15 +245,19 @@ async def test_routing_setup_advanced( assert result["errors"] == {"base": "no_router_discovered"} # invalid user input - result_invalid_input = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_KNX_MCAST_GRP: "10.1.2.3", # no valid multicast group - CONF_KNX_MCAST_PORT: 3675, - CONF_KNX_INDIVIDUAL_ADDRESS: "not_a_valid_address", - CONF_KNX_LOCAL_IP: "no_local_ip", - }, - ) + with patch( + "homeassistant.components.knx.config_flow.xknx_validate_ip", + new=AsyncMock(side_effect=_mock_validate_ip_for_invalid_local), + ): + result_invalid_input = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_MCAST_GRP: "10.1.2.3", # no valid multicast group + CONF_KNX_MCAST_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "not_a_valid_address", + CONF_KNX_LOCAL_IP: "no_local_ip", + }, + ) assert result_invalid_input["type"] is FlowResultType.FORM assert result_invalid_input["step_id"] == "routing" assert result_invalid_input["errors"] == { @@ -751,15 +762,19 @@ async def test_tunneling_setup_for_local_ip( "base": "no_tunnel_discovered", } # invalid local ip address - result_invalid_local = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, - CONF_HOST: "192.168.0.2", - CONF_PORT: 3675, - CONF_KNX_LOCAL_IP: "asdf", - }, - ) + with patch( + "homeassistant.components.knx.config_flow.xknx_validate_ip", + new=AsyncMock(side_effect=_mock_validate_ip_for_invalid_local), + ): + result_invalid_local = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.0.2", + CONF_PORT: 3675, + CONF_KNX_LOCAL_IP: "asdf", + }, + ) assert result_invalid_local["type"] is FlowResultType.FORM assert result_invalid_local["step_id"] == "manual_tunnel" assert result_invalid_local["errors"] == { From fabbfd93dfd313f9dee15d4c0609447b4e683698 Mon Sep 17 00:00:00 2001 From: Leon Grave Date: Wed, 25 Mar 2026 19:49:08 +0100 Subject: [PATCH 0002/1707] Add dynamic devices to freshr (#165942) --- homeassistant/components/freshr/__init__.py | 35 ++++++++- .../components/freshr/coordinator.py | 24 +++++- .../components/freshr/quality_scale.yaml | 8 +- homeassistant/components/freshr/sensor.py | 57 +++++++++----- tests/components/freshr/conftest.py | 1 + tests/components/freshr/test_init.py | 56 +++++++++++++- tests/components/freshr/test_sensor.py | 77 ++++++++++++++++++- 7 files changed, 223 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/freshr/__init__.py b/homeassistant/components/freshr/__init__.py index 52d62cff7589e4..7d9938dcd4cdf0 100644 --- a/homeassistant/components/freshr/__init__.py +++ b/homeassistant/components/freshr/__init__.py @@ -3,7 +3,7 @@ import asyncio from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from .coordinator import ( FreshrConfigEntry, @@ -21,10 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo await devices_coordinator.async_config_entry_first_refresh() readings: dict[str, FreshrReadingsCoordinator] = { - device.id: FreshrReadingsCoordinator( + device_id: FreshrReadingsCoordinator( hass, entry, device, devices_coordinator.client ) - for device in devices_coordinator.data + for device_id, device in devices_coordinator.data.items() } await asyncio.gather( *( @@ -38,6 +38,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo readings=readings, ) + known_devices: set[str] = set(readings) + + @callback + def _handle_coordinator_update() -> None: + current = set(devices_coordinator.data) + removed_ids = known_devices - current + if removed_ids: + known_devices.difference_update(removed_ids) + for device_id in removed_ids: + entry.runtime_data.readings.pop(device_id, None) + new_ids = current - known_devices + if not new_ids: + return + known_devices.update(new_ids) + for device_id in new_ids: + device = devices_coordinator.data[device_id] + readings_coordinator = FreshrReadingsCoordinator( + hass, entry, device, devices_coordinator.client + ) + entry.runtime_data.readings[device_id] = readings_coordinator + hass.async_create_task( + readings_coordinator.async_refresh(), + name=f"freshr_readings_refresh_{device_id}", + ) + + entry.async_on_unload( + devices_coordinator.async_add_listener(_handle_coordinator_update) + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True diff --git a/homeassistant/components/freshr/coordinator.py b/homeassistant/components/freshr/coordinator.py index 3f68f218687b87..133e1f03f115d5 100644 --- a/homeassistant/components/freshr/coordinator.py +++ b/homeassistant/components/freshr/coordinator.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -32,7 +33,7 @@ class FreshrData: type FreshrConfigEntry = ConfigEntry[FreshrData] -class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]): +class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]): """Coordinator that refreshes the device list once an hour.""" config_entry: FreshrConfigEntry @@ -48,7 +49,7 @@ def __init__(self, hass: HomeAssistant, config_entry: FreshrConfigEntry) -> None ) self.client = FreshrClient(session=async_create_clientsession(hass)) - async def _async_update_data(self) -> list[DeviceSummary]: + async def _async_update_data(self) -> dict[str, DeviceSummary]: """Fetch the list of devices from the Fresh-r API.""" username = self.config_entry.data[CONF_USERNAME] password = self.config_entry.data[CONF_PASSWORD] @@ -68,8 +69,23 @@ async def _async_update_data(self) -> list[DeviceSummary]: translation_domain=DOMAIN, translation_key="cannot_connect", ) from err - else: - return devices + + current = {device.id: device for device in devices} + + if self.data is not None: + stale_ids = set(self.data) - set(current) + if stale_ids: + device_registry = dr.async_get(self.hass) + for device_id in stale_ids: + if device := device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + return current class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]): diff --git a/homeassistant/components/freshr/quality_scale.yaml b/homeassistant/components/freshr/quality_scale.yaml index f8d2b1a97d7306..c8c60a6330cabf 100644 --- a/homeassistant/components/freshr/quality_scale.yaml +++ b/homeassistant/components/freshr/quality_scale.yaml @@ -45,7 +45,9 @@ rules: discovery-update-info: status: exempt comment: Integration connects to a cloud service; no local network discovery is possible. - discovery: todo + discovery: + status: exempt + comment: No local network discovery of devices is possible (no zeroconf, mdns or other discovery mechanisms). docs-data-update: done docs-examples: done docs-known-limitations: done @@ -53,7 +55,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 @@ -64,7 +66,7 @@ rules: repair-issues: status: exempt comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/freshr/sensor.py b/homeassistant/components/freshr/sensor.py index 210c3fccf08bdc..a943ecacabbec4 100644 --- a/homeassistant/components/freshr/sensor.py +++ b/homeassistant/components/freshr/sensor.py @@ -20,7 +20,7 @@ UnitOfTemperature, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -112,26 +112,43 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Fresh-r sensors from a config entry.""" - entities: list[FreshrSensor] = [] - for device in config_entry.runtime_data.devices.data: - descriptions = SENSOR_TYPES.get( - device.device_type, SENSOR_TYPES[DeviceType.FRESH_R] - ) - device_info = DeviceInfo( - identifiers={(DOMAIN, device.id)}, - name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"), - serial_number=device.id, - manufacturer="Fresh-r", - ) - entities.extend( - FreshrSensor( - config_entry.runtime_data.readings[device.id], - description, - device_info, + coordinator = config_entry.runtime_data.devices + known_devices: set[str] = set() + + @callback + def _check_devices() -> None: + current = set(coordinator.data) + removed_ids = known_devices - current + if removed_ids: + known_devices.difference_update(removed_ids) + new_ids = current - known_devices + if not new_ids: + return + known_devices.update(new_ids) + entities: list[FreshrSensor] = [] + for device_id in new_ids: + device = coordinator.data[device_id] + descriptions = SENSOR_TYPES.get( + device.device_type, SENSOR_TYPES[DeviceType.FRESH_R] ) - for description in descriptions - ) - async_add_entities(entities) + device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"), + serial_number=device_id, + manufacturer="Fresh-r", + ) + entities.extend( + FreshrSensor( + config_entry.runtime_data.readings[device_id], + description, + device_info, + ) + for description in descriptions + ) + async_add_entities(entities) + + _check_devices() + config_entry.async_on_unload(coordinator.async_add_listener(_check_devices)) class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity): diff --git a/tests/components/freshr/conftest.py b/tests/components/freshr/conftest.py index cceca7966cdf42..85be3bedb812b2 100644 --- a/tests/components/freshr/conftest.py +++ b/tests/components/freshr/conftest.py @@ -40,6 +40,7 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, data={CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass"}, unique_id="test-user", + entry_id="01JKRA6QKPBE00ZZ9BKWDB3CTB", ) diff --git a/tests/components/freshr/test_init.py b/tests/components/freshr/test_init.py index 9acb546b270a0d..e89bf80c64bf40 100644 --- a/tests/components/freshr/test_init.py +++ b/tests/components/freshr/test_init.py @@ -1,14 +1,22 @@ """Test the Fresh-r initialization.""" from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory from pyfreshr.exceptions import ApiResponseError, LoginError import pytest +from homeassistant.components.freshr.const import DOMAIN +from homeassistant.components.freshr.coordinator import ( + DEVICES_SCAN_INTERVAL, + READINGS_SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import DEVICE_ID, MagicMock, MockConfigEntry -from .conftest import MagicMock, MockConfigEntry +from tests.common import async_fire_time_changed @pytest.mark.usefixtures("init_integration") @@ -64,3 +72,47 @@ async def test_setup_no_devices( er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) == [] ) + + +@pytest.mark.usefixtures("init_integration") +async def test_stale_device_removed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_freshr_client: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a device absent from a successful poll is removed from the registry.""" + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + + mock_freshr_client.fetch_devices.return_value = [] + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) is None + + call_count = mock_freshr_client.fetch_device_current.call_count + freezer.tick(READINGS_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_freshr_client.fetch_device_current.call_count == call_count + + +@pytest.mark.usefixtures("init_integration") +async def test_stale_device_not_removed_on_poll_error( + hass: HomeAssistant, + mock_freshr_client: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a device is not removed when the devices poll fails.""" + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + + mock_freshr_client.fetch_devices.side_effect = ApiResponseError("cloud error") + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) diff --git a/tests/components/freshr/test_sensor.py b/tests/components/freshr/test_sensor.py index 9ee1a23df16ea7..6e33c90753a776 100644 --- a/tests/components/freshr/test_sensor.py +++ b/tests/components/freshr/test_sensor.py @@ -5,16 +5,19 @@ from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from pyfreshr.exceptions import ApiResponseError -from pyfreshr.models import DeviceReadings +from pyfreshr.models import DeviceReadings, DeviceSummary import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.freshr.const import DOMAIN -from homeassistant.components.freshr.coordinator import READINGS_SCAN_INTERVAL +from homeassistant.components.freshr.coordinator import ( + DEVICES_SCAN_INTERVAL, + READINGS_SCAN_INTERVAL, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import DEVICE_ID +from .conftest import DEVICE_ID, MOCK_DEVICE_CURRENT from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -82,3 +85,71 @@ async def test_readings_connection_error_makes_unavailable( state = hass.states.get("sensor.fresh_r_inside_temperature") assert state is not None assert state.state == "unavailable" + + +DEVICE_ID_2 = "SN002" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_device_reappears_after_removal( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_freshr_client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that entities are re-created when a previously removed device reappears.""" + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + + # Device disappears from the account + mock_freshr_client.fetch_devices.return_value = [] + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) is None + + # Device reappears + mock_freshr_client.fetch_devices.return_value = [DeviceSummary(id=DEVICE_ID)] + mock_freshr_client.fetch_device_current.return_value = MOCK_DEVICE_CURRENT + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + t1_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{DEVICE_ID}_t1" + ) + assert t1_entity_id + assert hass.states.get(t1_entity_id).state != "unavailable" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_dynamic_device_added( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_freshr_client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that sensors are created for a device that appears after initial setup.""" + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID_2)}) is None + + mock_freshr_client.fetch_devices.return_value = [ + DeviceSummary(id=DEVICE_ID), + DeviceSummary(id=DEVICE_ID_2), + ] + mock_freshr_client.fetch_device_current.return_value = MOCK_DEVICE_CURRENT + freezer.tick(DEVICES_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID_2)}) + t1_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{DEVICE_ID_2}_t1" + ) + assert t1_entity_id + assert entity_registry.async_get_entity_id("sensor", DOMAIN, f"{DEVICE_ID_2}_co2") + assert hass.states.get(t1_entity_id).state != "unavailable" From bd298e92d09d44597869b73caf4cea1910520e94 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 25 Mar 2026 19:55:59 +0100 Subject: [PATCH 0003/1707] Rework patching and handling of client runner in arcam (#165747) --- .../components/arcam_fmj/__init__.py | 37 +++++----- .../components/arcam_fmj/coordinator.py | 40 +++++++---- homeassistant/components/arcam_fmj/entity.py | 5 ++ tests/components/arcam_fmj/conftest.py | 72 +++++++++++++------ .../components/arcam_fmj/test_media_player.py | 17 ++++- 5 files changed, 113 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index df088738a649a6..f389bc55a2b98b 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -2,8 +2,8 @@ import asyncio from asyncio import timeout +from contextlib import AsyncExitStack import logging -from typing import Any from arcam.fmj import ConnectionFailed from arcam.fmj.client import Client @@ -54,36 +54,31 @@ async def _run_client( client = runtime_data.client coordinators = runtime_data.coordinators - def _listen(_: Any) -> None: - for coordinator in coordinators.values(): - coordinator.async_notify_data_updated() - while True: try: - async with timeout(interval): - await client.start() - - _LOGGER.debug("Client connected %s", client.host) + async with AsyncExitStack() as stack: + async with timeout(interval): + await client.start() + stack.push_async_callback(client.stop) - try: - for coordinator in coordinators.values(): - await coordinator.state.start() + _LOGGER.debug("Client connected %s", client.host) - with client.listen(_listen): + try: for coordinator in coordinators.values(): - coordinator.async_notify_connected() - await client.process() - finally: - await client.stop() + await stack.enter_async_context( + coordinator.async_monitor_client() + ) - _LOGGER.debug("Client disconnected %s", client.host) - for coordinator in coordinators.values(): - coordinator.async_notify_disconnected() + await client.process() + finally: + _LOGGER.debug("Client disconnected %s", client.host) except ConnectionFailed: - await asyncio.sleep(interval) + pass except TimeoutError: continue except Exception: _LOGGER.exception("Unexpected exception, aborting arcam client") return + + await asyncio.sleep(interval) diff --git a/homeassistant/components/arcam_fmj/coordinator.py b/homeassistant/components/arcam_fmj/coordinator.py index 83faef37d10f4c..39b3f28fc684e4 100644 --- a/homeassistant/components/arcam_fmj/coordinator.py +++ b/homeassistant/components/arcam_fmj/coordinator.py @@ -2,11 +2,13 @@ from __future__ import annotations +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from dataclasses import dataclass import logging from arcam.fmj import ConnectionFailed -from arcam.fmj.client import Client +from arcam.fmj.client import AmxDuetResponse, Client, ResponsePacket from arcam.fmj.state import State from homeassistant.config_entries import ConfigEntry @@ -51,7 +53,7 @@ def __init__( ) self.client = client self.state = State(client, zone) - self.last_update_success = False + self.update_in_progress = False name = config_entry.title unique_id = config_entry.unique_id or config_entry.entry_id @@ -74,24 +76,34 @@ def __init__( async def _async_update_data(self) -> None: """Fetch data for manual refresh.""" try: + self.update_in_progress = True await self.state.update() except ConnectionFailed as err: raise UpdateFailed( f"Connection failed during update for zone {self.state.zn}" ) from err + finally: + self.update_in_progress = False @callback - def async_notify_data_updated(self) -> None: - """Notify that new data has been received from the device.""" - self.async_set_updated_data(None) + def _async_notify_packet(self, packet: ResponsePacket | AmxDuetResponse) -> None: + """Packet callback to detect changes to state.""" + if ( + not isinstance(packet, ResponsePacket) + or packet.zn != self.state.zn + or self.update_in_progress + ): + return - @callback - def async_notify_connected(self) -> None: - """Handle client connected.""" - self.hass.async_create_task(self.async_refresh()) - - @callback - def async_notify_disconnected(self) -> None: - """Handle client disconnected.""" - self.last_update_success = False self.async_update_listeners() + + @asynccontextmanager + async def async_monitor_client(self) -> AsyncGenerator[None]: + """Monitor a client and state for changes while connected.""" + async with self.state: + self.hass.async_create_task(self.async_refresh()) + try: + with self.client.listen(self._async_notify_packet): + yield + finally: + self.hass.async_create_task(self.async_refresh()) diff --git a/homeassistant/components/arcam_fmj/entity.py b/homeassistant/components/arcam_fmj/entity.py index 6d635a5f1c5048..cf97ef32c38649 100644 --- a/homeassistant/components/arcam_fmj/entity.py +++ b/homeassistant/components/arcam_fmj/entity.py @@ -26,3 +26,8 @@ def __init__( if description is not None: self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" self.entity_description = description + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.client.connected diff --git a/tests/components/arcam_fmj/conftest.py b/tests/components/arcam_fmj/conftest.py index f11a1c3002f712..1fc6e6b607e48b 100644 --- a/tests/components/arcam_fmj/conftest.py +++ b/tests/components/arcam_fmj/conftest.py @@ -1,15 +1,17 @@ """Tests for the arcam_fmj component.""" -from collections.abc import AsyncGenerator -from unittest.mock import Mock, patch +from asyncio import CancelledError, Queue +from collections.abc import AsyncGenerator, Generator +from contextlib import contextmanager +from unittest.mock import AsyncMock, Mock, patch -from arcam.fmj.client import Client +from arcam.fmj.client import Client, ResponsePacket from arcam.fmj.state import State import pytest from homeassistant.components.arcam_fmj.const import DEFAULT_NAME from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -28,12 +30,50 @@ @pytest.fixture(name="client") -def client_fixture() -> Mock: +def client_fixture() -> Generator[Mock]: """Get a mocked client.""" client = Mock(Client) client.host = MOCK_HOST client.port = MOCK_PORT - return client + + queue = Queue[BaseException | None]() + listeners = set() + + async def _start(): + client.connected = True + + async def _process(): + result = await queue.get() + client.connected = False + if isinstance(result, BaseException): + raise result + + @contextmanager + def _listen(listener): + listeners.add(listener) + yield client + listeners.remove(listener) + + @callback + def _notify_data_updated(zn=1): + packet = Mock(ResponsePacket) + packet.zn = zn + for listener in listeners: + listener(packet) + + @callback + def _notify_connection(exception: Exception | None = None): + queue.put_nowait(exception) + + client.start.side_effect = _start + client.process.side_effect = _process + client.listen.side_effect = _listen + client.notify_data_updated = _notify_data_updated + client.notify_connection = _notify_connection + + yield client + + queue.put_nowait(CancelledError()) @pytest.fixture(name="state_1") @@ -52,6 +92,8 @@ def state_1_fixture(client: Mock) -> State: state.get_mute.return_value = None state.get_decode_modes.return_value = [] state.get_decode_mode.return_value = None + state.__aenter__ = AsyncMock() + state.__aexit__ = AsyncMock() return state @@ -71,6 +113,8 @@ def state_2_fixture(client: Mock) -> State: state.get_mute.return_value = None state.get_decode_modes.return_value = [] state.get_decode_mode.return_value = None + state.__aenter__ = AsyncMock() + state.__aexit__ = AsyncMock() return state @@ -104,18 +148,6 @@ def state_mock(cli, zone): return state_2 raise ValueError(f"Unknown player zone: {zone}") - async def _mock_run_client(hass: HomeAssistant, runtime_data, interval): - coordinators = runtime_data.coordinators - - def _notify_data_updated() -> None: - for coordinator in coordinators.values(): - coordinator.async_notify_data_updated() - - client.notify_data_updated = _notify_data_updated - - for coordinator in coordinators.values(): - coordinator.async_notify_connected() - await async_setup_component(hass, "homeassistant", {}) with ( @@ -124,10 +156,6 @@ def _notify_data_updated() -> None: "homeassistant.components.arcam_fmj.coordinator.State", side_effect=state_mock, ), - patch( - "homeassistant.components.arcam_fmj._run_client", - side_effect=_mock_run_client, - ), ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/arcam_fmj/test_media_player.py b/tests/components/arcam_fmj/test_media_player.py index b1a7468fb46719..d14fb8fc2f63b0 100644 --- a/tests/components/arcam_fmj/test_media_player.py +++ b/tests/components/arcam_fmj/test_media_player.py @@ -33,7 +33,7 @@ SERVICE_VOLUME_UP, MediaType, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, State as CoreState from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -62,6 +62,21 @@ async def test_setup( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("player_setup") +async def test_disconnect(hass: HomeAssistant, client: Mock) -> None: + """Test a disconnection is detected.""" + data = hass.states.get(MOCK_ENTITY_ID) + assert data + assert data.state != STATE_UNAVAILABLE + + client.notify_connection(ConnectionFailed()) + await hass.async_block_till_done() + + data = hass.states.get(MOCK_ENTITY_ID) + assert data + assert data.state == STATE_UNAVAILABLE + + async def update(hass: HomeAssistant, client: Mock, entity_id: str) -> CoreState: """Force a update of player and return current state data.""" client.notify_data_updated() From 599f4f01d08dee447f1e28c8ff93c8ab90a61e01 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Wed, 25 Mar 2026 19:58:18 +0100 Subject: [PATCH 0004/1707] Add HmIP-FLC support to HomematicIP Cloud (#165827) --- .../homematicip_cloud/binary_sensor.py | 78 +++++++++++- .../components/homematicip_cloud/button.py | 28 ++++- .../components/homematicip_cloud/conftest.py | 119 ++++++++++++++++++ tests/components/homematicip_cloud/helper.py | 17 ++- .../homematicip_cloud/test_binary_sensor.py | 45 +++++++ .../homematicip_cloud/test_button.py | 40 ++++++ 6 files changed, 323 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 6b8aa341ddac3d..d3b164209cebe5 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -4,7 +4,7 @@ from typing import Any -from homematicip.base.enums import SmokeDetectorAlarmType, WindowState +from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState from homematicip.base.functionalChannels import MultiModeInputChannel from homematicip.device import ( AccelerationSensor, @@ -74,6 +74,30 @@ } +def _is_full_flush_lock_controller(device: object) -> bool: + """Return whether the device is an HmIP-FLC.""" + return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( + device, "functionalChannels" + ) + + +def _get_channel_by_role( + device: object, + functional_channel_type: str, + channel_role: str, +) -> object | None: + """Return the matching functional channel for the device.""" + for channel in getattr(device, "functionalChannels", []): + channel_type = getattr(channel, "functionalChannelType", None) + channel_type_name = getattr(channel_type, "name", channel_type) + if channel_type_name != functional_channel_type: + continue + if getattr(channel, "channelRole", None) != channel_role: + continue + return channel + return None + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomematicIPConfigEntry, @@ -122,6 +146,9 @@ async def async_setup_entry( entities.append( HomematicipPluggableMainsFailureSurveillanceSensor(hap, device) ) + if _is_full_flush_lock_controller(device): + entities.append(HomematicipFullFlushLockControllerLocked(hap, device)) + entities.append(HomematicipFullFlushLockControllerGlassBreak(hap, device)) if isinstance(device, PresenceDetectorIndoor): entities.append(HomematicipPresenceDetector(hap, device)) if isinstance(device, SmokeDetector): @@ -298,6 +325,55 @@ def is_on(self) -> bool: return self._device.motionDetected +class HomematicipFullFlushLockControllerLocked( + HomematicipGenericEntity, BinarySensorEntity +): + """Representation of the HomematicIP full flush lock controller lock state.""" + + _attr_device_class = BinarySensorDeviceClass.LOCK + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the full flush lock controller lock sensor.""" + super().__init__(hap, device, post="Locked") + + @property + def is_on(self) -> bool: + """Return true if the controlled lock is locked.""" + channel = _get_channel_by_role( + self._device, + "MULTI_MODE_LOCK_INPUT_CHANNEL", + "DOOR_LOCK_SENSOR", + ) + if channel is None: + return False + lock_state = getattr(channel, "lockState", None) + return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name + + +class HomematicipFullFlushLockControllerGlassBreak( + HomematicipGenericEntity, BinarySensorEntity +): + """Representation of the HomematicIP full flush lock controller glass state.""" + + _attr_device_class = BinarySensorDeviceClass.PROBLEM + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the full flush lock controller glass break sensor.""" + super().__init__(hap, device, post="Glass break") + + @property + def is_on(self) -> bool: + """Return true if glass break has been detected.""" + channel = _get_channel_by_role( + self._device, + "MULTI_MODE_LOCK_INPUT_CHANNEL", + "DOOR_LOCK_SENSOR", + ) + if channel is None: + return False + return bool(getattr(channel, "glassBroken", False)) + + class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP presence detector.""" diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index 31fa2c889acf40..bcd157d44d6be7 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -12,6 +12,13 @@ from .hap import HomematicIPConfigEntry, HomematicipHAP +def _is_full_flush_lock_controller(device: object) -> bool: + """Return whether the device is an HmIP-FLC.""" + return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( + device, "send_start_impulse_async" + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomematicIPConfigEntry, @@ -20,11 +27,17 @@ async def async_setup_entry( """Set up the HomematicIP button from a config entry.""" hap = config_entry.runtime_data - async_add_entities( + entities: list[ButtonEntity] = [ HomematicipGarageDoorControllerButton(hap, device) for device in hap.home.devices if isinstance(device, WallMountedGarageDoorController) + ] + entities.extend( + HomematicipFullFlushLockControllerButton(hap, device) + for device in hap.home.devices + if _is_full_flush_lock_controller(device) ) + async_add_entities(entities) class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEntity): @@ -38,3 +51,16 @@ def __init__(self, hap: HomematicipHAP, device) -> None: async def async_press(self) -> None: """Handle the button press.""" await self._device.send_start_impulse_async() + + +class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonEntity): + """Representation of the HomematicIP full flush lock controller opener.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the full flush lock controller opener button.""" + super().__init__(hap, device, post="Door opener") + self._attr_icon = "mdi:door-open" + + async def async_press(self) -> None: + """Handle the button press.""" + await self._device.send_start_impulse_async() diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index 8f6ed62fbfcb62..26e359422d0beb 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -1,5 +1,6 @@ """Initializer helpers for HomematicIP fake server.""" +from typing import Any from unittest.mock import AsyncMock, Mock, patch from homematicip.async_home import AsyncHome @@ -69,6 +70,124 @@ async def default_mock_hap_factory_fixture( return HomeFactory(hass, mock_connection, hmip_config_entry) +@pytest.fixture(name="full_flush_lock_controller_device_data") +def full_flush_lock_controller_device_data_fixture() -> dict[str, Any]: + """Return fixture data for an HmIP-FLC device.""" + return { + "availableFirmwareVersion": "0.0.0", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.10", + "firmwareVersionInteger": 65546, + "functionalChannels": { + "0": { + "configPending": False, + "deviceId": "3014F7110000000000000026", + "dutyCycle": False, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": [], + "index": 0, + "label": "", + "lowBat": None, + "routerModuleEnabled": False, + "routerModuleSupported": False, + "rssiDeviceValue": -82, + "rssiPeerValue": -97, + "supportedOptionalFeatures": { + "IFeatureRssiValue": True, + "IOptionalFeatureDutyCycle": True, + "IOptionalFeatureLowBat": False, + }, + "unreach": False, + }, + "1": { + "actionParameter": "NOT_CUSTOMISABLE", + "binaryBehaviorType": "NORMALLY_OPEN", + "channelRole": "DOOR_LOCK_SENSOR", + "corrosionPreventionActive": False, + "deviceId": "3014F7110000000000000026", + "doorBellSensorEventTimestamp": None, + "eventDelay": 0, + "functionalChannelType": "MULTI_MODE_LOCK_INPUT_CHANNEL", + "glassBroken": True, + "groupIndex": 1, + "groups": [], + "index": 1, + "label": "", + "lockState": "LOCKED", + "multiModeInputMode": "BINARY_BEHAVIOR", + "supportedOptionalFeatures": {}, + "windowState": "OPEN", + }, + "3": { + "channelRole": "DOOR_LOCK_ACTUATOR", + "deviceId": "3014F7110000000000000026", + "doorLockActive": False, + "functionalChannelType": "DOOR_SWITCH_CHANNEL", + "groupIndex": 3, + "groups": [], + "impulseDuration": 111600.0, + "index": 3, + "internalLinkConfiguration": { + "firstInputAction": "TOGGLE", + "internalLinkConfigurationType": "SINGLE_INPUT_DOOR_SWITCH", + }, + "label": "", + "multiModeInputMode": "KEY_BEHAVIOR", + "processing": False, + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": {}, + "userDesiredProfileMode": "AUTOMATIC", + }, + "4": { + "channelRole": "DOOR_OPENER_ACTUATOR", + "deviceId": "3014F7110000000000000026", + "doorLockActive": False, + "functionalChannelType": "DOOR_SWITCH_CHANNEL", + "groupIndex": 4, + "groups": [], + "impulseDuration": 0.9, + "index": 4, + "internalLinkConfiguration": { + "firstInputAction": "LOCK_OPEN", + "internalLinkConfigurationType": "SINGLE_INPUT_DOOR_SWITCH", + }, + "label": "", + "multiModeInputMode": "SWITCH_BEHAVIOR", + "processing": False, + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": {}, + "userDesiredProfileMode": "AUTOMATIC", + }, + "5": { + "authorized": True, + "channelRole": "DOOR_LOCK_ACTUATOR", + "deviceId": "3014F7110000000000000026", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 3, + "groups": [], + "index": 5, + "label": "", + "supportedOptionalFeatures": {}, + }, + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000000026", + "label": "Universal Motorschloss Controller", + "lastStatusUpdate": 1760619002144, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 546, + "modelType": "HmIP-FLC", + "oem": "eQ-3", + "permanentlyReachable": True, + "serializedGlobalTradeItemNumber": "3014F7110000000000000026", + "type": "FULL_FLUSH_LOCK_CONTROLLER", + "updateState": "UP_TO_DATE", + } + + @pytest.fixture(name="hmip_config") def hmip_config_fixture() -> ConfigType: """Create a config for homematic ip cloud.""" diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py index d5083290bbd126..63866964f03004 100644 --- a/tests/components/homematicip_cloud/helper.py +++ b/tests/components/homematicip_cloud/helper.py @@ -109,7 +109,10 @@ def __init__( self.hmip_config_entry = hmip_config_entry async def async_get_mock_hap( - self, test_devices=None, test_groups=None + self, + test_devices=None, + test_groups=None, + extra_devices: list[dict[str, Any]] | None = None, ) -> HomematicipHAP: """Create a mocked homematic access point.""" home_name = self.hmip_config_entry.data["name"] @@ -119,6 +122,7 @@ async def async_get_mock_hap( home_name=home_name, test_devices=test_devices, test_groups=test_groups, + extra_devices=extra_devices, ) .init_home() .get_async_home_mock() @@ -156,7 +160,12 @@ class HomeTemplate(Home): _typeSecurityEventMap = TYPE_SECURITY_EVENT_MAP def __init__( - self, connection=None, home_name="", test_devices=None, test_groups=None + self, + connection=None, + home_name="", + test_devices=None, + test_groups=None, + extra_devices: list[dict[str, Any]] | None = None, ) -> None: """Init template with connection.""" super().__init__(connection=connection) @@ -166,8 +175,12 @@ def __init__( self.init_json_state = None self.test_devices = test_devices self.test_groups = test_groups + self.extra_devices = extra_devices or [] def _cleanup_json(self, json): + for extra_device in self.extra_devices: + json["devices"][extra_device["id"]] = extra_device + if self.test_devices is not None: new_devices = {} for json_device in json["devices"].items(): diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index e4aaa3e9b0b105..b8602a8179765b 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -1,5 +1,7 @@ """Tests for HomematicIP Cloud binary sensor.""" +from typing import Any + from homematicip.base.enums import SmokeDetectorAlarmType, WindowState from homeassistant.components.homematicip_cloud.binary_sensor import ( @@ -27,6 +29,49 @@ from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics +async def test_hmip_full_flush_lock_controller_binary_sensors( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + full_flush_lock_controller_device_data: dict[str, Any], +) -> None: + """Test HomematicIP full flush lock controller binary sensors.""" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universal Motorschloss Controller"], + extra_devices=[full_flush_lock_controller_device_data], + ) + + lock_entity_id = "binary_sensor.universal_motorschloss_controller_locked" + lock_state, hmip_device = get_and_check_entity_basics( + hass, + mock_hap, + lock_entity_id, + "Universal Motorschloss Controller Locked", + "HmIP-FLC", + ) + assert lock_state.state == STATE_ON + + glass_entity_id = "binary_sensor.universal_motorschloss_controller_glass_break" + glass_state, _ = get_and_check_entity_basics( + hass, + mock_hap, + glass_entity_id, + "Universal Motorschloss Controller Glass break", + "HmIP-FLC", + ) + assert glass_state.state == STATE_ON + + assert hmip_device is not None + await async_manipulate_test_data(hass, hmip_device, "lockState", "UNLOCKED") + lock_state = hass.states.get(lock_entity_id) + assert lock_state + assert lock_state.state == STATE_OFF + + await async_manipulate_test_data(hass, hmip_device, "glassBroken", False) + glass_state = hass.states.get(glass_entity_id) + assert glass_state + assert glass_state.state == STATE_OFF + + async def test_hmip_home_cloud_connection_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homematicip_cloud/test_button.py b/tests/components/homematicip_cloud/test_button.py index 7da86607096d4e..a1eb06a886176e 100644 --- a/tests/components/homematicip_cloud/test_button.py +++ b/tests/components/homematicip_cloud/test_button.py @@ -1,5 +1,7 @@ """Tests for HomematicIP Cloud button.""" +from typing import Any + from freezegun.api import FrozenDateTimeFactory from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -41,3 +43,41 @@ async def test_hmip_garage_door_controller_button( state = hass.states.get(entity_id) assert state assert state.state == now.isoformat() + + +async def test_hmip_full_flush_lock_controller_button( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + default_mock_hap_factory: HomeFactory, + full_flush_lock_controller_device_data: dict[str, Any], +) -> None: + """Test HomematicIP full flush lock controller opener button.""" + entity_id = "button.universal_motorschloss_controller_door_opener" + entity_name = "Universal Motorschloss Controller Door opener" + device_model = "HmIP-FLC" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universal Motorschloss Controller"], + extra_devices=[full_flush_lock_controller_device_data], + ) + + get_and_check_entity_basics(hass, mock_hap, entity_id, entity_name, device_model) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + hmip_device = mock_hap.hmip_device_by_entity_id[entity_id] + assert hmip_device.mock_calls[-1][0] == "send_start_impulse_async" + + state = hass.states.get(entity_id) + assert state + assert state.state == now.isoformat() From 3a77a638d5d2dc5617be0253e31b4f4c209dc750 Mon Sep 17 00:00:00 2001 From: johanzander Date: Wed, 25 Mar 2026 20:00:47 +0100 Subject: [PATCH 0005/1707] growatt_server: use human-readable labels in exception messages (#166024) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Norbert Rittel --- .../components/growatt_server/services.py | 30 +++++++---- .../components/growatt_server/strings.json | 53 +++++++++++-------- .../growatt_server/test_services.py | 10 ++-- 3 files changed, 54 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/growatt_server/services.py b/homeassistant/components/growatt_server/services.py index bebab342a04b52..49728598179055 100644 --- a/homeassistant/components/growatt_server/services.py +++ b/homeassistant/components/growatt_server/services.py @@ -87,22 +87,26 @@ def _get_coordinator( return coordinators[serial_number] -def _parse_time_str(time_str: str, field_name: str) -> time: +def _parse_time_str( + time_str: str, + translation_key: str, + translation_placeholders: dict[str, str] | None = None, +) -> time: """Parse a time string (HH:MM or HH:MM:SS) to a datetime.time object.""" parts = time_str.split(":") if len(parts) not in (2, 3): raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="invalid_time_format", - translation_placeholders={"field_name": field_name}, + translation_key=translation_key, + translation_placeholders=translation_placeholders or {}, ) try: return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time() except (ValueError, IndexError) as err: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="invalid_time_format", - translation_placeholders={"field_name": field_name}, + translation_key=translation_key, + translation_placeholders=translation_placeholders or {}, ) from err @@ -142,8 +146,8 @@ async def handle_update_time_segment(call: ServiceCall) -> None: ) batt_mode: int = valid_modes[batt_mode_str] - start_time = _parse_time_str(start_time_str, "start_time") - end_time = _parse_time_str(end_time_str, "end_time") + start_time = _parse_time_str(start_time_str, "invalid_time_format_start_time") + end_time = _parse_time_str(end_time_str, "invalid_time_format_end_time") coordinator: GrowattCoordinator = _get_coordinator(hass, device_id, "min") await coordinator.update_time_segment( @@ -192,11 +196,13 @@ async def handle_write_ac_charge_times(call: ServiceCall) -> None: cached = current["periods"][i - 1] start = _parse_time_str( call.data.get(f"period_{i}_start", cached["start_time"]), - f"period_{i}_start", + "invalid_time_format_period_start", + {"period": str(i)}, ) end = _parse_time_str( call.data.get(f"period_{i}_end", cached["end_time"]), - f"period_{i}_end", + "invalid_time_format_period_end", + {"period": str(i)}, ) enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"]) periods.append({"start_time": start, "end_time": end, "enabled": enabled}) @@ -238,11 +244,13 @@ async def handle_write_ac_discharge_times(call: ServiceCall) -> None: cached = current["periods"][i - 1] start = _parse_time_str( call.data.get(f"period_{i}_start", cached["start_time"]), - f"period_{i}_start", + "invalid_time_format_period_start", + {"period": str(i)}, ) end = _parse_time_str( call.data.get(f"period_{i}_end", cached["end_time"]), - f"period_{i}_end", + "invalid_time_format_period_end", + {"period": str(i)}, ) enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"]) periods.append({"start_time": start, "end_time": end, "enabled": enabled}) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 12322055da4456..ee65115f4933fc 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -579,7 +579,7 @@ "message": "Growatt API error: {error}" }, "device_not_configured": { - "message": "{device_type} device {serial_number} is not configured for services." + "message": "{device_type} device {serial_number} is not configured for actions." }, "device_not_found": { "message": "Device {device_id} not found in the device registry." @@ -591,22 +591,31 @@ "message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}." }, "invalid_charge_power": { - "message": "charge_power must be between 0 and 100, got {value}." + "message": "'Charge power' must be between 0 and 100, got {value}." }, "invalid_charge_stop_soc": { - "message": "charge_stop_soc must be between 0 and 100, got {value}." + "message": "'Charge stop SOC' must be between 0 and 100, got {value}." }, "invalid_discharge_power": { - "message": "discharge_power must be between 0 and 100, got {value}." + "message": "'Discharge power' must be between 0 and 100, got {value}." }, "invalid_discharge_stop_soc": { - "message": "discharge_stop_soc must be between 0 and 100, got {value}." + "message": "'Discharge stop SOC' must be between 0 and 100, got {value}." }, "invalid_segment_id": { - "message": "segment_id must be between 1 and 9, got {segment_id}." + "message": "'Segment ID' must be between 1 and 9, got {segment_id}." }, - "invalid_time_format": { - "message": "{field_name} must be in HH:MM or HH:MM:SS format." + "invalid_time_format_end_time": { + "message": "'End time' must be in HH:MM or HH:MM:SS format." + }, + "invalid_time_format_period_end": { + "message": "'Period {period} end' must be in HH:MM or HH:MM:SS format." + }, + "invalid_time_format_period_start": { + "message": "'Period {period} start' must be in HH:MM or HH:MM:SS format." + }, + "invalid_time_format_start_time": { + "message": "'Start time' must be in HH:MM or HH:MM:SS format." }, "no_devices_configured": { "message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access." @@ -636,27 +645,27 @@ }, "services": { "read_ac_charge_times": { - "description": "Read AC charge time periods from an SPH device.", + "description": "Reads AC charge time periods from an SPH device.", "fields": { "device_id": { - "description": "The Growatt SPH device to read from.", - "name": "Device" + "description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]" } }, "name": "Read AC charge times" }, "read_ac_discharge_times": { - "description": "Read AC discharge time periods from an SPH device.", + "description": "Reads AC discharge time periods from an SPH device.", "fields": { "device_id": { - "description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]", - "name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]" + "description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]" } }, "name": "Read AC discharge times" }, "read_time_segments": { - "description": "Read all time segments from a supported inverter.", + "description": "Reads all time segments from a supported inverter.", "fields": { "device_id": { "description": "The Growatt device to perform the action on.", @@ -666,7 +675,7 @@ "name": "Read time segments" }, "update_time_segment": { - "description": "Update a time segment for supported inverters.", + "description": "Updates a time segment for supported inverters.", "fields": { "batt_mode": { "description": "Battery operation mode for this time segment.", @@ -696,7 +705,7 @@ "name": "Update time segment" }, "write_ac_charge_times": { - "description": "Write AC charge time periods to an SPH device.", + "description": "Writes AC charge time periods to an SPH device.", "fields": { "charge_power": { "description": "Charge power limit (%).", @@ -707,8 +716,8 @@ "name": "Charge stop SOC" }, "device_id": { - "description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]", - "name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]" + "description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]" }, "mains_enabled": { "description": "Enable AC (mains) charging.", @@ -754,11 +763,11 @@ "name": "Write AC charge times" }, "write_ac_discharge_times": { - "description": "Write AC discharge time periods to an SPH device.", + "description": "Writes AC discharge time periods to an SPH device.", "fields": { "device_id": { - "description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]", - "name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]" + "description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]", + "name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]" }, "discharge_power": { "description": "Discharge power limit (%).", diff --git a/tests/components/growatt_server/test_services.py b/tests/components/growatt_server/test_services.py index b844eaa9651707..d2dd4aa68ec4a3 100644 --- a/tests/components/growatt_server/test_services.py +++ b/tests/components/growatt_server/test_services.py @@ -376,8 +376,7 @@ async def test_update_time_segment_invalid_time_format( blocking=True, ) assert excinfo.value.translation_domain == DOMAIN - assert excinfo.value.translation_key == "invalid_time_format" - assert excinfo.value.translation_placeholders == {"field_name": "start_time"} + assert excinfo.value.translation_key == "invalid_time_format_start_time" @pytest.mark.usefixtures("mock_growatt_v1_api") @@ -653,8 +652,7 @@ async def test_update_time_segment_invalid_end_time_format( blocking=True, ) assert excinfo.value.translation_domain == DOMAIN - assert excinfo.value.translation_key == "invalid_time_format" - assert excinfo.value.translation_placeholders == {"field_name": "end_time"} + assert excinfo.value.translation_key == "invalid_time_format_end_time" async def test_service_with_unloaded_config_entry( @@ -1056,8 +1054,8 @@ async def test_write_ac_charge_times_invalid_period_time( blocking=True, ) assert excinfo.value.translation_domain == DOMAIN - assert excinfo.value.translation_key == "invalid_time_format" - assert excinfo.value.translation_placeholders == {"field_name": "period_1_start"} + assert excinfo.value.translation_key == "invalid_time_format_period_start" + assert excinfo.value.translation_placeholders == {"period": "1"} async def test_no_sph_devices_fails_gracefully( From 84cd137baeede50ba86819ba72feaa95c5ccac79 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 20:24:07 +0100 Subject: [PATCH 0006/1707] Bump version to 2026.5.0dev0 (#166512) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index eb35d71dfa8a53..2afd120f42761f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 3 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2026.4" + HA_SHORT_VERSION: "2026.5" ADDITIONAL_PYTHON_VERSIONS: "[]" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6c0a918eb1ef97..34032df2a92c5a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 -MINOR_VERSION: Final = 4 +MINOR_VERSION: Final = 5 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 0f6ae3ad4ff676..d6d32b7f622ae2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.0.dev0" +version = "2026.5.0.dev0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From fd7d936a0d0d0d5cd3160a046fd4f0cda9581804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 25 Mar 2026 19:45:39 +0000 Subject: [PATCH 0007/1707] Instruct copilot to place main comment in collapsible section (#166503) --- .github/copilot-instructions.md | 1 + script/gen_copilot_instructions.py | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3bc651eb2f21a1..f43cefaacaacd0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,6 +6,7 @@ - Start review comments with a short, one-sentence summary of the suggested fix. - Do not add comments about code style, formatting or linting issues. +- The main review status comment should be inside a collapsible section with the summary as title. No header sections outside of the collapsible section. # GitHub Copilot & Claude Code Instructions diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py index ed8c75d18f5b20..b0bb41db0b8083 100755 --- a/script/gen_copilot_instructions.py +++ b/script/gen_copilot_instructions.py @@ -23,6 +23,7 @@ - Start review comments with a short, one-sentence summary of the suggested fix. - Do not add comments about code style, formatting or linting issues. +- The main review status comment should be inside a collapsible section with the summary as title. No header sections outside of the collapsible section. """ From c5955ada1a6c39e7d10d2e8ee875fede944a62fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Mar 2026 20:57:12 +0100 Subject: [PATCH 0008/1707] Use NumericThresholdSelector in numeric conditions (#166507) --- .../components/air_quality/conditions.yaml | 647 +++++++----------- .../components/air_quality/strings.json | 172 ++--- .../components/battery/conditions.yaml | 40 +- homeassistant/components/battery/strings.json | 20 +- .../components/climate/conditions.yaml | 94 ++- homeassistant/components/climate/strings.json | 32 +- .../components/humidifier/conditions.yaml | 43 +- .../components/humidifier/strings.json | 18 +- .../components/humidity/conditions.yaml | 43 +- .../components/humidity/strings.json | 18 +- .../components/illuminance/conditions.yaml | 39 +- .../components/illuminance/strings.json | 18 +- .../components/moisture/conditions.yaml | 42 +- .../components/moisture/strings.json | 18 +- .../components/power/conditions.yaml | 78 +-- homeassistant/components/power/strings.json | 22 +- .../components/temperature/conditions.yaml | 51 +- .../components/temperature/strings.json | 22 +- .../components/water_heater/conditions.yaml | 51 +- .../components/water_heater/strings.json | 22 +- homeassistant/helpers/automation.py | 69 +- homeassistant/helpers/condition.py | 168 +++-- homeassistant/helpers/trigger.py | 30 +- .../components/air_quality/test_condition.py | 67 +- tests/components/climate/test_condition.py | 43 +- tests/components/common.py | 118 +++- tests/components/power/test_condition.py | 52 +- .../components/temperature/test_condition.py | 94 ++- .../components/water_heater/test_condition.py | 43 +- tests/helpers/test_condition.py | 247 +++++-- 30 files changed, 1164 insertions(+), 1257 deletions(-) diff --git a/homeassistant/components/air_quality/conditions.yaml b/homeassistant/components/air_quality/conditions.yaml index d2589bb612ae83..97b7c1056dadba 100644 --- a/homeassistant/components/air_quality/conditions.yaml +++ b/homeassistant/components/air_quality/conditions.yaml @@ -10,366 +10,155 @@ - all - any -# --- Number or entity selectors --- - -.number_or_entity_co: &number_or_entity_co - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "ppm" - - "mg/m³" - - "μg/m³" - - domain: sensor - device_class: carbon_monoxide - - domain: number - device_class: carbon_monoxide - translation_key: number_or_entity - -.number_or_entity_co2: &number_or_entity_co2 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "ppm" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "ppm" - - domain: sensor - device_class: carbon_dioxide - - domain: number - device_class: carbon_dioxide - translation_key: number_or_entity - -.number_or_entity_pm1: &number_or_entity_pm1 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: pm1 - - domain: number - device_class: pm1 - translation_key: number_or_entity - -.number_or_entity_pm25: &number_or_entity_pm25 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: pm25 - - domain: number - device_class: pm25 - translation_key: number_or_entity - -.number_or_entity_pm4: &number_or_entity_pm4 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: pm4 - - domain: number - device_class: pm4 - translation_key: number_or_entity - -.number_or_entity_pm10: &number_or_entity_pm10 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: pm10 - - domain: number - device_class: pm10 - translation_key: number_or_entity - -.number_or_entity_ozone: &number_or_entity_ozone - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "ppm" - - "μg/m³" - - domain: sensor - device_class: ozone - - domain: number - device_class: ozone - translation_key: number_or_entity - -.number_or_entity_voc: &number_or_entity_voc - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "μg/m³" - - "mg/m³" - - domain: sensor - device_class: volatile_organic_compounds - - domain: number - device_class: volatile_organic_compounds - translation_key: number_or_entity - -.number_or_entity_voc_ratio: &number_or_entity_voc_ratio - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "ppm" - - domain: sensor - device_class: volatile_organic_compounds_parts - - domain: number - device_class: volatile_organic_compounds_parts - translation_key: number_or_entity - -.number_or_entity_no: &number_or_entity_no - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "μg/m³" - - domain: sensor - device_class: nitrogen_monoxide - - domain: number - device_class: nitrogen_monoxide - translation_key: number_or_entity - -.number_or_entity_no2: &number_or_entity_no2 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "ppm" - - "μg/m³" - - domain: sensor - device_class: nitrogen_dioxide - - domain: number - device_class: nitrogen_dioxide - translation_key: number_or_entity - -.number_or_entity_n2o: &number_or_entity_n2o - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "μg/m³" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "μg/m³" - - domain: sensor - device_class: nitrous_oxide - - domain: number - device_class: nitrous_oxide - translation_key: number_or_entity - -.number_or_entity_so2: &number_or_entity_so2 - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "ppb" - - "μg/m³" - - domain: sensor - device_class: sulphur_dioxide - - domain: number - device_class: sulphur_dioxide - translation_key: number_or_entity - -# --- Unit selectors --- - -.unit_co: &unit_co - required: false - selector: - select: - options: - - "ppb" - - "ppm" - - "mg/m³" - - "μg/m³" - -.unit_ozone: &unit_ozone - required: false - selector: - select: - options: - - "ppb" - - "ppm" - - "μg/m³" - -.unit_no2: &unit_no2 - required: false - selector: - select: - options: - - "ppb" - - "ppm" - - "μg/m³" - -.unit_no: &unit_no - required: false - selector: - select: - options: - - "ppb" - - "μg/m³" - -.unit_so2: &unit_so2 - required: false - selector: - select: - options: - - "ppb" - - "μg/m³" - -.unit_voc: &unit_voc - required: false - selector: - select: - options: - - "μg/m³" - - "mg/m³" - -.unit_voc_ratio: &unit_voc_ratio - required: false - selector: - select: - options: - - "ppb" - - "ppm" +# --- Unit lists for multi-unit pollutants --- + +.co_units: &co_units + - "ppb" + - "ppm" + - "mg/m³" + - "μg/m³" + +.ozone_units: &ozone_units + - "ppb" + - "ppm" + - "μg/m³" + +.voc_units: &voc_units + - "μg/m³" + - "mg/m³" + +.voc_ratio_units: &voc_ratio_units + - "ppb" + - "ppm" + +.no_units: &no_units + - "ppb" + - "μg/m³" + +.no2_units: &no2_units + - "ppb" + - "ppm" + - "μg/m³" + +.so2_units: &so2_units + - "ppb" + - "μg/m³" + +# --- Entity filter anchors --- + +.co_threshold_entity: &co_threshold_entity + - domain: input_number + unit_of_measurement: *co_units + - domain: sensor + device_class: carbon_monoxide + - domain: number + device_class: carbon_monoxide + +.co2_threshold_entity: &co2_threshold_entity + - domain: input_number + unit_of_measurement: "ppm" + - domain: sensor + device_class: carbon_dioxide + - domain: number + device_class: carbon_dioxide + +.pm1_threshold_entity: &pm1_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm1 + - domain: number + device_class: pm1 + +.pm25_threshold_entity: &pm25_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm25 + - domain: number + device_class: pm25 + +.pm4_threshold_entity: &pm4_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm4 + - domain: number + device_class: pm4 + +.pm10_threshold_entity: &pm10_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: pm10 + - domain: number + device_class: pm10 + +.ozone_threshold_entity: &ozone_threshold_entity + - domain: input_number + unit_of_measurement: *ozone_units + - domain: sensor + device_class: ozone + - domain: number + device_class: ozone + +.voc_threshold_entity: &voc_threshold_entity + - domain: input_number + unit_of_measurement: *voc_units + - domain: sensor + device_class: volatile_organic_compounds + - domain: number + device_class: volatile_organic_compounds + +.voc_ratio_threshold_entity: &voc_ratio_threshold_entity + - domain: input_number + unit_of_measurement: *voc_ratio_units + - domain: sensor + device_class: volatile_organic_compounds_parts + - domain: number + device_class: volatile_organic_compounds_parts + +.no_threshold_entity: &no_threshold_entity + - domain: input_number + unit_of_measurement: *no_units + - domain: sensor + device_class: nitrogen_monoxide + - domain: number + device_class: nitrogen_monoxide + +.no2_threshold_entity: &no2_threshold_entity + - domain: input_number + unit_of_measurement: *no2_units + - domain: sensor + device_class: nitrogen_dioxide + - domain: number + device_class: nitrogen_dioxide + +.n2o_threshold_entity: &n2o_threshold_entity + - domain: input_number + unit_of_measurement: "μg/m³" + - domain: sensor + device_class: nitrous_oxide + - domain: number + device_class: nitrous_oxide + +.so2_threshold_entity: &so2_threshold_entity + - domain: input_number + unit_of_measurement: *so2_units + - domain: sensor + device_class: sulphur_dioxide + - domain: number + device_class: sulphur_dioxide + +# --- Number anchors for single-unit pollutants --- + +.co2_threshold_number: &co2_threshold_number + mode: box + unit_of_measurement: "ppm" + +.ugm3_threshold_number: &ugm3_threshold_number + mode: box + unit_of_measurement: "μg/m³" # --- Binary sensor targets --- @@ -491,57 +280,99 @@ is_co_value: target: *target_co_sensor fields: behavior: *condition_behavior - above: *number_or_entity_co - below: *number_or_entity_co - unit: *unit_co + threshold: + required: true + selector: + numeric_threshold: + entity: *co_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *co_units is_ozone_value: target: *target_ozone fields: behavior: *condition_behavior - above: *number_or_entity_ozone - below: *number_or_entity_ozone - unit: *unit_ozone + threshold: + required: true + selector: + numeric_threshold: + entity: *ozone_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *ozone_units is_voc_value: target: *target_voc fields: behavior: *condition_behavior - above: *number_or_entity_voc - below: *number_or_entity_voc - unit: *unit_voc + threshold: + required: true + selector: + numeric_threshold: + entity: *voc_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *voc_units is_voc_ratio_value: target: *target_voc_ratio fields: behavior: *condition_behavior - above: *number_or_entity_voc_ratio - below: *number_or_entity_voc_ratio - unit: *unit_voc_ratio + threshold: + required: true + selector: + numeric_threshold: + entity: *voc_ratio_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *voc_ratio_units is_no_value: target: *target_no fields: behavior: *condition_behavior - above: *number_or_entity_no - below: *number_or_entity_no - unit: *unit_no + threshold: + required: true + selector: + numeric_threshold: + entity: *no_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *no_units is_no2_value: target: *target_no2 fields: behavior: *condition_behavior - above: *number_or_entity_no2 - below: *number_or_entity_no2 - unit: *unit_no2 + threshold: + required: true + selector: + numeric_threshold: + entity: *no2_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *no2_units is_so2_value: target: *target_so2 fields: behavior: *condition_behavior - above: *number_or_entity_so2 - below: *number_or_entity_so2 - unit: *unit_so2 + threshold: + required: true + selector: + numeric_threshold: + entity: *so2_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *so2_units # --- Numerical sensor conditions without unit conversion --- @@ -549,40 +380,70 @@ is_co2_value: target: *target_co2 fields: behavior: *condition_behavior - above: *number_or_entity_co2 - below: *number_or_entity_co2 + threshold: + required: true + selector: + numeric_threshold: + entity: *co2_threshold_entity + mode: is + number: *co2_threshold_number is_pm1_value: target: *target_pm1 fields: behavior: *condition_behavior - above: *number_or_entity_pm1 - below: *number_or_entity_pm1 + threshold: + required: true + selector: + numeric_threshold: + entity: *pm1_threshold_entity + mode: is + number: *ugm3_threshold_number is_pm25_value: target: *target_pm25 fields: behavior: *condition_behavior - above: *number_or_entity_pm25 - below: *number_or_entity_pm25 + threshold: + required: true + selector: + numeric_threshold: + entity: *pm25_threshold_entity + mode: is + number: *ugm3_threshold_number is_pm4_value: target: *target_pm4 fields: behavior: *condition_behavior - above: *number_or_entity_pm4 - below: *number_or_entity_pm4 + threshold: + required: true + selector: + numeric_threshold: + entity: *pm4_threshold_entity + mode: is + number: *ugm3_threshold_number is_pm10_value: target: *target_pm10 fields: behavior: *condition_behavior - above: *number_or_entity_pm10 - below: *number_or_entity_pm10 + threshold: + required: true + selector: + numeric_threshold: + entity: *pm10_threshold_entity + mode: is + number: *ugm3_threshold_number is_n2o_value: target: *target_n2o fields: behavior: *condition_behavior - above: *number_or_entity_n2o - below: *number_or_entity_n2o + threshold: + required: true + selector: + numeric_threshold: + entity: *n2o_threshold_entity + mode: is + number: *ugm3_threshold_number diff --git a/homeassistant/components/air_quality/strings.json b/homeassistant/components/air_quality/strings.json index 4a4d79e5b45d95..f3369398b34bd3 100644 --- a/homeassistant/components/air_quality/strings.json +++ b/homeassistant/components/air_quality/strings.json @@ -1,13 +1,9 @@ { "common": { - "condition_above_description": "Require the value to be above this value.", - "condition_above_name": "Above", "condition_behavior_description": "How the value should match on the targeted entities.", "condition_behavior_name": "Behavior", - "condition_below_description": "Require the value to be below this value.", - "condition_below_name": "Below", - "condition_unit_description": "All values will be converted to this unit when evaluating the condition.", - "condition_unit_name": "Unit of measurement", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -18,17 +14,13 @@ "is_co2_value": { "description": "Tests the carbon dioxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Carbon dioxide value" @@ -56,21 +48,13 @@ "is_co_value": { "description": "Tests the carbon monoxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Carbon monoxide value" @@ -98,17 +82,13 @@ "is_n2o_value": { "description": "Tests the nitrous oxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Nitrous oxide value" @@ -116,21 +96,13 @@ "is_no2_value": { "description": "Tests the nitrogen dioxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Nitrogen dioxide value" @@ -138,21 +110,13 @@ "is_no_value": { "description": "Tests the nitrogen monoxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Nitrogen monoxide value" @@ -160,21 +124,13 @@ "is_ozone_value": { "description": "Tests the ozone level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Ozone value" @@ -182,17 +138,13 @@ "is_pm10_value": { "description": "Tests the PM10 level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "PM10 value" @@ -200,17 +152,13 @@ "is_pm1_value": { "description": "Tests the PM1 level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "PM1 value" @@ -218,17 +166,13 @@ "is_pm25_value": { "description": "Tests the PM2.5 level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "PM2.5 value" @@ -236,17 +180,13 @@ "is_pm4_value": { "description": "Tests the PM4 level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "PM4 value" @@ -274,21 +214,13 @@ "is_so2_value": { "description": "Tests the sulphur dioxide level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Sulphur dioxide value" @@ -296,21 +228,13 @@ "is_voc_ratio_value": { "description": "Tests the volatile organic compounds ratio of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Volatile organic compounds ratio value" @@ -318,21 +242,13 @@ "is_voc_value": { "description": "Tests the volatile organic compounds level of one or more entities.", "fields": { - "above": { - "description": "[%key:component::air_quality::common::condition_above_description%]", - "name": "[%key:component::air_quality::common::condition_above_name%]" - }, "behavior": { "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, - "below": { - "description": "[%key:component::air_quality::common::condition_below_description%]", - "name": "[%key:component::air_quality::common::condition_below_name%]" - }, - "unit": { - "description": "[%key:component::air_quality::common::condition_unit_description%]", - "name": "[%key:component::air_quality::common::condition_unit_name%]" + "threshold": { + "description": "[%key:component::air_quality::common::condition_threshold_description%]", + "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Volatile organic compounds value" @@ -345,12 +261,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/battery/conditions.yaml b/homeassistant/components/battery/conditions.yaml index fa37c37e6a357c..98584b000444e2 100644 --- a/homeassistant/components/battery/conditions.yaml +++ b/homeassistant/components/battery/conditions.yaml @@ -14,24 +14,19 @@ - all - any -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - domain: - - input_number - - number - - sensor - translation_key: number_or_entity +.battery_threshold_entity: &battery_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: battery + - domain: number + device_class: battery + +.battery_threshold_number: &battery_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" is_low: *condition_common @@ -62,5 +57,10 @@ is_level: device_class: battery fields: behavior: *condition_behavior - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: *battery_threshold_entity + mode: is + number: *battery_threshold_number diff --git a/homeassistant/components/battery/strings.json b/homeassistant/components/battery/strings.json index 1b66656ce293a3..e0eec43b74e295 100644 --- a/homeassistant/components/battery/strings.json +++ b/homeassistant/components/battery/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_description": "How the state should match on the targeted batteries.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration" }, "conditions": { "is_charging": { @@ -17,17 +19,13 @@ "is_level": { "description": "Tests the battery level of one or more batteries.", "fields": { - "above": { - "description": "Require the battery percentage to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" }, - "below": { - "description": "Require the battery percentage to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::battery::common::condition_threshold_description%]", + "name": "[%key:component::battery::common::condition_threshold_name%]" } }, "name": "Battery level" @@ -69,12 +67,6 @@ "all": "All", "any": "Any" } - }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } } }, "title": "Battery" diff --git a/homeassistant/components/climate/conditions.yaml b/homeassistant/components/climate/conditions.yaml index db40d7e444c4ea..771d5e96332bc2 100644 --- a/homeassistant/components/climate/conditions.yaml +++ b/homeassistant/components/climate/conditions.yaml @@ -13,58 +13,31 @@ - all - any -.number_or_entity_humidity: &number_or_entity_humidity - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "%" - - domain: sensor - device_class: humidity - - domain: number - device_class: humidity - translation_key: number_or_entity +.humidity_threshold_entity: &humidity_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: humidity + - domain: number + device_class: humidity -.number_or_entity_temperature: &number_or_entity_temperature - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "°C" - - "°F" - - domain: sensor - device_class: temperature - - domain: number - device_class: temperature - translation_key: number_or_entity +.humidity_threshold_number: &humidity_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" -.condition_unit_temperature: &condition_unit_temperature - required: false - selector: - select: - options: - - "°C" - - "°F" +.temperature_units: &temperature_units + - "°C" + - "°F" + +.temperature_threshold_entity: &temperature_threshold_entity + - domain: input_number + unit_of_measurement: *temperature_units + - domain: sensor + device_class: temperature + - domain: number + device_class: temperature is_off: *condition_common is_on: *condition_common @@ -76,13 +49,24 @@ target_humidity: target: *condition_climate_target fields: behavior: *condition_behavior - above: *number_or_entity_humidity - below: *number_or_entity_humidity + threshold: + required: true + selector: + numeric_threshold: + entity: *humidity_threshold_entity + mode: is + number: *humidity_threshold_number target_temperature: target: *condition_climate_target fields: behavior: *condition_behavior - above: *number_or_entity_temperature - below: *number_or_entity_temperature - unit: *condition_unit_temperature + threshold: + required: true + selector: + numeric_threshold: + entity: *temperature_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *temperature_units diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 8f2e680c0eb238..ec6c99e51ab6fd 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted climate-control devices.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted climates to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -62,17 +64,13 @@ "target_humidity": { "description": "Tests the humidity setpoint of one or more climate-control devices.", "fields": { - "above": { - "description": "Require the target humidity to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, - "below": { - "description": "Require the target humidity to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::climate::common::condition_threshold_description%]", + "name": "[%key:component::climate::common::condition_threshold_name%]" } }, "name": "Climate-control device target humidity" @@ -80,21 +78,13 @@ "target_temperature": { "description": "Tests the temperature setpoint of one or more climate-control devices.", "fields": { - "above": { - "description": "Require the target temperature to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, - "below": { - "description": "Require the target temperature to be below this value.", - "name": "Below" - }, - "unit": { - "description": "All values will be converted to this unit when evaluating the condition.", - "name": "Unit of measurement" + "threshold": { + "description": "[%key:component::climate::common::condition_threshold_description%]", + "name": "[%key:component::climate::common::condition_threshold_name%]" } }, "name": "Climate-control device target temperature" @@ -284,12 +274,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/humidifier/conditions.yaml b/homeassistant/components/humidifier/conditions.yaml index 6ed179e3caad3a..bc10ab1db65834 100644 --- a/homeassistant/components/humidifier/conditions.yaml +++ b/homeassistant/components/humidifier/conditions.yaml @@ -13,27 +13,19 @@ - all - any -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "%" - - domain: sensor - device_class: humidity - - domain: number - device_class: humidity - translation_key: number_or_entity +.humidity_threshold_entity: &humidity_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: humidity + - domain: number + device_class: humidity + +.humidity_threshold_number: &humidity_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" is_off: *condition_common is_on: *condition_common @@ -44,5 +36,10 @@ is_target_humidity: target: *condition_humidifier_target fields: behavior: *condition_behavior - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: *humidity_threshold_entity + mode: is + number: *humidity_threshold_number diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index fc729062c4a7fa..09b01ce14de77e 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted humidifiers.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.", "trigger_behavior_name": "Behavior" }, @@ -49,17 +51,13 @@ "is_target_humidity": { "description": "Tests the target humidity of one or more humidifiers.", "fields": { - "above": { - "description": "Require the target humidity to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, - "below": { - "description": "Require the target humidity to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::humidifier::common::condition_threshold_description%]", + "name": "[%key:component::humidifier::common::condition_threshold_name%]" } }, "name": "Humidifier target humidity" @@ -159,12 +157,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/humidity/conditions.yaml b/homeassistant/components/humidity/conditions.yaml index 4fc6cd349635e3..733b2452891778 100644 --- a/homeassistant/components/humidity/conditions.yaml +++ b/homeassistant/components/humidity/conditions.yaml @@ -1,24 +1,16 @@ -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "%" - - domain: number - device_class: humidity - - domain: sensor - device_class: humidity - translation_key: number_or_entity +.humidity_threshold_entity: &humidity_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: humidity + - domain: number + device_class: humidity + +.humidity_threshold_number: &humidity_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" is_value: target: @@ -39,5 +31,10 @@ is_value: options: - all - any - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: *humidity_threshold_entity + mode: is + number: *humidity_threshold_number diff --git a/homeassistant/components/humidity/strings.json b/homeassistant/components/humidity/strings.json index 9327bf89e189b4..06836f05dce70c 100644 --- a/homeassistant/components/humidity/strings.json +++ b/homeassistant/components/humidity/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -12,17 +14,13 @@ "is_value": { "description": "Tests if a relative humidity value is above a threshold, below a threshold, or in a range of values.", "fields": { - "above": { - "description": "Require the relative humidity to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::humidity::common::condition_behavior_description%]", "name": "[%key:component::humidity::common::condition_behavior_name%]" }, - "below": { - "description": "Require the relative humidity to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::humidity::common::condition_threshold_description%]", + "name": "[%key:component::humidity::common::condition_threshold_name%]" } }, "name": "Relative humidity" @@ -35,12 +33,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/illuminance/conditions.yaml b/homeassistant/components/illuminance/conditions.yaml index d2e07200c4bb7f..37980efcae4966 100644 --- a/homeassistant/components/illuminance/conditions.yaml +++ b/homeassistant/components/illuminance/conditions.yaml @@ -14,27 +14,6 @@ - all - any -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - unit_of_measurement: "lx" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "lx" - - domain: number - device_class: illuminance - - domain: sensor - device_class: illuminance - translation_key: number_or_entity - is_detected: *detected_condition_common is_not_detected: *detected_condition_common @@ -48,5 +27,19 @@ is_value: device_class: illuminance fields: behavior: *condition_behavior - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: + - domain: input_number + unit_of_measurement: "lx" + - domain: sensor + device_class: illuminance + - domain: number + device_class: illuminance + mode: is + number: + min: 0 + mode: box + unit_of_measurement: "lx" diff --git a/homeassistant/components/illuminance/strings.json b/homeassistant/components/illuminance/strings.json index aa5090a5d3523a..5ed11170df0b26 100644 --- a/homeassistant/components/illuminance/strings.json +++ b/homeassistant/components/illuminance/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -32,17 +34,13 @@ "is_value": { "description": "Tests the illuminance value.", "fields": { - "above": { - "description": "Require the illuminance to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" }, - "below": { - "description": "Require the illuminance to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::illuminance::common::condition_threshold_description%]", + "name": "[%key:component::illuminance::common::condition_threshold_name%]" } }, "name": "Illuminance" @@ -55,12 +53,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/moisture/conditions.yaml b/homeassistant/components/moisture/conditions.yaml index 0381891273016a..a1e1f9b4bfd8ef 100644 --- a/homeassistant/components/moisture/conditions.yaml +++ b/homeassistant/components/moisture/conditions.yaml @@ -14,26 +14,19 @@ - all - any -.number_or_entity: &number_or_entity - required: false - selector: - choose: - choices: - number: - selector: - number: - unit_of_measurement: "%" - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: "%" - - domain: number - device_class: moisture - - domain: sensor - device_class: moisture - translation_key: number_or_entity +.moisture_threshold_entity: &moisture_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: sensor + device_class: moisture + - domain: number + device_class: moisture + +.moisture_threshold_number: &moisture_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" is_detected: *detected_condition_common @@ -48,5 +41,10 @@ is_value: device_class: moisture fields: behavior: *condition_behavior - above: *number_or_entity - below: *number_or_entity + threshold: + required: true + selector: + numeric_threshold: + entity: *moisture_threshold_entity + mode: is + number: *moisture_threshold_number diff --git a/homeassistant/components/moisture/strings.json b/homeassistant/components/moisture/strings.json index e4e33bbe061294..c2f9705bcca0bd 100644 --- a/homeassistant/components/moisture/strings.json +++ b/homeassistant/components/moisture/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -32,17 +34,13 @@ "is_value": { "description": "Tests the moisture level of one or more entities.", "fields": { - "above": { - "description": "Require the moisture level to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" }, - "below": { - "description": "Require the moisture level to be below this value.", - "name": "Below" + "threshold": { + "description": "[%key:component::moisture::common::condition_threshold_description%]", + "name": "[%key:component::moisture::common::condition_threshold_name%]" } }, "name": "Moisture level" @@ -55,12 +53,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/power/conditions.yaml b/homeassistant/components/power/conditions.yaml index c9a3498c18642c..a34beb6d24a845 100644 --- a/homeassistant/components/power/conditions.yaml +++ b/homeassistant/components/power/conditions.yaml @@ -1,43 +1,29 @@ -.number_or_entity_power: &number_or_entity_power - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "mW" - - "W" - - "kW" - - "MW" - - "GW" - - "TW" - - "BTU/h" - - domain: sensor - device_class: power - - domain: number - device_class: power - translation_key: number_or_entity - -.condition_unit_power: &condition_unit_power - required: false +.condition_behavior: &condition_behavior + required: true + default: any selector: select: + translation_key: condition_behavior options: - - "mW" - - "W" - - "kW" - - "MW" - - "GW" - - "TW" - - "BTU/h" + - all + - any + +.power_units: &power_units + - "mW" + - "W" + - "kW" + - "MW" + - "GW" + - "TW" + - "BTU/h" + +.power_threshold_entity: &power_threshold_entity + - domain: input_number + unit_of_measurement: *power_units + - domain: sensor + device_class: power + - domain: number + device_class: power is_value: target: @@ -47,15 +33,13 @@ is_value: - domain: sensor device_class: power fields: - behavior: + behavior: *condition_behavior + threshold: required: true - default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any - above: *number_or_entity_power - below: *number_or_entity_power - unit: *condition_unit_power + numeric_threshold: + entity: *power_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *power_units diff --git a/homeassistant/components/power/strings.json b/homeassistant/components/power/strings.json index 3f7ba415b7fe8c..f4369b0e225772 100644 --- a/homeassistant/components/power/strings.json +++ b/homeassistant/components/power/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the power value should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -12,21 +14,13 @@ "is_value": { "description": "Tests the power value of one or more entities.", "fields": { - "above": { - "description": "Require the power to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::power::common::condition_behavior_description%]", "name": "[%key:component::power::common::condition_behavior_name%]" }, - "below": { - "description": "Require the power to be below this value.", - "name": "Below" - }, - "unit": { - "description": "All values will be converted to this unit when evaluating the condition.", - "name": "Unit of measurement" + "threshold": { + "description": "[%key:component::power::common::condition_threshold_description%]", + "name": "[%key:component::power::common::condition_threshold_name%]" } }, "name": "Power value" @@ -39,12 +33,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/temperature/conditions.yaml b/homeassistant/components/temperature/conditions.yaml index bb87a6659248fa..a979b371e00434 100644 --- a/homeassistant/components/temperature/conditions.yaml +++ b/homeassistant/components/temperature/conditions.yaml @@ -1,33 +1,14 @@ -.number_or_entity_temperature: &number_or_entity_temperature - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "°C" - - "°F" - - domain: sensor - device_class: temperature - - domain: number - device_class: temperature - translation_key: number_or_entity +.temperature_units: &temperature_units + - "°C" + - "°F" -.condition_unit_temperature: &condition_unit_temperature - required: false - selector: - select: - options: - - "°C" - - "°F" +.temperature_threshold_entity: &temperature_threshold_entity + - domain: input_number + unit_of_measurement: *temperature_units + - domain: sensor + device_class: temperature + - domain: number + device_class: temperature is_value: target: @@ -47,6 +28,12 @@ is_value: options: - all - any - above: *number_or_entity_temperature - below: *number_or_entity_temperature - unit: *condition_unit_temperature + threshold: + required: true + selector: + numeric_threshold: + entity: *temperature_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *temperature_units diff --git a/homeassistant/components/temperature/strings.json b/homeassistant/components/temperature/strings.json index e20fd7b0c7db56..e1c74365759cc8 100644 --- a/homeassistant/components/temperature/strings.json +++ b/homeassistant/components/temperature/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the temperature should match on the targeted entities.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -12,21 +14,13 @@ "is_value": { "description": "Tests the temperature of one or more entities.", "fields": { - "above": { - "description": "Require the temperature to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::temperature::common::condition_behavior_description%]", "name": "[%key:component::temperature::common::condition_behavior_name%]" }, - "below": { - "description": "Require the temperature to be below this value.", - "name": "Below" - }, - "unit": { - "description": "All values will be converted to this unit when evaluating the condition.", - "name": "Unit of measurement" + "threshold": { + "description": "[%key:component::temperature::common::condition_threshold_description%]", + "name": "[%key:component::temperature::common::condition_threshold_name%]" } }, "name": "Temperature value" @@ -39,12 +33,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/components/water_heater/conditions.yaml b/homeassistant/components/water_heater/conditions.yaml index 6ce7ec9747e44e..a200dfcf8320bc 100644 --- a/homeassistant/components/water_heater/conditions.yaml +++ b/homeassistant/components/water_heater/conditions.yaml @@ -13,36 +13,17 @@ - all - any -.number_or_entity_temperature: &number_or_entity_temperature - required: false - selector: - choose: - choices: - number: - selector: - number: - mode: box - entity: - selector: - entity: - filter: - - domain: input_number - unit_of_measurement: - - "°C" - - "°F" - - domain: sensor - device_class: temperature - - domain: number - device_class: temperature - translation_key: number_or_entity +.temperature_units: &temperature_units + - "°C" + - "°F" -.condition_unit_temperature: &condition_unit_temperature - required: false - selector: - select: - options: - - "°C" - - "°F" +.temperature_threshold_entity: &temperature_threshold_entity + - domain: input_number + unit_of_measurement: *temperature_units + - domain: sensor + device_class: temperature + - domain: number + device_class: temperature is_off: *condition_common is_on: *condition_common @@ -67,6 +48,12 @@ is_target_temperature: target: *condition_water_heater_target fields: behavior: *condition_behavior - above: *number_or_entity_temperature - below: *number_or_entity_temperature - unit: *condition_unit_temperature + threshold: + required: true + selector: + numeric_threshold: + entity: *temperature_threshold_entity + mode: is + number: + mode: box + unit_of_measurement: *temperature_units diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index ab3590199310a5..5660564fe183cd 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted water heaters.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "trigger_behavior_description": "The behavior of the targeted water heaters to trigger on.", "trigger_behavior_name": "Behavior", "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", @@ -46,21 +48,13 @@ "is_target_temperature": { "description": "Tests the temperature setpoint of one or more water heaters.", "fields": { - "above": { - "description": "Require the target temperature to be above this value.", - "name": "Above" - }, "behavior": { "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, - "below": { - "description": "Require the target temperature to be below this value.", - "name": "Below" - }, - "unit": { - "description": "All values will be converted to this unit when evaluating the condition.", - "name": "Unit of measurement" + "threshold": { + "description": "[%key:component::water_heater::common::condition_threshold_description%]", + "name": "[%key:component::water_heater::common::condition_threshold_name%]" } }, "name": "Water heater target temperature" @@ -140,12 +134,6 @@ "any": "Any" } }, - "number_or_entity": { - "choices": { - "entity": "Entity", - "number": "Number" - } - }, "trigger_behavior": { "options": { "any": "Any", diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index 318e920fb1c606..83f827ad75e55c 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -3,16 +3,15 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from enum import Enum -from typing import Any, Final +from typing import Any, Final, Self import voluptuous as vol from homeassistant.const import CONF_OPTIONS from homeassistant.core import HomeAssistant, split_entity_id -from . import config_validation as cv from .entity import get_device_class_or_undefined -from .typing import ConfigType +from .typing import UNDEFINED, ConfigType, UndefinedType CONF_UNIT: Final = "unit" @@ -145,41 +144,29 @@ def move_options_fields_to_top_level( return new_config -_NUMBER_OR_ENTITY_CHOOSE_SCHEMA = vol.Schema( - { - vol.Required("active_choice"): vol.In(["number", "entity"]), - vol.Optional("entity"): cv.entity_id, - vol.Optional("number"): vol.Coerce(float), - } -) - - -def _validate_number_or_entity(value: dict | float | str) -> float | str: - """Validate number or entity selector result.""" - if isinstance(value, dict): - _NUMBER_OR_ENTITY_CHOOSE_SCHEMA(value) - return value[value["active_choice"]] # type: ignore[no-any-return] - return value - - -number_or_entity = vol.All( - _validate_number_or_entity, vol.Any(vol.Coerce(float), cv.entity_id) -) - - -def validate_unit_set_if_range_numerical[_T: dict[str, Any]]( - lower_limit: str, upper_limit: str -) -> Callable[[_T], _T]: - """Validate that unit is set if upper or lower limit is numerical.""" - - def _validate_unit_set_if_range_numerical_impl(options: _T) -> _T: - if ( - any( - opt in options and not isinstance(options[opt], str) - for opt in (lower_limit, upper_limit) - ) - ) and CONF_UNIT not in options: - raise vol.Invalid("Unit must be specified when using numerical thresholds.") - return options - - return _validate_unit_set_if_range_numerical_impl +@dataclass(frozen=True, kw_only=True) +class ThresholdConfig: + """Configuration for threshold conditions and triggers.""" + + numerical: bool + entity: str | None + number: float | None + unit: str | None | UndefinedType + + @classmethod + def from_config(cls, config: dict[str, Any] | None) -> Self | None: + """Create ThresholdConfig from config dict.""" + if config is None: + return None + + entity: str | None = None + number: float | None = None + unit: str | None | UndefinedType = UNDEFINED + numerical = "number" in config + if numerical: + number = config["number"] + unit = config.get("unit_of_measurement", UNDEFINED) + else: + entity = config["entity"] + + return cls(numerical=numerical, number=number, entity=entity, unit=unit) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 0456629a0b16a1..967ddefe1b84ad 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -78,17 +78,21 @@ from . import config_validation as cv, entity_registry as er, selector from .automation import ( - CONF_UNIT, DomainSpec, + ThresholdConfig, filter_by_domain_specs, get_absolute_description_key, get_relative_description_key, move_options_fields_to_top_level, - number_or_entity, - validate_unit_set_if_range_numerical, ) from .integration_platform import async_process_integration_platforms -from .selector import TargetSelector +from .selector import ( + NumericThresholdMode, + NumericThresholdSelector, + NumericThresholdSelectorConfig, + NumericThresholdType, + TargetSelector, +) from .target import TargetSelection, async_extract_referenced_entity_ids from .template import Template, render_complex from .trace import ( @@ -458,22 +462,6 @@ class CustomCondition(EntityStateConditionBase): return CustomCondition -def _validate_above_below(config: dict[str, Any]) -> dict[str, Any]: - """Validate that above < below when both are set.""" - above = config.get(CONF_ABOVE) - below = config.get(CONF_BELOW) - if above is None or below is None: - return config - if isinstance(above, str) or isinstance(below, str): - return config - if above >= below: - raise vol.Invalid( - f"A value can never be above {above} and below {below} at the same" - " time. You probably want two different conditions." - ) - return config - - NUMERICAL_CONDITION_SCHEMA = vol.Schema( { vol.Required(CONF_TARGET): cv.TARGET_FIELDS, @@ -482,11 +470,10 @@ def _validate_above_below(config: dict[str, Any]) -> dict[str, Any]: vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( [BEHAVIOR_ANY, BEHAVIOR_ALL] ), - vol.Optional(CONF_ABOVE): number_or_entity, - vol.Optional(CONF_BELOW): number_or_entity, + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS) + ), }, - cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), - _validate_above_below, ), } ) @@ -503,8 +490,15 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: super().__init__(hass, config) if TYPE_CHECKING: assert config.options is not None - self._above: float | str | None = config.options.get(CONF_ABOVE) - self._below: float | str | None = config.options.get(CONF_BELOW) + threshold_options: dict[str, Any] = config.options["threshold"] + self.threshold = ThresholdConfig.from_config(threshold_options.get("value")) + self.lower_threshold = ThresholdConfig.from_config( + threshold_options.get("value_min") + ) + self.upper_threshold = ThresholdConfig.from_config( + threshold_options.get("value_max") + ) + self._threshold_type = threshold_options["type"] def _is_valid_unit(self, unit: str | None) -> bool: """Check if the given unit is valid for this condition.""" @@ -512,20 +506,26 @@ def _is_valid_unit(self, unit: str | None) -> bool: return True return unit == self._valid_unit - def _get_numerical_value(self, entity_or_float: float | str) -> float | None: - """Get numerical value from float or entity state.""" - if isinstance(entity_or_float, str): - if not (ref_state := self._hass.states.get(entity_or_float)): - return None - if not self._is_valid_unit( - ref_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - ): - return None - try: - return float(ref_state.state) - except TypeError, ValueError: - return None - return entity_or_float + def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None: + """Get threshold value from float or entity state.""" + if threshold is None: + return None + if threshold.numerical: + return threshold.number + + if not (entity_state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type] + # Entity not found + return None + if not self._is_valid_unit( + entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + ): + # Entity unit does not match the expected unit + return None + try: + return float(entity_state.state) + except TypeError, ValueError: + # Entity state is not a valid number + return None def _get_tracked_value(self, entity_state: State) -> Any: """Get the tracked value from a state, with unit validation for state-based values.""" @@ -545,17 +545,27 @@ def is_valid_state(self, entity_state: State) -> bool: except TypeError, ValueError: return False - if self._above is not None: - if (above := self._get_numerical_value(self._above)) is None: - return False - if value <= above: + if self._threshold_type == NumericThresholdType.ABOVE: + if (limit := self._get_threshold_value(self.threshold)) is None: + # Entity not found or invalid number, don't trigger return False - if self._below is not None: - if (below := self._get_numerical_value(self._below)) is None: + return value > limit + if self._threshold_type == NumericThresholdType.BELOW: + if (limit := self._get_threshold_value(self.threshold)) is None: + # Entity not found or invalid number, don't trigger return False - if value >= below: - return False - return True + return value < limit + + # Mode is BETWEEN or OUTSIDE + lower_limit = self._get_threshold_value(self.lower_threshold) + upper_limit = self._get_threshold_value(self.upper_threshold) + if lower_limit is None or upper_limit is None: + # Entity not found or invalid number, don't trigger + return False + between = lower_limit < value < upper_limit + if self._threshold_type == NumericThresholdType.BETWEEN: + return between + return not between def make_entity_numerical_condition( @@ -586,13 +596,13 @@ def _make_numerical_condition_with_unit_schema( vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( [BEHAVIOR_ANY, BEHAVIOR_ALL] ), - vol.Optional(CONF_ABOVE): number_or_entity, - vol.Optional(CONF_BELOW): number_or_entity, - vol.Optional(CONF_UNIT): vol.In(unit_converter.VALID_UNITS), + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig( + mode=NumericThresholdMode.IS, + unit_of_measurement=list(unit_converter.VALID_UNITS), + ) + ), }, - cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), - _validate_above_below, - validate_unit_set_if_range_numerical(CONF_ABOVE, CONF_BELOW), ), } ) @@ -602,16 +612,8 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase): """Condition for numerical state comparisons with unit conversion.""" _base_unit: str | None # Base unit for the tracked value - _manual_limit_unit: str | None # Unit of above/below limits when numbers _unit_converter: type[BaseUnitConverter] - def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: - """Initialize the numerical condition with unit conversion.""" - super().__init__(hass, config) - if TYPE_CHECKING: - assert config.options is not None - self._manual_limit_unit = config.options.get(CONF_UNIT) - def __init_subclass__(cls, **kwargs: Any) -> None: """Create a schema.""" super().__init_subclass__(**kwargs) @@ -621,25 +623,34 @@ def _get_entity_unit(self, entity_state: State) -> str | None: """Get the unit of an entity from its state.""" return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - def _get_numerical_value(self, entity_or_float: float | str) -> float | None: - """Get numerical value from float or entity state.""" - if isinstance(entity_or_float, (int, float)): + def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None: + """Get threshold value from float or entity state.""" + if threshold is None: + return None + if threshold.numerical: return self._unit_converter.convert( - entity_or_float, self._manual_limit_unit, self._base_unit + threshold.number, # type: ignore[arg-type] + threshold.unit, # type: ignore[arg-type] + self._base_unit, ) - if not (_state := self._hass.states.get(entity_or_float)): + if not (entity_state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type] + # Entity not found return None try: - value = float(_state.state) + value = float(entity_state.state) except TypeError, ValueError: + # Entity state is not a valid number return None try: return self._unit_converter.convert( - value, _state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), self._base_unit + value, + entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + self._base_unit, ) except HomeAssistantError: + # Unit conversion failed (i.e. incompatible units), treat as invalid number return None def _get_tracked_value(self, entity_state: State) -> Any: @@ -663,23 +674,6 @@ def _get_tracked_value(self, entity_state: State) -> Any: except HomeAssistantError: return None - def is_valid_state(self, entity_state: State) -> bool: - """Check if the state is within the specified range.""" - if (value := self._get_tracked_value(entity_state)) is None: - return False - - if self._above is not None: - if (above := self._get_numerical_value(self._above)) is None: - return False - if value <= above: - return False - if self._below is not None: - if (below := self._get_numerical_value(self._below)) is None: - return False - if value >= below: - return False - return True - def make_entity_numerical_condition_with_unit( domain_specs: Mapping[str, DomainSpec], diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 25a0c35b8cbb06..fefbc416cb12c9 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -16,7 +16,6 @@ Final, Literal, Protocol, - Self, TypedDict, cast, override, @@ -70,6 +69,7 @@ from .automation import ( DomainSpec, NumericalDomainSpec, + ThresholdConfig, filter_by_domain_specs, get_absolute_description_key, get_relative_description_key, @@ -535,34 +535,6 @@ def is_valid_state(self, state: State) -> bool: ) -@dataclass(frozen=True, kw_only=True) -class ThresholdConfig: - """Configuration for threshold triggers.""" - - numerical: bool - entity: str | None - number: float | None - unit: str | None | UndefinedType - - @classmethod - def from_config(cls, config: dict[str, Any] | None) -> Self | None: - """Create ThresholdConfig from config dict.""" - if config is None: - return None - - entity: str | None = None - number: float | None = None - unit: str | None | UndefinedType = UNDEFINED - numerical = "number" in config - if numerical: - number = config["number"] - unit = config.get("unit_of_measurement", UNDEFINED) - else: - entity = config["entity"] - - return cls(numerical=numerical, number=number, entity=entity, unit=unit) - - class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]): """Base class for numerical state and state attribute triggers.""" diff --git a/tests/components/air_quality/test_condition.py b/tests/components/air_quality/test_condition.py index 1ba70a346c52fa..61eda516827693 100644 --- a/tests/components/air_quality/test_condition.py +++ b/tests/components/air_quality/test_condition.py @@ -11,8 +11,6 @@ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - CONF_ABOVE, - CONF_BELOW, STATE_OFF, STATE_ON, ) @@ -32,11 +30,9 @@ target_entities, ) -_UGM3_CONDITION_OPTIONS = {"unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER} _UGM3_UNIT_ATTRIBUTES = { ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER } -_PPB_CONDITION_OPTIONS = {"unit": CONCENTRATION_PARTS_PER_BILLION} _PPB_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION} _PPM_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION} @@ -241,43 +237,43 @@ async def test_air_quality_binary_condition_behavior_all( *parametrize_numerical_condition_above_below_any( "air_quality.is_co_value", device_class="carbon_monoxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_ozone_value", device_class="ozone", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_voc_value", device_class="volatile_organic_compounds", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_voc_ratio_value", device_class="volatile_organic_compounds_parts", - condition_options=_PPB_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_PARTS_PER_BILLION, unit_attributes=_PPB_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_no_value", device_class="nitrogen_monoxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_no2_value", device_class="nitrogen_dioxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_any( "air_quality.is_so2_value", device_class="sulphur_dioxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), ], @@ -316,43 +312,43 @@ async def test_air_quality_numerical_with_unit_condition_behavior_any( *parametrize_numerical_condition_above_below_all( "air_quality.is_co_value", device_class="carbon_monoxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_ozone_value", device_class="ozone", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_voc_value", device_class="volatile_organic_compounds", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_voc_ratio_value", device_class="volatile_organic_compounds_parts", - condition_options=_PPB_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_PARTS_PER_BILLION, unit_attributes=_PPB_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_no_value", device_class="nitrogen_monoxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_no2_value", device_class="nitrogen_dioxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), *parametrize_numerical_condition_above_below_all( "air_quality.is_so2_value", device_class="sulphur_dioxide", - condition_options=_UGM3_CONDITION_OPTIONS, + threshold_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, unit_attributes=_UGM3_UNIT_ATTRIBUTES, ), ], @@ -539,19 +535,38 @@ async def test_air_quality_condition_unit_conversion_co( ], numerical_condition_options=[ { - CONF_ABOVE: 0.2, - CONF_BELOW: 0.8, - "unit": CONCENTRATION_PARTS_PER_MILLION, + "threshold": { + "type": "between", + "value_min": { + "number": 0.2, + "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION, + }, + "value_max": { + "number": 0.8, + "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION, + }, + } }, { - CONF_ABOVE: 200, - CONF_BELOW: 800, - "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "threshold": { + "type": "between", + "value_min": { + "number": 200, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + "value_max": { + "number": 800, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + } }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 40f8407dfe55c7..2d1305b4850e67 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -13,8 +13,6 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -34,8 +32,6 @@ target_entities, ) -_TEMPERATURE_CONDITION_OPTIONS = {"unit": UnitOfTemperature.CELSIUS} - @pytest.fixture async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]: @@ -275,7 +271,7 @@ async def test_climate_attribute_condition_behavior_all( "climate.target_temperature", HVACMode.AUTO, ATTR_TEMPERATURE, - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ], ) @@ -319,7 +315,7 @@ async def test_climate_numerical_condition_behavior_any( "climate.target_temperature", HVACMode.AUTO, ATTR_TEMPERATURE, - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ], ) @@ -365,12 +361,39 @@ async def test_climate_numerical_condition_unit_conversion(hass: HomeAssistant) } ], numerical_condition_options=[ - {CONF_ABOVE: 75, CONF_BELOW: 90, "unit": UnitOfTemperature.FAHRENHEIT}, - {CONF_ABOVE: 24, CONF_BELOW: 30, "unit": UnitOfTemperature.CELSIUS}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 75, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + "value_max": { + "number": 90, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 24, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + "value_max": { + "number": 30, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/components/common.py b/tests/components/common.py index f3b0a0a0b7b212..8611cd30333a57 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -14,8 +14,6 @@ ATTR_FLOOR_ID, ATTR_LABEL_ID, ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, CONF_CONDITION, CONF_ENTITY_ID, CONF_OPTIONS, @@ -1300,6 +1298,7 @@ def parametrize_numerical_condition_above_below_any( *, device_class: str, condition_options: dict[str, Any] | None = None, + threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below threshold test cases for numerical conditions. @@ -1315,7 +1314,13 @@ def parametrize_numerical_condition_above_below_any( return [ *parametrize_condition_states_any( condition=condition, - condition_options={CONF_ABOVE: 20, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "above", "value": {"number": 20}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("21", unit_attributes), ("50", unit_attributes), @@ -1330,7 +1335,13 @@ def parametrize_numerical_condition_above_below_any( ), *parametrize_condition_states_any( condition=condition, - condition_options={CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "below", "value": {"number": 80}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("0", unit_attributes), ("50", unit_attributes), @@ -1345,7 +1356,17 @@ def parametrize_numerical_condition_above_below_any( ), *parametrize_condition_states_any( condition=condition, - condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + }, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("21", unit_attributes), ("50", unit_attributes), @@ -1367,6 +1388,7 @@ def parametrize_numerical_condition_above_below_all( *, device_class: str, condition_options: dict[str, Any] | None = None, + threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below threshold test cases for numerical conditions with 'all' behavior. @@ -1382,7 +1404,13 @@ def parametrize_numerical_condition_above_below_all( return [ *parametrize_condition_states_all( condition=condition, - condition_options={CONF_ABOVE: 20, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "above", "value": {"number": 20}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("21", unit_attributes), ("50", unit_attributes), @@ -1397,7 +1425,13 @@ def parametrize_numerical_condition_above_below_all( ), *parametrize_condition_states_all( condition=condition, - condition_options={CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "below", "value": {"number": 80}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("0", unit_attributes), ("50", unit_attributes), @@ -1412,7 +1446,17 @@ def parametrize_numerical_condition_above_below_all( ), *parametrize_condition_states_all( condition=condition, - condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + }, + **condition_options, + }, + threshold_unit, + ), target_states=[ ("21", unit_attributes), ("50", unit_attributes), @@ -1436,6 +1480,7 @@ def parametrize_numerical_attribute_condition_above_below_any( *, condition_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, + threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below threshold test cases for attribute-based numerical conditions. @@ -1448,7 +1493,13 @@ def parametrize_numerical_attribute_condition_above_below_any( return [ *parametrize_condition_states_any( condition=condition, - condition_options={CONF_ABOVE: 20, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "above", "value": {"number": 20}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 21} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1463,7 +1514,13 @@ def parametrize_numerical_attribute_condition_above_below_any( ), *parametrize_condition_states_any( condition=condition, - condition_options={CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "below", "value": {"number": 80}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 0} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1478,7 +1535,17 @@ def parametrize_numerical_attribute_condition_above_below_any( ), *parametrize_condition_states_any( condition=condition, - condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + }, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 21} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1502,6 +1569,7 @@ def parametrize_numerical_attribute_condition_above_below_all( *, condition_options: dict[str, Any] | None = None, required_filter_attributes: dict | None = None, + threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below threshold test cases for attribute-based numerical conditions with 'all' behavior. @@ -1514,7 +1582,13 @@ def parametrize_numerical_attribute_condition_above_below_all( return [ *parametrize_condition_states_all( condition=condition, - condition_options={CONF_ABOVE: 20, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "above", "value": {"number": 20}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 21} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1529,7 +1603,13 @@ def parametrize_numerical_attribute_condition_above_below_all( ), *parametrize_condition_states_all( condition=condition, - condition_options={CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": {"type": "below", "value": {"number": 80}}, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 0} | unit_attributes), (state, {attribute: 50} | unit_attributes), @@ -1544,7 +1624,17 @@ def parametrize_numerical_attribute_condition_above_below_all( ), *parametrize_condition_states_all( condition=condition, - condition_options={CONF_ABOVE: 20, CONF_BELOW: 80, **condition_options}, + condition_options=_add_threshold_unit( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + }, + **condition_options, + }, + threshold_unit, + ), target_states=[ (state, {attribute: 21} | unit_attributes), (state, {attribute: 50} | unit_attributes), diff --git a/tests/components/power/test_condition.py b/tests/components/power/test_condition.py index 6fb9a76a9756fd..b469d7ac2b6323 100644 --- a/tests/components/power/test_condition.py +++ b/tests/components/power/test_condition.py @@ -4,12 +4,7 @@ import pytest -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, - UnitOfPower, -) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower from homeassistant.core import HomeAssistant from tests.components.common import ( @@ -24,8 +19,6 @@ target_entities, ) -_POWER_CONDITION_OPTIONS = {"unit": UnitOfPower.WATT} - @pytest.fixture async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: @@ -60,7 +53,7 @@ async def test_power_conditions_gated_by_labs_flag( parametrize_numerical_condition_above_below_any( "power.is_value", device_class="power", - condition_options=_POWER_CONDITION_OPTIONS, + threshold_unit=UnitOfPower.WATT, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, ), ) @@ -97,7 +90,7 @@ async def test_power_sensor_condition_behavior_any( parametrize_numerical_condition_above_below_all( "power.is_value", device_class="power", - condition_options=_POWER_CONDITION_OPTIONS, + threshold_unit=UnitOfPower.WATT, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, ), ) @@ -134,7 +127,7 @@ async def test_power_sensor_condition_behavior_all( parametrize_numerical_condition_above_below_any( "power.is_value", device_class="power", - condition_options=_POWER_CONDITION_OPTIONS, + threshold_unit=UnitOfPower.WATT, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, ), ) @@ -171,7 +164,7 @@ async def test_power_number_condition_behavior_any( parametrize_numerical_condition_above_below_all( "power.is_value", device_class="power", - condition_options=_POWER_CONDITION_OPTIONS, + threshold_unit=UnitOfPower.WATT, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, ), ) @@ -230,12 +223,39 @@ async def test_power_condition_unit_conversion_sensor( } ], numerical_condition_options=[ - {CONF_ABOVE: 0.2, CONF_BELOW: 0.8, "unit": UnitOfPower.KILO_WATT}, - {CONF_ABOVE: 200, CONF_BELOW: 800, "unit": UnitOfPower.WATT}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 0.2, + "unit_of_measurement": UnitOfPower.KILO_WATT, + }, + "value_max": { + "number": 0.8, + "unit_of_measurement": UnitOfPower.KILO_WATT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 200, + "unit_of_measurement": UnitOfPower.WATT, + }, + "value_max": { + "number": 800, + "unit_of_measurement": UnitOfPower.WATT, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/components/temperature/test_condition.py b/tests/components/temperature/test_condition.py index 9faafd73a836db..96199ea2c881e7 100644 --- a/tests/components/temperature/test_condition.py +++ b/tests/components/temperature/test_condition.py @@ -6,12 +6,7 @@ from homeassistant.components.climate import HVACMode from homeassistant.components.weather import ATTR_WEATHER_TEMPERATURE_UNIT -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, - UnitOfTemperature, -) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant from tests.components.common import ( @@ -28,7 +23,6 @@ target_entities, ) -_TEMPERATURE_CONDITION_OPTIONS = {"unit": UnitOfTemperature.CELSIUS} _WEATHER_UNIT_ATTRIBUTES = {ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.CELSIUS} @@ -77,7 +71,7 @@ async def test_temperature_conditions_gated_by_labs_flag( parametrize_numerical_condition_above_below_any( "temperature.is_value", device_class="temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ), ) @@ -114,7 +108,7 @@ async def test_temperature_sensor_condition_behavior_any( parametrize_numerical_condition_above_below_all( "temperature.is_value", device_class="temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ), ) @@ -152,7 +146,7 @@ async def test_temperature_sensor_condition_behavior_all( "temperature.is_value", HVACMode.AUTO, "current_temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ) async def test_temperature_climate_condition_behavior_any( @@ -189,7 +183,7 @@ async def test_temperature_climate_condition_behavior_any( "temperature.is_value", HVACMode.AUTO, "current_temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ) async def test_temperature_climate_condition_behavior_all( @@ -226,7 +220,7 @@ async def test_temperature_climate_condition_behavior_all( "temperature.is_value", "eco", "current_temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ) async def test_temperature_water_heater_condition_behavior_any( @@ -263,7 +257,7 @@ async def test_temperature_water_heater_condition_behavior_any( "temperature.is_value", "eco", "current_temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ) async def test_temperature_water_heater_condition_behavior_all( @@ -300,7 +294,7 @@ async def test_temperature_water_heater_condition_behavior_all( "temperature.is_value", "sunny", "temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, ), ) @@ -338,7 +332,7 @@ async def test_temperature_weather_condition_behavior_any( "temperature.is_value", "sunny", "temperature", - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, ), ) @@ -397,12 +391,39 @@ async def test_temperature_condition_unit_conversion_sensor( } ], numerical_condition_options=[ - {CONF_ABOVE: 75, CONF_BELOW: 90, "unit": UnitOfTemperature.FAHRENHEIT}, - {CONF_ABOVE: 24, CONF_BELOW: 30, "unit": UnitOfTemperature.CELSIUS}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 75, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + "value_max": { + "number": 90, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 24, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + "value_max": { + "number": 30, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ @@ -448,12 +469,39 @@ async def test_temperature_condition_unit_conversion_climate( {"state": HVACMode.AUTO, "attributes": {"current_temperature": 20}} ], numerical_condition_options=[ - {CONF_ABOVE: 75, CONF_BELOW: 90, "unit": UnitOfTemperature.FAHRENHEIT}, - {CONF_ABOVE: 24, CONF_BELOW: 30, "unit": UnitOfTemperature.CELSIUS}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 75, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + "value_max": { + "number": 90, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 24, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + "value_max": { + "number": 30, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/components/water_heater/test_condition.py b/tests/components/water_heater/test_condition.py index 7c54c039eb379f..f4965ec70b3ea2 100644 --- a/tests/components/water_heater/test_condition.py +++ b/tests/components/water_heater/test_condition.py @@ -15,8 +15,6 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, STATE_OFF, STATE_ON, UnitOfTemperature, @@ -37,8 +35,6 @@ target_entities, ) -_TEMPERATURE_CONDITION_OPTIONS = {"unit": UnitOfTemperature.CELSIUS} - _ALL_STATES = [ STATE_ECO, STATE_ELECTRIC, @@ -205,7 +201,7 @@ async def test_water_heater_state_condition_behavior_all( "water_heater.is_target_temperature", "eco", ATTR_TEMPERATURE, - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ], ) @@ -244,7 +240,7 @@ async def test_water_heater_numerical_condition_behavior_any( "water_heater.is_target_temperature", "eco", ATTR_TEMPERATURE, - condition_options=_TEMPERATURE_CONDITION_OPTIONS, + threshold_unit=UnitOfTemperature.CELSIUS, ), ], ) @@ -292,12 +288,39 @@ async def test_water_heater_numerical_condition_unit_conversion( } ], numerical_condition_options=[ - {CONF_ABOVE: 120, CONF_BELOW: 140, "unit": UnitOfTemperature.FAHRENHEIT}, - {CONF_ABOVE: 49, CONF_BELOW: 60, "unit": UnitOfTemperature.CELSIUS}, + { + "threshold": { + "type": "between", + "value_min": { + "number": 120, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + "value_max": { + "number": 140, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + }, + } + }, + { + "threshold": { + "type": "between", + "value_min": { + "number": 49, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + "value_max": { + "number": 60, + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + } + }, ], limit_entity_condition_options={ - CONF_ABOVE: "sensor.above", - CONF_BELOW: "sensor.below", + "threshold": { + "type": "between", + "value_min": {"entity": "sensor.above"}, + "value_max": {"entity": "sensor.below"}, + } }, limit_entities=("sensor.above", "sensor.below"), limit_entity_states=[ diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 081a2b4b70b961..f454d51c48f464 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,6 +1,7 @@ """Test the condition helper.""" from collections.abc import Mapping +from contextlib import AbstractContextManager, nullcontext as does_not_raise from datetime import timedelta import io from typing import Any @@ -21,8 +22,6 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, - CONF_ABOVE, - CONF_BELOW, CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, @@ -50,7 +49,6 @@ ATTR_BEHAVIOR, BEHAVIOR_ALL, BEHAVIOR_ANY, - CONF_UNIT, Condition, ConditionChecker, EntityNumericalConditionWithUnitBase, @@ -3059,19 +3057,69 @@ async def async_get_conditions( ("condition_options", "state_value", "expected"), [ # above only - ({CONF_ABOVE: 50}, "75", True), - ({CONF_ABOVE: 50}, "50", False), - ({CONF_ABOVE: 50}, "25", False), + ({"threshold": {"type": "above", "value": {"number": 50}}}, "75", True), + ({"threshold": {"type": "above", "value": {"number": 50}}}, "50", False), + ({"threshold": {"type": "above", "value": {"number": 50}}}, "25", False), # below only - ({CONF_BELOW: 50}, "25", True), - ({CONF_BELOW: 50}, "50", False), - ({CONF_BELOW: 50}, "75", False), + ({"threshold": {"type": "below", "value": {"number": 50}}}, "25", True), + ({"threshold": {"type": "below", "value": {"number": 50}}}, "50", False), + ({"threshold": {"type": "below", "value": {"number": 50}}}, "75", False), # above and below (range) - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "50", True), - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "20", False), - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "80", False), - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "10", False), - ({CONF_ABOVE: 20, CONF_BELOW: 80}, "90", False), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "50", + True, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "20", + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "80", + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "10", + False, + ), + ( + { + "threshold": { + "type": "between", + "value_min": {"number": 20}, + "value_max": {"number": 80}, + } + }, + "90", + False, + ), ], ) async def test_numerical_condition_thresholds( @@ -3101,7 +3149,7 @@ async def test_numerical_condition_invalid_state( """Test numerical condition with non-numeric or unavailable state values.""" test = await _setup_numerical_condition( hass, - condition_options={CONF_ABOVE: 50}, + condition_options={"threshold": {"type": "above", "value": {"number": 50}}}, entity_ids="test.entity_1", ) @@ -3116,7 +3164,7 @@ async def test_numerical_condition_attribute_value_source( test = await _setup_numerical_condition( hass, domain_specs={"test": DomainSpec(value_source="brightness")}, - condition_options={CONF_ABOVE: 100}, + condition_options={"threshold": {"type": "above", "value": {"number": 100}}}, entity_ids="test.entity_1", ) @@ -3145,7 +3193,7 @@ async def test_numerical_condition_attribute_value_source_skips_unit_check( test = await _setup_numerical_condition( hass, domain_specs={"test": DomainSpec(value_source="humidity")}, - condition_options={CONF_ABOVE: 50}, + condition_options={"threshold": {"type": "above", "value": {"number": 50}}}, entity_ids="test.entity_1", valid_unit="%", ) @@ -3184,7 +3232,7 @@ async def test_numerical_condition_valid_unit( """Test numerical condition valid_unit filtering.""" test = await _setup_numerical_condition( hass, - condition_options={CONF_ABOVE: 50}, + condition_options={"threshold": {"type": "above", "value": {"number": 50}}}, entity_ids="test.entity_1", valid_unit=valid_unit, ) @@ -3209,7 +3257,10 @@ async def test_numerical_condition_behavior( """Test numerical condition with behavior any/all.""" test = await _setup_numerical_condition( hass, - condition_options={CONF_ABOVE: 50, ATTR_BEHAVIOR: behavior}, + condition_options={ + "threshold": {"type": "above", "value": {"number": 50}}, + ATTR_BEHAVIOR: behavior, + }, entity_ids=["test.entity_1", "test.entity_2"], ) @@ -3253,16 +3304,17 @@ async def async_get_conditions( @pytest.mark.parametrize( - ("above", "below"), + ("above", "below", "expected_result"), [ - (10.0, 10.0), - (20.0, 10.0), + (10.0, 10.0, does_not_raise()), + (20.0, 10.0, pytest.raises(vol.Invalid, match="must not be greater")), ], ) async def test_numerical_condition_schema_above_must_be_less_than_below( hass: HomeAssistant, above: float, below: float, + expected_result: AbstractContextManager, ) -> None: """Test numerical condition schema rejects above >= below.""" condition_cls = make_entity_numerical_condition({"test": DomainSpec()}) @@ -3280,9 +3332,15 @@ async def async_get_conditions( config: dict[str, Any] = { CONF_CONDITION: "test", CONF_TARGET: {CONF_ENTITY_ID: "test.entity_1"}, - CONF_OPTIONS: {CONF_ABOVE: above, CONF_BELOW: below}, + CONF_OPTIONS: { + "threshold": { + "type": "between", + "value_min": {"number": above}, + "value_max": {"number": below}, + } + }, } - with pytest.raises(vol.Invalid, match="can never be above"): + with expected_result: await async_validate_condition_config(hass, config) @@ -3329,42 +3387,102 @@ async def async_get_conditions( [ # above in °F, state in °C (base unit) # 75°F ≈ 23.89°C, so 25°C > 23.89°C → True - ({CONF_ABOVE: 75, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "25", True), + ( + { + "threshold": { + "type": "above", + "value": {"number": 75, "unit_of_measurement": "°F"}, + } + }, + "25", + True, + ), # 75°F ≈ 23.89°C, so 20°C < 23.89°C → False - ({CONF_ABOVE: 75, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "20", False), + ( + { + "threshold": { + "type": "above", + "value": {"number": 75, "unit_of_measurement": "°F"}, + } + }, + "20", + False, + ), # below in °F, state in °C # 70°F ≈ 21.11°C, so 20°C < 21.11°C → True - ({CONF_BELOW: 70, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "20", True), + ( + { + "threshold": { + "type": "below", + "value": {"number": 70, "unit_of_measurement": "°F"}, + } + }, + "20", + True, + ), # 70°F ≈ 21.11°C, so 25°C > 21.11°C → False - ({CONF_BELOW: 70, CONF_UNIT: UnitOfTemperature.FAHRENHEIT}, "25", False), + ( + { + "threshold": { + "type": "below", + "value": {"number": 70, "unit_of_measurement": "°F"}, + } + }, + "25", + False, + ), # above in °C (same as base), state in °C - ({CONF_ABOVE: 20, CONF_UNIT: UnitOfTemperature.CELSIUS}, "25", True), - ({CONF_ABOVE: 20, CONF_UNIT: UnitOfTemperature.CELSIUS}, "15", False), + ( + { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°C"}, + } + }, + "25", + True, + ), + ( + { + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°C"}, + } + }, + "15", + False, + ), # range with unit conversion # 60°F ≈ 15.56°C, 80°F ≈ 26.67°C ( { - CONF_ABOVE: 60, - CONF_BELOW: 80, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "between", + "value_min": {"number": 60, "unit_of_measurement": "°F"}, + "value_max": {"number": 80, "unit_of_measurement": "°F"}, + } }, "20", True, ), ( { - CONF_ABOVE: 60, - CONF_BELOW: 80, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "between", + "value_min": {"number": 60, "unit_of_measurement": "°F"}, + "value_max": {"number": 80, "unit_of_measurement": "°F"}, + } }, "10", False, ), ( { - CONF_ABOVE: 60, - CONF_BELOW: 80, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "between", + "value_min": {"number": 60, "unit_of_measurement": "°F"}, + "value_max": {"number": 80, "unit_of_measurement": "°F"}, + } }, "30", False, @@ -3399,8 +3517,7 @@ async def test_numerical_condition_with_unit_entity_reference( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: "sensor.temp_limit", - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": {"type": "above", "value": {"entity": "sensor.temp_limit"}}, }, entity_ids="test.entity_1", ) @@ -3435,8 +3552,7 @@ async def test_numerical_condition_with_unit_entity_reference_incompatible_unit( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: "sensor.bad_limit", - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": {"type": "above", "value": {"entity": "sensor.bad_limit"}}, }, entity_ids="test.entity_1", ) @@ -3462,8 +3578,10 @@ async def test_numerical_condition_with_unit_tracked_value_conversion( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: 20, - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°C"}, + } }, entity_ids="test.entity_1", ) @@ -3495,8 +3613,10 @@ async def test_numerical_condition_with_unit_attribute_value_source( "test": NumericalDomainSpec(value_source="temperature"), }, condition_options={ - CONF_ABOVE: 75, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "above", + "value": {"number": 75, "unit_of_measurement": "°F"}, + }, }, entity_ids="test.entity_1", ) @@ -3557,8 +3677,10 @@ async def async_get_conditions( CONF_CONDITION: "test", CONF_TARGET: {CONF_ENTITY_ID: ["test.entity_1"]}, CONF_OPTIONS: { - CONF_ABOVE: 20, - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°C"}, + } }, } config = await async_validate_condition_config(hass, config) @@ -3598,8 +3720,10 @@ async def async_get_conditions( CONF_CONDITION: "test", CONF_TARGET: {CONF_ENTITY_ID: "test.entity_1"}, CONF_OPTIONS: { - CONF_ABOVE: 20, - CONF_UNIT: UnitOfTemperature.FAHRENHEIT, + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "°F"}, + } }, } result = await async_validate_condition_config(hass, config) @@ -3629,8 +3753,10 @@ async def async_get_conditions( CONF_CONDITION: "test", CONF_TARGET: {CONF_ENTITY_ID: "test.entity_1"}, CONF_OPTIONS: { - CONF_ABOVE: 20, - CONF_UNIT: "%", + "threshold": { + "type": "above", + "value": {"number": 20, "unit_of_measurement": "%"}, + } }, } with pytest.raises(vol.Invalid): @@ -3648,8 +3774,10 @@ async def test_numerical_condition_with_unit_invalid_state( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: 50, - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": { + "type": "above", + "value": {"number": 50, "unit_of_measurement": "°C"}, + }, }, entity_ids="test.entity_1", ) @@ -3669,8 +3797,7 @@ async def test_numerical_condition_with_unit_missing_entity_reference( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: "sensor.nonexistent", - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": {"type": "above", "value": {"entity": "sensor.nonexistent"}} }, entity_ids="test.entity_1", ) @@ -3699,9 +3826,11 @@ async def test_numerical_condition_with_unit_behavior( test = await _setup_numerical_condition_with_unit( hass, condition_options={ - CONF_ABOVE: 50, ATTR_BEHAVIOR: behavior, - CONF_UNIT: UnitOfTemperature.CELSIUS, + "threshold": { + "type": "above", + "value": {"number": 50, "unit_of_measurement": "°C"}, + }, }, entity_ids=["test.entity_1", "test.entity_2"], ) From 33180a658a6264e9c4cecfd0fe321bc7205f5123 Mon Sep 17 00:00:00 2001 From: balloob-travel Date: Thu, 26 Mar 2026 05:44:30 +0900 Subject: [PATCH 0009/1707] Validate port ranges in URL validator (#166059) Co-authored-by: Paulus Schoutsen Co-authored-by: Franck Nijhof Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/config_validation.py | 11 ++++++++--- tests/helpers/test_config_validation.py | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 36424a06b2bb1b..f9b536a91417a4 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -868,11 +868,16 @@ def url( ) -> str: """Validate an URL.""" url_in = str(value) + parsed = urlparse(url_in) - if urlparse(url_in).scheme in _schema_list: - return cast(str, vol.Schema(vol.Url())(url_in)) + if parsed.scheme not in _schema_list: + raise vol.Invalid("invalid url") - raise vol.Invalid("invalid url") + try: + _port = parsed.port + except ValueError as err: + raise vol.Invalid("invalid url") from err + return cast(str, vol.Schema(vol.Url())(url_in)) def configuration_url(value: Any) -> str: diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 0630c584989eaf..7b0e220bb17a9a 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -172,6 +172,7 @@ def test_url_no_path() -> None: for value in ( "https://localhost/test/index.html", "http://home-assistant.io/test/", + "http://invalid-port.local:999999", ): with pytest.raises(vol.MultipleInvalid): schema(value) From c80a9aab7152e60f0ba3f090323a6d36b48a5d14 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Mar 2026 21:54:34 +0100 Subject: [PATCH 0010/1707] Add trigger water_heater.operation_mode_changed (#166450) --- .../components/water_heater/icons.json | 3 + .../components/water_heater/strings.json | 14 +++++ .../components/water_heater/trigger.py | 40 ++++++++++++- .../components/water_heater/triggers.yaml | 16 ++++++ tests/components/water_heater/test_trigger.py | 57 +++++++++++++++++++ 5 files changed, 128 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/water_heater/icons.json b/homeassistant/components/water_heater/icons.json index 2f4f36ee051d8b..50729c852eb432 100644 --- a/homeassistant/components/water_heater/icons.json +++ b/homeassistant/components/water_heater/icons.json @@ -53,6 +53,9 @@ } }, "triggers": { + "operation_mode_changed": { + "trigger": "mdi:water-boiler" + }, "target_temperature_changed": { "trigger": "mdi:thermometer" }, diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 5660564fe183cd..df8ce5a129708a 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -188,6 +188,20 @@ }, "title": "Water heater", "triggers": { + "operation_mode_changed": { + "description": "Triggers after the operation mode of one or more water heaters changes to a specific mode.", + "fields": { + "behavior": { + "description": "[%key:component::water_heater::common::trigger_behavior_description%]", + "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + }, + "operation_mode": { + "description": "The operation modes to trigger on.", + "name": "Operation mode" + } + }, + "name": "Water heater operation mode changed" + }, "target_temperature_changed": { "description": "Triggers after the temperature setpoint of one or more water heaters changes.", "fields": { diff --git a/homeassistant/components/water_heater/trigger.py b/homeassistant/components/water_heater/trigger.py index a08b5a13befd22..786f5b75016f5b 100644 --- a/homeassistant/components/water_heater/trigger.py +++ b/homeassistant/components/water_heater/trigger.py @@ -1,13 +1,24 @@ """Provides triggers for water heaters.""" -from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature +import voluptuous as vol + +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_OPTIONS, + STATE_OFF, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, EntityNumericalStateChangedTriggerWithUnitBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, EntityNumericalStateTriggerWithUnitBase, + EntityTargetStateTriggerBase, Trigger, + TriggerConfig, make_entity_origin_state_trigger, make_entity_target_state_trigger, ) @@ -15,6 +26,30 @@ from .const import DOMAIN +CONF_OPERATION_MODE = "operation_mode" + +_OPERATION_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_OPERATION_MODE): vol.All( + cv.ensure_list, vol.Length(min=1), [str] + ), + }, + } +) + + +class WaterHeaterOperationModeChangedTrigger(EntityTargetStateTriggerBase): + """Trigger for water heater operation mode changes.""" + + _domain_specs = {DOMAIN: DomainSpec()} + _schema = _OPERATION_MODE_CHANGED_TRIGGER_SCHEMA + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the operation mode changed trigger.""" + super().__init__(hass, config) + self._to_states = set(self._options[CONF_OPERATION_MODE]) + class _WaterHeaterTargetTemperatureTriggerMixin( EntityNumericalStateTriggerWithUnitBase @@ -46,6 +81,7 @@ class WaterHeaterTargetTemperatureCrossedThresholdTrigger( TRIGGERS: dict[str, type[Trigger]] = { + "operation_mode_changed": WaterHeaterOperationModeChangedTrigger, "target_temperature_changed": WaterHeaterTargetTemperatureChangedTrigger, "target_temperature_crossed_threshold": WaterHeaterTargetTemperatureCrossedThresholdTrigger, "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), diff --git a/homeassistant/components/water_heater/triggers.yaml b/homeassistant/components/water_heater/triggers.yaml index 5fcb73c939ed32..581b7dbb58c3cb 100644 --- a/homeassistant/components/water_heater/triggers.yaml +++ b/homeassistant/components/water_heater/triggers.yaml @@ -26,6 +26,22 @@ - domain: number device_class: temperature +operation_mode_changed: + target: *trigger_water_heater_target + fields: + behavior: *trigger_behavior + operation_mode: + context: + filter_target: target + required: true + selector: + state: + attribute: operation_mode + hide_states: + - unavailable + - unknown + multiple: true + turned_off: *trigger_common turned_on: *trigger_common diff --git a/tests/components/water_heater/test_trigger.py b/tests/components/water_heater/test_trigger.py index e21c99e6943942..39c2ffad8b9b5f 100644 --- a/tests/components/water_heater/test_trigger.py +++ b/tests/components/water_heater/test_trigger.py @@ -38,6 +38,8 @@ STATE_PERFORMANCE, ] +ALL_STATES = [STATE_OFF, *ALL_ON_STATES] + @pytest.fixture async def target_water_heaters(hass: HomeAssistant) -> list[str]: @@ -48,6 +50,7 @@ async def target_water_heaters(hass: HomeAssistant) -> list[str]: @pytest.mark.parametrize( "trigger_key", [ + "water_heater.operation_mode_changed", "water_heater.target_temperature_changed", "water_heater.target_temperature_crossed_threshold", "water_heater.turned_off", @@ -69,6 +72,24 @@ async def test_water_heater_triggers_gated_by_labs_flag( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ + *( + param + for mode in ALL_STATES + for param in parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [mode]}, + target_states=[mode], + other_states=[s for s in ALL_STATES if s != mode], + ) + ), + *parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [STATE_ECO, STATE_ELECTRIC]}, + target_states=[STATE_ECO, STATE_ELECTRIC], + other_states=[ + s for s in ALL_STATES if s not in (STATE_ECO, STATE_ELECTRIC) + ], + ), *parametrize_trigger_states( trigger="water_heater.turned_off", target_states=[STATE_OFF], @@ -161,6 +182,24 @@ async def test_water_heater_state_attribute_trigger_behavior_any( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ + *( + param + for mode in ALL_STATES + for param in parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [mode]}, + target_states=[mode], + other_states=[s for s in ALL_STATES if s != mode], + ) + ), + *parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [STATE_ECO, STATE_ELECTRIC]}, + target_states=[STATE_ECO, STATE_ELECTRIC], + other_states=[ + s for s in ALL_STATES if s not in (STATE_ECO, STATE_ELECTRIC) + ], + ), *parametrize_trigger_states( trigger="water_heater.turned_off", target_states=[STATE_OFF], @@ -247,6 +286,24 @@ async def test_water_heater_state_attribute_trigger_behavior_first( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ + *( + param + for mode in ALL_STATES + for param in parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [mode]}, + target_states=[mode], + other_states=[s for s in ALL_STATES if s != mode], + ) + ), + *parametrize_trigger_states( + trigger="water_heater.operation_mode_changed", + trigger_options={"operation_mode": [STATE_ECO, STATE_ELECTRIC]}, + target_states=[STATE_ECO, STATE_ELECTRIC], + other_states=[ + s for s in ALL_STATES if s not in (STATE_ECO, STATE_ELECTRIC) + ], + ), *parametrize_trigger_states( trigger="water_heater.turned_off", target_states=[STATE_OFF], From 269ef5f82449f28ecd2e0256ec8af5f3a67104b0 Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Wed, 25 Mar 2026 21:33:24 +0000 Subject: [PATCH 0011/1707] Bump pyanglianwater to 3.1.2 (#166531) --- .../components/anglian_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/anglian_water/conftest.py | 6 ++-- .../snapshots/test_coordinator.ambr | 34 ++++++++----------- .../anglian_water/snapshots/test_sensor.ambr | 2 +- 6 files changed, 21 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/anglian_water/manifest.json b/homeassistant/components/anglian_water/manifest.json index c81038e9731423..71a0fbd9f787eb 100644 --- a/homeassistant/components/anglian_water/manifest.json +++ b/homeassistant/components/anglian_water/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["pyanglianwater"], "quality_scale": "bronze", - "requirements": ["pyanglianwater==3.1.1"] + "requirements": ["pyanglianwater==3.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 028b32546d1e0e..8ed2c60aab31c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1959,7 +1959,7 @@ pyairobotrest==0.3.0 pyairvisual==2023.08.1 # homeassistant.components.anglian_water -pyanglianwater==3.1.1 +pyanglianwater==3.1.2 # homeassistant.components.aprilaire pyaprilaire==0.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42d8acebfda4c5..5f449ab8c1208e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1696,7 +1696,7 @@ pyairobotrest==0.3.0 pyairvisual==2023.08.1 # homeassistant.components.anglian_water -pyanglianwater==3.1.1 +pyanglianwater==3.1.2 # homeassistant.components.aprilaire pyaprilaire==0.9.1 diff --git a/tests/components/anglian_water/conftest.py b/tests/components/anglian_water/conftest.py index a482a7894555de..6a56dfcc76104d 100644 --- a/tests/components/anglian_water/conftest.py +++ b/tests/components/anglian_water/conftest.py @@ -40,9 +40,9 @@ def mock_smart_meter(freezer: FrozenDateTimeFactory) -> SmartMeter: meter = SmartMeter("TESTSN") meter.readings = [ - {"read_at": "2024-06-01T12:00:00Z", "consumption": 10, "read": 10}, - {"read_at": "2024-06-01T13:00:00Z", "consumption": 15, "read": 25}, - {"read_at": "2024-06-01T14:00:00Z", "consumption": 25, "read": 50}, + {"read_at": "2024-06-01T12:00:00", "consumption": 10, "read": 10}, + {"read_at": "2024-06-01T13:00:00", "consumption": 15, "read": 25}, + {"read_at": "2024-06-01T14:00:00", "consumption": 25, "read": 50}, ] meter.yesterday_water_cost = 0.5 meter.yesterday_sewerage_cost = 0.5 diff --git a/tests/components/anglian_water/snapshots/test_coordinator.ambr b/tests/components/anglian_water/snapshots/test_coordinator.ambr index 8fe7079c5d77b3..dab378d29c14ee 100644 --- a/tests/components/anglian_water/snapshots/test_coordinator.ambr +++ b/tests/components/anglian_water/snapshots/test_coordinator.ambr @@ -3,20 +3,20 @@ defaultdict({ 'anglian_water:171266493_testsn_usage': list([ dict({ - 'end': 1717243200.0, - 'start': 1717239600.0, + 'end': 1717268400.0, + 'start': 1717264800.0, 'state': 0.01, 'sum': 10.0, }), dict({ - 'end': 1717246800.0, - 'start': 1717243200.0, + 'end': 1717272000.0, + 'start': 1717268400.0, 'state': 0.015, 'sum': 25.0, }), dict({ - 'end': 1717250400.0, - 'start': 1717246800.0, + 'end': 1717275600.0, + 'start': 1717272000.0, 'state': 0.025, 'sum': 50.0, }), @@ -27,28 +27,22 @@ defaultdict({ 'anglian_water:171266493_testsn_usage': list([ dict({ - 'end': 1717243200.0, - 'start': 1717239600.0, + 'end': 1717268400.0, + 'start': 1717264800.0, 'state': 0.01, 'sum': 10.0, }), dict({ - 'end': 1717246800.0, - 'start': 1717243200.0, + 'end': 1717272000.0, + 'start': 1717268400.0, 'state': 0.015, 'sum': 25.0, }), dict({ - 'end': 1717250400.0, - 'start': 1717246800.0, - 'state': 0.035, - 'sum': 70.0, - }), - dict({ - 'end': 1717254000.0, - 'start': 1717250400.0, - 'state': 0.02, - 'sum': 90.0, + 'end': 1717275600.0, + 'start': 1717272000.0, + 'state': 0.025, + 'sum': 50.0, }), ]), }) diff --git a/tests/components/anglian_water/snapshots/test_sensor.ambr b/tests/components/anglian_water/snapshots/test_sensor.ambr index ef49050f35848a..58addf7f189847 100644 --- a/tests/components/anglian_water/snapshots/test_sensor.ambr +++ b/tests/components/anglian_water/snapshots/test_sensor.ambr @@ -47,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2024-06-01T14:00:00+00:00', + 'state': '2024-06-01T13:00:00+00:00', }) # --- # name: test_sensor[sensor.testsn_latest_reading-entry] From bcca7655f8b10b76a926ae0e2768106948abcbf7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:01:53 +0100 Subject: [PATCH 0012/1707] Improve water heater action naming consistency (#166535) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/water_heater/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index df8ce5a129708a..46362df0654c13 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -144,27 +144,27 @@ }, "services": { "set_away_mode": { - "description": "Turns away mode on/off.", + "description": "Sets the away mode of a water heater.", "fields": { "away_mode": { "description": "New value of away mode.", "name": "Away mode" } }, - "name": "Set away mode" + "name": "Set water heater away mode" }, "set_operation_mode": { - "description": "Sets the operation mode.", + "description": "Sets the operation mode of a water heater.", "fields": { "operation_mode": { "description": "[%key:component::water_heater::services::set_temperature::fields::operation_mode::description%]", "name": "[%key:component::water_heater::services::set_temperature::fields::operation_mode::name%]" } }, - "name": "Set operation mode" + "name": "Set water heater operation mode" }, "set_temperature": { - "description": "Sets the target temperature.", + "description": "Sets the target temperature of a water heater.", "fields": { "operation_mode": { "description": "New value of the operation mode. For a list of possible modes, refer to the integration documentation.", @@ -175,15 +175,15 @@ "name": "Temperature" } }, - "name": "Set temperature" + "name": "Set water heater target temperature" }, "turn_off": { - "description": "Turns water heater off.", - "name": "[%key:common::action::turn_off%]" + "description": "Turns off a water heater.", + "name": "Turn off water heater" }, "turn_on": { - "description": "Turns water heater on.", - "name": "[%key:common::action::turn_on%]" + "description": "Turns on a water heater.", + "name": "Turn on water heater" } }, "title": "Water heater", From a045c2907faab38b385c379e22ad67079148db96 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:02:16 +0100 Subject: [PATCH 0013/1707] Improve logger action naming consistency (#166538) --- homeassistant/components/logger/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/logger/strings.json b/homeassistant/components/logger/strings.json index 4fa4195eb81095..3b7f650b8553a9 100644 --- a/homeassistant/components/logger/strings.json +++ b/homeassistant/components/logger/strings.json @@ -20,11 +20,11 @@ "name": "Level" } }, - "name": "Set default level" + "name": "Set logger default level" }, "set_level": { "description": "Sets the log level for one or more integrations.", - "name": "Set level" + "name": "Set logger level" } } } From 88d0bd5a1d7dc0c8a6fd0bbc075a9c561f80865e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:03:15 +0100 Subject: [PATCH 0014/1707] Improve group action naming consistency (#166537) --- homeassistant/components/group/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 5c8aee6b2d28c6..61dba512e340c7 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -296,7 +296,7 @@ "services": { "reload": { "description": "Reloads group configuration, entities, and notify services from YAML-configuration.", - "name": "[%key:common::action::reload%]" + "name": "Reload groups" }, "remove": { "description": "Removes a group.", @@ -306,10 +306,10 @@ "name": "[%key:component::group::services::set::fields::object_id::name%]" } }, - "name": "Remove" + "name": "Remove group" }, "set": { - "description": "Creates/Updates a group.", + "description": "Creates or updates a group.", "fields": { "add_entities": { "description": "List of members to be added to the group. Cannot be used in combination with `Entities` or `Remove entities`.", @@ -340,7 +340,7 @@ "name": "Remove entities" } }, - "name": "Set" + "name": "Set group" } }, "title": "Group" From dd89fa0f5b5d99a73f41ccabcb05dca32bb1ee32 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:04:37 +0100 Subject: [PATCH 0015/1707] Improve device tracker action naming consistency (#166534) --- homeassistant/components/device_tracker/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 67f6eff1c1693f..f4f7031fa79238 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -120,7 +120,7 @@ "name": "MAC address" } }, - "name": "See" + "name": "See device tracker" } }, "title": "Device tracker", From a9083d53625544fa3d804e7caadba58a0f6ce66e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:08:29 +0100 Subject: [PATCH 0016/1707] Improve weather action naming consistency (#166540) --- homeassistant/components/weather/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index cda8b91ff79b24..02b017a7658bde 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -101,24 +101,24 @@ }, "services": { "get_forecast": { - "description": "Retrieves the forecast from a selected weather service.", + "description": "Retrieves the forecast from a weather service.", "fields": { "type": { "description": "[%key:component::weather::services::get_forecasts::fields::type::description%]", "name": "[%key:component::weather::services::get_forecasts::fields::type::name%]" } }, - "name": "Get forecast" + "name": "Get weather forecast" }, "get_forecasts": { - "description": "Retrieves the forecast from selected weather services.", + "description": "Retrieves the forecasts from one or more weather services.", "fields": { "type": { "description": "The scope of the weather forecast.", "name": "Forecast type" } }, - "name": "Get forecasts" + "name": "Get weather forecasts" } }, "title": "Weather" From bcc02d7adc2727c13f8f9e6a27ff060eddd02db6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:08:49 +0100 Subject: [PATCH 0017/1707] Improve automation action naming consistency (#166525) --- homeassistant/components/automation/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index b1dd9779ce21a1..40add9c8d14ae2 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -78,11 +78,11 @@ "services": { "reload": { "description": "Reloads the automation configuration.", - "name": "[%key:common::action::reload%]" + "name": "Reload automations" }, "toggle": { "description": "Toggles (enable / disable) an automation.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle automation" }, "trigger": { "description": "Triggers the actions of an automation.", @@ -92,7 +92,7 @@ "name": "Skip conditions" } }, - "name": "Trigger" + "name": "Trigger automation" }, "turn_off": { "description": "Disables an automation.", @@ -102,11 +102,11 @@ "name": "Stop actions" } }, - "name": "[%key:common::action::turn_off%]" + "name": "Turn off automation" }, "turn_on": { "description": "Enables an automation.", - "name": "[%key:common::action::turn_on%]" + "name": "Turn on automation" } }, "title": "Automation" From 619582bd03a275698298bef775d109bae1fa3f52 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:10:50 +0100 Subject: [PATCH 0018/1707] Improve image action naming consistency (#166527) --- homeassistant/components/image/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/image/strings.json b/homeassistant/components/image/strings.json index b90665f0e94f60..dc56df4704f87c 100644 --- a/homeassistant/components/image/strings.json +++ b/homeassistant/components/image/strings.json @@ -13,7 +13,7 @@ "name": "Filename" } }, - "name": "Take snapshot" + "name": "Take image snapshot" } }, "title": "Image" From f72a9e52f57550b14f19f20cfabf527bbfc1cc12 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:11:16 +0100 Subject: [PATCH 0019/1707] Improve counter action naming consistency (#166526) --- homeassistant/components/counter/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 14ff121969f7e2..e09fd1ba9fdcb2 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -41,25 +41,25 @@ "services": { "decrement": { "description": "Decrements a counter by its step size.", - "name": "Decrement" + "name": "Decrement counter" }, "increment": { "description": "Increments a counter by its step size.", - "name": "Increment" + "name": "Increment counter" }, "reset": { "description": "Resets a counter to its initial value.", - "name": "Reset" + "name": "Reset counter" }, "set_value": { - "description": "Sets the counter to a specific value.", + "description": "Sets a counter to a specific value.", "fields": { "value": { "description": "The new counter value the entity should be set to.", "name": "Value" } }, - "name": "Set" + "name": "Set counter value" } }, "title": "Counter", From c5807463fd71abfe300f794c0adbac881ed41338 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:13:12 +0100 Subject: [PATCH 0020/1707] Improve humidifier action naming consistency (#166524) --- .../components/humidifier/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 09b01ce14de77e..beee0502bc044c 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -167,36 +167,36 @@ }, "services": { "set_humidity": { - "description": "Sets the target humidity.", + "description": "Sets the target humidity of a humidifier.", "fields": { "humidity": { "description": "Target humidity.", "name": "Humidity" } }, - "name": "Set humidity" + "name": "Set humidifier target humidity" }, "set_mode": { - "description": "Sets the humidifier operation mode.", + "description": "Sets the mode of a humidifier.", "fields": { "mode": { "description": "Operation mode. For example, \"normal\", \"eco\", or \"away\". For a list of possible values, refer to the integration documentation.", "name": "Mode" } }, - "name": "Set mode" + "name": "Set humidifier mode" }, "toggle": { - "description": "Toggles the humidifier on/off.", - "name": "[%key:common::action::toggle%]" + "description": "Toggles a humidifier on/off.", + "name": "Toggle humidifier" }, "turn_off": { - "description": "Turns the humidifier off.", - "name": "[%key:common::action::turn_off%]" + "description": "Turns off a humidifier.", + "name": "Turn off humidifier" }, "turn_on": { - "description": "Turns the humidifier on.", - "name": "[%key:common::action::turn_on%]" + "description": "Turns on a humidifier.", + "name": "Turn on humidifier" } }, "title": "Humidifier", From 9e28db0535dd1daa555b0585c3f782900aef7cff Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:13:56 +0100 Subject: [PATCH 0021/1707] Improve valve action naming consistency (#166521) --- homeassistant/components/valve/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index e781829b781231..10e5e302ebab53 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -25,11 +25,11 @@ "services": { "close_valve": { "description": "Closes a valve.", - "name": "[%key:common::action::close%]" + "name": "Close valve" }, "open_valve": { "description": "Opens a valve.", - "name": "[%key:common::action::open%]" + "name": "Open valve" }, "set_valve_position": { "description": "Moves a valve to a specific position.", @@ -39,15 +39,15 @@ "name": "Position" } }, - "name": "Set position" + "name": "Set valve position" }, "stop_valve": { - "description": "Stops the valve movement.", - "name": "[%key:common::action::stop%]" + "description": "Stops a valve.", + "name": "Stop valve" }, "toggle": { "description": "Toggles a valve open/closed.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle valve" } }, "title": "Valve" From 668d220400b14352dca8ea97ff18463d61b01d5d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Mar 2026 23:14:19 +0100 Subject: [PATCH 0022/1707] Improve script action naming consistency (#166517) --- homeassistant/components/script/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index cde7a962112e9d..ea826d3eaa6a77 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -51,19 +51,19 @@ "services": { "reload": { "description": "Reloads all the available scripts.", - "name": "[%key:common::action::reload%]" + "name": "Reload scripts" }, "toggle": { "description": "Starts a script if it isn't running, stops it otherwise.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle script" }, "turn_off": { "description": "Stops a running script.", - "name": "[%key:common::action::turn_off%]" + "name": "Turn off script" }, "turn_on": { "description": "Runs the sequence of actions defined in a script.", - "name": "[%key:common::action::turn_on%]" + "name": "Turn on script" } }, "title": "Script" From 90524e53ec90716ac5bfc2ff14dcbad6e21dd948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 25 Mar 2026 22:15:21 +0000 Subject: [PATCH 0023/1707] Revert "Instruct copilot to place main comment in collapsible section" (#166543) --- .github/copilot-instructions.md | 1 - script/gen_copilot_instructions.py | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f43cefaacaacd0..3bc651eb2f21a1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,7 +6,6 @@ - Start review comments with a short, one-sentence summary of the suggested fix. - Do not add comments about code style, formatting or linting issues. -- The main review status comment should be inside a collapsible section with the summary as title. No header sections outside of the collapsible section. # GitHub Copilot & Claude Code Instructions diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py index b0bb41db0b8083..ed8c75d18f5b20 100755 --- a/script/gen_copilot_instructions.py +++ b/script/gen_copilot_instructions.py @@ -23,7 +23,6 @@ - Start review comments with a short, one-sentence summary of the suggested fix. - Do not add comments about code style, formatting or linting issues. -- The main review status comment should be inside a collapsible section with the summary as title. No header sections outside of the collapsible section. """ From d2cef2d26e0af38ef5ce398f5c43fe4178132ba9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Mar 2026 00:33:48 +0100 Subject: [PATCH 0024/1707] Improve cloud action naming consistency (#166516) --- homeassistant/components/cloud/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index d642f1df682664..6cb6b505b84c4c 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -75,11 +75,11 @@ "services": { "remote_connect": { "description": "Makes the instance UI accessible from outside of the local network by enabling your Home Assistant Cloud connection.", - "name": "Enable remote access" + "name": "Enable Home Assistant Cloud remote access" }, "remote_disconnect": { "description": "Disconnects the instance UI from Home Assistant Cloud. This disables access to it from outside your local network.", - "name": "Disable remote access" + "name": "Disable Home Assistant Cloud remote access" } }, "system_health": { From f361d01b8b358f0f3c008fd4f40a44f6c501cf62 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Mar 2026 00:34:08 +0100 Subject: [PATCH 0025/1707] Improve dashboard action naming consistency (#166539) --- homeassistant/components/lovelace/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lovelace/strings.json b/homeassistant/components/lovelace/strings.json index 49581cbcea62b3..2f0fa4ccbf132e 100644 --- a/homeassistant/components/lovelace/strings.json +++ b/homeassistant/components/lovelace/strings.json @@ -13,7 +13,7 @@ "services": { "reload_resources": { "description": "Reloads dashboard resources from the YAML-configuration.", - "name": "Reload resources" + "name": "Reload dashboard resources" } }, "system_health": { From aca5adb673be388b4fe1c435701cef691450b84f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Mar 2026 00:34:22 +0100 Subject: [PATCH 0026/1707] Improve conversation action naming consistency (#166542) --- homeassistant/components/conversation/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/strings.json b/homeassistant/components/conversation/strings.json index b09d3dccac2d54..be083c0171d8fb 100644 --- a/homeassistant/components/conversation/strings.json +++ b/homeassistant/components/conversation/strings.json @@ -6,7 +6,7 @@ }, "services": { "process": { - "description": "Launches a conversation from a transcribed text.", + "description": "Sends text to a conversation agent for processing.", "fields": { "agent_id": { "description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands.", @@ -25,10 +25,10 @@ "name": "Text" } }, - "name": "Process" + "name": "Process conversation" }, "reload": { - "description": "Reloads the intent configuration.", + "description": "Reloads the intent configuration of conversation agents.", "fields": { "agent_id": { "description": "Conversation agent to reload.", @@ -39,7 +39,7 @@ "name": "[%key:common::config_flow::data::language%]" } }, - "name": "[%key:common::action::reload%]" + "name": "Reload conversation agents" } }, "title": "Conversation" From f84398eb9cd4217dff8254d56373642326f7d68e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 00:51:14 +0100 Subject: [PATCH 0027/1707] Speed up trigger tests (#166522) --- tests/components/air_quality/test_trigger.py | 23 +-- .../alarm_control_panel/test_trigger.py | 8 +- .../assist_satellite/test_trigger.py | 8 +- tests/components/button/test_trigger.py | 20 +-- tests/components/climate/test_trigger.py | 14 +- tests/components/common.py | 132 +++++++++++------- tests/components/counter/test_trigger.py | 26 ++-- tests/components/cover/test_trigger.py | 8 +- .../components/device_tracker/test_trigger.py | 8 +- tests/components/door/test_trigger.py | 14 +- tests/components/event/test_trigger.py | 20 +-- tests/components/fan/test_trigger.py | 8 +- tests/components/garage_door/test_trigger.py | 14 +- tests/components/gate/test_trigger.py | 8 +- tests/components/humidifier/test_trigger.py | 14 +- tests/components/humidity/test_trigger.py | 28 +--- tests/components/illuminance/test_trigger.py | 20 +-- tests/components/lawn_mower/test_trigger.py | 8 +- tests/components/light/test_trigger.py | 16 +-- tests/components/lock/test_trigger.py | 8 +- tests/components/media_player/test_trigger.py | 8 +- tests/components/moisture/test_trigger.py | 22 +-- tests/components/motion/test_trigger.py | 8 +- tests/components/occupancy/test_trigger.py | 8 +- tests/components/person/test_trigger.py | 8 +- tests/components/power/test_trigger.py | 14 +- tests/components/remote/test_trigger.py | 8 +- tests/components/scene/test_trigger.py | 20 +-- tests/components/schedule/test_trigger.py | 33 ++--- tests/components/select/test_trigger.py | 38 +++-- tests/components/siren/test_trigger.py | 8 +- tests/components/switch/test_trigger.py | 29 ++-- tests/components/temperature/test_trigger.py | 64 +++------ tests/components/text/test_trigger.py | 36 ++--- tests/components/update/test_trigger.py | 8 +- tests/components/vacuum/test_trigger.py | 8 +- tests/components/water_heater/test_trigger.py | 14 +- tests/components/window/test_trigger.py | 14 +- 38 files changed, 232 insertions(+), 521 deletions(-) diff --git a/tests/components/air_quality/test_trigger.py b/tests/components/air_quality/test_trigger.py index 8dcddddedef6a6..5546f5d23ca377 100644 --- a/tests/components/air_quality/test_trigger.py +++ b/tests/components/air_quality/test_trigger.py @@ -16,7 +16,7 @@ STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -153,7 +153,6 @@ async def test_air_quality_triggers_gated_by_labs_flag( ) async def test_air_quality_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -165,7 +164,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_any( """Test air quality triggers fire for binary_sensor entities with gas, CO, and smoke device classes.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -234,7 +232,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_any( ) async def test_air_quality_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -246,7 +243,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_first( """Test air quality trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -315,7 +311,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_first( ) async def test_air_quality_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -327,7 +322,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_last( """Test air quality trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -497,7 +491,6 @@ async def test_air_quality_trigger_binary_sensor_behavior_last( ) async def test_air_quality_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -509,7 +502,6 @@ async def test_air_quality_trigger_sensor_behavior_any( """Test air quality trigger fires for sensor entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -607,7 +599,6 @@ async def test_air_quality_trigger_sensor_behavior_any( ) async def test_air_quality_trigger_sensor_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -619,7 +610,6 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_first( """Test air quality crossed_threshold trigger fires on the first sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -717,7 +707,6 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_first( ) async def test_air_quality_trigger_sensor_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -729,7 +718,6 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_last( """Test air quality crossed_threshold trigger fires when the last sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -743,9 +731,9 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test CO crossed_threshold trigger converts sensor value from ppm to μg/m³.""" + calls: list[str] = [] entity_id = "sensor.test_co" # Sensor reports in ppm, trigger threshold is in μg/m³ (fixed unit for CO) @@ -770,6 +758,7 @@ async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 0.5 ppm ≈ 582 μg/m³, which is below 1000 μg/m³ - should NOT trigger @@ -782,7 +771,7 @@ async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 1 ppm ≈ 1164 μg/m³, which is above 1000 μg/m³ - should trigger hass.states.async_set( @@ -794,5 +783,5 @@ async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() diff --git a/tests/components/alarm_control_panel/test_trigger.py b/tests/components/alarm_control_panel/test_trigger.py index b6403bccd17d5f..8aeca55ea63846 100644 --- a/tests/components/alarm_control_panel/test_trigger.py +++ b/tests/components/alarm_control_panel/test_trigger.py @@ -9,7 +9,7 @@ AlarmControlPanelState, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -124,7 +124,6 @@ async def test_alarm_control_panel_triggers_gated_by_labs_flag( ) async def test_alarm_control_panel_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_alarm_control_panels: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -136,7 +135,6 @@ async def test_alarm_control_panel_state_trigger_behavior_any( """Test that the alarm control panel state trigger fires when any alarm control panel state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_alarm_control_panels, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -222,7 +220,6 @@ async def test_alarm_control_panel_state_trigger_behavior_any( ) async def test_alarm_control_panel_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_alarm_control_panels: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -234,7 +231,6 @@ async def test_alarm_control_panel_state_trigger_behavior_first( """Test that the alarm control panel state trigger fires when the first alarm control panel changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_alarm_control_panels, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -320,7 +316,6 @@ async def test_alarm_control_panel_state_trigger_behavior_first( ) async def test_alarm_control_panel_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_alarm_control_panels: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -332,7 +327,6 @@ async def test_alarm_control_panel_state_trigger_behavior_last( """Test that the alarm_control_panel state trigger fires when the last alarm_control_panel changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_alarm_control_panels, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/assist_satellite/test_trigger.py b/tests/components/assist_satellite/test_trigger.py index 085659e9ef0559..dd23714375c651 100644 --- a/tests/components/assist_satellite/test_trigger.py +++ b/tests/components/assist_satellite/test_trigger.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.assist_satellite.entity import AssistSatelliteState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -74,7 +74,6 @@ async def test_assist_satellite_triggers_gated_by_labs_flag( ) async def test_assist_satellite_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_assist_satellites: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -86,7 +85,6 @@ async def test_assist_satellite_state_trigger_behavior_any( """Test that the assist satellite state trigger fires when any assist satellite state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_assist_satellites, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -129,7 +127,6 @@ async def test_assist_satellite_state_trigger_behavior_any( ) async def test_assist_satellite_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_assist_satellites: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -141,7 +138,6 @@ async def test_assist_satellite_state_trigger_behavior_first( """Test that the assist satellite state trigger fires when the first assist satellite changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_assist_satellites, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -184,7 +180,6 @@ async def test_assist_satellite_state_trigger_behavior_first( ) async def test_assist_satellite_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_assist_satellites: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -196,7 +191,6 @@ async def test_assist_satellite_state_trigger_behavior_last( """Test that the assist_satellite state trigger fires when the last assist_satellite changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_assist_satellites, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/button/test_trigger.py b/tests/components/button/test_trigger.py index 8e910357a61526..6b9edfbc1acb43 100644 --- a/tests/components/button/test_trigger.py +++ b/tests/components/button/test_trigger.py @@ -2,8 +2,8 @@ import pytest -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -147,7 +147,6 @@ async def test_button_triggers_gated_by_labs_flag( ) async def test_button_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_buttons: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -156,6 +155,7 @@ async def test_button_state_trigger( states: list[TriggerStateDescription], ) -> None: """Test that the button state trigger fires when targeted button state changes.""" + calls: list[str] = [] other_entity_ids = set(target_buttons["included_entities"]) - {entity_id} # Set all buttons, including the tested button, to the initial state @@ -163,20 +163,20 @@ async def test_button_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other buttons also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index 1fac3f5f38248c..cb6813b913a37b 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -20,7 +20,7 @@ CONF_TARGET, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger import async_validate_trigger_config from tests.components.common import ( @@ -157,7 +157,6 @@ async def test_climate_trigger_validation( ) async def test_climate_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -169,7 +168,6 @@ async def test_climate_state_trigger_behavior_any( """Test that the climate state trigger fires when any climate state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -225,7 +223,6 @@ async def test_climate_state_trigger_behavior_any( ) async def test_climate_state_attribute_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -237,7 +234,6 @@ async def test_climate_state_attribute_trigger_behavior_any( """Test that the climate state trigger fires when any climate state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -285,7 +281,6 @@ async def test_climate_state_attribute_trigger_behavior_any( ) async def test_climate_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entities_in_target: int, @@ -297,7 +292,6 @@ async def test_climate_state_trigger_behavior_first( """Test that the climate state trigger fires when the first climate changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -344,7 +338,6 @@ async def test_climate_state_trigger_behavior_first( ) async def test_climate_state_attribute_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -356,7 +349,6 @@ async def test_climate_state_attribute_trigger_behavior_first( """Test that the climate state trigger fires when the first climate state changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -404,7 +396,6 @@ async def test_climate_state_attribute_trigger_behavior_first( ) async def test_climate_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entities_in_target: int, @@ -416,7 +407,6 @@ async def test_climate_state_trigger_behavior_last( """Test that the climate state trigger fires when the last climate changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -463,7 +453,6 @@ async def test_climate_state_trigger_behavior_last( ) async def test_climate_state_attribute_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -475,7 +464,6 @@ async def test_climate_state_attribute_trigger_behavior_last( """Test that the climate state trigger fires when the last climate state changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/common.py b/tests/components/common.py index 8611cd30333a57..7b2459dfc9a233 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -4,6 +4,7 @@ import copy from enum import StrEnum import itertools +import logging from typing import Any, TypedDict import pytest @@ -22,7 +23,7 @@ STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -34,7 +35,8 @@ ConditionCheckerTypeOptional, async_from_config as async_condition_from_config, ) -from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.helpers.trigger import async_initialize_triggers +from homeassistant.helpers.typing import UNDEFINED, TemplateVarsType, UndefinedType from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, mock_device_registry @@ -931,30 +933,32 @@ async def arm_trigger( trigger: str, trigger_options: dict[str, Any] | None, trigger_target: dict, + calls: list[str], ) -> None: - """Arm the specified trigger, call service test.automation when it triggers.""" + """Arm the specified trigger and record fired entity_ids in calls when it triggers.""" + options = {CONF_OPTIONS: {**trigger_options}} if trigger_options is not None else {} - # Local include to avoid importing the automation component unnecessarily - from homeassistant.components import automation # noqa: PLC0415 + trigger_config = { + CONF_PLATFORM: trigger, + CONF_TARGET: {**trigger_target}, + } | options - options = {CONF_OPTIONS: {**trigger_options}} if trigger_options is not None else {} + @callback + def action(run_variables: TemplateVarsType, context: Context | None = None) -> None: + calls.append(run_variables["trigger"]["entity_id"]) - await async_setup_component( + logger = logging.getLogger(__name__) + + def log_cb(level: int, msg: str, **kwargs: Any) -> None: + logger._log(level, "%s", msg, **kwargs) + + await async_initialize_triggers( hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - CONF_PLATFORM: trigger, - CONF_TARGET: {**trigger_target}, - } - | options, - "action": { - "service": "test.automation", - "data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"}, - }, - } - }, + [trigger_config], + action, + domain="test", + name="test_trigger", + log_cb=log_cb, ) @@ -1044,7 +1048,25 @@ async def assert_trigger_gated_by_labs_flag( ) -> None: """Helper to check that a trigger is gated by the labs flag.""" - await arm_trigger(hass, trigger, None, {ATTR_LABEL_ID: "test_label"}) + # Local include to avoid importing the automation component unnecessarily + from homeassistant.components import automation # noqa: PLC0415 + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + CONF_PLATFORM: trigger, + CONF_TARGET: {ATTR_LABEL_ID: "test_label"}, + }, + "action": { + "service": "test.automation", + }, + } + }, + ) + assert ( "Unnamed automation failed to setup triggers and has been disabled: Trigger " f"'{trigger}' requires the experimental 'New triggers and conditions' " @@ -1157,7 +1179,6 @@ async def assert_condition_behavior_all( async def assert_trigger_behavior_any( hass: HomeAssistant, *, - service_calls: list[ServiceCall], target_entities: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -1167,6 +1188,7 @@ async def assert_trigger_behavior_any( states: list[TriggerStateDescription], ) -> None: """Test trigger fires in mode any.""" + calls: list[str] = [] other_entity_ids = set(target_entities["included_entities"]) - {entity_id} excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id} @@ -1177,17 +1199,17 @@ async def assert_trigger_behavior_any( set_or_remove_state(hass, eid, states[0]["excluded_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, trigger_options, trigger_target_config) + await arm_trigger(hass, trigger, trigger_options, trigger_target_config, calls) for state in states[1:]: excluded_state = state["excluded_state"] included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) @@ -1195,14 +1217,13 @@ async def assert_trigger_behavior_any( for excluded_entity_id in excluded_entity_ids: set_or_remove_state(hass, excluded_entity_id, excluded_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() async def assert_trigger_behavior_first( hass: HomeAssistant, *, - service_calls: list[ServiceCall], target_entities: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -1212,6 +1233,7 @@ async def assert_trigger_behavior_first( states: list[TriggerStateDescription], ) -> None: """Test trigger fires in mode first.""" + calls: list[str] = [] other_entity_ids = set(target_entities["included_entities"]) - {entity_id} excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id} @@ -1223,7 +1245,11 @@ async def assert_trigger_behavior_first( await hass.async_block_till_done() await arm_trigger( - hass, trigger, {"behavior": "first"} | trigger_options, trigger_target_config + hass, + trigger, + {"behavior": "first"} | trigger_options, + trigger_target_config, + calls, ) for state in states[1:]: @@ -1231,10 +1257,10 @@ async def assert_trigger_behavior_first( included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) @@ -1242,13 +1268,12 @@ async def assert_trigger_behavior_first( for excluded_entity_id in excluded_entity_ids: set_or_remove_state(hass, excluded_entity_id, excluded_state) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 async def assert_trigger_behavior_last( hass: HomeAssistant, *, - service_calls: list[ServiceCall], target_entities: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -1258,6 +1283,7 @@ async def assert_trigger_behavior_last( states: list[TriggerStateDescription], ) -> None: """Test trigger fires in mode last.""" + calls: list[str] = [] other_entity_ids = set(target_entities["included_entities"]) - {entity_id} excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id} @@ -1269,7 +1295,11 @@ async def assert_trigger_behavior_last( await hass.async_block_till_done() await arm_trigger( - hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config + hass, + trigger, + {"behavior": "last"} | trigger_options, + trigger_target_config, + calls, ) for state in states[1:]: @@ -1278,19 +1308,19 @@ async def assert_trigger_behavior_last( for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() for excluded_entity_id in excluded_entity_ids: set_or_remove_state(hass, excluded_entity_id, excluded_state) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 def parametrize_numerical_condition_above_below_any( @@ -1654,7 +1684,6 @@ def parametrize_numerical_attribute_condition_above_below_all( async def assert_trigger_ignores_limit_entities_with_wrong_unit( hass: HomeAssistant, *, - service_calls: list[ServiceCall], trigger: str, trigger_options: dict[str, Any], entity_id: str, @@ -1682,6 +1711,7 @@ async def assert_trigger_ignores_limit_entities_with_wrong_unit( wrong_unit: A unit that the trigger should reject (e.g. "lx"). """ + calls: list[str] = [] # Set up entity in triggering state set_or_remove_state(hass, entity_id, trigger_state) # Set up all limit entities with the wrong unit @@ -1693,14 +1723,16 @@ async def assert_trigger_ignores_limit_entities_with_wrong_unit( ) await hass.async_block_till_done() - await arm_trigger(hass, trigger, trigger_options, {CONF_ENTITY_ID: [entity_id]}) + await arm_trigger( + hass, trigger, trigger_options, {CONF_ENTITY_ID: [entity_id]}, calls + ) # Cycle entity state - should NOT fire (all limit entities have wrong unit) set_or_remove_state(hass, entity_id, reset_state) await hass.async_block_till_done() set_or_remove_state(hass, entity_id, trigger_state) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # Fix limit entities one at a time; trigger should not fire until all are fixed for i, (limit_entity_id, limit_value) in enumerate(limit_entities): @@ -1718,10 +1750,10 @@ async def assert_trigger_ignores_limit_entities_with_wrong_unit( if i < len(limit_entities) - 1: # Not all limits fixed yet - should not fire - assert len(service_calls) == 0 + assert len(calls) == 0 else: # All limits fixed - should fire - assert len(service_calls) == 1 + assert len(calls) == 1 async def assert_numerical_condition_unit_conversion( diff --git a/tests/components/counter/test_trigger.py b/tests/components/counter/test_trigger.py index 43eccabeca46db..8ef5df0c10642d 100644 --- a/tests/components/counter/test_trigger.py +++ b/tests/components/counter/test_trigger.py @@ -10,8 +10,8 @@ CONF_MINIMUM, DOMAIN, ) -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( BasicTriggerStateDescription, @@ -119,7 +119,6 @@ async def test_counter_triggers_gated_by_labs_flag( ) async def test_counter_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_counters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -128,6 +127,7 @@ async def test_counter_state_trigger( states: list[BasicTriggerStateDescription], ) -> None: """Test that the counter decrement and increment triggers fire correctly.""" + calls: list[str] = [] other_entity_ids = set(target_counters["included_entities"]) - {entity_id} # Set all counters, including the tested one, to the initial state @@ -135,23 +135,23 @@ async def test_counter_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other counters also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() @pytest.mark.usefixtures("enable_labs_preview_features") @@ -164,7 +164,6 @@ async def test_counter_state_trigger( ) async def test_counter_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_counters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -176,7 +175,6 @@ async def test_counter_state_trigger_behavior_any( """Test that the counter state trigger fires when any counter state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_counters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -197,7 +195,6 @@ async def test_counter_state_trigger_behavior_any( ) async def test_counter_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_counters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -209,7 +206,6 @@ async def test_counter_state_trigger_behavior_first( """Test that the counter state trigger fires when the first counter changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_counters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -230,7 +226,6 @@ async def test_counter_state_trigger_behavior_first( ) async def test_counter_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_counters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -242,7 +237,6 @@ async def test_counter_state_trigger_behavior_last( """Test that the counter state trigger fires when the last counter changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_counters, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/cover/test_trigger.py b/tests/components/cover/test_trigger.py index 76db0e5c131f3b..667b8cd1c309d4 100644 --- a/tests/components/cover/test_trigger.py +++ b/tests/components/cover/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -100,7 +100,6 @@ async def test_cover_triggers_gated_by_labs_flag( ) async def test_cover_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -112,7 +111,6 @@ async def test_cover_trigger_behavior_any( """Test cover trigger fires for cover entities with matching device_class.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -174,7 +172,6 @@ async def test_cover_trigger_behavior_any( ) async def test_cover_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -186,7 +183,6 @@ async def test_cover_trigger_behavior_first( """Test cover trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -248,7 +244,6 @@ async def test_cover_trigger_behavior_first( ) async def test_cover_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -260,7 +255,6 @@ async def test_cover_trigger_behavior_last( """Test cover trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/device_tracker/test_trigger.py b/tests/components/device_tracker/test_trigger.py index e7750443fbb5ef..caa53e2fa27cba 100644 --- a/tests/components/device_tracker/test_trigger.py +++ b/tests/components/device_tracker/test_trigger.py @@ -5,7 +5,7 @@ import pytest from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -60,7 +60,6 @@ async def test_device_tracker_triggers_gated_by_labs_flag( ) async def test_device_tracker_home_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_device_trackers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -72,7 +71,6 @@ async def test_device_tracker_home_trigger_behavior_any( """Test that the device_tracker home triggers when any device_tracker changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_device_trackers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -105,7 +103,6 @@ async def test_device_tracker_home_trigger_behavior_any( ) async def test_device_tracker_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_device_trackers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -117,7 +114,6 @@ async def test_device_tracker_state_trigger_behavior_first( """Test that the device_tracker home triggers when the first device_tracker changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_device_trackers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -150,7 +146,6 @@ async def test_device_tracker_state_trigger_behavior_first( ) async def test_device_tracker_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_device_trackers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -162,7 +157,6 @@ async def test_device_tracker_state_trigger_behavior_last( """Test that the device_tracker home triggers when the last device_tracker changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_device_trackers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/door/test_trigger.py b/tests/components/door/test_trigger.py index 9f95ac8bb70876..94685211e9b951 100644 --- a/tests/components/door/test_trigger.py +++ b/tests/components/door/test_trigger.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -77,7 +77,6 @@ async def test_door_triggers_gated_by_labs_flag( ) async def test_door_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -89,7 +88,6 @@ async def test_door_trigger_binary_sensor_behavior_any( """Test door trigger fires for binary_sensor entities with device_class door.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -147,7 +145,6 @@ async def test_door_trigger_binary_sensor_behavior_any( ) async def test_door_trigger_cover_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -159,7 +156,6 @@ async def test_door_trigger_cover_behavior_any( """Test door trigger fires for cover entities with device_class door.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_door_trigger_cover_behavior_any( ) async def test_door_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_door_trigger_binary_sensor_behavior_first( """Test door trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -253,7 +247,6 @@ async def test_door_trigger_binary_sensor_behavior_first( ) async def test_door_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -265,7 +258,6 @@ async def test_door_trigger_binary_sensor_behavior_last( """Test door trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -323,7 +315,6 @@ async def test_door_trigger_binary_sensor_behavior_last( ) async def test_door_trigger_cover_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -335,7 +326,6 @@ async def test_door_trigger_cover_behavior_first( """Test door trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -393,7 +383,6 @@ async def test_door_trigger_cover_behavior_first( ) async def test_door_trigger_cover_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -405,7 +394,6 @@ async def test_door_trigger_cover_behavior_last( """Test door trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/event/test_trigger.py b/tests/components/event/test_trigger.py index 98800e16a26289..86328949b334ea 100644 --- a/tests/components/event/test_trigger.py +++ b/tests/components/event/test_trigger.py @@ -3,8 +3,8 @@ import pytest from homeassistant.components.event.const import ATTR_EVENT_TYPE -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -251,7 +251,6 @@ async def test_event_triggers_gated_by_labs_flag( ) async def test_event_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_events: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -261,6 +260,7 @@ async def test_event_state_trigger( states: list[TriggerStateDescription], ) -> None: """Test that the event trigger fires when an event entity receives a matching event.""" + calls: list[str] = [] other_entity_ids = set(target_events["included_entities"]) - {entity_id} # Set all events to the initial state @@ -268,20 +268,20 @@ async def test_event_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, trigger_options, trigger_target_config) + await arm_trigger(hass, trigger, trigger_options, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other events also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() diff --git a/tests/components/fan/test_trigger.py b/tests/components/fan/test_trigger.py index a56b0f62c83b61..0434f1f0787691 100644 --- a/tests/components/fan/test_trigger.py +++ b/tests/components/fan/test_trigger.py @@ -5,7 +5,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -61,7 +61,6 @@ async def test_fan_triggers_gated_by_labs_flag( ) async def test_fan_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_fans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -73,7 +72,6 @@ async def test_fan_state_trigger_behavior_any( """Test that the fan state trigger fires when any fan state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_fans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -106,7 +104,6 @@ async def test_fan_state_trigger_behavior_any( ) async def test_fan_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_fans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -118,7 +115,6 @@ async def test_fan_state_trigger_behavior_first( """Test that the fan state trigger fires when the first fan changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_fans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -151,7 +147,6 @@ async def test_fan_state_trigger_behavior_first( ) async def test_fan_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_fans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -163,7 +158,6 @@ async def test_fan_state_trigger_behavior_last( """Test that the fan state trigger fires when the last fan changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_fans, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/garage_door/test_trigger.py b/tests/components/garage_door/test_trigger.py index 2ebaacae017b22..a1ac9deac96581 100644 --- a/tests/components/garage_door/test_trigger.py +++ b/tests/components/garage_door/test_trigger.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -77,7 +77,6 @@ async def test_garage_door_triggers_gated_by_labs_flag( ) async def test_garage_door_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -89,7 +88,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_any( """Test garage door trigger fires for binary_sensor entities with device_class garage_door.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -147,7 +145,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_any( ) async def test_garage_door_trigger_cover_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -159,7 +156,6 @@ async def test_garage_door_trigger_cover_behavior_any( """Test garage door trigger fires for cover entities with device_class garage.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_garage_door_trigger_cover_behavior_any( ) async def test_garage_door_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_first( """Test garage door trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -253,7 +247,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_first( ) async def test_garage_door_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -265,7 +258,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_last( """Test garage door trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -323,7 +315,6 @@ async def test_garage_door_trigger_binary_sensor_behavior_last( ) async def test_garage_door_trigger_cover_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -335,7 +326,6 @@ async def test_garage_door_trigger_cover_behavior_first( """Test garage door trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -393,7 +383,6 @@ async def test_garage_door_trigger_cover_behavior_first( ) async def test_garage_door_trigger_cover_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -405,7 +394,6 @@ async def test_garage_door_trigger_cover_behavior_last( """Test garage door trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/gate/test_trigger.py b/tests/components/gate/test_trigger.py index 8658f4d724317d..e698ee1b41476d 100644 --- a/tests/components/gate/test_trigger.py +++ b/tests/components/gate/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -87,7 +87,6 @@ async def test_gate_triggers_gated_by_labs_flag( ) async def test_gate_trigger_cover_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -99,7 +98,6 @@ async def test_gate_trigger_cover_behavior_any( """Test gate trigger fires for cover entities with device_class gate.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -157,7 +155,6 @@ async def test_gate_trigger_cover_behavior_any( ) async def test_gate_trigger_cover_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -169,7 +166,6 @@ async def test_gate_trigger_cover_behavior_first( """Test gate trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -227,7 +223,6 @@ async def test_gate_trigger_cover_behavior_first( ) async def test_gate_trigger_cover_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -239,7 +234,6 @@ async def test_gate_trigger_cover_behavior_last( """Test gate trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/humidifier/test_trigger.py b/tests/components/humidifier/test_trigger.py index 436c85c896ddb0..fde5ed83a63dea 100644 --- a/tests/components/humidifier/test_trigger.py +++ b/tests/components/humidifier/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.humidifier.const import ATTR_ACTION, HumidifierAction from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -64,7 +64,6 @@ async def test_humidifier_triggers_gated_by_labs_flag( ) async def test_humidifier_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -76,7 +75,6 @@ async def test_humidifier_state_trigger_behavior_any( """Test that the humidifier state trigger fires when any humidifier state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -109,7 +107,6 @@ async def test_humidifier_state_trigger_behavior_any( ) async def test_humidifier_state_attribute_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -121,7 +118,6 @@ async def test_humidifier_state_attribute_trigger_behavior_any( """Test that the humidifier state trigger fires when any humidifier state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -154,7 +150,6 @@ async def test_humidifier_state_attribute_trigger_behavior_any( ) async def test_humidifier_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -166,7 +161,6 @@ async def test_humidifier_state_trigger_behavior_first( """Test that the humidifier state trigger fires when the first humidifier changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -199,7 +193,6 @@ async def test_humidifier_state_trigger_behavior_first( ) async def test_humidifier_state_attribute_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -211,7 +204,6 @@ async def test_humidifier_state_attribute_trigger_behavior_first( """Test that the humidifier state trigger fires when the first humidifier state changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -244,7 +236,6 @@ async def test_humidifier_state_attribute_trigger_behavior_first( ) async def test_humidifier_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -256,7 +247,6 @@ async def test_humidifier_state_trigger_behavior_last( """Test that the humidifier state trigger fires when the last humidifier changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -289,7 +279,6 @@ async def test_humidifier_state_trigger_behavior_last( ) async def test_humidifier_state_attribute_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -301,7 +290,6 @@ async def test_humidifier_state_attribute_trigger_behavior_last( """Test that the humidifier state trigger fires when the last humidifier state changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/humidity/test_trigger.py b/tests/components/humidity/test_trigger.py index 8a9dd3df18edef..bc89a1c490f09f 100644 --- a/tests/components/humidity/test_trigger.py +++ b/tests/components/humidity/test_trigger.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -95,7 +95,6 @@ async def test_humidity_triggers_gated_by_labs_flag( ) async def test_humidity_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -107,7 +106,6 @@ async def test_humidity_trigger_sensor_behavior_any( """Test humidity trigger fires for sensor entities with device_class humidity.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -135,7 +133,6 @@ async def test_humidity_trigger_sensor_behavior_any( ) async def test_humidity_trigger_sensor_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -147,7 +144,6 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_first( """Test humidity crossed_threshold trigger fires on the first sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -175,7 +171,6 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_first( ) async def test_humidity_trigger_sensor_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -187,7 +182,6 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_last( """Test humidity crossed_threshold trigger fires when the last sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -221,7 +215,6 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_last( ) async def test_humidity_trigger_climate_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -233,7 +226,6 @@ async def test_humidity_trigger_climate_behavior_any( """Test humidity trigger fires for climate entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -261,7 +253,6 @@ async def test_humidity_trigger_climate_behavior_any( ) async def test_humidity_trigger_climate_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -273,7 +264,6 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_first( """Test humidity crossed_threshold trigger fires on the first climate state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -301,7 +291,6 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_first( ) async def test_humidity_trigger_climate_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -313,7 +302,6 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_last( """Test humidity crossed_threshold trigger fires when the last climate changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -347,7 +335,6 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_last( ) async def test_humidity_trigger_humidifier_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -359,7 +346,6 @@ async def test_humidity_trigger_humidifier_behavior_any( """Test humidity trigger fires for humidifier entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -387,7 +373,6 @@ async def test_humidity_trigger_humidifier_behavior_any( ) async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -399,7 +384,6 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first( """Test humidity crossed_threshold trigger fires on the first humidifier state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -427,7 +411,6 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first( ) async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_humidifiers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -439,7 +422,6 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last( """Test humidity crossed_threshold trigger fires when the last humidifier changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_humidifiers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -473,7 +455,6 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last( ) async def test_humidity_trigger_weather_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -485,7 +466,6 @@ async def test_humidity_trigger_weather_behavior_any( """Test humidity trigger fires for weather entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -513,7 +493,6 @@ async def test_humidity_trigger_weather_behavior_any( ) async def test_humidity_trigger_weather_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -525,7 +504,6 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_first( """Test humidity crossed_threshold trigger fires on the first weather state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -553,7 +531,6 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_first( ) async def test_humidity_trigger_weather_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -565,7 +542,6 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_last( """Test humidity crossed_threshold trigger fires when the last weather changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -606,7 +582,6 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_humidity_trigger_ignores_limit_entity_with_wrong_unit( hass: HomeAssistant, - service_calls: list[ServiceCall], trigger: str, trigger_options: dict[str, Any], limit_entities: list[str], @@ -614,7 +589,6 @@ async def test_humidity_trigger_ignores_limit_entity_with_wrong_unit( """Test humidity triggers do not fire if limit entity unit is not %.""" await assert_trigger_ignores_limit_entities_with_wrong_unit( hass, - service_calls=service_calls, trigger=trigger, trigger_options=trigger_options, entity_id="climate.test_climate", diff --git a/tests/components/illuminance/test_trigger.py b/tests/components/illuminance/test_trigger.py index e9309b9d8d9e7b..a53b218d5b7dbb 100644 --- a/tests/components/illuminance/test_trigger.py +++ b/tests/components/illuminance/test_trigger.py @@ -14,7 +14,7 @@ STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -97,7 +97,6 @@ async def test_illuminance_triggers_gated_by_labs_flag( ) async def test_illuminance_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -109,7 +108,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_any( """Test illuminance trigger fires for binary_sensor entities with device_class light.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -150,7 +148,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_any( ) async def test_illuminance_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -162,7 +159,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_first( """Test illuminance trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -203,7 +199,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_first( ) async def test_illuminance_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -215,7 +210,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_last( """Test illuminance trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -251,7 +245,6 @@ async def test_illuminance_trigger_binary_sensor_behavior_last( ) async def test_illuminance_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -263,7 +256,6 @@ async def test_illuminance_trigger_sensor_behavior_any( """Test illuminance trigger fires for sensor entities with device_class illuminance.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -291,7 +283,6 @@ async def test_illuminance_trigger_sensor_behavior_any( ) async def test_illuminance_trigger_sensor_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -303,7 +294,6 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_first( """Test illuminance crossed_threshold trigger fires on the first sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -331,7 +321,6 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_first( ) async def test_illuminance_trigger_sensor_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -343,7 +332,6 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_last( """Test illuminance crossed_threshold trigger fires when the last sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -379,7 +367,6 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_last( ) async def test_illuminance_trigger_number_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -391,7 +378,6 @@ async def test_illuminance_trigger_number_behavior_any( """Test illuminance trigger fires for number entities with device_class illuminance.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -419,7 +405,6 @@ async def test_illuminance_trigger_number_behavior_any( ) async def test_illuminance_trigger_number_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -431,7 +416,6 @@ async def test_illuminance_trigger_number_crossed_threshold_behavior_first( """Test illuminance crossed_threshold trigger fires on the first number state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -459,7 +443,6 @@ async def test_illuminance_trigger_number_crossed_threshold_behavior_first( ) async def test_illuminance_trigger_number_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -471,7 +454,6 @@ async def test_illuminance_trigger_number_crossed_threshold_behavior_last( """Test illuminance crossed_threshold trigger fires when the last number changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/lawn_mower/test_trigger.py b/tests/components/lawn_mower/test_trigger.py index 9d031993b3c3b3..41e57307b1789d 100644 --- a/tests/components/lawn_mower/test_trigger.py +++ b/tests/components/lawn_mower/test_trigger.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.lawn_mower import LawnMowerActivity -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -80,7 +80,6 @@ async def test_lawn_mower_triggers_gated_by_labs_flag( ) async def test_lawn_mower_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lawn_mowers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -92,7 +91,6 @@ async def test_lawn_mower_state_trigger_behavior_any( """Test that the lawn mower state trigger fires when any lawn mower state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_lawn_mowers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -140,7 +138,6 @@ async def test_lawn_mower_state_trigger_behavior_any( ) async def test_lawn_mower_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lawn_mowers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -152,7 +149,6 @@ async def test_lawn_mower_state_trigger_behavior_first( """Test that the lawn mower state trigger fires when the first lawn mower changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_lawn_mowers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_lawn_mower_state_trigger_behavior_first( ) async def test_lawn_mower_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lawn_mowers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_lawn_mower_state_trigger_behavior_last( """Test that the lawn_mower state trigger fires when the last lawn_mower changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_lawn_mowers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/light/test_trigger.py b/tests/components/light/test_trigger.py index 3e235637ce56cb..b80a8d13e1be9c 100644 --- a/tests/components/light/test_trigger.py +++ b/tests/components/light/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -188,7 +188,6 @@ async def test_light_triggers_gated_by_labs_flag( ) async def test_light_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -200,7 +199,6 @@ async def test_light_state_trigger_behavior_any( """Test that the light state trigger fires when any light state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -229,7 +227,6 @@ async def test_light_state_trigger_behavior_any( ) async def test_light_state_attribute_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -241,7 +238,6 @@ async def test_light_state_attribute_trigger_behavior_any( """Test that the light state trigger fires when any light state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -274,7 +270,6 @@ async def test_light_state_attribute_trigger_behavior_any( ) async def test_light_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -286,7 +281,6 @@ async def test_light_state_trigger_behavior_first( """Test that the light state trigger fires when the first light changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -312,7 +306,6 @@ async def test_light_state_trigger_behavior_first( ) async def test_light_state_attribute_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -324,7 +317,6 @@ async def test_light_state_attribute_trigger_behavior_first( """Test that the light state trigger fires when the first light state changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -357,7 +349,6 @@ async def test_light_state_attribute_trigger_behavior_first( ) async def test_light_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -369,7 +360,6 @@ async def test_light_state_trigger_behavior_last( """Test that the light state trigger fires when the last light changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -395,7 +385,6 @@ async def test_light_state_trigger_behavior_last( ) async def test_light_state_attribute_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_lights: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -407,7 +396,6 @@ async def test_light_state_attribute_trigger_behavior_last( """Test that the light state trigger fires when the last light state changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_lights, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -448,7 +436,6 @@ async def test_light_state_attribute_trigger_behavior_last( ) async def test_light_trigger_ignores_limit_entity_with_wrong_unit( hass: HomeAssistant, - service_calls: list[ServiceCall], trigger: str, trigger_options: dict[str, Any], limit_entities: list[str], @@ -456,7 +443,6 @@ async def test_light_trigger_ignores_limit_entity_with_wrong_unit( """Test numerical triggers do not fire if limit entities have the wrong unit.""" await assert_trigger_ignores_limit_entities_with_wrong_unit( hass, - service_calls=service_calls, trigger=trigger, trigger_options=trigger_options, entity_id="light.test_light", diff --git a/tests/components/lock/test_trigger.py b/tests/components/lock/test_trigger.py index 7e51f72cf44fea..b281736030e830 100644 --- a/tests/components/lock/test_trigger.py +++ b/tests/components/lock/test_trigger.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.lock import DOMAIN, LockState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -74,7 +74,6 @@ async def test_lock_triggers_gated_by_labs_flag( ) async def test_lock_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_locks: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -86,7 +85,6 @@ async def test_lock_state_trigger_behavior_any( """Test that the lock state trigger fires when any lock state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_locks, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -129,7 +127,6 @@ async def test_lock_state_trigger_behavior_any( ) async def test_lock_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_locks: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -141,7 +138,6 @@ async def test_lock_state_trigger_behavior_first( """Test that the lock state trigger fires when the first lock changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_locks, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -184,7 +180,6 @@ async def test_lock_state_trigger_behavior_first( ) async def test_lock_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_locks: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -196,7 +191,6 @@ async def test_lock_state_trigger_behavior_last( """Test that the lock state trigger fires when the last lock changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_locks, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/media_player/test_trigger.py b/tests/components/media_player/test_trigger.py index 62d83bd9186a5f..84267568423093 100644 --- a/tests/components/media_player/test_trigger.py +++ b/tests/components/media_player/test_trigger.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.media_player import MediaPlayerState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -63,7 +63,6 @@ async def test_media_player_triggers_gated_by_labs_flag( ) async def test_media_player_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_media_players: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -75,7 +74,6 @@ async def test_media_player_state_trigger_behavior_any( """Test that the media player state trigger fires when any media player state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_media_players, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -111,7 +109,6 @@ async def test_media_player_state_trigger_behavior_any( ) async def test_media_player_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_media_players: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -123,7 +120,6 @@ async def test_media_player_state_trigger_behavior_first( """Test that the media player state trigger fires when the first media player changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_media_players, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -159,7 +155,6 @@ async def test_media_player_state_trigger_behavior_first( ) async def test_media_player_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_media_players: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -171,7 +166,6 @@ async def test_media_player_state_trigger_behavior_last( """Test that the media player state trigger fires when the last media player changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_media_players, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/moisture/test_trigger.py b/tests/components/moisture/test_trigger.py index 42393ac86856aa..4137a661aa30c7 100644 --- a/tests/components/moisture/test_trigger.py +++ b/tests/components/moisture/test_trigger.py @@ -13,7 +13,7 @@ STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -94,7 +94,6 @@ async def test_moisture_triggers_gated_by_labs_flag( ) async def test_moisture_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -106,7 +105,6 @@ async def test_moisture_trigger_binary_sensor_behavior_any( """Test moisture trigger fires for binary_sensor entities with device_class moisture.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -147,7 +145,6 @@ async def test_moisture_trigger_binary_sensor_behavior_any( ) async def test_moisture_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -159,7 +156,6 @@ async def test_moisture_trigger_binary_sensor_behavior_first( """Test moisture trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_moisture_trigger_binary_sensor_behavior_first( ) async def test_moisture_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_moisture_trigger_binary_sensor_behavior_last( """Test moisture trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -245,7 +239,6 @@ async def test_moisture_trigger_binary_sensor_behavior_last( ) async def test_moisture_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -257,7 +250,6 @@ async def test_moisture_trigger_sensor_behavior_any( """Test moisture trigger fires for sensor entities with device_class moisture.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -285,7 +277,6 @@ async def test_moisture_trigger_sensor_behavior_any( ) async def test_moisture_trigger_sensor_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -297,7 +288,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_first( """Test moisture crossed_threshold trigger fires on the first sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -325,7 +315,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_first( ) async def test_moisture_trigger_sensor_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -337,7 +326,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_last( """Test moisture crossed_threshold trigger fires when the last sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -373,7 +361,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_last( ) async def test_moisture_trigger_number_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -385,7 +372,6 @@ async def test_moisture_trigger_number_behavior_any( """Test moisture trigger fires for number entities with device_class moisture.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -413,7 +399,6 @@ async def test_moisture_trigger_number_behavior_any( ) async def test_moisture_trigger_number_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -425,7 +410,6 @@ async def test_moisture_trigger_number_crossed_threshold_behavior_first( """Test moisture crossed_threshold trigger fires on the first number state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -453,7 +437,6 @@ async def test_moisture_trigger_number_crossed_threshold_behavior_first( ) async def test_moisture_trigger_number_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -465,7 +448,6 @@ async def test_moisture_trigger_number_crossed_threshold_behavior_last( """Test moisture crossed_threshold trigger fires when the last number changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -506,7 +488,6 @@ async def test_moisture_trigger_number_crossed_threshold_behavior_last( ) async def test_moisture_trigger_ignores_limit_entity_with_wrong_unit( hass: HomeAssistant, - service_calls: list[ServiceCall], trigger: str, trigger_options: dict[str, Any], limit_entities: list[str], @@ -518,7 +499,6 @@ async def test_moisture_trigger_ignores_limit_entity_with_wrong_unit( } await assert_trigger_ignores_limit_entities_with_wrong_unit( hass, - service_calls=service_calls, trigger=trigger, trigger_options=trigger_options, entity_id="sensor.test_moisture", diff --git a/tests/components/motion/test_trigger.py b/tests/components/motion/test_trigger.py index e46a93958c7987..9185f581e7dbe8 100644 --- a/tests/components/motion/test_trigger.py +++ b/tests/components/motion/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -70,7 +70,6 @@ async def test_motion_triggers_gated_by_labs_flag( ) async def test_motion_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -82,7 +81,6 @@ async def test_motion_trigger_binary_sensor_behavior_any( """Test motion trigger fires for binary_sensor entities with device_class motion.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -123,7 +121,6 @@ async def test_motion_trigger_binary_sensor_behavior_any( ) async def test_motion_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -135,7 +132,6 @@ async def test_motion_trigger_binary_sensor_behavior_first( """Test motion trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -176,7 +172,6 @@ async def test_motion_trigger_binary_sensor_behavior_first( ) async def test_motion_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -188,7 +183,6 @@ async def test_motion_trigger_binary_sensor_behavior_last( """Test motion trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/occupancy/test_trigger.py b/tests/components/occupancy/test_trigger.py index 641b9052e4e985..3c53e1fcfc05d5 100644 --- a/tests/components/occupancy/test_trigger.py +++ b/tests/components/occupancy/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -70,7 +70,6 @@ async def test_occupancy_triggers_gated_by_labs_flag( ) async def test_occupancy_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -82,7 +81,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_any( """Test occupancy trigger fires for binary_sensor entities with device_class occupancy.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -123,7 +121,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_any( ) async def test_occupancy_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -135,7 +132,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_first( """Test occupancy trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -176,7 +172,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_first( ) async def test_occupancy_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -188,7 +183,6 @@ async def test_occupancy_trigger_binary_sensor_behavior_last( """Test occupancy trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/person/test_trigger.py b/tests/components/person/test_trigger.py index 72e9213907487f..a9ca192f8011ad 100644 --- a/tests/components/person/test_trigger.py +++ b/tests/components/person/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.person.const import DOMAIN from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -61,7 +61,6 @@ async def test_person_triggers_gated_by_labs_flag( ) async def test_person_home_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_persons: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -73,7 +72,6 @@ async def test_person_home_trigger_behavior_any( """Test that the person home triggers when any person changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_persons, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -106,7 +104,6 @@ async def test_person_home_trigger_behavior_any( ) async def test_person_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_persons: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -118,7 +115,6 @@ async def test_person_state_trigger_behavior_first( """Test that the person home triggers when the first person changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_persons, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -151,7 +147,6 @@ async def test_person_state_trigger_behavior_first( ) async def test_person_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_persons: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -163,7 +158,6 @@ async def test_person_state_trigger_behavior_last( """Test that the person home triggers when the last person changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_persons, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/power/test_trigger.py b/tests/components/power/test_trigger.py index be902c46873f61..a7599dca96c9bd 100644 --- a/tests/components/power/test_trigger.py +++ b/tests/components/power/test_trigger.py @@ -7,7 +7,7 @@ from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -74,7 +74,6 @@ async def test_power_triggers_gated_by_labs_flag( ) async def test_power_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -86,7 +85,6 @@ async def test_power_trigger_sensor_behavior_any( """Test power trigger fires for sensor entities with device_class power.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -115,7 +113,6 @@ async def test_power_trigger_sensor_behavior_any( ) async def test_power_trigger_sensor_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -127,7 +124,6 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_first( """Test power crossed_threshold trigger fires on the first sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -156,7 +152,6 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_first( ) async def test_power_trigger_sensor_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -168,7 +163,6 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_last( """Test power crossed_threshold trigger fires when the last sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -206,7 +200,6 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_last( ) async def test_power_trigger_number_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -218,7 +211,6 @@ async def test_power_trigger_number_behavior_any( """Test power trigger fires for number entities with device_class power.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -247,7 +239,6 @@ async def test_power_trigger_number_behavior_any( ) async def test_power_trigger_number_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -259,7 +250,6 @@ async def test_power_trigger_number_crossed_threshold_behavior_first( """Test power crossed_threshold trigger fires on the first number state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -288,7 +278,6 @@ async def test_power_trigger_number_crossed_threshold_behavior_first( ) async def test_power_trigger_number_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_numbers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -300,7 +289,6 @@ async def test_power_trigger_number_crossed_threshold_behavior_last( """Test power crossed_threshold trigger fires when the last number changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_numbers, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/remote/test_trigger.py b/tests/components/remote/test_trigger.py index bc8654bab48091..84d67e2f9499cd 100644 --- a/tests/components/remote/test_trigger.py +++ b/tests/components/remote/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.remote import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -59,7 +59,6 @@ async def test_remote_triggers_gated_by_labs_flag( ) async def test_remote_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_remotes: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -71,7 +70,6 @@ async def test_remote_state_trigger_behavior_any( """Test that the remote triggers when any remote changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_remotes, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -104,7 +102,6 @@ async def test_remote_state_trigger_behavior_any( ) async def test_remote_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_remotes: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -116,7 +113,6 @@ async def test_remote_state_trigger_behavior_first( """Test that the remote triggers when the first remote changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_remotes, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -149,7 +145,6 @@ async def test_remote_state_trigger_behavior_first( ) async def test_remote_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_remotes: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -161,7 +156,6 @@ async def test_remote_state_trigger_behavior_last( """Test that the remote triggers when the last remote changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_remotes, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/scene/test_trigger.py b/tests/components/scene/test_trigger.py index cca28a1d120ddd..6b4621b709a882 100644 --- a/tests/components/scene/test_trigger.py +++ b/tests/components/scene/test_trigger.py @@ -2,8 +2,8 @@ import pytest -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -147,7 +147,6 @@ async def test_scene_triggers_gated_by_labs_flag( ) async def test_scene_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_scenes: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -156,6 +155,7 @@ async def test_scene_state_trigger( states: list[TriggerStateDescription], ) -> None: """Test that the scene state trigger fires when targeted scene state changes.""" + calls: list[str] = [] other_entity_ids = set(target_scenes["included_entities"]) - {entity_id} # Set all scenes, including the tested scene, to the initial state @@ -163,20 +163,20 @@ async def test_scene_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other scenes also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() diff --git a/tests/components/schedule/test_trigger.py b/tests/components/schedule/test_trigger.py index 4a4f155383986d..a5882fc2d3847a 100644 --- a/tests/components/schedule/test_trigger.py +++ b/tests/components/schedule/test_trigger.py @@ -13,14 +13,8 @@ CONF_TO, DOMAIN, ) -from homeassistant.const import ( - CONF_ENTITY_ID, - CONF_ICON, - CONF_NAME, - STATE_OFF, - STATE_ON, -) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_ICON, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant from tests.common import async_fire_time_changed from tests.components.common import ( @@ -78,7 +72,6 @@ async def test_schedule_triggers_gated_by_labs_flag( ) async def test_schedule_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_schedules: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -90,7 +83,6 @@ async def test_schedule_state_trigger_behavior_any( """Test that the schedule state trigger fires when any schedule state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_schedules, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -123,7 +115,6 @@ async def test_schedule_state_trigger_behavior_any( ) async def test_schedule_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_schedules: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -135,7 +126,6 @@ async def test_schedule_state_trigger_behavior_first( """Test that the schedule state trigger fires when the first schedule changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_schedules, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -168,7 +158,6 @@ async def test_schedule_state_trigger_behavior_first( ) async def test_schedule_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_schedules: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -180,7 +169,6 @@ async def test_schedule_state_trigger_behavior_last( """Test that the schedule state trigger fires when the last schedule changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_schedules, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -194,11 +182,11 @@ async def test_schedule_state_trigger_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_schedule_state_trigger_back_to_back( hass: HomeAssistant, - service_calls: list[ServiceCall], schedule_setup: Callable[..., Coroutine[Any, Any, bool]], freezer: FrozenDateTimeFactory, ) -> None: """Test that the schedule state trigger fires when transitioning between two back-to-back schedule blocks.""" + calls: list[str] = [] freezer.move_to("2022-08-30 13:20:00-07:00") entity_id = "schedule.from_yaml" @@ -223,6 +211,7 @@ async def test_schedule_state_trigger_back_to_back( "schedule.turned_on", {}, {"entity_id": [entity_id]}, + calls, ) # initial state @@ -241,9 +230,9 @@ async def test_schedule_state_trigger_back_to_back( assert state.state == STATE_ON assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T22:30:00-07:00" - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id + calls.clear() # move time into second block (back-to-back) freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) @@ -255,9 +244,9 @@ async def test_schedule_state_trigger_back_to_back( assert state.state == STATE_ON assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T23:00:00-07:00" - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id + calls.clear() # move time to after second block to ensure it turns off freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) @@ -269,4 +258,4 @@ async def test_schedule_state_trigger_back_to_back( assert state.state == STATE_OFF assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-11T22:00:00-07:00" - assert len(service_calls) == 0 + assert len(calls) == 0 diff --git a/tests/components/select/test_trigger.py b/tests/components/select/test_trigger.py index 89319000b9ed67..081b61edaeb08d 100644 --- a/tests/components/select/test_trigger.py +++ b/tests/components/select/test_trigger.py @@ -3,7 +3,7 @@ import pytest from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -93,7 +93,6 @@ async def test_select_triggers_gated_by_labs_flag( @pytest.mark.parametrize(("trigger", "states"), STATE_SEQUENCE) async def test_select_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_selects: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -104,7 +103,6 @@ async def test_select_state_trigger( """Test that the select trigger fires when targeted select state changes.""" await _assert_select_trigger_fires( hass, - service_calls=service_calls, target_entities=target_selects, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -122,7 +120,6 @@ async def test_select_state_trigger( @pytest.mark.parametrize(("trigger", "states"), STATE_SEQUENCE) async def test_input_select_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_selects: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -133,7 +130,6 @@ async def test_input_select_state_trigger( """Test that the select trigger fires when targeted input_select state changes.""" await _assert_select_trigger_fires( hass, - service_calls=service_calls, target_entities=target_input_selects, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -145,7 +141,6 @@ async def test_input_select_state_trigger( async def _assert_select_trigger_fires( hass: HomeAssistant, - service_calls: list[ServiceCall], target_entities: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -154,7 +149,7 @@ async def _assert_select_trigger_fires( states: list[TriggerStateDescription], ) -> None: """Test that the select trigger fires when targeted state changes.""" - + calls: list[str] = [] other_entity_ids = set(target_entities["included_entities"]) - {entity_id} # Set all entities to the initial state @@ -162,23 +157,23 @@ async def _assert_select_trigger_fires( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other targeted entities also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() # --- Cross-domain test --- @@ -187,9 +182,9 @@ async def _assert_select_trigger_fires( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_select_trigger_fires_for_both_domains( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test that the select trigger fires for both select and input_select entities.""" + calls: list[str] = [] entity_id_select = "select.test_select" entity_id_input_select = "input_select.test_input_select" @@ -202,18 +197,19 @@ async def test_select_trigger_fires_for_both_domains( "select.selection_changed", None, {CONF_ENTITY_ID: [entity_id_select, entity_id_input_select]}, + calls, ) # select entity changes - should trigger hass.states.async_set(entity_id_select, "option_b") await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_select - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id_select + calls.clear() # input_select entity changes - should also trigger hass.states.async_set(entity_id_input_select, "option_b") await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_input_select - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id_input_select + calls.clear() diff --git a/tests/components/siren/test_trigger.py b/tests/components/siren/test_trigger.py index ff33894ddc9fec..46d97d6379c0da 100644 --- a/tests/components/siren/test_trigger.py +++ b/tests/components/siren/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.siren import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -62,7 +62,6 @@ async def test_siren_triggers_gated_by_labs_flag( ) async def test_siren_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sirens: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -74,7 +73,6 @@ async def test_siren_state_trigger_behavior_any( """Test that the siren state trigger fires when any siren state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sirens, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -107,7 +105,6 @@ async def test_siren_state_trigger_behavior_any( ) async def test_siren_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sirens: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -119,7 +116,6 @@ async def test_siren_state_trigger_behavior_first( """Test that the siren state trigger fires when the first siren changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_sirens, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -152,7 +148,6 @@ async def test_siren_state_trigger_behavior_first( ) async def test_siren_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sirens: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -164,7 +159,6 @@ async def test_siren_state_trigger_behavior_last( """Test that the siren state trigger fires when the last siren changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_sirens, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/switch/test_trigger.py b/tests/components/switch/test_trigger.py index 47a1578c01f6e9..8fe7a97529735d 100644 --- a/tests/components/switch/test_trigger.py +++ b/tests/components/switch/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.switch import DOMAIN from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -74,7 +74,6 @@ async def test_switch_triggers_gated_by_labs_flag( ) async def test_switch_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_switches: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -86,7 +85,6 @@ async def test_switch_state_trigger_behavior_any( """Test that the switch state trigger fires when any switch state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_switches, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -108,7 +106,6 @@ async def test_switch_state_trigger_behavior_any( ) async def test_switch_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_switches: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -120,7 +117,6 @@ async def test_switch_state_trigger_behavior_first( """Test that the switch state trigger fires when the first switch changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_switches, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -142,7 +138,6 @@ async def test_switch_state_trigger_behavior_first( ) async def test_switch_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_switches: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -154,7 +149,6 @@ async def test_switch_state_trigger_behavior_last( """Test that the switch state trigger fires when the last switch changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_switches, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -179,7 +173,6 @@ async def test_switch_state_trigger_behavior_last( ) async def test_input_boolean_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_booleans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -191,7 +184,6 @@ async def test_input_boolean_state_trigger_behavior_any( """Test that the switch trigger fires when any input_boolean state changes.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_input_booleans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -213,7 +205,6 @@ async def test_input_boolean_state_trigger_behavior_any( ) async def test_input_boolean_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_booleans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -225,7 +216,6 @@ async def test_input_boolean_state_trigger_behavior_first( """Test that the switch trigger fires when the first input_boolean changes.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_input_booleans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -247,7 +237,6 @@ async def test_input_boolean_state_trigger_behavior_first( ) async def test_input_boolean_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_booleans: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -259,7 +248,6 @@ async def test_input_boolean_state_trigger_behavior_last( """Test that the switch trigger fires when the last input_boolean changes.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_input_booleans, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -276,9 +264,9 @@ async def test_input_boolean_state_trigger_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_switch_trigger_fires_for_both_domains( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test that the switch trigger fires for both switch and input_boolean entities.""" + calls: list[str] = [] entity_id_switch = "switch.test_switch" entity_id_input_boolean = "input_boolean.test_input_boolean" @@ -291,18 +279,19 @@ async def test_switch_trigger_fires_for_both_domains( "switch.turned_on", {}, {CONF_ENTITY_ID: [entity_id_switch, entity_id_input_boolean]}, + calls, ) # switch entity changes - should trigger hass.states.async_set(entity_id_switch, STATE_ON) await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_switch - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id_switch + calls.clear() # input_boolean entity changes - should also trigger hass.states.async_set(entity_id_input_boolean, STATE_ON) await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_input_boolean - service_calls.clear() + assert len(calls) == 1 + assert calls[0] == entity_id_input_boolean + calls.clear() diff --git a/tests/components/temperature/test_trigger.py b/tests/components/temperature/test_trigger.py index 6709867e698703..29e565cda178f0 100644 --- a/tests/components/temperature/test_trigger.py +++ b/tests/components/temperature/test_trigger.py @@ -22,7 +22,7 @@ CONF_ENTITY_ID, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -112,7 +112,6 @@ async def test_temperature_triggers_gated_by_labs_flag( ) async def test_temperature_trigger_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -124,7 +123,6 @@ async def test_temperature_trigger_sensor_behavior_any( """Test temperature trigger fires for sensor entities with device_class temperature.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -153,7 +151,6 @@ async def test_temperature_trigger_sensor_behavior_any( ) async def test_temperature_trigger_sensor_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -165,7 +162,6 @@ async def test_temperature_trigger_sensor_crossed_threshold_behavior_first( """Test temperature crossed_threshold trigger fires on the first sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -194,7 +190,6 @@ async def test_temperature_trigger_sensor_crossed_threshold_behavior_first( ) async def test_temperature_trigger_sensor_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -206,7 +201,6 @@ async def test_temperature_trigger_sensor_crossed_threshold_behavior_last( """Test temperature crossed_threshold trigger fires when the last sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -244,7 +238,6 @@ async def test_temperature_trigger_sensor_crossed_threshold_behavior_last( ) async def test_temperature_trigger_climate_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -256,7 +249,6 @@ async def test_temperature_trigger_climate_behavior_any( """Test temperature trigger fires for climate entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -285,7 +277,6 @@ async def test_temperature_trigger_climate_behavior_any( ) async def test_temperature_trigger_climate_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -297,7 +288,6 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_first( """Test temperature crossed_threshold trigger fires on the first climate state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -326,7 +316,6 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_first( ) async def test_temperature_trigger_climate_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_climates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -338,7 +327,6 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_last( """Test temperature crossed_threshold trigger fires when the last climate changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_climates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -376,7 +364,6 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_last( ) async def test_temperature_trigger_water_heater_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -388,7 +375,6 @@ async def test_temperature_trigger_water_heater_behavior_any( """Test temperature trigger fires for water_heater entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -417,7 +403,6 @@ async def test_temperature_trigger_water_heater_behavior_any( ) async def test_temperature_trigger_water_heater_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -429,7 +414,6 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_first """Test temperature crossed_threshold trigger fires on the first water_heater state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -458,7 +442,6 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_first ) async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -470,7 +453,6 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last( """Test temperature crossed_threshold trigger fires when the last water_heater changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -510,7 +492,6 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last( ) async def test_temperature_trigger_weather_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -522,7 +503,6 @@ async def test_temperature_trigger_weather_behavior_any( """Test temperature trigger fires for weather entities.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -552,7 +532,6 @@ async def test_temperature_trigger_weather_behavior_any( ) async def test_temperature_trigger_weather_crossed_threshold_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -564,7 +543,6 @@ async def test_temperature_trigger_weather_crossed_threshold_behavior_first( """Test temperature crossed_threshold trigger fires on the first weather state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -594,7 +572,6 @@ async def test_temperature_trigger_weather_crossed_threshold_behavior_first( ) async def test_temperature_trigger_weather_crossed_threshold_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_weathers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -606,7 +583,6 @@ async def test_temperature_trigger_weather_crossed_threshold_behavior_last( """Test temperature crossed_threshold trigger fires when the last weather changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_weathers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -623,9 +599,9 @@ async def test_temperature_trigger_weather_crossed_threshold_behavior_last( @pytest.mark.usefixtures("enable_labs_preview_features") async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test temperature trigger converts sensor value from °C to °F for threshold comparison.""" + calls: list[str] = [] entity_id = "sensor.test_temp" # Sensor reports in °C, trigger configured in °F with threshold above 70°F @@ -649,6 +625,7 @@ async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 20°C = 68°F, which is below 70°F - should NOT trigger @@ -661,7 +638,7 @@ async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 22°C = 71.6°F, which is above 70°F - should trigger hass.states.async_set( @@ -673,16 +650,16 @@ async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() @pytest.mark.usefixtures("enable_labs_preview_features") async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test temperature trigger converts sensor value from °F to °C for threshold comparison.""" + calls: list[str] = [] entity_id = "sensor.test_temp" # Sensor reports in °F, trigger configured in °C with threshold above 25°C @@ -706,6 +683,7 @@ async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 70°F = 21.1°C, which is below 25°C - should NOT trigger @@ -718,7 +696,7 @@ async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 80°F = 26.7°C, which is above 25°C - should trigger hass.states.async_set( @@ -730,16 +708,16 @@ async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() @pytest.mark.usefixtures("enable_labs_preview_features") async def test_temperature_trigger_unit_conversion_changed( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test temperature changed trigger with unit conversion and above/below limits.""" + calls: list[str] = [] entity_id = "sensor.test_temp" # Sensor reports in °C, trigger configured in °F: above 68°F (20°C), below 77°F (25°C) @@ -764,6 +742,7 @@ async def test_temperature_trigger_unit_conversion_changed( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 18°C = 64.4°F, below 68°F - should NOT trigger @@ -776,7 +755,7 @@ async def test_temperature_trigger_unit_conversion_changed( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 22°C = 71.6°F, between 68°F and 77°F - should trigger hass.states.async_set( @@ -788,8 +767,8 @@ async def test_temperature_trigger_unit_conversion_changed( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() # 26°C = 78.8°F, above 77°F - should NOT trigger hass.states.async_set( @@ -801,15 +780,15 @@ async def test_temperature_trigger_unit_conversion_changed( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 @pytest.mark.usefixtures("enable_labs_preview_features") async def test_temperature_trigger_unit_conversion_weather( hass: HomeAssistant, - service_calls: list[ServiceCall], ) -> None: """Test temperature trigger with unit conversion for weather entities.""" + calls: list[str] = [] entity_id = "weather.test" # Weather reports temperature in °F, trigger configured in °C with threshold above 25°C @@ -833,6 +812,7 @@ async def test_temperature_trigger_unit_conversion_weather( } }, {CONF_ENTITY_ID: [entity_id]}, + calls, ) # 70°F = 21.1°C, below 25°C - should NOT trigger @@ -845,7 +825,7 @@ async def test_temperature_trigger_unit_conversion_weather( }, ) await hass.async_block_till_done() - assert len(service_calls) == 0 + assert len(calls) == 0 # 80°F = 26.7°C, above 25°C - should trigger hass.states.async_set( @@ -857,5 +837,5 @@ async def test_temperature_trigger_unit_conversion_weather( }, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - service_calls.clear() + assert len(calls) == 1 + calls.clear() diff --git a/tests/components/text/test_trigger.py b/tests/components/text/test_trigger.py index 1a76eaed44f93a..38f5ed85f5fde1 100644 --- a/tests/components/text/test_trigger.py +++ b/tests/components/text/test_trigger.py @@ -4,8 +4,8 @@ from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN from homeassistant.components.text.const import DOMAIN -from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from tests.components.common import ( BasicTriggerStateDescription, @@ -142,7 +142,6 @@ async def test_text_triggers_gated_by_labs_flag( @pytest.mark.parametrize(("trigger", "states"), TEST_TRIGGER_STATES) async def test_text_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_texts: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -151,6 +150,7 @@ async def test_text_state_trigger( states: list[BasicTriggerStateDescription], ) -> None: """Test that the text state trigger fires when targeted text state changes.""" + calls: list[str] = [] other_entity_ids = set(target_texts["included_entities"]) - {entity_id} # Set all texts, including the tested text, to the initial state @@ -158,23 +158,23 @@ async def test_text_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other texts also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() @pytest.mark.usefixtures("enable_labs_preview_features") @@ -185,7 +185,6 @@ async def test_text_state_trigger( @pytest.mark.parametrize(("trigger", "states"), TEST_TRIGGER_STATES) async def test_input_text_state_trigger( hass: HomeAssistant, - service_calls: list[ServiceCall], target_input_texts: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -194,6 +193,7 @@ async def test_input_text_state_trigger( states: list[BasicTriggerStateDescription], ) -> None: """Test that the `text.changed` trigger fires when any input_text entity's state changes.""" + calls: list[str] = [] other_entity_ids = set(target_input_texts["included_entities"]) - {entity_id} # Set all input_texts, including the tested input_text, to the initial state @@ -201,20 +201,20 @@ async def test_input_text_state_trigger( set_or_remove_state(hass, eid, states[0]["included_state"]) await hass.async_block_till_done() - await arm_trigger(hass, trigger, None, trigger_target_config) + await arm_trigger(hass, trigger, None, trigger_target_config, calls) for state in states[1:]: included_state = state["included_state"] set_or_remove_state(hass, entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == state["count"] - for service_call in service_calls: - assert service_call.data[CONF_ENTITY_ID] == entity_id - service_calls.clear() + assert len(calls) == state["count"] + for call in calls: + assert call == entity_id + calls.clear() # Check if changing other input_texts also triggers for other_entity_id in other_entity_ids: set_or_remove_state(hass, other_entity_id, included_state) await hass.async_block_till_done() - assert len(service_calls) == (entities_in_target - 1) * state["count"] - service_calls.clear() + assert len(calls) == (entities_in_target - 1) * state["count"] + calls.clear() diff --git a/tests/components/update/test_trigger.py b/tests/components/update/test_trigger.py index a4a1dcc00a8c59..4e66c7774e1b01 100644 --- a/tests/components/update/test_trigger.py +++ b/tests/components/update/test_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.update import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -56,7 +56,6 @@ async def test_update_triggers_gated_by_labs_flag( ) async def test_update_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_updates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -68,7 +67,6 @@ async def test_update_state_trigger_behavior_any( """Test that the update state trigger fires when any update state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_updates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -96,7 +94,6 @@ async def test_update_state_trigger_behavior_any( ) async def test_update_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_updates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -108,7 +105,6 @@ async def test_update_state_trigger_behavior_first( """Test that the update state trigger fires when the first update changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_updates, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -136,7 +132,6 @@ async def test_update_state_trigger_behavior_first( ) async def test_update_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_updates: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -148,7 +143,6 @@ async def test_update_state_trigger_behavior_last( """Test that the update state trigger fires when the last update changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_updates, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/vacuum/test_trigger.py b/tests/components/vacuum/test_trigger.py index ae2f5f95d08bf1..a0c93ee272c4c3 100644 --- a/tests/components/vacuum/test_trigger.py +++ b/tests/components/vacuum/test_trigger.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.vacuum import VacuumActivity -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -80,7 +80,6 @@ async def test_vacuum_triggers_gated_by_labs_flag( ) async def test_vacuum_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_vacuums: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -92,7 +91,6 @@ async def test_vacuum_state_trigger_behavior_any( """Test that the vacuum state trigger fires when any vacuum state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_vacuums, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -140,7 +138,6 @@ async def test_vacuum_state_trigger_behavior_any( ) async def test_vacuum_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_vacuums: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -152,7 +149,6 @@ async def test_vacuum_state_trigger_behavior_first( """Test that the vacuum state trigger fires when the first vacuum changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_vacuums, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_vacuum_state_trigger_behavior_first( ) async def test_vacuum_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_vacuums: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_vacuum_state_trigger_behavior_last( """Test that the vacuum state trigger fires when the last vacuum changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_vacuums, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/water_heater/test_trigger.py b/tests/components/water_heater/test_trigger.py index 39c2ffad8b9b5f..6c33a35e794f29 100644 --- a/tests/components/water_heater/test_trigger.py +++ b/tests/components/water_heater/test_trigger.py @@ -13,7 +13,7 @@ STATE_PERFORMANCE, ) from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, STATE_ON, UnitOfTemperature -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -104,7 +104,6 @@ async def test_water_heater_triggers_gated_by_labs_flag( ) async def test_water_heater_state_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -116,7 +115,6 @@ async def test_water_heater_state_trigger_behavior_any( """Test that the water heater state trigger fires when any water heater state changes to a specific state.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -151,7 +149,6 @@ async def test_water_heater_state_trigger_behavior_any( ) async def test_water_heater_state_attribute_trigger_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -163,7 +160,6 @@ async def test_water_heater_state_attribute_trigger_behavior_any( """Test that the water heater target temperature attribute triggers fire when any water heater's target temperature changes or crosses a threshold.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -214,7 +210,6 @@ async def test_water_heater_state_attribute_trigger_behavior_any( ) async def test_water_heater_state_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -226,7 +221,6 @@ async def test_water_heater_state_trigger_behavior_first( """Test that the water heater state trigger fires when the first water heater changes to a specific state.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -255,7 +249,6 @@ async def test_water_heater_state_trigger_behavior_first( ) async def test_water_heater_state_attribute_trigger_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -267,7 +260,6 @@ async def test_water_heater_state_attribute_trigger_behavior_first( """Test that the water heater attribute threshold trigger fires when the first water heater's target temperature crosses the configured threshold.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -318,7 +310,6 @@ async def test_water_heater_state_attribute_trigger_behavior_first( ) async def test_water_heater_state_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -330,7 +321,6 @@ async def test_water_heater_state_trigger_behavior_last( """Test that the water heater state trigger fires when the last water heater changes to a specific state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -359,7 +349,6 @@ async def test_water_heater_state_trigger_behavior_last( ) async def test_water_heater_state_attribute_trigger_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_water_heaters: list[str], trigger_target_config: dict, entity_id: str, @@ -371,7 +360,6 @@ async def test_water_heater_state_attribute_trigger_behavior_last( """Test that the water heater trigger fires when the last water heater's target temperature crosses the configured threshold.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_water_heaters, trigger_target_config=trigger_target_config, entity_id=entity_id, diff --git a/tests/components/window/test_trigger.py b/tests/components/window/test_trigger.py index c5d110117c073c..26ab972cce0e0d 100644 --- a/tests/components/window/test_trigger.py +++ b/tests/components/window/test_trigger.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, @@ -77,7 +77,6 @@ async def test_window_triggers_gated_by_labs_flag( ) async def test_window_trigger_binary_sensor_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -89,7 +88,6 @@ async def test_window_trigger_binary_sensor_behavior_any( """Test window trigger fires for binary_sensor entities with device_class window.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -147,7 +145,6 @@ async def test_window_trigger_binary_sensor_behavior_any( ) async def test_window_trigger_cover_behavior_any( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -159,7 +156,6 @@ async def test_window_trigger_cover_behavior_any( """Test window trigger fires for cover entities with device_class window.""" await assert_trigger_behavior_any( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -200,7 +196,6 @@ async def test_window_trigger_cover_behavior_any( ) async def test_window_trigger_binary_sensor_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -212,7 +207,6 @@ async def test_window_trigger_binary_sensor_behavior_first( """Test window trigger fires on the first binary_sensor state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -253,7 +247,6 @@ async def test_window_trigger_binary_sensor_behavior_first( ) async def test_window_trigger_binary_sensor_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_binary_sensors: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -265,7 +258,6 @@ async def test_window_trigger_binary_sensor_behavior_last( """Test window trigger fires when the last binary_sensor changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_binary_sensors, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -323,7 +315,6 @@ async def test_window_trigger_binary_sensor_behavior_last( ) async def test_window_trigger_cover_behavior_first( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -335,7 +326,6 @@ async def test_window_trigger_cover_behavior_first( """Test window trigger fires on the first cover state change.""" await assert_trigger_behavior_first( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, @@ -393,7 +383,6 @@ async def test_window_trigger_cover_behavior_first( ) async def test_window_trigger_cover_behavior_last( hass: HomeAssistant, - service_calls: list[ServiceCall], target_covers: dict[str, list[str]], trigger_target_config: dict, entity_id: str, @@ -405,7 +394,6 @@ async def test_window_trigger_cover_behavior_last( """Test window trigger fires when the last cover changes state.""" await assert_trigger_behavior_last( hass, - service_calls=service_calls, target_entities=target_covers, trigger_target_config=trigger_target_config, entity_id=entity_id, From 8208eecf8c5fbf71c4657690eff275441b1c4f91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:37:25 +0100 Subject: [PATCH 0028/1707] Bump j178/prek-action from 1.1.1 to 2.0.0 (#166561) --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2afd120f42761f..1b76dbb1898437 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -280,7 +280,7 @@ jobs: echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" echo "::add-matcher::.github/workflows/matchers/codespell.json" - name: Run prek - uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1 + uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0 env: PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor RUFF_OUTPUT_FORMAT: github @@ -301,7 +301,7 @@ jobs: with: persist-credentials: false - name: Run zizmor - uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1 + uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0 with: extra-args: --all-files zizmor From a205623d520ab192b45a0d3dc210c4c8f965e4a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:38:31 +0100 Subject: [PATCH 0029/1707] Bump codecov/codecov-action from 5.5.2 to 5.5.3 (#166562) --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1b76dbb1898437..9c4dac1c12a3ab 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1392,7 +1392,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: fail_ci_if_error: true flags: full-suite @@ -1563,7 +1563,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] @@ -1591,7 +1591,7 @@ jobs: with: pattern: test-results-* - name: Upload test results to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: report_type: test_results fail_ci_if_error: true From d5efc3abd510035f68e05e935c1d7efac73fb3c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:41:07 +0100 Subject: [PATCH 0030/1707] Bump actions/cache from 5.0.3 to 5.0.4 (#166563) --- .github/workflows/ci.yaml | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9c4dac1c12a3ab..6aa251db25f106 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -364,7 +364,7 @@ jobs: echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv key: >- @@ -372,7 +372,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -384,7 +384,7 @@ jobs: env.HA_SHORT_VERSION }}- - name: Check if apt cache exists id: cache-apt-check - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }} path: | @@ -430,7 +430,7 @@ jobs: fi - name: Save apt cache if: steps.cache-apt-check.outputs.cache-hit != 'true' - uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ${{ env.APT_CACHE_DIR }} @@ -484,7 +484,7 @@ jobs: && github.event.inputs.audit-licenses-only != 'true' steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ${{ env.APT_CACHE_DIR }} @@ -515,7 +515,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -552,7 +552,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -643,7 +643,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -694,7 +694,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -747,7 +747,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -804,7 +804,7 @@ jobs: echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -812,7 +812,7 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: .mypy_cache key: >- @@ -854,7 +854,7 @@ jobs: - base steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ${{ env.APT_CACHE_DIR }} @@ -887,7 +887,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -930,7 +930,7 @@ jobs: group: ${{ fromJson(needs.info.outputs.test_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ${{ env.APT_CACHE_DIR }} @@ -964,7 +964,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -1080,7 +1080,7 @@ jobs: mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1115,7 +1115,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -1238,7 +1238,7 @@ jobs: postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1275,7 +1275,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true @@ -1421,7 +1421,7 @@ jobs: group: ${{ fromJson(needs.info.outputs.test_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1455,7 +1455,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: venv fail-on-cache-miss: true From 412a9a050eedcda26b3540a7cba634a9dbde785f Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:45:05 +0100 Subject: [PATCH 0031/1707] Bump music-assistant-client to 1.3.4 (#166567) --- homeassistant/components/music_assistant/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index f5dc0e4a8d14d0..c59f88aa5e7fa8 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["music_assistant"], "quality_scale": "bronze", - "requirements": ["music-assistant-client==1.3.3"], + "requirements": ["music-assistant-client==1.3.4"], "zeroconf": ["_mass._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 8ed2c60aab31c5..980cb800ff766e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1564,7 +1564,7 @@ mozart-api==5.3.1.108.2 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.3.3 +music-assistant-client==1.3.4 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f449ab8c1208e..745a606c9da34e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1377,7 +1377,7 @@ mozart-api==5.3.1.108.2 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.3.3 +music-assistant-client==1.3.4 # homeassistant.components.tts mutagen==1.47.0 From 0f41a311c8fd0e52031f4b546ed778239a5bbfdd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:45:40 +0100 Subject: [PATCH 0032/1707] Bump dawidd6/action-download-artifact from 16 to 19 (#166564) --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 15701f81a05153..4a9b7033842296 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -112,7 +112,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 + uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -123,7 +123,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 + uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: OHF-Voice/intents-package From ad522d723cc624dbb839677037754c727da659bc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 10:03:49 +0100 Subject: [PATCH 0033/1707] Add trigger humidifier.mode_changed (#166241) Co-authored-by: Norbert Rittel --- .../components/humidifier/icons.json | 3 + .../components/humidifier/strings.json | 14 +++ .../components/humidifier/trigger.py | 58 ++++++++- .../components/humidifier/triggers.yaml | 17 ++- tests/components/humidifier/test_trigger.py | 116 +++++++++++++++++- 5 files changed, 201 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index fde5c3c9598680..778aa6d0f47a65 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -67,6 +67,9 @@ } }, "triggers": { + "mode_changed": { + "trigger": "mdi:air-humidifier" + }, "started_drying": { "trigger": "mdi:arrow-down-bold" }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index beee0502bc044c..6acd851b3deafd 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -201,6 +201,20 @@ }, "title": "Humidifier", "triggers": { + "mode_changed": { + "description": "Triggers after the operation mode of one or more humidifiers changes.", + "fields": { + "behavior": { + "description": "[%key:component::humidifier::common::trigger_behavior_description%]", + "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "mode": { + "description": "The operation modes to trigger on.", + "name": "Mode" + } + }, + "name": "Humidifier mode changed" + }, "started_drying": { "description": "Triggers after one or more humidifiers start drying.", "fields": { diff --git a/homeassistant/components/humidifier/trigger.py b/homeassistant/components/humidifier/trigger.py index 44179856f2758e..b0df91267337f0 100644 --- a/homeassistant/components/humidifier/trigger.py +++ b/homeassistant/components/humidifier/trigger.py @@ -1,13 +1,65 @@ """Provides triggers for humidifiers.""" -from homeassistant.const import STATE_OFF, STATE_ON +import voluptuous as vol + +from homeassistant.const import ATTR_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec -from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger +from homeassistant.helpers.entity import get_supported_features +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, + EntityTargetStateTriggerBase, + Trigger, + TriggerConfig, + make_entity_target_state_trigger, +) + +from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature + +CONF_MODE = "mode" + +MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]), + }, + } +) + + +def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool: + """Test if an entity supports the specified features.""" + try: + return bool(get_supported_features(hass, entity_id) & features) + except HomeAssistantError: + return False + + +class ModeChangedTrigger(EntityTargetStateTriggerBase): + """Trigger for humidifier mode changes.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)} + _schema = MODE_CHANGED_TRIGGER_SCHEMA + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the mode trigger.""" + super().__init__(hass, config) + self._to_states = set(self._options[CONF_MODE]) + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities of this domain.""" + entities = super().entity_filter(entities) + return { + entity_id + for entity_id in entities + if _supports_feature(self._hass, entity_id, HumidifierEntityFeature.MODES) + } -from .const import ATTR_ACTION, DOMAIN, HumidifierAction TRIGGERS: dict[str, type[Trigger]] = { + "mode_changed": ModeChangedTrigger, "started_drying": make_entity_target_state_trigger( {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING ), diff --git a/homeassistant/components/humidifier/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml index 5773f999c88e40..12072ab71eb1f9 100644 --- a/homeassistant/components/humidifier/triggers.yaml +++ b/homeassistant/components/humidifier/triggers.yaml @@ -1,9 +1,9 @@ .trigger_common: &trigger_common - target: + target: &trigger_humidifier_target entity: domain: humidifier fields: - behavior: + behavior: &trigger_behavior required: true default: any selector: @@ -18,3 +18,16 @@ started_drying: *trigger_common started_humidifying: *trigger_common turned_on: *trigger_common turned_off: *trigger_common + +mode_changed: + target: *trigger_humidifier_target + fields: + behavior: *trigger_behavior + mode: + context: + filter_target: target + required: true + selector: + state: + attribute: available_modes + multiple: true diff --git a/tests/components/humidifier/test_trigger.py b/tests/components/humidifier/test_trigger.py index fde5ed83a63dea..81af5d51afa812 100644 --- a/tests/components/humidifier/test_trigger.py +++ b/tests/components/humidifier/test_trigger.py @@ -1,12 +1,28 @@ """Test humidifier trigger.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from typing import Any import pytest +import voluptuous as vol -from homeassistant.components.humidifier.const import ATTR_ACTION, HumidifierAction -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.humidifier.const import ( + ATTR_ACTION, + HumidifierAction, + HumidifierEntityFeature, +) +from homeassistant.components.humidifier.trigger import CONF_MODE +from homeassistant.const import ( + ATTR_MODE, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITY_ID, + CONF_OPTIONS, + CONF_TARGET, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers.trigger import async_validate_trigger_config from tests.components.common import ( TriggerStateDescription, @@ -29,6 +45,7 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "trigger_key", [ + "humidifier.mode_changed", "humidifier.started_drying", "humidifier.started_humidifying", "humidifier.turned_off", @@ -103,6 +120,21 @@ async def test_humidifier_state_trigger_behavior_any( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_trigger_states( + trigger="humidifier.mode_changed", + trigger_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + trigger_from_none=False, + ), ], ) async def test_humidifier_state_attribute_trigger_behavior_any( @@ -189,6 +221,21 @@ async def test_humidifier_state_trigger_behavior_first( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_trigger_states( + trigger="humidifier.mode_changed", + trigger_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + trigger_from_none=False, + ), ], ) async def test_humidifier_state_attribute_trigger_behavior_first( @@ -275,6 +322,21 @@ async def test_humidifier_state_trigger_behavior_last( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_trigger_states( + trigger="humidifier.mode_changed", + trigger_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + trigger_from_none=False, + ), ], ) async def test_humidifier_state_attribute_trigger_behavior_last( @@ -298,3 +360,53 @@ async def test_humidifier_state_attribute_trigger_behavior_last( trigger_options=trigger_options, states=states, ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger", "trigger_options", "expected_result"), + [ + # Valid configurations + ( + "humidifier.mode_changed", + {CONF_MODE: ["eco", "sleep"]}, + does_not_raise(), + ), + ( + "humidifier.mode_changed", + {CONF_MODE: "eco"}, + does_not_raise(), + ), + # Invalid configurations + ( + "humidifier.mode_changed", + # Empty mode list + {CONF_MODE: []}, + pytest.raises(vol.Invalid), + ), + ( + "humidifier.mode_changed", + # Missing CONF_MODE + {}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_humidifier_mode_changed_trigger_validation( + hass: HomeAssistant, + trigger: str, + trigger_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test humidifier mode_changed trigger config validation.""" + with expected_result: + await async_validate_trigger_config( + hass, + [ + { + "platform": trigger, + CONF_TARGET: {CONF_ENTITY_ID: "humidifier.test"}, + CONF_OPTIONS: trigger_options, + } + ], + ) From 0a8f76864c4425cf42f7c3725f01db96eff93432 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Thu, 26 Mar 2026 05:25:29 -0400 Subject: [PATCH 0034/1707] Bump asyncsleepiq to 1.7.1 (#166552) --- homeassistant/components/sleepiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index 39a889997f8f92..9cecbfbbcec551 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.7.0"] + "requirements": ["asyncsleepiq==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 980cb800ff766e..14be365147f152 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -562,7 +562,7 @@ asyncinotify==4.4.0 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.7.0 +asyncsleepiq==1.7.1 # homeassistant.components.sftp_storage asyncssh==2.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 745a606c9da34e..27cc6ec3f76520 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -523,7 +523,7 @@ async-upnp-client==0.46.2 asyncarve==0.1.1 # homeassistant.components.sleepiq -asyncsleepiq==1.7.0 +asyncsleepiq==1.7.1 # homeassistant.components.sftp_storage asyncssh==2.21.0 From ea99f88d109b2f118c22bacdc4d9de30088e5b78 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Thu, 26 Mar 2026 05:27:02 -0400 Subject: [PATCH 0035/1707] Bump sense-energy to 0.14.0 (#166550) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 3e9d6c81881262..2a517aee359292 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.8"] + "requirements": ["sense-energy==0.14.0"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 351d3bea7c22d2..3816a8c4ff91d9 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -21,5 +21,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.8"] + "requirements": ["sense-energy==0.14.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 14be365147f152..d36396d50cb755 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2902,7 +2902,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.8 +sense-energy==0.14.0 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27cc6ec3f76520..201c5feefd48aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2459,7 +2459,7 @@ securetar==2026.2.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.8 +sense-energy==0.14.0 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From 758d5469aa6b4ac59ef88e01ba57f0e9c9a7ea4a Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 26 Mar 2026 02:31:07 -0700 Subject: [PATCH 0036/1707] Add Google Drive backup upload progress (#166549) --- .../components/google_drive/backup.py | 17 ++++++- tests/components/google_drive/test_backup.py | 49 ++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index e6967d95eaf7bd..40ebc7c7cecf73 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps import logging from typing import Any @@ -84,8 +85,22 @@ async def async_upload_backup( :param open_stream: A function returning an async iterator that yields bytes. :param backup: Metadata about the backup that should be uploaded. """ + + @wraps(open_stream) + async def wrapped_open_stream() -> AsyncIterator[bytes]: + stream = await open_stream() + + async def _progress_stream() -> AsyncIterator[bytes]: + bytes_uploaded = 0 + async for chunk in stream: + yield chunk + bytes_uploaded += len(chunk) + on_progress(bytes_uploaded=bytes_uploaded) + + return _progress_stream() + try: - await self._client.async_upload_backup(open_stream, backup) + await self._client.async_upload_backup(wrapped_open_stream, backup) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: raise BackupAgentError(f"Failed to upload backup: {err}") from err diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index b731be0c34e5cf..48e2b72878a35a 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -1,5 +1,6 @@ """Test the Google Drive backup platform.""" +from collections.abc import AsyncIterator from io import StringIO import json from typing import Any @@ -16,6 +17,7 @@ AgentBackup, ) from homeassistant.components.google_drive import DOMAIN +from homeassistant.components.google_drive.backup import GoogleDriveBackupAgent from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -59,6 +61,18 @@ } +async def consume_stream( + file_metadata: Any, + open_stream: Any, + *args: Any, + **kwargs: Any, +) -> None: + """Consume the stream from the open_stream callable.""" + stream = await open_stream() + async for _ in stream: + pass + + @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, @@ -283,7 +297,7 @@ async def test_agents_upload( snapshot: SnapshotAssertion, ) -> None: """Test agent upload backup.""" - mock_api.resumable_upload_file = AsyncMock(return_value=None) + mock_api.resumable_upload_file = AsyncMock(side_effect=consume_stream) client = await hass_client() @@ -324,7 +338,7 @@ async def test_agents_upload_create_folder_if_missing( mock_api.create_file = AsyncMock( return_value={"id": "new folder id", "name": "Home Assistant"} ) - mock_api.resumable_upload_file = AsyncMock(return_value=None) + mock_api.resumable_upload_file = AsyncMock(side_effect=consume_stream) client = await hass_client() @@ -354,6 +368,37 @@ async def test_agents_upload_create_folder_if_missing( assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot +async def test_agents_upload_progress( + hass: HomeAssistant, + mock_api: MagicMock, +) -> None: + """Test agent upload reports progress.""" + mock_api.resumable_upload_file = AsyncMock(side_effect=consume_stream) + + entries = hass.config_entries.async_entries(DOMAIN) + agent = GoogleDriveBackupAgent(entries[0]) + + progress_calls = [] + + def on_progress(*, bytes_uploaded: int, **kwargs: Any) -> None: + progress_calls.append(bytes_uploaded) + + async def open_stream() -> AsyncIterator[bytes]: + async def stream() -> AsyncIterator[bytes]: + yield b"chunk1" + yield b"chunk2" + + return stream() + + await agent.async_upload_backup( + open_stream=open_stream, + backup=TEST_AGENT_BACKUP, + on_progress=on_progress, + ) + + assert progress_calls == [6, 12] + + async def test_agents_upload_fail( hass: HomeAssistant, hass_client: ClientSessionGenerator, From b6c2fbb8c0f952824bb50b4d21b11f3d9b8a06c5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 11:32:39 +0100 Subject: [PATCH 0037/1707] Adjust some trigger and condition schemas (#166568) --- homeassistant/components/text/condition.py | 12 ++---- .../components/water_heater/condition.py | 11 +---- homeassistant/helpers/condition.py | 42 +++++++------------ homeassistant/helpers/trigger.py | 39 +++++++---------- 4 files changed, 35 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/text/condition.py b/homeassistant/components/text/condition.py index 3945488d66606b..7fe4ee44568256 100644 --- a/homeassistant/components/text/condition.py +++ b/homeassistant/components/text/condition.py @@ -5,14 +5,12 @@ import voluptuous as vol from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN -from homeassistant.const import CONF_OPTIONS, CONF_TARGET +from homeassistant.const import CONF_OPTIONS from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( - ATTR_BEHAVIOR, - BEHAVIOR_ALL, - BEHAVIOR_ANY, + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionConfig, EntityConditionBase, @@ -22,13 +20,9 @@ CONF_VALUE = "value" -_TEXT_CONDITION_SCHEMA = vol.Schema( +_TEXT_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( { - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, vol.Required(CONF_OPTIONS): { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_ANY, BEHAVIOR_ALL] - ), vol.Required(CONF_VALUE): cv.string, }, } diff --git a/homeassistant/components/water_heater/condition.py b/homeassistant/components/water_heater/condition.py index 64f4d128954e00..ce1f36c52694cd 100644 --- a/homeassistant/components/water_heater/condition.py +++ b/homeassistant/components/water_heater/condition.py @@ -9,7 +9,6 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_OPTIONS, - CONF_TARGET, STATE_OFF, UnitOfTemperature, ) @@ -17,9 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec from homeassistant.helpers.condition import ( - ATTR_BEHAVIOR, - BEHAVIOR_ALL, - BEHAVIOR_ANY, + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, ConditionConfig, EntityConditionBase, @@ -33,13 +30,9 @@ ATTR_OPERATION_MODE = "operation_mode" -_OPERATION_MODE_CONDITION_SCHEMA = vol.Schema( +_OPERATION_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( { - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, vol.Required(CONF_OPTIONS): { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_ANY, BEHAVIOR_ALL] - ), vol.Required(ATTR_OPERATION_MODE): vol.All( cv.ensure_list, vol.Length(min=1), [str] ), diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 967ddefe1b84ad..19537a20f0b1c7 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -462,19 +462,13 @@ class CustomCondition(EntityStateConditionBase): return CustomCondition -NUMERICAL_CONDITION_SCHEMA = vol.Schema( +NUMERICAL_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( { - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, - vol.Required(CONF_OPTIONS): vol.All( - { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_ANY, BEHAVIOR_ALL] - ), - vol.Required("threshold"): NumericThresholdSelector( - NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS) - ), - }, - ), + vol.Required(CONF_OPTIONS): { + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS) + ), + }, } ) @@ -588,22 +582,16 @@ def _make_numerical_condition_with_unit_schema( unit_converter: type[BaseUnitConverter], ) -> vol.Schema: """Factory for numerical condition schema with unit option.""" - return vol.Schema( + return ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( { - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, - vol.Required(CONF_OPTIONS): vol.All( - { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_ANY, BEHAVIOR_ALL] - ), - vol.Required("threshold"): NumericThresholdSelector( - NumericThresholdSelectorConfig( - mode=NumericThresholdMode.IS, - unit_of_measurement=list(unit_converter.VALID_UNITS), - ) - ), - }, - ), + vol.Required(CONF_OPTIONS): { + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig( + mode=NumericThresholdMode.IS, + unit_of_measurement=list(unit_converter.VALID_UNITS), + ) + ), + }, } ) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index fefbc416cb12c9..975c3dddc3c0c3 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -336,7 +336,6 @@ async def async_attach_runner( [BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY] ), }, - vol.Required(CONF_TARGET): cv.TARGET_FIELDS, } ) @@ -746,19 +745,16 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls._schema = make_numerical_state_changed_with_unit_schema(cls._unit_converter) -NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( - { - vol.Required(CONF_OPTIONS): vol.All( - { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY] - ), +NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ( + ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( + { + vol.Required(CONF_OPTIONS): { vol.Required("threshold"): NumericThresholdSelector( NumericThresholdSelectorConfig(mode=NumericThresholdMode.CROSSED) ), }, - ) - } + } + ) ) @@ -787,21 +783,16 @@ def _make_numerical_state_crossed_threshold_with_unit_schema( This trigger only fires when the observed attribute changes from not within to within the defined threshold. """ - return ENTITY_STATE_TRIGGER_SCHEMA.extend( + return ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend( { - vol.Required(CONF_OPTIONS, default={}): vol.All( - { - vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( - [BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY] - ), - vol.Required("threshold"): NumericThresholdSelector( - NumericThresholdSelectorConfig( - mode=NumericThresholdMode.CROSSED, - unit_of_measurement=list(unit_converter.VALID_UNITS), - ) - ), - }, - ) + vol.Required(CONF_OPTIONS, default={}): { + vol.Required("threshold"): NumericThresholdSelector( + NumericThresholdSelectorConfig( + mode=NumericThresholdMode.CROSSED, + unit_of_measurement=list(unit_converter.VALID_UNITS), + ) + ), + }, } ) From d39ef523b85cf26c3e30e66d554ea48d353dc131 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 26 Mar 2026 11:45:34 +0100 Subject: [PATCH 0038/1707] Revert: Create repair issue for legacy Z-Wave Door state sensors that are still in use (#166583) --- .../components/zwave_js/binary_sensor.py | 103 +------ .../components/zwave_js/strings.json | 4 - .../components/zwave_js/test_binary_sensor.py | 252 +----------------- 3 files changed, 7 insertions(+), 352 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 1af91d168f5652..c0df675a25dabe 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -18,27 +18,17 @@ ) from zwave_js_server.model.driver import Driver -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) -from homeassistant.helpers.start import async_at_started from .const import DOMAIN from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity @@ -413,93 +403,6 @@ def is_valid_notification_binary_sensor( return len(info.primary_value.metadata.states) > 1 -@callback -def _async_check_legacy_entity_repair( - hass: HomeAssistant, - driver: Driver, - entity: ZWaveLegacyDoorStateBinarySensor, -) -> None: - """Schedule a repair issue check once HA has fully started.""" - - @callback - def _async_do_check(hass: HomeAssistant) -> None: - """Create or delete a repair issue for a deprecated legacy door state entity.""" - ent_reg = er.async_get(hass) - if entity.unique_id is None: - return - entity_id = ent_reg.async_get_entity_id( - BINARY_SENSOR_DOMAIN, DOMAIN, entity.unique_id - ) - if entity_id is None: - return - - issue_id = f"deprecated_legacy_door_state.{entity_id}" - - # Delete any stale repair issue if the entity is disabled or missing — - # the user has already dealt with it. - entity_entry = ent_reg.async_get(entity_id) - if entity_entry is None or entity_entry.disabled: - async_delete_issue(hass, DOMAIN, issue_id) - return - - entity_automations = automations_with_entity(hass, entity_id) - entity_scripts = scripts_with_entity(hass, entity_id) - - # Delete any stale repair issue if the entity is no longer referenced - # in any automation or script. - if not entity_automations and not entity_scripts: - async_delete_issue(hass, DOMAIN, issue_id) - return - - opening_state_value = get_opening_state_notification_value( - entity.info.node, entity.info.primary_value.endpoint - ) - if opening_state_value is None: - async_delete_issue(hass, DOMAIN, issue_id) - return - opening_state_unique_id = ( - f"{driver.controller.home_id}.{opening_state_value.value_id}" - ) - opening_state_entity_id = ent_reg.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, opening_state_unique_id - ) - # Delete any stale repair issue if the replacement opening state sensor - # no longer exists for some reason - if opening_state_entity_id is None: - async_delete_issue(hass, DOMAIN, issue_id) - return - - items = [ - f"- [{item.name or item.original_name or eid}](/config/{domain}/edit/{item.unique_id})" - for domain, entity_ids in ( - ("automation", entity_automations), - ("script", entity_scripts), - ) - for eid in entity_ids - if (item := ent_reg.async_get(eid)) - ] - - async_create_issue( - hass, - DOMAIN, - issue_id, - is_fixable=False, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_legacy_door_state", - translation_placeholders={ - "entity_id": entity_id, - "entity_name": entity_entry.name - or entity_entry.original_name - or entity_id, - "opening_state_entity_id": opening_state_entity_id, - "items": "\n".join(items), - }, - ) - - async_at_started(hass, _async_do_check) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, @@ -543,9 +446,9 @@ def async_add_binary_sensor( isinstance(info, NewZwaveDiscoveryInfo) and info.entity_class is ZWaveLegacyDoorStateBinarySensor ): - entity = ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) - entities.append(entity) - _async_check_legacy_entity_repair(hass, driver, entity) + entities.append( + ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) + ) elif isinstance(info, NewZwaveDiscoveryInfo): pass # other entity classes are not migrated yet elif info.platform_hint == "notification": diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 21db14ed598e4a..dbaefc4f1cf8c3 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -303,10 +303,6 @@ } }, "issues": { - "deprecated_legacy_door_state": { - "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the opening state sensor `{opening_state_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the opening state sensor `{opening_state_entity_id}` and disable the binary sensor `{entity_id}` to fix this issue.\n\nNote that `{opening_state_entity_id}` reports three states:\n- Closed\n- Open\n- Tilted (if supported by the device).", - "title": "Deprecation: {entity_name}" - }, "device_config_file_changed": { "fix_flow": { "abort": { diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 9a1ac6ad6b31bc..ad7db02950c29d 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -7,14 +7,9 @@ import pytest from zwave_js_server.event import Event -from zwave_js_server.model.node import Node, NodeDataType +from zwave_js_server.model.node import Node -from homeassistant.components import automation -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, - BinarySensorDeviceClass, -) -from homeassistant.components.zwave_js.const import DOMAIN +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -25,9 +20,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import ( @@ -36,6 +29,7 @@ NOTIFICATION_MOTION_BINARY_SENSOR, PROPERTY_DOOR_STATUS_BINARY_SENSOR, TAMPER_SENSOR, + NodeDataType, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -989,241 +983,3 @@ async def test_hoppe_ehandle_connectsense( assert entry.original_name == "Window/door is tilted" assert entry.original_device_class == BinarySensorDeviceClass.WINDOW assert entry.disabled_by is None, "Entity should be enabled by default" - - -async def test_legacy_door_state_repair_issue( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - client: MagicMock, - hoppe_ehandle_connectsense_state: NodeDataType, -) -> None: - """Test repair issue is created only when legacy door state entity is in automation.""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - home_id = client.driver.controller.home_id - - # Pre-register the legacy entity as enabled (simulating existing user entity). - unique_id = f"{home_id}.20-113-0-Access Control-Door state.22" - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id="ehandle_connectsense_window_door_is_open", - original_name="Window/door is open", - ) - entity_id = entity_entry.entity_id - - # Load the integration without any automation referencing the entity. - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # No repair issues should exist without automations. - issues = [ - issue - for issue in issue_registry.issues.values() - if issue.domain == DOMAIN - and issue.translation_key == "deprecated_legacy_door_state" - ] - assert len(issues) == 0 - - # Now set up an automation referencing the legacy entity. - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "test_automation", - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": {"entity_id": "automation.test_automation"}, - }, - } - }, - ) - - # Reload the integration so the repair check runs again. - await hass.config_entries.async_reload(entry.entry_id) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - assert issue is not None - assert issue.translation_key == "deprecated_legacy_door_state" - assert issue.translation_placeholders["entity_id"] == entity_id - assert issue.translation_placeholders["entity_name"] == "Window/door is open" - assert ( - issue.translation_placeholders["opening_state_entity_id"] - == "sensor.ehandle_connectsense_opening_state" - ) - assert "test" in issue.translation_placeholders["items"] - - -async def test_legacy_door_state_no_repair_issue_when_disabled( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - client: MagicMock, - hoppe_ehandle_connectsense_state: NodeDataType, -) -> None: - """Test no repair issue when legacy door state entity is disabled.""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - home_id = client.driver.controller.home_id - - # Pre-register the legacy entity as disabled. - unique_id = f"{home_id}.20-113-0-Access Control-Door state.22" - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id="ehandle_connectsense_window_door_is_open", - original_name="Window/door is open", - disabled_by=er.RegistryEntryDisabler.INTEGRATION, - ) - entity_id = entity_entry.entity_id - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "test_automation", - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": {"entity_id": "automation.test_automation"}, - }, - } - }, - ) - - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # No repair issue should be created since the entity is disabled. - issue = issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - assert issue is None - - -async def test_hoppe_custom_tilt_sensor_no_repair_issue( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - client: MagicMock, - hoppe_ehandle_connectsense_state: NodeDataType, -) -> None: - """Test no repair issue for Hoppe eHandle custom tilt sensor (Binary Sensor CC).""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - - # Pre-register the Hoppe tilt entity as enabled (simulating existing user entity). - home_id = client.driver.controller.home_id - unique_id = f"{home_id}.20-48-0-Tilt" - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id="ehandle_connectsense_window_door_is_tilted", - original_name="Window/door is tilted", - ) - entity_id = entity_entry.entity_id - - # Set up automation referencing the custom tilt entity. - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "test_automation", - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": {"entity_id": "automation.test_automation"}, - }, - } - }, - ) - - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # No repair issue should be created - this is a custom Binary Sensor CC entity, - # not a legacy Notification CC door state entity. - issue = issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - assert issue is None - - -async def test_legacy_door_state_stale_repair_issue_cleaned_up( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - client: MagicMock, - hoppe_ehandle_connectsense_state: NodeDataType, -) -> None: - """Test that a stale repair issue is deleted when there are no automations.""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - home_id = client.driver.controller.home_id - - # Pre-register the legacy entity as enabled. - unique_id = f"{home_id}.20-113-0-Access Control-Door state.22" - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id="ehandle_connectsense_window_door_is_open", - original_name="Window/door is open", - ) - entity_id = entity_entry.entity_id - - # Seed a stale repair issue as if it had been created in a previous run. - async_create_issue( - hass, - DOMAIN, - f"deprecated_legacy_door_state.{entity_id}", - is_fixable=False, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_legacy_door_state", - translation_placeholders={ - "entity_id": entity_id, - "entity_name": "Window/door is open", - "opening_state_entity_id": "sensor.ehandle_connectsense_opening_state", - "items": "- [test](/config/automation/edit/test_automation)", - }, - ) - assert ( - issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - is not None - ) - - # Load the integration with no automation referencing the legacy entity. - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Stale issue should have been cleaned up. - assert ( - issue_registry.async_get_issue( - DOMAIN, f"deprecated_legacy_door_state.{entity_id}" - ) - is None - ) From 30dfd23da889a414d9e68f32fa0ed8d8fc581902 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Mar 2026 11:51:45 +0100 Subject: [PATCH 0039/1707] Improve MySensors tests and avoid dns lookups (#166509) --- tests/components/mysensors/test_config_flow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py index c75f8743cdec20..f398a625b3b738 100644 --- a/tests/components/mysensors/test_config_flow.py +++ b/tests/components/mysensors/test_config_flow.py @@ -160,6 +160,7 @@ async def test_config_tcp(hass: HomeAssistant) -> None: "homeassistant.components.mysensors.config_flow.try_connect", return_value=True, ), + patch("homeassistant.components.mysensors.gateway.socket.getaddrinfo"), patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, @@ -198,6 +199,7 @@ async def test_fail_to_connect(hass: HomeAssistant) -> None: "homeassistant.components.mysensors.config_flow.try_connect", return_value=False, ), + patch("homeassistant.components.mysensors.gateway.socket.getaddrinfo"), patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, @@ -677,6 +679,7 @@ async def test_duplicate( "homeassistant.components.mysensors.config_flow.try_connect", return_value=True, ), + patch("homeassistant.components.mysensors.gateway.socket.getaddrinfo"), patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, From cd63d14e6f7b9f8c0a38fa1c10fac872def69b68 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:51:49 +0100 Subject: [PATCH 0040/1707] Add battery triggers (#166258) --- .../components/automation/__init__.py | 1 + homeassistant/components/battery/__init__.py | 2 +- homeassistant/components/battery/icons.json | 20 + homeassistant/components/battery/strings.json | 82 ++- homeassistant/components/battery/trigger.py | 56 ++ .../components/battery/triggers.yaml | 85 +++ tests/components/battery/test_trigger.py | 485 ++++++++++++++++++ 7 files changed, 728 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/battery/trigger.py create mode 100644 homeassistant/components/battery/triggers.yaml create mode 100644 tests/components/battery/test_trigger.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 79109168157c99..b5fc6b5f015545 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -155,6 +155,7 @@ "air_quality", "alarm_control_panel", "assist_satellite", + "battery", "button", "climate", "counter", diff --git a/homeassistant/components/battery/__init__.py b/homeassistant/components/battery/__init__.py index ec07ec5e77ed82..52644072bba0ed 100644 --- a/homeassistant/components/battery/__init__.py +++ b/homeassistant/components/battery/__init__.py @@ -1,4 +1,4 @@ -"""Integration for battery conditions.""" +"""Integration for battery triggers and conditions.""" from __future__ import annotations diff --git a/homeassistant/components/battery/icons.json b/homeassistant/components/battery/icons.json index 64ec4dc8f3f185..f3c9e2b938536b 100644 --- a/homeassistant/components/battery/icons.json +++ b/homeassistant/components/battery/icons.json @@ -15,5 +15,25 @@ "is_not_low": { "condition": "mdi:battery" } + }, + "triggers": { + "level_changed": { + "trigger": "mdi:battery-unknown" + }, + "level_crossed_threshold": { + "trigger": "mdi:battery-alert" + }, + "low": { + "trigger": "mdi:battery-alert" + }, + "not_low": { + "trigger": "mdi:battery" + }, + "started_charging": { + "trigger": "mdi:battery-charging" + }, + "stopped_charging": { + "trigger": "mdi:battery" + } } } diff --git a/homeassistant/components/battery/strings.json b/homeassistant/components/battery/strings.json index e0eec43b74e295..dc6c518f665f12 100644 --- a/homeassistant/components/battery/strings.json +++ b/homeassistant/components/battery/strings.json @@ -3,7 +3,12 @@ "condition_behavior_description": "How the state should match on the targeted batteries.", "condition_behavior_name": "Behavior", "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration" + "condition_threshold_name": "Threshold configuration", + "trigger_behavior_description": "The behavior of the targeted batteries to trigger on.", + "trigger_behavior_name": "Behavior", + "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", + "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", + "trigger_threshold_name": "Threshold configuration" }, "conditions": { "is_charging": { @@ -67,7 +72,80 @@ "all": "All", "any": "Any" } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } } }, - "title": "Battery" + "title": "Battery", + "triggers": { + "level_changed": { + "description": "Triggers after the battery level of one or more batteries changes.", + "fields": { + "threshold": { + "description": "[%key:component::battery::common::trigger_threshold_changed_description%]", + "name": "[%key:component::battery::common::trigger_threshold_name%]" + } + }, + "name": "Battery level changed" + }, + "level_crossed_threshold": { + "description": "Triggers after the battery level of one or more batteries crosses a threshold.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + }, + "threshold": { + "description": "[%key:component::battery::common::trigger_threshold_crossed_description%]", + "name": "[%key:component::battery::common::trigger_threshold_name%]" + } + }, + "name": "Battery level crossed threshold" + }, + "low": { + "description": "Triggers after one or more batteries become low.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + } + }, + "name": "Battery low" + }, + "not_low": { + "description": "Triggers after one or more batteries are no longer low.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + } + }, + "name": "Battery not low" + }, + "started_charging": { + "description": "Triggers after one or more batteries start charging.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + } + }, + "name": "Battery started charging" + }, + "stopped_charging": { + "description": "Triggers after one or more batteries stop charging.", + "fields": { + "behavior": { + "description": "[%key:component::battery::common::trigger_behavior_description%]", + "name": "[%key:component::battery::common::trigger_behavior_name%]" + } + }, + "name": "Battery stopped charging" + } + } } diff --git a/homeassistant/components/battery/trigger.py b/homeassistant/components/battery/trigger.py new file mode 100644 index 00000000000000..ff4d681c5d3fe7 --- /dev/null +++ b/homeassistant/components/battery/trigger.py @@ -0,0 +1,56 @@ +"""Provides triggers for batteries.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.trigger import ( + Trigger, + make_entity_numerical_state_changed_trigger, + make_entity_numerical_state_crossed_threshold_trigger, + make_entity_target_state_trigger, +) + +BATTERY_LOW_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY), +} + +BATTERY_CHARGING_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec( + device_class=BinarySensorDeviceClass.BATTERY_CHARGING + ), +} + +BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { + SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.BATTERY), + NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.BATTERY), +} + +TRIGGERS: dict[str, type[Trigger]] = { + "low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON), + "not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF), + "started_charging": make_entity_target_state_trigger( + BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON + ), + "stopped_charging": make_entity_target_state_trigger( + BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF + ), + "level_changed": make_entity_numerical_state_changed_trigger( + BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%" + ), + "level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( + BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%" + ), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for batteries.""" + return TRIGGERS diff --git a/homeassistant/components/battery/triggers.yaml b/homeassistant/components/battery/triggers.yaml new file mode 100644 index 00000000000000..a8f64995d3fca7 --- /dev/null +++ b/homeassistant/components/battery/triggers.yaml @@ -0,0 +1,85 @@ +.trigger_common_fields: + behavior: &trigger_behavior + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +.battery_threshold_entity: &battery_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: number + device_class: battery + - domain: sensor + device_class: battery + +.battery_threshold_number: &battery_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" + +.trigger_target_battery: &trigger_target_battery + entity: + - domain: binary_sensor + device_class: battery + +.trigger_target_charging: &trigger_target_charging + entity: + - domain: binary_sensor + device_class: battery_charging + +.trigger_target_percentage: &trigger_target_percentage + entity: + - domain: sensor + device_class: battery + - domain: number + device_class: battery + +low: + fields: + behavior: *trigger_behavior + target: *trigger_target_battery + +not_low: + fields: + behavior: *trigger_behavior + target: *trigger_target_battery + +started_charging: + fields: + behavior: *trigger_behavior + target: *trigger_target_charging + +stopped_charging: + fields: + behavior: *trigger_behavior + target: *trigger_target_charging + +level_changed: + target: *trigger_target_percentage + fields: + threshold: + required: true + selector: + numeric_threshold: + entity: *battery_threshold_entity + mode: changed + number: *battery_threshold_number + +level_crossed_threshold: + target: *trigger_target_percentage + fields: + behavior: *trigger_behavior + threshold: + required: true + selector: + numeric_threshold: + entity: *battery_threshold_entity + mode: crossed + number: *battery_threshold_number diff --git a/tests/components/battery/test_trigger.py b/tests/components/battery/test_trigger.py new file mode 100644 index 00000000000000..16836f4bc352cb --- /dev/null +++ b/tests/components/battery/test_trigger.py @@ -0,0 +1,485 @@ +"""Test battery triggers.""" + +from typing import Any + +import pytest + +from homeassistant.components.number import NumberDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_any, + assert_trigger_behavior_first, + assert_trigger_behavior_last, + assert_trigger_gated_by_labs_flag, + parametrize_numerical_state_value_changed_trigger_states, + parametrize_numerical_state_value_crossed_threshold_trigger_states, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.fixture +async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple sensor entities associated with different targets.""" + return await target_entities(hass, "sensor") + + +@pytest.fixture +async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple number entities associated with different targets.""" + return await target_entities(hass, "number") + + +@pytest.mark.parametrize( + "trigger_key", + [ + "battery.low", + "battery.not_low", + "battery.started_charging", + "battery.stopped_charging", + "battery.level_changed", + "battery.level_crossed_threshold", + ], +) +async def test_battery_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the battery triggers are gated by the labs flag.""" + await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="battery.low", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.not_low", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.started_charging", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.stopped_charging", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + ], +) +async def test_battery_binary_sensor_trigger_behavior_any( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test the battery binary sensor triggers with 'any' behavior.""" + await assert_trigger_behavior_any( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="battery.low", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.not_low", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.started_charging", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.stopped_charging", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + ], +) +async def test_battery_binary_sensor_trigger_behavior_first( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test the battery binary sensor triggers with 'first' behavior.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="battery.low", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.not_low", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.started_charging", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="battery.stopped_charging", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"}, + trigger_from_none=False, + ), + ], +) +async def test_battery_binary_sensor_trigger_behavior_last( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test the battery binary sensor triggers with 'last' behavior.""" + await assert_trigger_behavior_last( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_changed_trigger_states( + "battery.level_changed", + device_class=SensorDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "battery.level_crossed_threshold", + device_class=SensorDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_sensor_trigger_behavior_any( + hass: HomeAssistant, + target_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test battery sensor triggers with 'any' behavior.""" + await assert_trigger_behavior_any( + hass, + target_entities=target_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "battery.level_crossed_threshold", + device_class=SensorDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_level_crossed_threshold_sensor_behavior_first( + hass: HomeAssistant, + target_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test battery level_crossed_threshold trigger fires on the first sensor state change.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "battery.level_crossed_threshold", + device_class=SensorDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_level_crossed_threshold_sensor_behavior_last( + hass: HomeAssistant, + target_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test battery level_crossed_threshold trigger fires when the last sensor changes state.""" + await assert_trigger_behavior_last( + hass, + target_entities=target_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("number"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_changed_trigger_states( + "battery.level_changed", + device_class=NumberDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "battery.level_crossed_threshold", + device_class=NumberDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_number_trigger_behavior_any( + hass: HomeAssistant, + target_numbers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test battery number triggers with 'any' behavior.""" + await assert_trigger_behavior_any( + hass, + target_entities=target_numbers, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("number"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "battery.level_crossed_threshold", + device_class=NumberDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_level_crossed_threshold_number_behavior_first( + hass: HomeAssistant, + target_numbers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test battery level_crossed_threshold trigger fires on the first number state change.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_numbers, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("number"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_numerical_state_value_crossed_threshold_trigger_states( + "battery.level_crossed_threshold", + device_class=NumberDeviceClass.BATTERY, + unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, + ), + ], +) +async def test_battery_level_crossed_threshold_number_behavior_last( + hass: HomeAssistant, + target_numbers: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test battery level_crossed_threshold trigger fires when the last number changes state.""" + await assert_trigger_behavior_last( + hass, + target_entities=target_numbers, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) From f0fc98cb667220cc0acfc5aba0123709f46ab681 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 13:13:07 +0100 Subject: [PATCH 0041/1707] Remove class NumericalDomainSpec (#166588) --- .../components/air_quality/condition.py | 44 +++------- .../components/air_quality/trigger.py | 86 ++++++------------- homeassistant/components/battery/trigger.py | 8 +- homeassistant/components/climate/condition.py | 6 +- homeassistant/components/climate/trigger.py | 8 +- homeassistant/components/cover/condition.py | 6 +- homeassistant/components/cover/trigger.py | 6 +- .../components/humidifier/condition.py | 4 +- .../components/humidity/condition.py | 6 +- homeassistant/components/humidity/trigger.py | 12 +-- .../components/illuminance/trigger.py | 8 +- homeassistant/components/light/trigger.py | 56 +++++++----- homeassistant/components/moisture/trigger.py | 8 +- homeassistant/components/power/condition.py | 6 +- homeassistant/components/power/trigger.py | 8 +- .../components/temperature/condition.py | 10 +-- .../components/temperature/trigger.py | 12 ++- .../components/water_heater/condition.py | 4 +- .../components/water_heater/trigger.py | 4 +- homeassistant/helpers/automation.py | 10 +-- homeassistant/helpers/condition.py | 4 +- homeassistant/helpers/trigger.py | 26 ++---- tests/helpers/test_condition.py | 5 +- tests/helpers/test_trigger.py | 33 ++----- 24 files changed, 158 insertions(+), 222 deletions(-) diff --git a/homeassistant/components/air_quality/condition.py b/homeassistant/components/air_quality/condition.py index 0e11ce9016b85a..0a8ad107daaef7 100644 --- a/homeassistant/components/air_quality/condition.py +++ b/homeassistant/components/air_quality/condition.py @@ -13,7 +13,7 @@ STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, make_entity_numerical_condition, @@ -59,18 +59,18 @@ def _make_cleared_condition( "is_smoke_cleared": _make_cleared_condition(BinarySensorDeviceClass.SMOKE), # Numerical sensor conditions with unit conversion "is_co_value": make_entity_numerical_condition_with_unit( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CarbonMonoxideConcentrationConverter, ), "is_ozone_value": make_entity_numerical_condition_with_unit( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, OzoneConcentrationConverter, ), "is_voc_value": make_entity_numerical_condition_with_unit( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS ) }, @@ -79,7 +79,7 @@ def _make_cleared_condition( ), "is_voc_ratio_value": make_entity_numerical_condition_with_unit( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS ) }, @@ -87,59 +87,43 @@ def _make_cleared_condition( UnitlessRatioConverter, ), "is_no_value": make_entity_numerical_condition_with_unit( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_MONOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenMonoxideConcentrationConverter, ), "is_no2_value": make_entity_numerical_condition_with_unit( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenDioxideConcentrationConverter, ), "is_so2_value": make_entity_numerical_condition_with_unit( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.SULPHUR_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SulphurDioxideConcentrationConverter, ), # Numerical sensor conditions without unit conversion (single-unit device classes) "is_co2_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)}, valid_unit=CONCENTRATION_PARTS_PER_MILLION, ), "is_pm1_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "is_pm25_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "is_pm4_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "is_pm10_value": make_entity_numerical_condition( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "is_n2o_value": make_entity_numerical_condition( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROUS_OXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), } diff --git a/homeassistant/components/air_quality/trigger.py b/homeassistant/components/air_quality/trigger.py index abf65300424d5f..6e42b55e9059cd 100644 --- a/homeassistant/components/air_quality/trigger.py +++ b/homeassistant/components/air_quality/trigger.py @@ -13,7 +13,7 @@ STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( EntityTargetStateTriggerBase, Trigger, @@ -64,28 +64,28 @@ def _make_cleared_trigger( "smoke_cleared": _make_cleared_trigger(BinarySensorDeviceClass.SMOKE), # Numerical sensor triggers with unit conversion "co_changed": make_entity_numerical_state_changed_with_unit_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CarbonMonoxideConcentrationConverter, ), "co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CarbonMonoxideConcentrationConverter, ), "ozone_changed": make_entity_numerical_state_changed_with_unit_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, OzoneConcentrationConverter, ), "ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, OzoneConcentrationConverter, ), "voc_changed": make_entity_numerical_state_changed_with_unit_trigger( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS ) }, @@ -94,7 +94,7 @@ def _make_cleared_trigger( ), "voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS ) }, @@ -103,7 +103,7 @@ def _make_cleared_trigger( ), "voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS ) }, @@ -112,7 +112,7 @@ def _make_cleared_trigger( ), "voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( { - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS ) }, @@ -120,114 +120,82 @@ def _make_cleared_trigger( UnitlessRatioConverter, ), "no_changed": make_entity_numerical_state_changed_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_MONOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenMonoxideConcentrationConverter, ), "no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_MONOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenMonoxideConcentrationConverter, ), "no2_changed": make_entity_numerical_state_changed_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenDioxideConcentrationConverter, ), "no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROGEN_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, NitrogenDioxideConcentrationConverter, ), "so2_changed": make_entity_numerical_state_changed_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.SULPHUR_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SulphurDioxideConcentrationConverter, ), "so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.SULPHUR_DIOXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)}, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SulphurDioxideConcentrationConverter, ), # Numerical sensor triggers without unit conversion (single-unit device classes) "co2_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)}, valid_unit=CONCENTRATION_PARTS_PER_MILLION, ), "co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)}, valid_unit=CONCENTRATION_PARTS_PER_MILLION, ), "pm1_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm25_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm4_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm10_changed": make_entity_numerical_state_changed_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)}, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "n2o_changed": make_entity_numerical_state_changed_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROUS_OXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), "n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - { - SENSOR_DOMAIN: NumericalDomainSpec( - device_class=SensorDeviceClass.NITROUS_OXIDE - ) - }, + {SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)}, valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), } diff --git a/homeassistant/components/battery/trigger.py b/homeassistant/components/battery/trigger.py index ff4d681c5d3fe7..2547b70cd7d408 100644 --- a/homeassistant/components/battery/trigger.py +++ b/homeassistant/components/battery/trigger.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_trigger, @@ -28,9 +28,9 @@ ), } -BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { - SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.BATTERY), - NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.BATTERY), +BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = { + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY), + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.BATTERY), } TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index 4f3c4bf1f6c555..8279b9bf5839c3 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -2,7 +2,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, EntityNumericalConditionWithUnitBase, @@ -18,7 +18,7 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): """Mixin for climate target temperature conditions with unit conversion.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter def _get_entity_unit(self, entity_state: State) -> str | None: @@ -50,7 +50,7 @@ def _get_entity_unit(self, entity_state: State) -> str | None: {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING ), "target_humidity": make_entity_numerical_condition( - {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}, + {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit="%", ), "target_temperature": ClimateTargetTemperatureCondition, diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index 4faf4a1a4dfb27..9f9f02d70710fd 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -5,7 +5,7 @@ from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, EntityNumericalStateChangedTriggerWithUnitBase, @@ -52,7 +52,7 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB """Mixin for climate target temperature triggers with unit conversion.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter def _get_entity_unit(self, state: State) -> str | None: @@ -84,11 +84,11 @@ class ClimateTargetTemperatureCrossedThresholdTrigger( {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING ), "target_humidity_changed": make_entity_numerical_state_changed_trigger( - {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}, + {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit="%", ), "target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}, + {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit="%", ), "target_temperature_changed": ClimateTargetTemperatureChangedTrigger, diff --git a/homeassistant/components/cover/condition.py b/homeassistant/components/cover/condition.py index 7092c021c56e57..f44ad6582cbbfd 100644 --- a/homeassistant/components/cover/condition.py +++ b/homeassistant/components/cover/condition.py @@ -1,5 +1,7 @@ """Provides conditions for covers.""" +from collections.abc import Mapping + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.helpers.condition import Condition, EntityConditionBase @@ -8,9 +10,11 @@ from .models import CoverDomainSpec -class CoverConditionBase(EntityConditionBase[CoverDomainSpec]): +class CoverConditionBase(EntityConditionBase): """Base condition for cover state checks.""" + _domain_specs: Mapping[str, CoverDomainSpec] + def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected cover state.""" domain_spec = self._domain_specs[entity_state.domain] diff --git a/homeassistant/components/cover/trigger.py b/homeassistant/components/cover/trigger.py index 149a3e01cc0bd9..1d3ceba1177731 100644 --- a/homeassistant/components/cover/trigger.py +++ b/homeassistant/components/cover/trigger.py @@ -1,5 +1,7 @@ """Provides triggers for covers.""" +from collections.abc import Mapping + from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.helpers.trigger import EntityTriggerBase, Trigger @@ -8,9 +10,11 @@ from .models import CoverDomainSpec -class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]): +class CoverTriggerBase(EntityTriggerBase): """Base trigger for cover state changes.""" + _domain_specs: Mapping[str, CoverDomainSpec] + def _get_value(self, state: State) -> str | bool | None: """Extract the relevant value from state based on domain spec.""" domain_spec = self._domain_specs[state.domain] diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py index a787bd3d1f56e8..0795291ae971fd 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -2,7 +2,7 @@ from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, make_entity_numerical_condition, @@ -21,7 +21,7 @@ {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING ), "is_target_humidity": make_entity_numerical_condition( - {DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}, + {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit=PERCENTAGE, ), } diff --git a/homeassistant/components/humidity/condition.py b/homeassistant/components/humidity/condition.py index a41fd8ab4e41c5..818f649e2c2a21 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -14,14 +14,14 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import Condition, make_entity_numerical_condition HUMIDITY_DOMAIN_SPECS = { - CLIMATE_DOMAIN: NumericalDomainSpec( + CLIMATE_DOMAIN: DomainSpec( value_source=CLIMATE_ATTR_CURRENT_HUMIDITY, ), - HUMIDIFIER_DOMAIN: NumericalDomainSpec( + HUMIDIFIER_DOMAIN: DomainSpec( value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.HUMIDITY), diff --git a/homeassistant/components/humidity/trigger.py b/homeassistant/components/humidity/trigger.py index 9729543b450ce8..53347675045601 100644 --- a/homeassistant/components/humidity/trigger.py +++ b/homeassistant/components/humidity/trigger.py @@ -16,24 +16,24 @@ DOMAIN as WEATHER_DOMAIN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_trigger, make_entity_numerical_state_crossed_threshold_trigger, ) -HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { - CLIMATE_DOMAIN: NumericalDomainSpec( +HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = { + CLIMATE_DOMAIN: DomainSpec( value_source=CLIMATE_ATTR_CURRENT_HUMIDITY, ), - HUMIDIFIER_DOMAIN: NumericalDomainSpec( + HUMIDIFIER_DOMAIN: DomainSpec( value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ), - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.HUMIDITY, ), - WEATHER_DOMAIN: NumericalDomainSpec( + WEATHER_DOMAIN: DomainSpec( value_source=ATTR_WEATHER_HUMIDITY, ), } diff --git a/homeassistant/components/illuminance/trigger.py b/homeassistant/components/illuminance/trigger.py index c6511980b91734..042a207beb6a33 100644 --- a/homeassistant/components/illuminance/trigger.py +++ b/homeassistant/components/illuminance/trigger.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_trigger, @@ -18,9 +18,9 @@ make_entity_target_state_trigger, ) -ILLUMINANCE_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { - NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.ILLUMINANCE), - SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.ILLUMINANCE), +ILLUMINANCE_DOMAIN_SPECS: dict[str, DomainSpec] = { + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.ILLUMINANCE), + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE), } TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/light/trigger.py b/homeassistant/components/light/trigger.py index 82570a16803dac..25bd6fbaeeee0c 100644 --- a/homeassistant/components/light/trigger.py +++ b/homeassistant/components/light/trigger.py @@ -1,40 +1,54 @@ """Provides triggers for lights.""" -from typing import Any - from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( + EntityNumericalStateChangedTriggerBase, + EntityNumericalStateCrossedThresholdTriggerBase, + EntityNumericalStateTriggerBase, Trigger, - make_entity_numerical_state_changed_trigger, - make_entity_numerical_state_crossed_threshold_trigger, make_entity_target_state_trigger, ) from . import ATTR_BRIGHTNESS from .const import DOMAIN +BRIGHTNESS_DOMAIN_SPECS = { + DOMAIN: DomainSpec(value_source=ATTR_BRIGHTNESS), +} + -def _convert_uint8_to_percentage(value: Any) -> float: - """Convert a uint8 value (0-255) to a percentage (0-100).""" - return (float(value) / 255.0) * 100.0 +class BrightnessTriggerMixin(EntityNumericalStateTriggerBase): + """Mixin for brightness triggers.""" + _domain_specs = BRIGHTNESS_DOMAIN_SPECS + _valid_unit = "%" + + def _get_tracked_value(self, state: State) -> float | None: + """Get tracked brightness as a percentage.""" + value = super()._get_tracked_value(state) + if value is None: + return None + # Convert uint8 value (0-255) to a percentage (0-100) + return (value / 255.0) * 100.0 + + +class BrightnessChangedTrigger( + EntityNumericalStateChangedTriggerBase, BrightnessTriggerMixin +): + """Trigger for light brightness changes.""" + + +class BrightnessCrossedThresholdTrigger( + EntityNumericalStateCrossedThresholdTriggerBase, BrightnessTriggerMixin +): + """Trigger for light brightness crossing a threshold.""" -BRIGHTNESS_DOMAIN_SPECS = { - DOMAIN: NumericalDomainSpec( - value_source=ATTR_BRIGHTNESS, - value_converter=_convert_uint8_to_percentage, - ), -} TRIGGERS: dict[str, type[Trigger]] = { - "brightness_changed": make_entity_numerical_state_changed_trigger( - BRIGHTNESS_DOMAIN_SPECS, valid_unit="%" - ), - "brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - BRIGHTNESS_DOMAIN_SPECS, valid_unit="%" - ), + "brightness_changed": BrightnessChangedTrigger, + "brightness_crossed_threshold": BrightnessCrossedThresholdTrigger, "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), } diff --git a/homeassistant/components/moisture/trigger.py b/homeassistant/components/moisture/trigger.py index 07b20b50b1e8cf..6c50a83a9528ff 100644 --- a/homeassistant/components/moisture/trigger.py +++ b/homeassistant/components/moisture/trigger.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_trigger, @@ -22,9 +22,9 @@ BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOISTURE), } -MOISTURE_NUMERICAL_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { - NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.MOISTURE), - SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.MOISTURE), +MOISTURE_NUMERICAL_DOMAIN_SPECS: dict[str, DomainSpec] = { + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.MOISTURE), + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.MOISTURE), } diff --git a/homeassistant/components/power/condition.py b/homeassistant/components/power/condition.py index 1b3ac519a12c4d..78a8ee98ae0eda 100644 --- a/homeassistant/components/power/condition.py +++ b/homeassistant/components/power/condition.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, make_entity_numerical_condition_with_unit, @@ -14,8 +14,8 @@ from homeassistant.util.unit_conversion import PowerConverter POWER_DOMAIN_SPECS = { - NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.POWER), - SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.POWER), + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER), + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER), } diff --git a/homeassistant/components/power/trigger.py b/homeassistant/components/power/trigger.py index 6a2d3d8b1d856d..b1e8274527fd4e 100644 --- a/homeassistant/components/power/trigger.py +++ b/homeassistant/components/power/trigger.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( Trigger, make_entity_numerical_state_changed_with_unit_trigger, @@ -14,9 +14,9 @@ ) from homeassistant.util.unit_conversion import PowerConverter -POWER_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = { - NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.POWER), - SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.POWER), +POWER_DOMAIN_SPECS: dict[str, DomainSpec] = { + NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER), + SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER), } diff --git a/homeassistant/components/temperature/condition.py b/homeassistant/components/temperature/condition.py index e8cf3150be44a6..3bae43cc03bc9c 100644 --- a/homeassistant/components/temperature/condition.py +++ b/homeassistant/components/temperature/condition.py @@ -18,7 +18,7 @@ ) from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( Condition, EntityNumericalConditionWithUnitBase, @@ -26,16 +26,16 @@ from homeassistant.util.unit_conversion import TemperatureConverter TEMPERATURE_DOMAIN_SPECS = { - CLIMATE_DOMAIN: NumericalDomainSpec( + CLIMATE_DOMAIN: DomainSpec( value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, ), - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), - WATER_HEATER_DOMAIN: NumericalDomainSpec( + WATER_HEATER_DOMAIN: DomainSpec( value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE, ), - WEATHER_DOMAIN: NumericalDomainSpec( + WEATHER_DOMAIN: DomainSpec( value_source=ATTR_WEATHER_TEMPERATURE, ), } diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py index c255d39d129281..79995349e66e65 100644 --- a/homeassistant/components/temperature/trigger.py +++ b/homeassistant/components/temperature/trigger.py @@ -18,7 +18,7 @@ ) from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.automation import NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( EntityNumericalStateChangedTriggerWithUnitBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, @@ -28,16 +28,14 @@ from homeassistant.util.unit_conversion import TemperatureConverter TEMPERATURE_DOMAIN_SPECS = { - CLIMATE_DOMAIN: NumericalDomainSpec( + CLIMATE_DOMAIN: DomainSpec( value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, ), - SENSOR_DOMAIN: NumericalDomainSpec( + SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), - WATER_HEATER_DOMAIN: NumericalDomainSpec( - value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE - ), - WEATHER_DOMAIN: NumericalDomainSpec( + WATER_HEATER_DOMAIN: DomainSpec(value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE), + WEATHER_DOMAIN: DomainSpec( value_source=ATTR_WEATHER_TEMPERATURE, ), } diff --git a/homeassistant/components/water_heater/condition.py b/homeassistant/components/water_heater/condition.py index ce1f36c52694cd..da9b8a383d96c6 100644 --- a/homeassistant/components/water_heater/condition.py +++ b/homeassistant/components/water_heater/condition.py @@ -14,7 +14,7 @@ ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, @@ -73,7 +73,7 @@ class WaterHeaterTargetTemperatureCondition(EntityNumericalConditionWithUnitBase """Condition for water heater target temperature.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter def _get_entity_unit(self, entity_state: State) -> str | None: diff --git a/homeassistant/components/water_heater/trigger.py b/homeassistant/components/water_heater/trigger.py index 786f5b75016f5b..0a434b498b5dee 100644 --- a/homeassistant/components/water_heater/trigger.py +++ b/homeassistant/components/water_heater/trigger.py @@ -10,7 +10,7 @@ ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST, EntityNumericalStateChangedTriggerWithUnitBase, @@ -57,7 +57,7 @@ class _WaterHeaterTargetTemperatureTriggerMixin( """Mixin for water heater target temperature triggers with unit conversion.""" _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)} + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter def _get_entity_unit(self, state: State) -> str | None: diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py index 83f827ad75e55c..80c3da754ccab7 100644 --- a/homeassistant/helpers/automation.py +++ b/homeassistant/helpers/automation.py @@ -1,6 +1,6 @@ """Helpers for automation.""" -from collections.abc import Callable, Mapping +from collections.abc import Mapping from dataclasses import dataclass from enum import Enum from typing import Any, Final, Self @@ -37,14 +37,6 @@ class DomainSpec: """Attribute name to extract the value from, or None for state.state.""" -@dataclass(frozen=True, slots=True) -class NumericalDomainSpec(DomainSpec): - """DomainSpec with an optional value converter for numerical triggers.""" - - value_converter: Callable[[float], float] | None = None - """Optional converter for numerical values (e.g. uint8 → percentage).""" - - def filter_by_domain_specs( hass: HomeAssistant, domain_specs: Mapping[str, DomainSpec], diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 19537a20f0b1c7..e71dc1b991b8be 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -342,10 +342,10 @@ async def async_get_checker(self) -> ConditionChecker: ) -class EntityConditionBase[DomainSpecT: DomainSpec = DomainSpec](Condition): +class EntityConditionBase(Condition): """Base class for entity conditions.""" - _domain_specs: Mapping[str, DomainSpecT] + _domain_specs: Mapping[str, DomainSpec] _schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL @override diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 975c3dddc3c0c3..404051fd5fc0eb 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -68,7 +68,6 @@ from . import config_validation as cv, selector from .automation import ( DomainSpec, - NumericalDomainSpec, ThresholdConfig, filter_by_domain_specs, get_absolute_description_key, @@ -340,10 +339,10 @@ async def async_attach_runner( ) -class EntityTriggerBase[DomainSpecT: DomainSpec = DomainSpec](Trigger): +class EntityTriggerBase(Trigger): """Trigger for entity state changes.""" - _domain_specs: Mapping[str, DomainSpecT] + _domain_specs: Mapping[str, DomainSpec] _schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST @override @@ -534,7 +533,7 @@ def is_valid_state(self, state: State) -> bool: ) -class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]): +class EntityNumericalStateTriggerBase(EntityTriggerBase): """Base class for numerical state and state attribute triggers.""" _valid_unit: str | None | UndefinedType = UNDEFINED @@ -595,21 +594,12 @@ def _get_tracked_value(self, state: State) -> float | None: # Entity state is not a valid number return None - def _get_converter(self, state: State) -> Callable[[float], float]: - """Get the value converter for an entity.""" - domain_spec = self._domain_specs[state.domain] - if domain_spec.value_converter is not None: - return domain_spec.value_converter - return lambda x: x - def is_valid_state(self, state: State) -> bool: """Check if the new state or state attribute matches the expected one.""" # Handle missing or None value case first to avoid expensive exceptions - if (_attribute_value := self._get_tracked_value(state)) is None: + if (current_value := self._get_tracked_value(state)) is None: return False - current_value = self._get_converter(state)(_attribute_value) - if self._threshold_type == NumericThresholdType.ANY: # If the threshold type is "any" we always trigger on valid state # changes @@ -890,7 +880,7 @@ class CustomTrigger(EntityOriginStateTriggerBase): def make_entity_numerical_state_changed_trigger( - domain_specs: Mapping[str, NumericalDomainSpec], + domain_specs: Mapping[str, DomainSpec], valid_unit: str | None | UndefinedType = UNDEFINED, ) -> type[EntityNumericalStateChangedTriggerBase]: """Create a trigger for numerical state value change.""" @@ -905,7 +895,7 @@ class CustomTrigger(EntityNumericalStateChangedTriggerBase): def make_entity_numerical_state_crossed_threshold_trigger( - domain_specs: Mapping[str, NumericalDomainSpec], + domain_specs: Mapping[str, DomainSpec], valid_unit: str | None | UndefinedType = UNDEFINED, ) -> type[EntityNumericalStateCrossedThresholdTriggerBase]: """Create a trigger for numerical state value crossing a threshold.""" @@ -920,7 +910,7 @@ class CustomTrigger(EntityNumericalStateCrossedThresholdTriggerBase): def make_entity_numerical_state_changed_with_unit_trigger( - domain_specs: Mapping[str, NumericalDomainSpec], + domain_specs: Mapping[str, DomainSpec], base_unit: str, unit_converter: type[BaseUnitConverter], ) -> type[EntityNumericalStateChangedTriggerWithUnitBase]: @@ -937,7 +927,7 @@ class CustomTrigger(EntityNumericalStateChangedTriggerWithUnitBase): def make_entity_numerical_state_crossed_threshold_with_unit_trigger( - domain_specs: Mapping[str, NumericalDomainSpec], + domain_specs: Mapping[str, DomainSpec], base_unit: str, unit_converter: type[BaseUnitConverter], ) -> type[EntityNumericalStateCrossedThresholdTriggerWithUnitBase]: diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index f454d51c48f464..21a8ab6875f7d0 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -42,7 +42,6 @@ ) from homeassistant.helpers.automation import ( DomainSpec, - NumericalDomainSpec, move_top_level_schema_fields_to_options, ) from homeassistant.helpers.condition import ( @@ -3610,7 +3609,7 @@ async def test_numerical_condition_with_unit_attribute_value_source( test = await _setup_numerical_condition_with_unit( hass, domain_specs={ - "test": NumericalDomainSpec(value_source="temperature"), + "test": DomainSpec(value_source="temperature"), }, condition_options={ "threshold": { @@ -3656,7 +3655,7 @@ async def test_numerical_condition_with_unit_get_entity_unit_override( class CustomCondition(EntityNumericalConditionWithUnitBase): """Condition that always reports entities as °F regardless of attributes.""" - _domain_specs = {"test": NumericalDomainSpec(value_source="temperature")} + _domain_specs = {"test": DomainSpec(value_source="temperature")} _base_unit = UnitOfTemperature.CELSIUS _unit_converter = TemperatureConverter diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 29092c7e02bb79..8ba53241771acc 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -37,9 +37,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, trigger from homeassistant.helpers.automation import ( - ANY_DEVICE_CLASS, DomainSpec, - NumericalDomainSpec, move_top_level_schema_fields_to_options, ) from homeassistant.helpers.trigger import ( @@ -1283,7 +1281,7 @@ async def test_numerical_state_attribute_changed_trigger_config_validation( async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "test_trigger": make_entity_numerical_state_changed_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")} + {"test": DomainSpec(value_source="test_attribute")} ), } @@ -1312,7 +1310,7 @@ class _TestChangedTrigger( EntityNumericalStateChangedTriggerWithUnitBase, ): _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {"test": NumericalDomainSpec(value_source="test_attribute")} + _domain_specs = {"test": DomainSpec(value_source="test_attribute")} _unit_converter = TemperatureConverter return _TestChangedTrigger @@ -1514,7 +1512,7 @@ async def test_numerical_state_attribute_changed_error_handling( async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "attribute_changed": make_entity_numerical_state_changed_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")} + {"test": DomainSpec(value_source="test_attribute")} ), } @@ -1633,7 +1631,7 @@ async def test_numerical_state_attribute_changed_entity_limit_unit_validation( async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "attribute_changed": make_entity_numerical_state_changed_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")}, + {"test": DomainSpec(value_source="test_attribute")}, valid_unit="%", ), } @@ -2232,7 +2230,7 @@ async def test_numerical_state_attribute_crossed_threshold_trigger_config_valida async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "test_trigger": make_entity_numerical_state_crossed_threshold_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")} + {"test": DomainSpec(value_source="test_attribute")} ), } @@ -2261,7 +2259,7 @@ class _TestCrossedThresholdTrigger( EntityNumericalStateCrossedThresholdTriggerWithUnitBase, ): _base_unit = UnitOfTemperature.CELSIUS - _domain_specs = {"test": NumericalDomainSpec(value_source="test_attribute")} + _domain_specs = {"test": DomainSpec(value_source="test_attribute")} _unit_converter = TemperatureConverter return _TestCrossedThresholdTrigger @@ -2414,7 +2412,7 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling( async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")} + {"test": DomainSpec(value_source="test_attribute")} ), } @@ -2540,7 +2538,7 @@ async def test_numerical_state_attribute_crossed_threshold_entity_limit_unit_val async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { "crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {"test": NumericalDomainSpec(value_source="test_attribute")}, + {"test": DomainSpec(value_source="test_attribute")}, valid_unit="%", ), } @@ -2850,21 +2848,6 @@ async def test_entity_filter_no_device_class_means_match_all_in_domain( assert result == entities -async def test_numerical_domain_spec_converter(hass: HomeAssistant) -> None: - """Test NumericalDomainSpec stores converter correctly.""" - converter = lambda v: float(v) / 255.0 * 100.0 # noqa: E731 - num_domain_spec = NumericalDomainSpec( - value_source="brightness", value_converter=converter - ) - assert num_domain_spec.value_source == "brightness" - assert num_domain_spec.value_converter is converter - assert num_domain_spec.device_class is ANY_DEVICE_CLASS - - # Plain DomainSpec has no converter - domain_spec = DomainSpec(value_source="brightness") - assert not isinstance(domain_spec, NumericalDomainSpec) - - @pytest.mark.parametrize( ("domain_specs", "to_states", "from_state", "to_state", "wrong_value_state"), [ From 299c6556bb6af8857d9e017f86f92234a2a5e696 Mon Sep 17 00:00:00 2001 From: reneboer Date: Thu, 26 Mar 2026 13:16:50 +0100 Subject: [PATCH 0042/1707] Bump renault-api to 0.5.7 (#166586) --- homeassistant/components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 8498001de7b2bb..a2f907aaf6402c 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.5.6"] + "requirements": ["renault-api==0.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index d36396d50cb755..f7229872ec2fad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2823,7 +2823,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.5.6 +renault-api==0.5.7 # homeassistant.components.renson renson-endura-delta==1.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 201c5feefd48aa..e67fe935df2c02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2401,7 +2401,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.5.6 +renault-api==0.5.7 # homeassistant.components.renson renson-endura-delta==1.7.2 From 6c864a1725f3e8f01532d1b71be4df788879bc8e Mon Sep 17 00:00:00 2001 From: John Meyers Date: Thu, 26 Mar 2026 09:11:12 -0400 Subject: [PATCH 0043/1707] =?UTF-8?q?Update=20rainmachine=20solar=20radiat?= =?UTF-8?q?ion=20to=20reflect=20it=20is=20per=20day,=20not=20per=20?= =?UTF-8?q?=E2=80=A6=20(#166040)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/rainmachine/__init__.py | 2 +- homeassistant/components/rainmachine/services.yaml | 4 ++-- homeassistant/components/rainmachine/strings.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 2727e877bfec7f..c4fe2b4900689e 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -102,7 +102,7 @@ CV_WX_DATA_VALID_RAIN_RANGE = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1000.0)) CV_WX_DATA_VALID_WIND_SPEED = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=65.0)) CV_WX_DATA_VALID_PRESSURE = vol.All(vol.Coerce(float), vol.Range(min=60.0, max=110.0)) -CV_WX_DATA_VALID_SOLARRAD = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=5.0)) +CV_WX_DATA_VALID_SOLARRAD = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)) SERVICE_NAME_PAUSE_WATERING = "pause_watering" SERVICE_NAME_PUSH_FLOW_METER_DATA = "push_flow_meter_data" diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml index 2f799afd028d18..2c548e3947a4d8 100644 --- a/homeassistant/components/rainmachine/services.yaml +++ b/homeassistant/components/rainmachine/services.yaml @@ -131,9 +131,9 @@ push_weather_data: selector: number: min: 0 - max: 5 + max: 100 step: 0.1 - unit_of_measurement: "MJ/m²/h" + unit_of_measurement: "MJ/m²/d" et: selector: number: diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 778d8ebb16ff54..df9df38d4f46bc 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -175,7 +175,7 @@ "name": "Measured rainfall" }, "solarrad": { - "description": "Current solar radiation (MJ/m²/h).", + "description": "Daily solar radiation (MJ/m²/d).", "name": "Solar radiation" }, "temperature": { From 704c0d1eb007cd34d0d02bf3ba8629f6e92f0560 Mon Sep 17 00:00:00 2001 From: Devin Slick Date: Thu, 26 Mar 2026 08:15:04 -0500 Subject: [PATCH 0044/1707] Bump lojack-api to 0.7.2 (#166560) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/lojack/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lojack/manifest.json b/homeassistant/components/lojack/manifest.json index fa2e0fec4502d6..af0a0cb6afa700 100644 --- a/homeassistant/components/lojack/manifest.json +++ b/homeassistant/components/lojack/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["lojack_api"], "quality_scale": "silver", - "requirements": ["lojack-api==0.7.1"] + "requirements": ["lojack-api==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f7229872ec2fad..dfed1b2ac55d1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1452,7 +1452,7 @@ livisi==0.0.25 locationsharinglib==5.0.1 # homeassistant.components.lojack -lojack-api==0.7.1 +lojack-api==0.7.2 # homeassistant.components.london_underground london-tube-status==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e67fe935df2c02..4de49c01278811 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1274,7 +1274,7 @@ libsoundtouch==0.8 livisi==0.0.25 # homeassistant.components.lojack -lojack-api==0.7.1 +lojack-api==0.7.2 # homeassistant.components.london_underground london-tube-status==0.5 From bc7c3f06176f062b43337a3f6f49aace4fa753f3 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Thu, 26 Mar 2026 14:32:52 +0100 Subject: [PATCH 0045/1707] Bump pooldose 0.9.0 (#166589) --- homeassistant/components/pooldose/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pooldose/manifest.json b/homeassistant/components/pooldose/manifest.json index 66f95e7c82f561..acc8e90c3d6976 100644 --- a/homeassistant/components/pooldose/manifest.json +++ b/homeassistant/components/pooldose/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["python-pooldose==0.8.6"] + "requirements": ["python-pooldose==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dfed1b2ac55d1e..0eac7f42df050d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2651,7 +2651,7 @@ python-overseerr==0.9.0 python-picnic-api2==1.3.1 # homeassistant.components.pooldose -python-pooldose==0.8.6 +python-pooldose==0.9.0 # homeassistant.components.hr_energy_qube python-qube-heatpump==1.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4de49c01278811..588c488ec5f842 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2253,7 +2253,7 @@ python-overseerr==0.9.0 python-picnic-api2==1.3.1 # homeassistant.components.pooldose -python-pooldose==0.8.6 +python-pooldose==0.9.0 # homeassistant.components.hr_energy_qube python-qube-heatpump==1.7.0 From 5defb4dbff75516dd7f1d498bbc9320e4c863642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 26 Mar 2026 13:36:16 +0000 Subject: [PATCH 0046/1707] Add todo to experimental triggers (#166591) --- homeassistant/components/automation/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index b5fc6b5f015545..8d4fc2ebc1291a 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -186,6 +186,7 @@ "switch", "temperature", "text", + "todo", "update", "vacuum", "water_heater", From 45069b623c9a8badb0c0e396dd995ee8921e037a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 14:40:56 +0100 Subject: [PATCH 0047/1707] Remove number entity support from moisture triggers and conditions (#166596) --- .../components/moisture/condition.py | 2 - .../components/moisture/conditions.yaml | 4 - homeassistant/components/moisture/trigger.py | 2 - .../components/moisture/triggers.yaml | 4 - tests/components/moisture/test_condition.py | 78 ----------- tests/components/moisture/test_trigger.py | 129 ------------------ 6 files changed, 219 deletions(-) diff --git a/homeassistant/components/moisture/condition.py b/homeassistant/components/moisture/condition.py index aaeee6359e136a..2c789480d8d654 100644 --- a/homeassistant/components/moisture/condition.py +++ b/homeassistant/components/moisture/condition.py @@ -6,7 +6,6 @@ DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -25,7 +24,6 @@ _MOISTURE_NUMERICAL_DOMAIN_SPECS = { SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.MOISTURE), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.MOISTURE), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/moisture/conditions.yaml b/homeassistant/components/moisture/conditions.yaml index a1e1f9b4bfd8ef..4c4899de8589dc 100644 --- a/homeassistant/components/moisture/conditions.yaml +++ b/homeassistant/components/moisture/conditions.yaml @@ -19,8 +19,6 @@ unit_of_measurement: "%" - domain: sensor device_class: moisture - - domain: number - device_class: moisture .moisture_threshold_number: &moisture_threshold_number min: 0 @@ -37,8 +35,6 @@ is_value: entity: - domain: sensor device_class: moisture - - domain: number - device_class: moisture fields: behavior: *condition_behavior threshold: diff --git a/homeassistant/components/moisture/trigger.py b/homeassistant/components/moisture/trigger.py index 6c50a83a9528ff..08c14ecf0eb4b0 100644 --- a/homeassistant/components/moisture/trigger.py +++ b/homeassistant/components/moisture/trigger.py @@ -6,7 +6,6 @@ DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -23,7 +22,6 @@ } MOISTURE_NUMERICAL_DOMAIN_SPECS: dict[str, DomainSpec] = { - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.MOISTURE), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.MOISTURE), } diff --git a/homeassistant/components/moisture/triggers.yaml b/homeassistant/components/moisture/triggers.yaml index 2453da578ac81c..a111c58ecc9365 100644 --- a/homeassistant/components/moisture/triggers.yaml +++ b/homeassistant/components/moisture/triggers.yaml @@ -13,8 +13,6 @@ .moisture_threshold_entity: &moisture_threshold_entity - domain: input_number unit_of_measurement: "%" - - domain: number - device_class: moisture - domain: sensor device_class: moisture @@ -31,8 +29,6 @@ .trigger_numerical_target: &trigger_numerical_target entity: - - domain: number - device_class: moisture - domain: sensor device_class: moisture diff --git a/tests/components/moisture/test_condition.py b/tests/components/moisture/test_condition.py index e56834fb5d1543..65d7e7c76d07e8 100644 --- a/tests/components/moisture/test_condition.py +++ b/tests/components/moisture/test_condition.py @@ -40,12 +40,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@pytest.fixture -async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple number entities associated with different targets.""" - return await target_entities(hass, "number") - - @pytest.mark.parametrize( "condition", [ @@ -221,75 +215,3 @@ async def test_moisture_sensor_condition_behavior_all( condition_options=condition_options, states=states, ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_any( - "moisture.is_value", - device_class="moisture", - unit_attributes=_MOISTURE_UNIT_ATTRS, - ), -) -async def test_moisture_number_condition_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the moisture number condition with 'any' behavior.""" - await assert_condition_behavior_any( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_all( - "moisture.is_value", - device_class="moisture", - unit_attributes=_MOISTURE_UNIT_ATTRS, - ), -) -async def test_moisture_number_condition_behavior_all( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the moisture number condition with 'all' behavior.""" - await assert_condition_behavior_all( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) diff --git a/tests/components/moisture/test_trigger.py b/tests/components/moisture/test_trigger.py index 4137a661aa30c7..1d6d304e779082 100644 --- a/tests/components/moisture/test_trigger.py +++ b/tests/components/moisture/test_trigger.py @@ -5,7 +5,6 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -36,12 +35,6 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") -@pytest.fixture -async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple number entities associated with different targets.""" - return await target_entities(hass, "number") - - @pytest.fixture async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple sensor entities associated with different targets.""" @@ -336,128 +329,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_last( ) -# --- Number entity tests --- - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_changed_trigger_states( - "moisture.changed", - device_class=NumberDeviceClass.MOISTURE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "moisture.crossed_threshold", - device_class=NumberDeviceClass.MOISTURE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_moisture_trigger_number_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test moisture trigger fires for number entities with device_class moisture.""" - await assert_trigger_behavior_any( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "moisture.crossed_threshold", - device_class=NumberDeviceClass.MOISTURE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_moisture_trigger_number_crossed_threshold_behavior_first( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test moisture crossed_threshold trigger fires on the first number state change.""" - await assert_trigger_behavior_first( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "moisture.crossed_threshold", - device_class=NumberDeviceClass.MOISTURE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_moisture_trigger_number_crossed_threshold_behavior_last( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test moisture crossed_threshold trigger fires when the last number changes state.""" - await assert_trigger_behavior_last( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) - - @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger", "trigger_options", "limit_entities"), From 33f11f22631034d1cb87f638ac295f9c37610176 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 14:46:39 +0100 Subject: [PATCH 0048/1707] Remove number entity support from battery triggers and conditions (#166593) --- homeassistant/components/battery/condition.py | 2 - .../components/battery/conditions.yaml | 4 - homeassistant/components/battery/trigger.py | 2 - .../components/battery/triggers.yaml | 4 - tests/components/battery/test_condition.py | 6 - tests/components/battery/test_trigger.py | 126 ------------------ 6 files changed, 144 deletions(-) diff --git a/homeassistant/components/battery/condition.py b/homeassistant/components/battery/condition.py index 4f523e078d866d..60f479aa4af27f 100644 --- a/homeassistant/components/battery/condition.py +++ b/homeassistant/components/battery/condition.py @@ -6,7 +6,6 @@ DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -27,7 +26,6 @@ } BATTERY_PERCENTAGE_DOMAIN_SPECS = { SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.BATTERY), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/battery/conditions.yaml b/homeassistant/components/battery/conditions.yaml index 98584b000444e2..6589e644bb34da 100644 --- a/homeassistant/components/battery/conditions.yaml +++ b/homeassistant/components/battery/conditions.yaml @@ -19,8 +19,6 @@ unit_of_measurement: "%" - domain: sensor device_class: battery - - domain: number - device_class: battery .battery_threshold_number: &battery_threshold_number min: 0 @@ -53,8 +51,6 @@ is_level: entity: - domain: sensor device_class: battery - - domain: number - device_class: battery fields: behavior: *condition_behavior threshold: diff --git a/homeassistant/components/battery/trigger.py b/homeassistant/components/battery/trigger.py index 2547b70cd7d408..426dae8256976e 100644 --- a/homeassistant/components/battery/trigger.py +++ b/homeassistant/components/battery/trigger.py @@ -6,7 +6,6 @@ DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -30,7 +29,6 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = { SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.BATTERY), } TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/battery/triggers.yaml b/homeassistant/components/battery/triggers.yaml index a8f64995d3fca7..97a5c6e55154d1 100644 --- a/homeassistant/components/battery/triggers.yaml +++ b/homeassistant/components/battery/triggers.yaml @@ -13,8 +13,6 @@ .battery_threshold_entity: &battery_threshold_entity - domain: input_number unit_of_measurement: "%" - - domain: number - device_class: battery - domain: sensor device_class: battery @@ -38,8 +36,6 @@ entity: - domain: sensor device_class: battery - - domain: number - device_class: battery low: fields: diff --git a/tests/components/battery/test_condition.py b/tests/components/battery/test_condition.py index 230f57112f1395..8c828c0add8c9a 100644 --- a/tests/components/battery/test_condition.py +++ b/tests/components/battery/test_condition.py @@ -40,12 +40,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@pytest.fixture -async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple number entities associated with different targets.""" - return await target_entities(hass, "number") - - @pytest.mark.parametrize( "condition", [ diff --git a/tests/components/battery/test_trigger.py b/tests/components/battery/test_trigger.py index 16836f4bc352cb..e09eeda0efdcfa 100644 --- a/tests/components/battery/test_trigger.py +++ b/tests/components/battery/test_trigger.py @@ -4,7 +4,6 @@ import pytest -from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -40,12 +39,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@pytest.fixture -async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple number entities associated with different targets.""" - return await target_entities(hass, "number") - - @pytest.mark.parametrize( "trigger_key", [ @@ -364,122 +357,3 @@ async def test_battery_level_crossed_threshold_sensor_behavior_last( trigger_options=trigger_options, states=states, ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_changed_trigger_states( - "battery.level_changed", - device_class=NumberDeviceClass.BATTERY, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "battery.level_crossed_threshold", - device_class=NumberDeviceClass.BATTERY, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_battery_number_trigger_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test battery number triggers with 'any' behavior.""" - await assert_trigger_behavior_any( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "battery.level_crossed_threshold", - device_class=NumberDeviceClass.BATTERY, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_battery_level_crossed_threshold_number_behavior_first( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test battery level_crossed_threshold trigger fires on the first number state change.""" - await assert_trigger_behavior_first( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "battery.level_crossed_threshold", - device_class=NumberDeviceClass.BATTERY, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"}, - ), - ], -) -async def test_battery_level_crossed_threshold_number_behavior_last( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test battery level_crossed_threshold trigger fires when the last number changes state.""" - await assert_trigger_behavior_last( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) From 51a5f5793fd8bbaa2f473aaca17f20e44b0ead48 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Mar 2026 15:12:17 +0100 Subject: [PATCH 0049/1707] Improve Nuki tests and avoid dns lookups (#166506) --- tests/components/nuki/test_binary_sensor.py | 6 ++++++ tests/components/nuki/test_lock.py | 6 ++++++ tests/components/nuki/test_sensor.py | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/tests/components/nuki/test_binary_sensor.py b/tests/components/nuki/test_binary_sensor.py index 20551a66307642..9b09c749d73a4f 100644 --- a/tests/components/nuki/test_binary_sensor.py +++ b/tests/components/nuki/test_binary_sensor.py @@ -27,3 +27,9 @@ async def test_binary_sensors( entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + # Unload the config entry after taking a snapshot is required because the integration may cache + # DNS results or keep references to the original gethostbyname, so unloading ensures the patch + # is effective for subsequent tests and avoids DNS lookups + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/nuki/test_lock.py b/tests/components/nuki/test_lock.py index 6d8c3cc43fc927..ec3b42e4d59c9f 100644 --- a/tests/components/nuki/test_lock.py +++ b/tests/components/nuki/test_lock.py @@ -25,3 +25,9 @@ async def test_locks( entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + # Unload the config entry after taking a snapshot is required because the integration may cache + # DNS results or keep references to the original gethostbyname, so unloading ensures the patch + # is effective for subsequent tests and avoids DNS lookups + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/nuki/test_sensor.py b/tests/components/nuki/test_sensor.py index d03fe7f0da604e..8989cf853c94e6 100644 --- a/tests/components/nuki/test_sensor.py +++ b/tests/components/nuki/test_sensor.py @@ -25,3 +25,9 @@ async def test_sensors( entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + # Unload the config entry after taking a snapshot is required because the integration may cache + # DNS results or keep references to the original gethostbyname, so unloading ensures the patch + # is effective for subsequent tests and avoids DNS lookups + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 2c9ecb394def3e2e91d6d4074d8538f7e4a031e5 Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Thu, 26 Mar 2026 15:24:22 +0100 Subject: [PATCH 0050/1707] Bump sfrbox-api to 0.1.1 (#166605) --- homeassistant/components/sfr_box/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sfr_box/manifest.json b/homeassistant/components/sfr_box/manifest.json index afea6a999fe444..534f6996abbb11 100644 --- a/homeassistant/components/sfr_box/manifest.json +++ b/homeassistant/components/sfr_box/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["sfrbox-api==0.1.0"] + "requirements": ["sfrbox-api==0.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0eac7f42df050d..ec97efc77fbffd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2933,7 +2933,7 @@ sentry-sdk==2.48.0 serialx==0.6.2 # homeassistant.components.sfr_box -sfrbox-api==0.1.0 +sfrbox-api==0.1.1 # homeassistant.components.sharkiq sharkiq==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 588c488ec5f842..a18386f6f73513 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2490,7 +2490,7 @@ sentry-sdk==2.48.0 serialx==0.6.2 # homeassistant.components.sfr_box -sfrbox-api==0.1.0 +sfrbox-api==0.1.1 # homeassistant.components.sharkiq sharkiq==1.5.0 From 213b37069356483f41537f75c6a4173e5a7844d9 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 26 Mar 2026 15:43:13 +0100 Subject: [PATCH 0051/1707] Add new OAuth exceptions to Netatmo (#166585) --- homeassistant/components/netatmo/__init__.py | 21 ++++++++++---------- tests/components/netatmo/test_init.py | 4 +++- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index f11325b02bf033..a8e6e52d7d3c9f 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations -from http import HTTPStatus import logging import secrets from typing import Any -import aiohttp +from aiohttp import ClientError import pyatmo from homeassistant.components import cloud @@ -19,7 +18,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -89,14 +93,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() - except aiohttp.ClientResponseError as ex: - _LOGGER.warning("API error: %s (%s)", ex.status, ex.message) - if ex.status in ( - HTTPStatus.BAD_REQUEST, - HTTPStatus.UNAUTHORIZED, - HTTPStatus.FORBIDDEN, - ): - raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + except OAuth2TokenRequestReauthError as ex: + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + except (OAuth2TokenRequestError, ClientError) as ex: raise ConfigEntryNotReady from ex required_scopes = api.get_api_scopes(entry.data["auth_implementation"]) diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 540927fdb78a8e..d0b0b3d13fe3b2 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import CoreState, HomeAssistant +from homeassistant.exceptions import OAuth2TokenRequestReauthError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -446,7 +447,7 @@ async def test_setup_component_invalid_token( """Test handling of invalid token.""" async def fake_ensure_valid_token(*args, **kwargs): - raise aiohttp.ClientResponseError( + raise OAuth2TokenRequestReauthError( request_info=aiohttp.client.RequestInfo( url="http://example.com", method="GET", @@ -455,6 +456,7 @@ async def fake_ensure_valid_token(*args, **kwargs): ), status=400, history=(), + domain="netatmo", ) with ( From 2547563e8c4831c301d5f2205a09c3543285b437 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 15:49:40 +0100 Subject: [PATCH 0052/1707] Remove number entity support from humidity triggers and conditions (#166594) --- .../components/humidity/condition.py | 2 - .../components/humidity/conditions.yaml | 2 - tests/components/humidity/test_condition.py | 78 ------------------- 3 files changed, 82 deletions(-) diff --git a/homeassistant/components/humidity/condition.py b/homeassistant/components/humidity/condition.py index 818f649e2c2a21..6a990837b0c338 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -10,7 +10,6 @@ ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY, DOMAIN as HUMIDIFIER_DOMAIN, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant @@ -25,7 +24,6 @@ value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.HUMIDITY), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.HUMIDITY), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/humidity/conditions.yaml b/homeassistant/components/humidity/conditions.yaml index 733b2452891778..2f518db77d849f 100644 --- a/homeassistant/components/humidity/conditions.yaml +++ b/homeassistant/components/humidity/conditions.yaml @@ -17,8 +17,6 @@ is_value: entity: - domain: sensor device_class: humidity - - domain: number - device_class: humidity - domain: climate - domain: humidifier fields: diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index 345e0f433391c3..e71bbf7ded1350 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -36,12 +36,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@pytest.fixture -async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple number entities associated with different targets.""" - return await target_entities(hass, "number") - - @pytest.fixture async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple climate entities associated with different targets.""" @@ -139,78 +133,6 @@ async def test_humidity_sensor_condition_behavior_all( ) -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_any( - "humidity.is_value", - device_class="humidity", - unit_attributes=_HUMIDITY_UNIT_ATTRS, - ), -) -async def test_humidity_number_condition_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the humidity number condition with 'any' behavior.""" - await assert_condition_behavior_any( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_all( - "humidity.is_value", - device_class="humidity", - unit_attributes=_HUMIDITY_UNIT_ATTRS, - ), -) -async def test_humidity_number_condition_behavior_all( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the humidity number condition with 'all' behavior.""" - await assert_condition_behavior_all( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), From cb7f9b5f49aca692be46f445d8b8c92c5af5fb06 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 26 Mar 2026 15:53:12 +0100 Subject: [PATCH 0053/1707] Google Assistant SDK add new OAuth exceptions (#166587) --- .../google_assistant_sdk/__init__.py | 21 +++++++++++-------- .../google_assistant_sdk/helpers.py | 12 ++++++----- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 8d98da2fe4e921..27573a68a27bf9 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -2,14 +2,19 @@ from __future__ import annotations -import aiohttp +from aiohttp import ClientError from gassist_text import TextAssistant from google.oauth2.credentials import Credentials from homeassistant.components import conversation from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import config_validation as cv, discovery, intent from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -51,13 +56,11 @@ async def async_setup_entry( session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() - except aiohttp.ClientResponseError as err: - if 400 <= err.status < 500: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="reauth_required" - ) from err - raise ConfigEntryNotReady from err - except aiohttp.ClientError as err: + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="reauth_required" + ) from err + except (OAuth2TokenRequestError, ClientError) as err: raise ConfigEntryNotReady from err mem_storage = InMemoryStorage(hass) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index b8318436a3a50b..364756cd00a729 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -8,7 +8,6 @@ from typing import Any import uuid -import aiohttp from aiohttp import web from gassist_text import TextAssistant from google.oauth2.credentials import Credentials @@ -26,7 +25,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + OAuth2TokenRequestReauthError, + ServiceValidationError, +) from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.event import async_call_later @@ -79,9 +82,8 @@ async def async_send_text_commands( session = entry.runtime_data.session try: await session.async_ensure_token_valid() - except aiohttp.ClientResponseError as err: - if 400 <= err.status < 500: - entry.async_start_reauth(hass) + except OAuth2TokenRequestReauthError: + entry.async_start_reauth(hass) raise credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] From 3c67c6087aaa36c2cb9438edd1edac8f35c05e2e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 26 Mar 2026 15:53:57 +0100 Subject: [PATCH 0054/1707] Create IntegrationType enum (#166598) --- script/hassfest/codeowners.py | 4 ++-- script/hassfest/config_flow.py | 17 ++++++++++------- script/hassfest/core_files.py | 4 ++-- script/hassfest/icons.py | 14 +++++++++----- script/hassfest/integration_info.py | 8 ++++---- script/hassfest/manifest.py | 20 ++++++-------------- script/hassfest/model.py | 25 ++++++++++++++++++++++--- script/hassfest/quality_scale.py | 6 +++--- script/hassfest/translations.py | 6 ++++-- 9 files changed, 62 insertions(+), 42 deletions(-) diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index 958f54a77ff443..ae5af2f3a99514 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .model import Config, Integration +from .model import Config, Integration, IntegrationType BASE = """ # This file is generated by script/hassfest/codeowners.py @@ -65,7 +65,7 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config) for domain in sorted(integrations): integration = integrations[domain] - if integration.integration_type == "virtual": + if integration.integration_type == IntegrationType.VIRTUAL: continue codeowners = integration.manifest["codeowners"] diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 1f8b7d1139ba2b..54ca4230001e68 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -6,7 +6,7 @@ from typing import Any from .brand import validate as validate_brands -from .model import Brand, Config, Integration +from .model import Brand, Config, Integration, IntegrationType from .serializer import format_python_namespace UNIQUE_ID_IGNORE = {"huawei_lte", "mqtt", "adguard"} @@ -75,7 +75,7 @@ def _generate_and_validate(integrations: dict[str, Integration], config: Config) _validate_integration(config, integration) - if integration.integration_type == "helper": + if integration.integration_type == IntegrationType.HELPER: domains["helper"].append(domain) else: domains["integration"].append(domain) @@ -94,8 +94,8 @@ def _populate_brand_integrations( for domain in sub_integrations: integration = integrations.get(domain) if not integration or integration.integration_type in ( - "entity", - "system", + IntegrationType.ENTITY, + IntegrationType.SYSTEM, ): continue metadata: dict[str, Any] = { @@ -170,7 +170,10 @@ def _generate_integrations( result["integration"][domain] = metadata else: # integration integration = integrations[domain] - if integration.integration_type in ("entity", "system"): + if integration.integration_type in ( + IntegrationType.ENTITY, + IntegrationType.SYSTEM, + ): continue if integration.translated_name: @@ -180,7 +183,7 @@ def _generate_integrations( metadata["integration_type"] = integration.integration_type - if integration.integration_type == "virtual": + if integration.integration_type == IntegrationType.VIRTUAL: if integration.supported_by: metadata["supported_by"] = integration.supported_by if integration.iot_standards: @@ -195,7 +198,7 @@ def _generate_integrations( ): metadata["single_config_entry"] = single_config_entry - if integration.integration_type == "helper": + if integration.integration_type == IntegrationType.HELPER: result["helper"][domain] = metadata else: result["integration"][domain] = metadata diff --git a/script/hassfest/core_files.py b/script/hassfest/core_files.py index ac480de11a3480..24e9315a02bbe8 100644 --- a/script/hassfest/core_files.py +++ b/script/hassfest/core_files.py @@ -4,7 +4,7 @@ from homeassistant.util.yaml import load_yaml_dict -from .model import Config, Integration +from .model import Config, Integration, IntegrationType # Non-entity-platform components that belong in base_platforms EXTRA_BASE_PLATFORMS = {"diagnostics"} @@ -29,7 +29,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: entity_platforms = { integration.domain for integration in integrations.values() - if integration.manifest.get("integration_type") == "entity" + if integration.integration_type == IntegrationType.ENTITY and integration.domain != "tag" } diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 6d2187e3fe60c4..83efb6d6764afe 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -11,7 +11,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.icon import convert_shorthand_service_icon -from .model import Config, Integration +from .model import Config, Integration, IntegrationType from .translations import translation_key_validator @@ -141,7 +141,7 @@ def ensure_range_is_sorted(value: dict) -> dict: def icon_schema( - core_integration: bool, integration_type: str, no_entity_platform: bool + core_integration: bool, integration_type: IntegrationType, no_entity_platform: bool ) -> vol.Schema: """Create an icon schema.""" @@ -189,8 +189,12 @@ def icon_schema_slug(marker: type[vol.Marker]) -> dict[vol.Marker, Any]: } ) - if integration_type in ("entity", "helper", "system"): - if integration_type != "entity" or no_entity_platform: + if integration_type in ( + IntegrationType.ENTITY, + IntegrationType.HELPER, + IntegrationType.SYSTEM, + ): + if integration_type != IntegrationType.ENTITY or no_entity_platform: field = vol.Optional("entity_component") else: field = vol.Required("entity_component") @@ -207,7 +211,7 @@ def icon_schema_slug(marker: type[vol.Marker]) -> dict[vol.Marker, Any]: ) } ) - if integration_type not in ("entity", "system"): + if integration_type not in (IntegrationType.ENTITY, IntegrationType.SYSTEM): schema = schema.extend( { vol.Optional("entity"): vol.All( diff --git a/script/hassfest/integration_info.py b/script/hassfest/integration_info.py index 8747e256be7e84..85a327e147d087 100644 --- a/script/hassfest/integration_info.py +++ b/script/hassfest/integration_info.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .model import Config, Integration +from .model import Config, Integration, IntegrationType from .serializer import format_python @@ -12,12 +12,12 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - int_type = "entity" + int_type = IntegrationType.ENTITY domains = [ integration.domain for integration in integrations.values() - if integration.manifest.get("integration_type") == int_type + if integration.integration_type == int_type # Tag is type "entity" but has no entity platform and integration.domain != "tag" ] @@ -36,7 +36,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate integration file.""" - int_type = "entity" + int_type = IntegrationType.ENTITY filename = "entity_platforms" platform_path = config.root / f"homeassistant/generated/{filename}.py" platform_path.write_text(config.cache[f"integrations_{int_type}"]) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 6e6a5e2d84dfce..dacc34d3dea4da 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -21,7 +21,7 @@ from homeassistant.helpers import config_validation as cv from script.util import sort_manifest as util_sort_manifest -from .model import Config, Integration, ScaledQualityScaleTiers +from .model import Config, Integration, IntegrationType, ScaledQualityScaleTiers DOCUMENTATION_URL_SCHEMA = "https" DOCUMENTATION_URL_HOST = "www.home-assistant.io" @@ -206,15 +206,7 @@ def verify_wildcard(value: str) -> str: vol.Required("domain"): str, vol.Required("name"): str, vol.Optional("integration_type", default="hub"): vol.In( - [ - "device", - "entity", - "hardware", - "helper", - "hub", - "service", - "system", - ] + [t.value for t in IntegrationType if t != IntegrationType.VIRTUAL] ), vol.Optional("config_flow"): bool, vol.Optional("mqtt"): [str], @@ -311,7 +303,7 @@ def verify_wildcard(value: str) -> str: { vol.Required("domain"): str, vol.Required("name"): str, - vol.Required("integration_type"): "virtual", + vol.Required("integration_type"): IntegrationType.VIRTUAL.value, vol.Exclusive("iot_standards", "virtual_integration"): [ vol.Any("homekit", "zigbee", "zwave") ], @@ -322,7 +314,7 @@ def verify_wildcard(value: str) -> str: def manifest_schema(value: dict[str, Any]) -> vol.Schema: """Validate integration manifest.""" - if value.get("integration_type") == "virtual": + if value.get("integration_type") == IntegrationType.VIRTUAL: return VIRTUAL_INTEGRATION_MANIFEST_SCHEMA(value) return INTEGRATION_MANIFEST_SCHEMA(value) @@ -373,12 +365,12 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No if ( domain not in NO_IOT_CLASS and "iot_class" not in integration.manifest - and integration.manifest.get("integration_type") != "virtual" + and integration.integration_type != IntegrationType.VIRTUAL ): integration.add_error("manifest", "Domain is missing an IoT Class") if ( - integration.manifest.get("integration_type") == "virtual" + integration.integration_type == IntegrationType.VIRTUAL and (supported_by := integration.manifest.get("supported_by")) and not (core_components_dir / supported_by).exists() ): diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 06e1df4caf3870..494d727fccce27 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from enum import IntEnum +from enum import IntEnum, StrEnum import json import pathlib from typing import Any, Literal @@ -200,9 +200,15 @@ def supported_by(self) -> str: return self.manifest.get("supported_by", {}) @property - def integration_type(self) -> str: + def integration_type(self) -> IntegrationType: """Get integration_type.""" - return self.manifest.get("integration_type", "hub") + integration_type = self.manifest.get("integration_type", "hub") + try: + return IntegrationType(integration_type) + except ValueError: + # The manifest validation will catch this as an error, so we can default to + # a valid value here to avoid ValueErrors in other plugins + return IntegrationType.HUB @property def iot_class(self) -> str | None: @@ -248,6 +254,19 @@ def load_manifest(self) -> None: self.manifest_path = manifest_path +class IntegrationType(StrEnum): + """Supported integration types.""" + + DEVICE = "device" + ENTITY = "entity" + HARDWARE = "hardware" + HELPER = "helper" + HUB = "hub" + SERVICE = "service" + SYSTEM = "system" + VIRTUAL = "virtual" + + class ScaledQualityScaleTiers(IntEnum): """Supported manifest quality scales.""" diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 94b23e32b555f4..3e5b852d5b45a7 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util.yaml import load_yaml_dict -from .model import Config, Integration, ScaledQualityScaleTiers +from .model import Config, Integration, IntegrationType, ScaledQualityScaleTiers from .quality_scale_validation import ( RuleValidationProtocol, action_setup, @@ -2200,7 +2200,7 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: if ( integration.domain not in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE and integration.domain not in NO_QUALITY_SCALE - and integration.integration_type != "virtual" + and integration.integration_type != IntegrationType.VIRTUAL ): integration.add_error( "quality_scale", @@ -2218,7 +2218,7 @@ def validate_iqs_file(config: Config, integration: Integration) -> None: ) return return - if integration.integration_type == "virtual": + if integration.integration_type == IntegrationType.VIRTUAL: integration.add_error( "quality_scale", "Virtual integrations are not allowed to have a quality scale file.", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 5e55b9a1ca8286..cd24b42879d6f0 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from script.translations import upload -from .model import Config, Integration +from .model import Config, Integration, IntegrationType UNDEFINED = 0 REQUIRED = 1 @@ -345,7 +345,9 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: flow_title=REMOVED, require_step_title=False, mandatory_description=( - "user" if integration.integration_type == "helper" else None + "user" + if integration.integration_type == IntegrationType.HELPER + else None ), ), vol.Optional("config_subentries"): cv.schema_with_slug_keys( From fe485f853f05cc661939b1c5768752b53306adc6 Mon Sep 17 00:00:00 2001 From: hanwg Date: Thu, 26 Mar 2026 23:03:21 +0800 Subject: [PATCH 0055/1707] Add missing translations for Telegram bot (#166581) Co-authored-by: Robert Resch --- homeassistant/components/telegram_bot/config_flow.py | 2 +- homeassistant/components/telegram_bot/services.yaml | 9 +-------- homeassistant/components/telegram_bot/strings.json | 12 ++++++++++++ 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index e501ae337ed3e3..c2d6ed368edc6f 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -188,7 +188,7 @@ async def async_step_init( ) -class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): +class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Telegram.""" VERSION = 1 diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index b764ba31a06e7f..d3bb993376f792 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -225,9 +225,9 @@ send_media_group: multiple: true label_field: url description_field: caption + translation_key: "media" fields: media_type: - label: Media type selector: select: options: @@ -237,20 +237,16 @@ send_media_group: - "video" translation_key: "media_type" caption: - label: Caption selector: text: url: - label: URL selector: text: type: url verify_ssl: - label: Verify SSL selector: boolean: authentication: - label: Authentication selector: select: options: @@ -259,16 +255,13 @@ send_media_group: - "bearer_token" translation_key: "authentication" username: - label: Username selector: text: password: - label: Password selector: text: type: password file: - label: File selector: text: parse_mode: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 42e94054fb4cd6..c332484911c907 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -279,6 +279,18 @@ "upload_voice": "Uploading voice" } }, + "media": { + "fields": { + "authentication": "Authentication", + "caption": "Caption", + "file": "File", + "media_type": "Media type", + "password": "Password", + "url": "URL", + "username": "Username", + "verify_ssl": "Verify SSL" + } + }, "media_type": { "options": { "animation": "Animation", From bd79958d10c7541e27a422b7a41e36e48e086f09 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:05:21 +0100 Subject: [PATCH 0056/1707] Remove number entity support from power triggers and conditions (#166597) --- homeassistant/components/power/condition.py | 2 - .../components/power/conditions.yaml | 2 - homeassistant/components/power/trigger.py | 2 - homeassistant/components/power/triggers.yaml | 2 - tests/components/power/test_condition.py | 80 ----------- tests/components/power/test_trigger.py | 133 ------------------ 6 files changed, 221 deletions(-) diff --git a/homeassistant/components/power/condition.py b/homeassistant/components/power/condition.py index 78a8ee98ae0eda..114417a8d57c1b 100644 --- a/homeassistant/components/power/condition.py +++ b/homeassistant/components/power/condition.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant @@ -14,7 +13,6 @@ from homeassistant.util.unit_conversion import PowerConverter POWER_DOMAIN_SPECS = { - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER), } diff --git a/homeassistant/components/power/conditions.yaml b/homeassistant/components/power/conditions.yaml index a34beb6d24a845..63f2c82b20f805 100644 --- a/homeassistant/components/power/conditions.yaml +++ b/homeassistant/components/power/conditions.yaml @@ -28,8 +28,6 @@ is_value: target: entity: - - domain: number - device_class: power - domain: sensor device_class: power fields: diff --git a/homeassistant/components/power/trigger.py b/homeassistant/components/power/trigger.py index b1e8274527fd4e..b43dc072f7a8a7 100644 --- a/homeassistant/components/power/trigger.py +++ b/homeassistant/components/power/trigger.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant @@ -15,7 +14,6 @@ from homeassistant.util.unit_conversion import PowerConverter POWER_DOMAIN_SPECS: dict[str, DomainSpec] = { - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.POWER), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER), } diff --git a/homeassistant/components/power/triggers.yaml b/homeassistant/components/power/triggers.yaml index 88d2f765c84b53..22dac96db363df 100644 --- a/homeassistant/components/power/triggers.yaml +++ b/homeassistant/components/power/triggers.yaml @@ -29,8 +29,6 @@ .trigger_target: &trigger_target entity: - - domain: number - device_class: power - domain: sensor device_class: power diff --git a/tests/components/power/test_condition.py b/tests/components/power/test_condition.py index b469d7ac2b6323..e5bff95dff50eb 100644 --- a/tests/components/power/test_condition.py +++ b/tests/components/power/test_condition.py @@ -26,12 +26,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@pytest.fixture -async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple number entities associated with different targets.""" - return await target_entities(hass, "number") - - @pytest.mark.parametrize( "condition", ["power.is_value"], @@ -117,80 +111,6 @@ async def test_power_sensor_condition_behavior_all( ) -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_any( - "power.is_value", - device_class="power", - threshold_unit=UnitOfPower.WATT, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, - ), -) -async def test_power_number_condition_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the power number condition with 'any' behavior.""" - await assert_condition_behavior_any( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_all( - "power.is_value", - device_class="power", - threshold_unit=UnitOfPower.WATT, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, - ), -) -async def test_power_number_condition_behavior_all( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the power number condition with 'all' behavior.""" - await assert_condition_behavior_all( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - @pytest.mark.usefixtures("enable_labs_preview_features") async def test_power_condition_unit_conversion_sensor( hass: HomeAssistant, diff --git a/tests/components/power/test_trigger.py b/tests/components/power/test_trigger.py index a7599dca96c9bd..f56f1bcaed41c8 100644 --- a/tests/components/power/test_trigger.py +++ b/tests/components/power/test_trigger.py @@ -4,7 +4,6 @@ import pytest -from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower from homeassistant.core import HomeAssistant @@ -24,12 +23,6 @@ _POWER_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT} -@pytest.fixture -async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple number entities associated with different targets.""" - return await target_entities(hass, "number") - - @pytest.fixture async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple sensor entities associated with different targets.""" @@ -171,129 +164,3 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_last( trigger_options=trigger_options, states=states, ) - - -# --- Number entity tests --- - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_changed_trigger_states( - "power.changed", - device_class=NumberDeviceClass.POWER, - threshold_unit=UnitOfPower.WATT, - unit_attributes=_POWER_UNIT_ATTRIBUTES, - ), - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "power.crossed_threshold", - device_class=NumberDeviceClass.POWER, - threshold_unit=UnitOfPower.WATT, - unit_attributes=_POWER_UNIT_ATTRIBUTES, - ), - ], -) -async def test_power_trigger_number_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test power trigger fires for number entities with device_class power.""" - await assert_trigger_behavior_any( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "power.crossed_threshold", - device_class=NumberDeviceClass.POWER, - threshold_unit=UnitOfPower.WATT, - unit_attributes=_POWER_UNIT_ATTRIBUTES, - ), - ], -) -async def test_power_trigger_number_crossed_threshold_behavior_first( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test power crossed_threshold trigger fires on the first number state change.""" - await assert_trigger_behavior_first( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "power.crossed_threshold", - device_class=NumberDeviceClass.POWER, - threshold_unit=UnitOfPower.WATT, - unit_attributes=_POWER_UNIT_ATTRIBUTES, - ), - ], -) -async def test_power_trigger_number_crossed_threshold_behavior_last( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test power crossed_threshold trigger fires when the last number changes state.""" - await assert_trigger_behavior_last( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) From e5ad6092d1cf8d3e06c6481728fff3be6cde8913 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:08:28 +0100 Subject: [PATCH 0057/1707] Remove number entity support from illuminance triggers and conditions (#166595) --- .../components/illuminance/condition.py | 2 - .../components/illuminance/conditions.yaml | 2 - .../components/illuminance/trigger.py | 2 - .../components/illuminance/triggers.yaml | 2 - .../components/illuminance/test_condition.py | 78 ----------- tests/components/illuminance/test_trigger.py | 129 ------------------ 6 files changed, 215 deletions(-) diff --git a/homeassistant/components/illuminance/condition.py b/homeassistant/components/illuminance/condition.py index 97bdbd330e94e2..c074c3331000e7 100644 --- a/homeassistant/components/illuminance/condition.py +++ b/homeassistant/components/illuminance/condition.py @@ -6,7 +6,6 @@ DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -22,7 +21,6 @@ } ILLUMINANCE_VALUE_DOMAIN_SPECS = { SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE), - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.ILLUMINANCE), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/illuminance/conditions.yaml b/homeassistant/components/illuminance/conditions.yaml index 37980efcae4966..b23ac8007e0da8 100644 --- a/homeassistant/components/illuminance/conditions.yaml +++ b/homeassistant/components/illuminance/conditions.yaml @@ -23,8 +23,6 @@ is_value: entity: - domain: sensor device_class: illuminance - - domain: number - device_class: illuminance fields: behavior: *condition_behavior threshold: diff --git a/homeassistant/components/illuminance/trigger.py b/homeassistant/components/illuminance/trigger.py index 042a207beb6a33..56fe49108093f4 100644 --- a/homeassistant/components/illuminance/trigger.py +++ b/homeassistant/components/illuminance/trigger.py @@ -6,7 +6,6 @@ DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -19,7 +18,6 @@ ) ILLUMINANCE_DOMAIN_SPECS: dict[str, DomainSpec] = { - NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.ILLUMINANCE), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE), } diff --git a/homeassistant/components/illuminance/triggers.yaml b/homeassistant/components/illuminance/triggers.yaml index d015efb36b8f79..c2f77fd4292037 100644 --- a/homeassistant/components/illuminance/triggers.yaml +++ b/homeassistant/components/illuminance/triggers.yaml @@ -29,8 +29,6 @@ .trigger_numerical_target: &trigger_numerical_target entity: - - domain: number - device_class: illuminance - domain: sensor device_class: illuminance diff --git a/tests/components/illuminance/test_condition.py b/tests/components/illuminance/test_condition.py index 9a3b0c16c9cfa3..d82a29581c3d0e 100644 --- a/tests/components/illuminance/test_condition.py +++ b/tests/components/illuminance/test_condition.py @@ -40,12 +40,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "sensor") -@pytest.fixture -async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple number entities associated with different targets.""" - return await target_entities(hass, "number") - - @pytest.mark.parametrize( "condition", [ @@ -221,75 +215,3 @@ async def test_illuminance_value_condition_behavior_all( condition_options=condition_options, states=states, ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_any( - "illuminance.is_value", - device_class="illuminance", - unit_attributes=_ILLUMINANCE_UNIT_ATTRS, - ), -) -async def test_illuminance_value_number_condition_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the illuminance value condition with number entities and 'any' behavior.""" - await assert_condition_behavior_any( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("condition_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("condition", "condition_options", "states"), - parametrize_numerical_condition_above_below_all( - "illuminance.is_value", - device_class="illuminance", - unit_attributes=_ILLUMINANCE_UNIT_ATTRS, - ), -) -async def test_illuminance_value_number_condition_behavior_all( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - condition_target_config: dict, - entity_id: str, - entities_in_target: int, - condition: str, - condition_options: dict[str, Any], - states: list[ConditionStateDescription], -) -> None: - """Test the illuminance value condition with number entities and 'all' behavior.""" - await assert_condition_behavior_all( - hass, - target_entities=target_numbers, - condition_target_config=condition_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - condition=condition, - condition_options=condition_options, - states=states, - ) diff --git a/tests/components/illuminance/test_trigger.py b/tests/components/illuminance/test_trigger.py index a53b218d5b7dbb..908cb0fe39ab84 100644 --- a/tests/components/illuminance/test_trigger.py +++ b/tests/components/illuminance/test_trigger.py @@ -5,7 +5,6 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -36,12 +35,6 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") -@pytest.fixture -async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]: - """Create multiple number entities associated with different targets.""" - return await target_entities(hass, "number") - - @pytest.fixture async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple sensor entities associated with different targets.""" @@ -340,125 +333,3 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_last( trigger_options=trigger_options, states=states, ) - - -# --- Number changed/crossed_threshold tests --- - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_changed_trigger_states( - "illuminance.changed", - device_class=NumberDeviceClass.ILLUMINANCE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, - ), - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "illuminance.crossed_threshold", - device_class=NumberDeviceClass.ILLUMINANCE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, - ), - ], -) -async def test_illuminance_trigger_number_behavior_any( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test illuminance trigger fires for number entities with device_class illuminance.""" - await assert_trigger_behavior_any( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "illuminance.crossed_threshold", - device_class=NumberDeviceClass.ILLUMINANCE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, - ), - ], -) -async def test_illuminance_trigger_number_crossed_threshold_behavior_first( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test illuminance crossed_threshold trigger fires on the first number state change.""" - await assert_trigger_behavior_first( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) - - -@pytest.mark.usefixtures("enable_labs_preview_features") -@pytest.mark.parametrize( - ("trigger_target_config", "entity_id", "entities_in_target"), - parametrize_target_entities("number"), -) -@pytest.mark.parametrize( - ("trigger", "trigger_options", "states"), - [ - *parametrize_numerical_state_value_crossed_threshold_trigger_states( - "illuminance.crossed_threshold", - device_class=NumberDeviceClass.ILLUMINANCE, - unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, - ), - ], -) -async def test_illuminance_trigger_number_crossed_threshold_behavior_last( - hass: HomeAssistant, - target_numbers: dict[str, list[str]], - trigger_target_config: dict, - entity_id: str, - entities_in_target: int, - trigger: str, - trigger_options: dict[str, Any], - states: list[TriggerStateDescription], -) -> None: - """Test illuminance crossed_threshold trigger fires when the last number changes state.""" - await assert_trigger_behavior_last( - hass, - target_entities=target_numbers, - trigger_target_config=trigger_target_config, - entity_id=entity_id, - entities_in_target=entities_in_target, - trigger=trigger, - trigger_options=trigger_options, - states=states, - ) From 5ffe3013848d68fc501cac2db5641ae1898cc985 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:24:27 +0100 Subject: [PATCH 0058/1707] Add climate.is_hvac_mode condition (#166570) --- homeassistant/components/climate/condition.py | 41 ++++++++++++++++++- .../components/climate/conditions.yaml | 15 +++++++ homeassistant/components/climate/icons.json | 3 ++ homeassistant/components/climate/strings.json | 14 +++++++ tests/components/climate/test_condition.py | 37 +++++++++++++++++ 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index 8279b9bf5839c3..0d1b5803b599d7 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -1,10 +1,18 @@ """Provides conditions for climates.""" -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, + ConditionConfig, + EntityConditionBase, EntityNumericalConditionWithUnitBase, make_entity_numerical_condition, make_entity_state_condition, @@ -13,6 +21,36 @@ from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode +CONF_HVAC_MODE = "hvac_mode" + +_HVAC_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_HVAC_MODE): vol.All( + cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)] + ), + }, + } +) + + +class ClimateHVACModeCondition(EntityConditionBase): + """Condition for climate HVAC mode.""" + + _domain_specs = {DOMAIN: DomainSpec()} + _schema = _HVAC_MODE_CONDITION_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the HVAC mode condition.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._hvac_modes: set[str] = set(config.options[CONF_HVAC_MODE]) + + def is_valid_state(self, entity_state: State) -> bool: + """Check if the state matches any of the expected HVAC modes.""" + return entity_state.state in self._hvac_modes + class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): """Mixin for climate target temperature conditions with unit conversion.""" @@ -28,6 +66,7 @@ def _get_entity_unit(self, entity_state: State) -> str | None: CONDITIONS: dict[str, type[Condition]] = { + "is_hvac_mode": ClimateHVACModeCondition, "is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF), "is_on": make_entity_state_condition( DOMAIN, diff --git a/homeassistant/components/climate/conditions.yaml b/homeassistant/components/climate/conditions.yaml index 771d5e96332bc2..cb1e09abac07c3 100644 --- a/homeassistant/components/climate/conditions.yaml +++ b/homeassistant/components/climate/conditions.yaml @@ -45,6 +45,21 @@ is_cooling: *condition_common is_drying: *condition_common is_heating: *condition_common +is_hvac_mode: + target: *condition_climate_target + fields: + behavior: *condition_behavior + hvac_mode: + context: + filter_target: target + required: true + selector: + state: + hide_states: + - unavailable + - unknown + multiple: true + target_humidity: target: *condition_climate_target fields: diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index 3300deb17e9067..b88d4ba63f2bfa 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -9,6 +9,9 @@ "is_heating": { "condition": "mdi:fire" }, + "is_hvac_mode": { + "condition": "mdi:thermostat" + }, "is_off": { "condition": "mdi:power-off" }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index ec6c99e51ab6fd..7fc608ff419719 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -41,6 +41,20 @@ }, "name": "Climate-control device is heating" }, + "is_hvac_mode": { + "description": "Tests if one or more climate-control devices are set to a specific HVAC mode.", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::condition_behavior_description%]", + "name": "[%key:component::climate::common::condition_behavior_name%]" + }, + "hvac_mode": { + "description": "The HVAC modes to test for.", + "name": "Modes" + } + }, + "name": "Climate-control device HVAC mode" + }, "is_off": { "description": "Tests if one or more climate-control devices are off.", "fields": { diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 2d1305b4850e67..13bf598241a204 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -47,6 +47,7 @@ async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]: "climate.is_cooling", "climate.is_drying", "climate.is_heating", + "climate.is_hvac_mode", "climate.target_humidity", "climate.target_temperature", ], @@ -83,6 +84,24 @@ async def test_climate_conditions_gated_by_labs_flag( ], other_states=[HVACMode.OFF], ), + *( + param + for mode in HVACMode + for param in parametrize_condition_states_any( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [mode]}, + target_states=[mode], + other_states=[m for m in HVACMode if m != mode], + ) + ), + *parametrize_condition_states_any( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [HVACMode.HEAT, HVACMode.COOL]}, + target_states=[HVACMode.HEAT, HVACMode.COOL], + other_states=[ + m for m in HVACMode if m not in (HVACMode.HEAT, HVACMode.COOL) + ], + ), ], ) async def test_climate_state_condition_behavior_any( @@ -133,6 +152,24 @@ async def test_climate_state_condition_behavior_any( ], other_states=[HVACMode.OFF], ), + *( + param + for mode in HVACMode + for param in parametrize_condition_states_all( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [mode]}, + target_states=[mode], + other_states=[m for m in HVACMode if m != mode], + ) + ), + *parametrize_condition_states_all( + condition="climate.is_hvac_mode", + condition_options={"hvac_mode": [HVACMode.HEAT, HVACMode.COOL]}, + target_states=[HVACMode.HEAT, HVACMode.COOL], + other_states=[ + m for m in HVACMode if m not in (HVACMode.HEAT, HVACMode.COOL) + ], + ), ], ) async def test_climate_state_condition_behavior_all( From ee3c2e6f802b96a8ac6c8e99ee0d2c52711c4ffe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:35:59 +0100 Subject: [PATCH 0059/1707] Restore support for number entities as limits in battery conditions and triggers (#166607) --- homeassistant/components/battery/conditions.yaml | 2 ++ homeassistant/components/battery/triggers.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/battery/conditions.yaml b/homeassistant/components/battery/conditions.yaml index 6589e644bb34da..9bd7c1f3596185 100644 --- a/homeassistant/components/battery/conditions.yaml +++ b/homeassistant/components/battery/conditions.yaml @@ -19,6 +19,8 @@ unit_of_measurement: "%" - domain: sensor device_class: battery + - domain: number + device_class: battery .battery_threshold_number: &battery_threshold_number min: 0 diff --git a/homeassistant/components/battery/triggers.yaml b/homeassistant/components/battery/triggers.yaml index 97a5c6e55154d1..2ca59cf423f380 100644 --- a/homeassistant/components/battery/triggers.yaml +++ b/homeassistant/components/battery/triggers.yaml @@ -13,6 +13,8 @@ .battery_threshold_entity: &battery_threshold_entity - domain: input_number unit_of_measurement: "%" + - domain: number + device_class: battery - domain: sensor device_class: battery From f690e6de6aca702be5da6e8ee08132231f28cf3d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:42:51 +0100 Subject: [PATCH 0060/1707] Restore support for number entities as limits in moisture conditions and triggers (#166608) --- homeassistant/components/moisture/conditions.yaml | 2 ++ homeassistant/components/moisture/triggers.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/moisture/conditions.yaml b/homeassistant/components/moisture/conditions.yaml index 4c4899de8589dc..2bdf154950c599 100644 --- a/homeassistant/components/moisture/conditions.yaml +++ b/homeassistant/components/moisture/conditions.yaml @@ -19,6 +19,8 @@ unit_of_measurement: "%" - domain: sensor device_class: moisture + - domain: number + device_class: moisture .moisture_threshold_number: &moisture_threshold_number min: 0 diff --git a/homeassistant/components/moisture/triggers.yaml b/homeassistant/components/moisture/triggers.yaml index a111c58ecc9365..a8225e53b7ec1f 100644 --- a/homeassistant/components/moisture/triggers.yaml +++ b/homeassistant/components/moisture/triggers.yaml @@ -13,6 +13,8 @@ .moisture_threshold_entity: &moisture_threshold_entity - domain: input_number unit_of_measurement: "%" + - domain: number + device_class: moisture - domain: sensor device_class: moisture From 69e691f042f5daa6eecc7866ba8d1be8c7218481 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 16:51:51 +0100 Subject: [PATCH 0061/1707] Add input_boolean support to switch conditions (#166602) --- homeassistant/components/switch/condition.py | 8 +- .../components/switch/conditions.yaml | 3 +- tests/components/switch/test_condition.py | 135 +++++++++++++++++- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switch/condition.py b/homeassistant/components/switch/condition.py index 283524d21785c9..65d61ce723cc88 100644 --- a/homeassistant/components/switch/condition.py +++ b/homeassistant/components/switch/condition.py @@ -1,14 +1,18 @@ """Provides conditions for switches.""" +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import Condition, make_entity_state_condition from .const import DOMAIN +SWITCH_DOMAIN_SPECS = {DOMAIN: DomainSpec(), INPUT_BOOLEAN_DOMAIN: DomainSpec()} + CONDITIONS: dict[str, type[Condition]] = { - "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), - "is_on": make_entity_state_condition(DOMAIN, STATE_ON), + "is_off": make_entity_state_condition(SWITCH_DOMAIN_SPECS, STATE_OFF), + "is_on": make_entity_state_condition(SWITCH_DOMAIN_SPECS, STATE_ON), } diff --git a/homeassistant/components/switch/conditions.yaml b/homeassistant/components/switch/conditions.yaml index f293d020b45d44..ea9adeb6f74e45 100644 --- a/homeassistant/components/switch/conditions.yaml +++ b/homeassistant/components/switch/conditions.yaml @@ -1,7 +1,8 @@ .condition_common: &condition_common target: entity: - domain: switch + - domain: switch + - domain: input_boolean fields: behavior: required: true diff --git a/tests/components/switch/test_condition.py b/tests/components/switch/test_condition.py index eaa906fca04c12..76a0dffffb858e 100644 --- a/tests/components/switch/test_condition.py +++ b/tests/components/switch/test_condition.py @@ -4,11 +4,13 @@ import pytest -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, assert_condition_gated_by_labs_flag, create_target_condition, parametrize_condition_states_all, @@ -35,6 +37,12 @@ async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "switch") +@pytest.fixture +async def target_input_booleans(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple input_boolean entities associated with different targets.""" + return await target_entities(hass, "input_boolean") + + @pytest.mark.parametrize( "condition", [ @@ -176,3 +184,128 @@ async def test_switch_state_condition_behavior_all( await hass.async_block_till_done() assert condition(hass) == state["condition_true"] + + +CONDITION_STATES = [ + *parametrize_condition_states_any( + condition="switch.is_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_condition_states_any( + condition="switch.is_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), +] + +CONDITION_STATES_ALL = [ + *parametrize_condition_states_all( + condition="switch.is_on", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + *parametrize_condition_states_all( + condition="switch.is_off", + target_states=[STATE_OFF], + other_states=[STATE_ON], + ), +] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("input_boolean"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + CONDITION_STATES, +) +async def test_input_boolean_state_condition_behavior_any( + hass: HomeAssistant, + target_input_booleans: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the switch condition fires for input_boolean with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_input_booleans, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("input_boolean"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + CONDITION_STATES_ALL, +) +async def test_input_boolean_state_condition_behavior_all( + hass: HomeAssistant, + target_input_booleans: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the switch condition fires for input_boolean with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_input_booleans, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_switch_condition_evaluates_both_domains( + hass: HomeAssistant, +) -> None: + """Test that the switch condition evaluates both switch and input_boolean entities.""" + entity_id_switch = "switch.test_switch" + entity_id_input_boolean = "input_boolean.test_input_boolean" + + hass.states.async_set(entity_id_switch, STATE_OFF) + hass.states.async_set(entity_id_input_boolean, STATE_OFF) + await hass.async_block_till_done() + + condition = await create_target_condition( + hass, + condition="switch.is_on", + target={CONF_ENTITY_ID: [entity_id_switch, entity_id_input_boolean]}, + behavior="any", + ) + + # Both off - condition should be false + assert condition(hass) is False + + # switch entity turns on - condition should be true + hass.states.async_set(entity_id_switch, STATE_ON) + await hass.async_block_till_done() + assert condition(hass) is True + + # Reset switch, turn on input_boolean - condition should still be true + hass.states.async_set(entity_id_switch, STATE_OFF) + hass.states.async_set(entity_id_input_boolean, STATE_ON) + await hass.async_block_till_done() + assert condition(hass) is True From 7fd7b2c20367a376c2c998f77324ddc427e89ea8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 26 Mar 2026 17:06:40 +0100 Subject: [PATCH 0062/1707] Make `siren` conditions consistent with new wording (#166600) --- homeassistant/components/siren/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index 9feaa614d5de1b..b33c2592255eac 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -14,7 +14,7 @@ "name": "[%key:component::siren::common::condition_behavior_name%]" } }, - "name": "If a siren is off" + "name": "Siren is off" }, "is_on": { "description": "Tests if one or more sirens are on.", @@ -24,7 +24,7 @@ "name": "[%key:component::siren::common::condition_behavior_name%]" } }, - "name": "If a siren is on" + "name": "Siren is on" } }, "entity_component": { From fb65cf48c977d651e62c05d3c7bdcf1e2b53ce10 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 17:14:11 +0100 Subject: [PATCH 0063/1707] Add condition humidifier.is_mode (#166610) --- .../components/humidifier/condition.py | 63 ++++++++++++- .../components/humidifier/conditions.yaml | 13 +++ .../components/humidifier/icons.json | 3 + .../components/humidifier/strings.json | 14 +++ tests/components/humidifier/test_condition.py | 92 ++++++++++++++++++- 5 files changed, 182 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py index 0795291ae971fd..2a96eaffe376d2 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -1,15 +1,73 @@ """Provides conditions for humidifiers.""" -from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, Condition, + ConditionConfig, + EntityStateConditionBase, make_entity_numerical_condition, make_entity_state_condition, ) +from homeassistant.helpers.entity import get_supported_features + +from .const import ( + ATTR_ACTION, + ATTR_HUMIDITY, + DOMAIN, + HumidifierAction, + HumidifierEntityFeature, +) + +CONF_MODE = "mode" + +IS_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]), + }, + } +) + + +def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool: + """Test if an entity supports the specified features.""" + try: + return bool(get_supported_features(hass, entity_id) & features) + except HomeAssistantError: + return False + + +class IsModeCondition(EntityStateConditionBase): + """Condition for humidifier mode.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)} + _schema = IS_MODE_CONDITION_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the mode condition.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._states = set(config.options[CONF_MODE]) + + def entity_filter(self, entities: set[str]) -> set[str]: + """Filter entities of this domain.""" + entities = super().entity_filter(entities) + return { + entity_id + for entity_id in entities + if _supports_feature(self._hass, entity_id, HumidifierEntityFeature.MODES) + } -from .const import ATTR_ACTION, ATTR_HUMIDITY, DOMAIN, HumidifierAction CONDITIONS: dict[str, type[Condition]] = { "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), @@ -20,6 +78,7 @@ "is_humidifying": make_entity_state_condition( {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING ), + "is_mode": IsModeCondition, "is_target_humidity": make_entity_numerical_condition( {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, valid_unit=PERCENTAGE, diff --git a/homeassistant/components/humidifier/conditions.yaml b/homeassistant/components/humidifier/conditions.yaml index bc10ab1db65834..25c29301f26443 100644 --- a/homeassistant/components/humidifier/conditions.yaml +++ b/homeassistant/components/humidifier/conditions.yaml @@ -32,6 +32,19 @@ is_on: *condition_common is_drying: *condition_common is_humidifying: *condition_common +is_mode: + target: *condition_humidifier_target + fields: + behavior: *condition_behavior + mode: + context: + filter_target: target + required: true + selector: + state: + attribute: available_modes + multiple: true + is_target_humidity: target: *condition_humidifier_target fields: diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index 778aa6d0f47a65..8f4e3f89a1179d 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -6,6 +6,9 @@ "is_humidifying": { "condition": "mdi:arrow-up-bold" }, + "is_mode": { + "condition": "mdi:air-humidifier" + }, "is_off": { "condition": "mdi:air-humidifier-off" }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 6acd851b3deafd..82ae8b57436419 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -28,6 +28,20 @@ }, "name": "Humidifier is humidifying" }, + "is_mode": { + "description": "Tests if one or more humidifiers are set to a specific mode.", + "fields": { + "behavior": { + "description": "[%key:component::humidifier::common::condition_behavior_description%]", + "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "mode": { + "description": "The operation modes to check for.", + "name": "Mode" + } + }, + "name": "Humidifier is in mode" + }, "is_off": { "description": "Tests if one or more humidifiers are off.", "fields": { diff --git a/tests/components/humidifier/test_condition.py b/tests/components/humidifier/test_condition.py index 98e27a406a9d72..b45f8882964ed4 100644 --- a/tests/components/humidifier/test_condition.py +++ b/tests/components/humidifier/test_condition.py @@ -1,16 +1,29 @@ """Test humidifier conditions.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from typing import Any import pytest +import voluptuous as vol +from homeassistant.components.humidifier.condition import CONF_MODE from homeassistant.components.humidifier.const import ( ATTR_ACTION, ATTR_HUMIDITY, HumidifierAction, + HumidifierEntityFeature, +) +from homeassistant.const import ( + ATTR_MODE, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITY_ID, + CONF_OPTIONS, + CONF_TARGET, + STATE_OFF, + STATE_ON, ) -from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import async_validate_condition_config from tests.components.common import ( ConditionStateDescription, @@ -39,6 +52,7 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]: "humidifier.is_on", "humidifier.is_drying", "humidifier.is_humidifying", + "humidifier.is_mode", "humidifier.is_target_humidity", ], ) @@ -153,6 +167,20 @@ async def test_humidifier_state_condition_behavior_all( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_condition_states_any( + condition="humidifier.is_mode", + condition_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + ), ], ) async def test_humidifier_attribute_condition_behavior_any( @@ -196,6 +224,20 @@ async def test_humidifier_attribute_condition_behavior_any( target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})], other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})], ), + *parametrize_condition_states_all( + condition="humidifier.is_mode", + condition_options={CONF_MODE: ["eco", "sleep"]}, + target_states=[ + (STATE_ON, {ATTR_MODE: "eco"}), + (STATE_ON, {ATTR_MODE: "sleep"}), + ], + other_states=[ + (STATE_ON, {ATTR_MODE: "normal"}), + ], + required_filter_attributes={ + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES + }, + ), ], ) async def test_humidifier_attribute_condition_behavior_all( @@ -291,3 +333,51 @@ async def test_humidifier_numerical_condition_behavior_all( condition_options=condition_options, states=states, ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition", "condition_options", "expected_result"), + [ + # Valid configurations + ( + "humidifier.is_mode", + {CONF_MODE: ["eco", "sleep"]}, + does_not_raise(), + ), + ( + "humidifier.is_mode", + {CONF_MODE: "eco"}, + does_not_raise(), + ), + # Invalid configurations + ( + "humidifier.is_mode", + # Empty mode list + {CONF_MODE: []}, + pytest.raises(vol.Invalid), + ), + ( + "humidifier.is_mode", + # Missing CONF_MODE + {}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_humidifier_is_mode_condition_validation( + hass: HomeAssistant, + condition: str, + condition_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test humidifier is_mode condition config validation.""" + with expected_result: + await async_validate_condition_config( + hass, + { + "condition": condition, + CONF_TARGET: {CONF_ENTITY_ID: "humidifier.test"}, + CONF_OPTIONS: condition_options, + }, + ) From 5620cfbfd803ad80c2308a7d4e9404d7d8bd242c Mon Sep 17 00:00:00 2001 From: Andres Ruiz Date: Thu, 26 Mar 2026 12:16:38 -0400 Subject: [PATCH 0064/1707] Add support for unloading the waterfurnace config (#166555) --- .../components/waterfurnace/__init__.py | 7 ++++ .../waterfurnace/quality_scale.yaml | 2 +- tests/components/waterfurnace/test_init.py | 33 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py index aa79ae7efe2170..066fbc530af1ae 100644 --- a/homeassistant/components/waterfurnace/__init__.py +++ b/homeassistant/components/waterfurnace/__init__.py @@ -138,6 +138,13 @@ async def async_setup_entry( return True +async def async_unload_entry( + hass: HomeAssistant, entry: WaterFurnaceConfigEntry +) -> bool: + """Unload a WaterFurnace config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + async def async_migrate_entry( hass: HomeAssistant, entry: WaterFurnaceConfigEntry ) -> bool: diff --git a/homeassistant/components/waterfurnace/quality_scale.yaml b/homeassistant/components/waterfurnace/quality_scale.yaml index 814828dabe3c43..cbf85a7ab597af 100644 --- a/homeassistant/components/waterfurnace/quality_scale.yaml +++ b/homeassistant/components/waterfurnace/quality_scale.yaml @@ -29,7 +29,7 @@ rules: action-exceptions: status: exempt comment: This integration does not have custom service actions. - config-entry-unloading: todo + config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done entity-unavailable: done diff --git a/tests/components/waterfurnace/test_init.py b/tests/components/waterfurnace/test_init.py index b8d01bb6d7e289..ba686caf16ee16 100644 --- a/tests/components/waterfurnace/test_init.py +++ b/tests/components/waterfurnace/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import Mock +import pytest from waterfurnace.waterfurnace import WFCredentialError from homeassistant.components.waterfurnace.const import DOMAIN @@ -99,3 +100,35 @@ async def test_migrate_unique_id_auth_failure( assert old_entry.state is ConfigEntryState.MIGRATION_ERROR assert old_entry.unique_id == "TEST_GWID_12345" + + +@pytest.mark.usefixtures("init_integration") +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading a config entry.""" + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("init_integration") +async def test_reload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client: Mock, +) -> None: + """Test reloading a config entry.""" + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_waterfurnace_client.login.call_count == 2 + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_waterfurnace_client.login.call_count == 4 + assert "TEST_GWID_12345" in mock_config_entry.runtime_data From 0a9d4ef138fd45e62ec6674ad299fe38332e9003 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 26 Mar 2026 17:21:30 +0100 Subject: [PATCH 0065/1707] Verify Proxmox permissions when creating snapshots (#166547) --- homeassistant/components/proxmoxve/button.py | 39 +++++++++++++++---- homeassistant/components/proxmoxve/const.py | 9 ++++- homeassistant/components/proxmoxve/helpers.py | 4 +- .../components/proxmoxve/strings.json | 3 ++ tests/components/proxmoxve/__init__.py | 15 ++++++- tests/components/proxmoxve/test_button.py | 15 ++++++- 6 files changed, 71 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 6229d5ae67116c..833600d8ebddd6 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN +from .const import DOMAIN, ProxmoxPermission from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity from .helpers import is_granted @@ -34,6 +34,8 @@ class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): """Class to hold Proxmox node button description.""" press_action: Callable[[ProxmoxCoordinator, str], None] + permission: ProxmoxPermission = ProxmoxPermission.POWER + permission_raise: str = "no_permission_node_power" @dataclass(frozen=True, kw_only=True) @@ -41,6 +43,8 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): """Class to hold Proxmox VM button description.""" press_action: Callable[[ProxmoxCoordinator, str, int], None] + permission: ProxmoxPermission = ProxmoxPermission.POWER + permission_raise: str = "no_permission_vm_lxc_power" @dataclass(frozen=True, kw_only=True) @@ -48,6 +52,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): """Class to hold Proxmox container button description.""" press_action: Callable[[ProxmoxCoordinator, str, int], None] + permission: ProxmoxPermission = ProxmoxPermission.POWER + permission_raise: str = "no_permission_vm_lxc_power" NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( @@ -156,6 +162,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ) ) ), + permission=ProxmoxPermission.SNAPSHOT, + permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -199,6 +207,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ) ) ), + permission=ProxmoxPermission.SNAPSHOT, + permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -315,10 +325,15 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton): async def _async_press_call(self) -> None: """Execute the node button action via executor.""" node_id = self._node_data.node["node"] - if not is_granted(self.coordinator.permissions, p_type="nodes", p_id=node_id): + if not is_granted( + self.coordinator.permissions, + p_type="nodes", + p_id=node_id, + permission=self.entity_description.permission, + ): raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_permission_node_power", + translation_key=self.entity_description.permission_raise, ) await self.hass.async_add_executor_job( self.entity_description.press_action, @@ -335,10 +350,15 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton): async def _async_press_call(self) -> None: """Execute the VM button action via executor.""" vmid = self.vm_data["vmid"] - if not is_granted(self.coordinator.permissions, p_type="vms", p_id=vmid): + if not is_granted( + self.coordinator.permissions, + p_type="vms", + p_id=vmid, + permission=self.entity_description.permission, + ): raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_permission_vm_lxc_power", + translation_key=self.entity_description.permission_raise, ) await self.hass.async_add_executor_job( self.entity_description.press_action, @@ -357,10 +377,15 @@ async def _async_press_call(self) -> None: """Execute the container button action via executor.""" vmid = self.container_data["vmid"] # Container power actions fall under vms - if not is_granted(self.coordinator.permissions, p_type="vms", p_id=vmid): + if not is_granted( + self.coordinator.permissions, + p_type="vms", + p_id=vmid, + permission=self.entity_description.permission, + ): raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_permission_vm_lxc_power", + translation_key=self.entity_description.permission_raise, ) await self.hass.async_add_executor_job( self.entity_description.press_action, diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index ad0a0ebda69a4b..4cf821446c1c0a 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -1,5 +1,7 @@ """Constants for ProxmoxVE.""" +from enum import StrEnum + DOMAIN = "proxmoxve" CONF_AUTH_METHOD = "auth_method" CONF_REALM = "realm" @@ -33,4 +35,9 @@ TYPE_CONTAINER = 1 UPDATE_INTERVAL = 60 -PERM_POWER = "VM.PowerMgmt" + +class ProxmoxPermission(StrEnum): + """Proxmox permissions.""" + + POWER = "VM.PowerMgmt" + SNAPSHOT = "VM.Snapshot" diff --git a/homeassistant/components/proxmoxve/helpers.py b/homeassistant/components/proxmoxve/helpers.py index 0096170a954707..c7e96bcd300be8 100644 --- a/homeassistant/components/proxmoxve/helpers.py +++ b/homeassistant/components/proxmoxve/helpers.py @@ -1,13 +1,13 @@ """Helpers for Proxmox VE.""" -from .const import PERM_POWER +from .const import ProxmoxPermission def is_granted( permissions: dict[str, dict[str, int]], p_type: str = "vms", p_id: str | int | None = None, # can be str for nodes - permission: str = PERM_POWER, + permission: ProxmoxPermission = ProxmoxPermission.POWER, ) -> bool: """Validate user permissions for the given type and permission.""" paths = [f"/{p_type}/{p_id}", f"/{p_type}", "/"] diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index fcb13b68a93ce5..12ee765d9f23ed 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -315,6 +315,9 @@ "no_permission_node_power": { "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'VM.PowerMgmt' permission and try again." }, + "no_permission_snapshot": { + "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again." + }, "no_permission_vm_lxc_power": { "message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again." }, diff --git a/tests/components/proxmoxve/__init__.py b/tests/components/proxmoxve/__init__.py index 3241c45ac52cb0..e8c596b77dc375 100644 --- a/tests/components/proxmoxve/__init__.py +++ b/tests/components/proxmoxve/__init__.py @@ -31,9 +31,20 @@ "/vms/101": {"VM.PowerMgmt": 0}, } +SNAPSHOT_PERMISSIONS = { + "/vms": {"VM.Snapshot": 1}, + "/vms/101": {"VM.Snapshot": 0}, +} + MERGED_PERMISSIONS = { - key: {**AUDIT_PERMISSIONS.get(key, {}), **POWER_PERMISSIONS.get(key, {})} - for key in set(AUDIT_PERMISSIONS) | set(POWER_PERMISSIONS) + key: { + **AUDIT_PERMISSIONS.get(key, {}), + **POWER_PERMISSIONS.get(key, {}), + **SNAPSHOT_PERMISSIONS.get(key, {}), + } + for key in set(AUDIT_PERMISSIONS) + | set(POWER_PERMISSIONS) + | set(SNAPSHOT_PERMISSIONS) } diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index 9ca1b13ff8e721..fb2c5a8850824c 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -370,6 +370,7 @@ async def test_container_buttons_exceptions( ("button.pve1_start_all", "no_permission_node_power"), ("button.ct_nginx_start", "no_permission_vm_lxc_power"), ("button.vm_web_start", "no_permission_vm_lxc_power"), + ("button.vm_web_create_snapshot", "no_permission_snapshot"), ], ) async def test_node_buttons_permission_denied_for_auditor_role( @@ -394,19 +395,29 @@ async def test_node_buttons_permission_denied_for_auditor_role( assert exc_info.value.translation_key == translation_key +@pytest.mark.parametrize( + ("entity_id", "translation_key"), + [ + ("button.vm_db_start", "no_permission_vm_lxc_power"), + ("button.vm_db_create_snapshot", "no_permission_snapshot"), + ], +) async def test_vm_buttons_denied_for_specific_vm( hass: HomeAssistant, mock_proxmox_client: MagicMock, mock_config_entry: MockConfigEntry, + entity_id: str, + translation_key: str, ) -> None: """Test that button only works on actual permissions.""" await setup_integration(hass, mock_config_entry) mock_proxmox_client._node_mock.qemu(101) - with pytest.raises(ServiceValidationError): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.vm_db_start"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + assert exc_info.value.translation_key == translation_key From e359a8952b693f97cee3ca576e236ad1ca172307 Mon Sep 17 00:00:00 2001 From: Andres Ruiz Date: Thu, 26 Mar 2026 12:34:52 -0400 Subject: [PATCH 0066/1707] Add support for unloading the waterfurnace config (#166555) From a7de418213b6ab5f5d5a3e440f50f58431e89199 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 17:58:44 +0100 Subject: [PATCH 0067/1707] Add light.is_brightness condition (#166601) --- homeassistant/components/light/condition.py | 35 +++- .../components/light/conditions.yaml | 30 ++- homeassistant/components/light/icons.json | 3 + homeassistant/components/light/strings.json | 16 ++ tests/components/light/test_condition.py | 186 ++++++++++++++++++ tests/helpers/test_condition.py | 18 +- 6 files changed, 283 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/light/condition.py b/homeassistant/components/light/condition.py index 59fcd10c8317b9..57593bbc218263 100644 --- a/homeassistant/components/light/condition.py +++ b/homeassistant/components/light/condition.py @@ -1,12 +1,43 @@ """Provides conditions for lights.""" +from typing import Any + from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant -from homeassistant.helpers.condition import Condition, make_entity_state_condition +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import ( + Condition, + EntityNumericalConditionBase, + make_entity_state_condition, +) +from . import ATTR_BRIGHTNESS from .const import DOMAIN +BRIGHTNESS_DOMAIN_SPECS = { + DOMAIN: DomainSpec(value_source=ATTR_BRIGHTNESS), +} + + +class BrightnessCondition(EntityNumericalConditionBase): + """Condition for light brightness with uint8 to percentage conversion.""" + + _domain_specs = BRIGHTNESS_DOMAIN_SPECS + _valid_unit = "%" + + def _get_tracked_value(self, entity_state: State) -> Any: + """Get the brightness value converted from uint8 (0-255) to percentage (0-100).""" + raw = super()._get_tracked_value(entity_state) + if raw is None: + return None + try: + return (float(raw) / 255.0) * 100.0 + except TypeError, ValueError: + return None + + CONDITIONS: dict[str, type[Condition]] = { + "is_brightness": BrightnessCondition, "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), "is_on": make_entity_state_condition(DOMAIN, STATE_ON), } diff --git a/homeassistant/components/light/conditions.yaml b/homeassistant/components/light/conditions.yaml index a35a581ffc15f6..229707d6c89913 100644 --- a/homeassistant/components/light/conditions.yaml +++ b/homeassistant/components/light/conditions.yaml @@ -1,9 +1,9 @@ .condition_common: &condition_common - target: + target: &condition_light_target entity: domain: light fields: - behavior: + behavior: &condition_behavior required: true default: any selector: @@ -13,5 +13,31 @@ - all - any +.brightness_threshold_entity: &brightness_threshold_entity + - domain: input_number + unit_of_measurement: "%" + - domain: number + unit_of_measurement: "%" + - domain: sensor + unit_of_measurement: "%" + +.brightness_threshold_number: &brightness_threshold_number + min: 0 + max: 100 + mode: box + unit_of_measurement: "%" + is_off: *condition_common is_on: *condition_common + +is_brightness: + target: *condition_light_target + fields: + behavior: *condition_behavior + threshold: + required: true + selector: + numeric_threshold: + entity: *brightness_threshold_entity + mode: is + number: *brightness_threshold_number diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index 8fee85b7024d23..8a5716f774ac13 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -1,5 +1,8 @@ { "conditions": { + "is_brightness": { + "condition": "mdi:lightbulb-on-50" + }, "is_off": { "condition": "mdi:lightbulb-off" }, diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index d77b9ad0ea75df..dd0ae383c924ff 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -2,6 +2,8 @@ "common": { "condition_behavior_description": "How the state should match on the targeted lights.", "condition_behavior_name": "Behavior", + "condition_threshold_description": "What to test for and threshold values.", + "condition_threshold_name": "Threshold configuration", "field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.", "field_brightness_name": "Brightness value", "field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.", @@ -42,6 +44,20 @@ "trigger_threshold_name": "Threshold configuration" }, "conditions": { + "is_brightness": { + "description": "Tests the brightness of one or more lights.", + "fields": { + "behavior": { + "description": "[%key:component::light::common::condition_behavior_description%]", + "name": "[%key:component::light::common::condition_behavior_name%]" + }, + "threshold": { + "description": "[%key:component::light::common::condition_threshold_description%]", + "name": "[%key:component::light::common::condition_threshold_name%]" + } + }, + "name": "Light brightness" + }, "is_off": { "description": "Tests if one or more lights are off.", "fields": { diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index ca41f5f415f064..0d55a992af1bf3 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -4,11 +4,14 @@ import pytest +from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, assert_condition_gated_by_labs_flag, create_target_condition, parametrize_condition_states_all, @@ -19,6 +22,116 @@ ) +def parametrize_brightness_condition_states_any( + condition: str, state: str, attribute: str +) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: + """Parametrize above/below threshold test cases for brightness conditions. + + Note: The brightness in the condition configuration is in percentage (0-100) scale, + the underlying attribute in the state is in uint8 (0-255) scale. + """ + return [ + *parametrize_condition_states_any( + condition=condition, + condition_options={"threshold": {"type": "above", "value": {"number": 10}}}, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 255}), + ], + other_states=[ + (state, {attribute: 0}), + (state, {attribute: None}), + ], + ), + *parametrize_condition_states_any( + condition=condition, + condition_options={"threshold": {"type": "below", "value": {"number": 90}}}, + target_states=[ + (state, {attribute: 0}), + (state, {attribute: 128}), + ], + other_states=[ + (state, {attribute: 255}), + (state, {attribute: None}), + ], + ), + *parametrize_condition_states_any( + condition=condition, + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 10}, + "value_max": {"number": 90}, + } + }, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 153}), + ], + other_states=[ + (state, {attribute: 0}), + (state, {attribute: 255}), + (state, {attribute: None}), + ], + ), + ] + + +def parametrize_brightness_condition_states_all( + condition: str, state: str, attribute: str +) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: + """Parametrize above/below threshold test cases for brightness conditions with 'all' behavior. + + Note: The brightness in the condition configuration is in percentage (0-100) scale, + the underlying attribute in the state is in uint8 (0-255) scale. + """ + return [ + *parametrize_condition_states_all( + condition=condition, + condition_options={"threshold": {"type": "above", "value": {"number": 10}}}, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 255}), + ], + other_states=[ + (state, {attribute: 0}), + (state, {attribute: None}), + ], + ), + *parametrize_condition_states_all( + condition=condition, + condition_options={"threshold": {"type": "below", "value": {"number": 90}}}, + target_states=[ + (state, {attribute: 0}), + (state, {attribute: 128}), + ], + other_states=[ + (state, {attribute: 255}), + (state, {attribute: None}), + ], + ), + *parametrize_condition_states_all( + condition=condition, + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 10}, + "value_max": {"number": 90}, + } + }, + target_states=[ + (state, {attribute: 128}), + (state, {attribute: 153}), + ], + other_states=[ + (state, {attribute: 0}), + (state, {attribute: 255}), + (state, {attribute: None}), + ], + ), + ] + + @pytest.fixture async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]: """Create multiple light entities associated with different targets.""" @@ -38,6 +151,7 @@ async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "condition", [ + "light.is_brightness", "light.is_off", "light.is_on", ], @@ -176,3 +290,75 @@ async def test_light_state_condition_behavior_all( await hass.async_block_till_done() assert condition(hass) == state["condition_true"] + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("light"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_brightness_condition_states_any( + "light.is_brightness", STATE_ON, ATTR_BRIGHTNESS + ), + ], +) +async def test_light_brightness_condition_behavior_any( + hass: HomeAssistant, + target_lights: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the light brightness condition with the 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_lights, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("light"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_brightness_condition_states_all( + "light.is_brightness", STATE_ON, ATTR_BRIGHTNESS + ), + ], +) +async def test_light_brightness_condition_behavior_all( + hass: HomeAssistant, + target_lights: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the light brightness condition with the 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_lights, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 21a8ab6875f7d0..e21a3d048d005d 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2540,6 +2540,10 @@ async def test_async_get_all_descriptions( target: entity: domain: light + is_brightness: + target: + entity: + domain: light """ ws_client = await hass_ws_client(hass) @@ -2725,6 +2729,18 @@ def _load_yaml(fname, secrets=None): ], }, }, + "light.is_brightness": { + "fields": {}, + "target": { + "entity": [ + { + "domain": [ + "light", + ], + }, + ], + }, + }, } # Verify the cache returns the same object @@ -2898,7 +2914,7 @@ async def good_subscriber(new_conditions: set[str]): @pytest.mark.parametrize( ("new_triggers_conditions_enabled", "expected_events"), [ - (True, [{"light.is_off", "light.is_on"}]), + (True, [{"light.is_off", "light.is_on", "light.is_brightness"}]), (False, []), ], ) From fe76fe5408bb97d9fd7a5b0e91809a55df836abd Mon Sep 17 00:00:00 2001 From: Alessio Magliarella <49778555+magliaral@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:35:48 +0100 Subject: [PATCH 0068/1707] Bump ttn_client from 1.2.3 to 1.3.0 (#166613) --- homeassistant/components/thethingsnetwork/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thethingsnetwork/manifest.json b/homeassistant/components/thethingsnetwork/manifest.json index 777c9c1a2110f5..7157af1c8ec02c 100644 --- a/homeassistant/components/thethingsnetwork/manifest.json +++ b/homeassistant/components/thethingsnetwork/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/thethingsnetwork", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["ttn_client==1.2.3"] + "requirements": ["ttn_client==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ec97efc77fbffd..14cf7528970f1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3157,7 +3157,7 @@ trmnl==0.1.1 ttls==1.8.3 # homeassistant.components.thethingsnetwork -ttn_client==1.2.3 +ttn_client==1.3.0 # homeassistant.components.tuya tuya-device-handlers==0.0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a18386f6f73513..505ca7ac7e3968 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2666,7 +2666,7 @@ trmnl==0.1.1 ttls==1.8.3 # homeassistant.components.thethingsnetwork -ttn_client==1.2.3 +ttn_client==1.3.0 # homeassistant.components.tuya tuya-device-handlers==0.0.15 From 8d28b399b03581a596730337cd6c14093395ec0d Mon Sep 17 00:00:00 2001 From: Daniel Nicoara Date: Thu, 26 Mar 2026 14:46:58 -0400 Subject: [PATCH 0069/1707] Add Matter radon sensor support (#166298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa Co-authored-by: TheJulianJES --- homeassistant/components/matter/const.py | 3 + homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/sensor.py | 14 +++ homeassistant/components/matter/strings.json | 3 + .../fixtures/nodes/air_quality_sensor.json | 15 ++- .../matter/snapshots/test_sensor.ambr | 108 ++++++++++++++++++ tests/components/matter/test_sensor.py | 12 ++ 7 files changed, 155 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index cb42401725a54a..939011281d1be0 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -1,6 +1,7 @@ """Constants for the Matter integration.""" import logging +from typing import Final from chip.clusters import Objects as clusters @@ -114,3 +115,5 @@ CRED_TYPE_FINGER_VEIN, CRED_TYPE_FACE, ] + +CONCENTRATION_BECQUERELS_PER_CUBIC_METER: Final = "Bq/m³" diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index be65b462108085..e5645a7fcdd7e1 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -140,6 +140,9 @@ "pump_status": { "default": "mdi:pump" }, + "radon_concentration": { + "default": "mdi:radioactive" + }, "tank_percentage": { "default": "mdi:water-boiler" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 6a0273e05bba08..36fdbc7d3f68e7 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -48,6 +48,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify +from .const import CONCENTRATION_BECQUERELS_PER_CUBIC_METER from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -744,6 +745,19 @@ def _update_from_device(self) -> None: clusters.OzoneConcentrationMeasurement.Attributes.MeasuredValue, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="RadonSensor", + native_unit_of_measurement=CONCENTRATION_BECQUERELS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + translation_key="radon_concentration", + ), + entity_class=MatterSensor, + required_attributes=( + clusters.RadonConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index b8db87c58b8ecc..b5e481c26e5751 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -549,6 +549,9 @@ "pump_speed": { "name": "Rotation speed" }, + "radon_concentration": { + "name": "Radon concentration" + }, "reactive_current": { "name": "Reactive current" }, diff --git a/tests/components/matter/fixtures/nodes/air_quality_sensor.json b/tests/components/matter/fixtures/nodes/air_quality_sensor.json index 4a533f0a1661ff..a772ace4f2ede4 100644 --- a/tests/components/matter/fixtures/nodes/air_quality_sensor.json +++ b/tests/components/matter/fixtures/nodes/air_quality_sensor.json @@ -197,7 +197,7 @@ "1": 1 } ], - "1/29/1": [3, 29, 91, 1026, 1029, 1037, 1043, 1066, 1068, 1069, 1070], + "1/29/1": [3, 29, 91, 1026, 1029, 1037, 1043, 1066, 1068, 1069, 1070, 1071], "1/29/2": [], "1/29/3": [], "1/29/65532": 0, @@ -274,7 +274,15 @@ "1/1070/65533": 3, "1/1070/65528": [], "1/1070/65529": [], - "1/1070/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + "1/1070/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1071/0": 60.0, + "1/1071/1": 0.0, + "1/1071/2": 500.0, + "1/1071/65532": 1, + "1/1071/65533": 3, + "1/1071/65528": [], + "1/1071/65529": [], + "1/1071/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] }, "attribute_subscriptions": [ [1, 1037, 0], @@ -283,6 +291,7 @@ [1, 1068, 0], [1, 1069, 0], [1, 1026, 0], - [1, 1029, 0] + [1, 1029, 0], + [1, 1071, 0] ] } diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 3f96bdfe1df453..d339bed55735cc 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -397,6 +397,60 @@ 'state': '3.0', }) # --- +# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_radon_concentration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_radon_concentration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Radon concentration', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radon concentration', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radon_concentration', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-RadonSensor-1071-0', + 'unit_of_measurement': 'Bq/m³', + }) +# --- +# name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_radon_concentration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'lightfi-aq1-air-quality-sensor Radon concentration', + 'state_class': , + 'unit_of_measurement': 'Bq/m³', + }), + 'context': , + 'entity_id': 'sensor.lightfi_aq1_air_quality_sensor_radon_concentration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- # name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -8382,6 +8436,60 @@ 'state': '2.0', }) # --- +# name: test_sensors[mock_air_purifier][sensor.mock_air_purifier_radon_concentration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_air_purifier_radon_concentration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Radon concentration', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radon concentration', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radon_concentration', + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-RadonSensor-1071-0', + 'unit_of_measurement': 'Bq/m³', + }) +# --- +# name: test_sensors[mock_air_purifier][sensor.mock_air_purifier_radon_concentration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Air Purifier Radon concentration', + 'state_class': , + 'unit_of_measurement': 'Bq/m³', + }), + 'context': , + 'entity_id': 'sensor.mock_air_purifier_radon_concentration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- # name: test_sensors[mock_air_purifier][sensor.mock_air_purifier_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 4657931a0d790d..96d7b8fdca74d4 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -359,6 +359,18 @@ async def test_air_quality_sensor( assert state assert state.state == "50.0" + # Radon + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_radon_concentration") + assert state + assert state.state == "60.0" + + set_node_attribute(matter_node, 1, 1071, 0, 50) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_radon_concentration") + assert state + assert state.state == "50.0" + @pytest.mark.parametrize("node_fixture", ["mock_air_purifier"]) async def test_tvoc_level_sensor( From cc363e4ebd8b0b2eb480becf8e707a5dda59c9a0 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Thu, 26 Mar 2026 11:47:39 -0700 Subject: [PATCH 0070/1707] Remove tplink_lte integration (#166615) --- .../components/tplink_lte/__init__.py | 172 ++---------------- .../components/tplink_lte/manifest.json | 3 +- homeassistant/components/tplink_lte/notify.py | 55 ------ .../components/tplink_lte/strings.json | 8 + requirements_all.txt | 3 - tests/components/tplink_lte/__init__.py | 1 + tests/components/tplink_lte/test_init.py | 23 +++ 7 files changed, 48 insertions(+), 217 deletions(-) delete mode 100644 homeassistant/components/tplink_lte/notify.py create mode 100644 homeassistant/components/tplink_lte/strings.json create mode 100644 tests/components/tplink_lte/__init__.py create mode 100644 tests/components/tplink_lte/test_init.py diff --git a/homeassistant/components/tplink_lte/__init__.py b/homeassistant/components/tplink_lte/__init__.py index ca9b8311ebe9cd..9713a1aa227c53 100644 --- a/homeassistant/components/tplink_lte/__init__.py +++ b/homeassistant/components/tplink_lte/__init__.py @@ -1,172 +1,30 @@ -"""Support for TP-Link LTE modems.""" +"""The tplink_lte integration.""" -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -import aiohttp -import attr -import tp_connected import voluptuous as vol -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_RECIPIENT, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - DOMAIN = "tplink_lte" -DATA_KEY = "tplink_lte" - -CONF_NOTIFY = "notify" - -_NOTIFY_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_RECIPIENT): vol.All(cv.ensure_list, [cv.string]), - } -) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NOTIFY): vol.All( - cv.ensure_list, [_NOTIFY_SCHEMA] - ), - } - ) - ], - ) - }, + {DOMAIN: cv.match_all}, extra=vol.ALLOW_EXTRA, ) -@attr.s -class ModemData: - """Class for modem state.""" - - host: str = attr.ib() - modem: tp_connected.Modem = attr.ib() - - connected: bool = attr.ib(init=False, default=True) - - -@attr.s -class LTEData: - """Shared state.""" - - websession: aiohttp.ClientSession = attr.ib() - modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict) - - def get_modem_data(self, config: dict[str, Any]) -> ModemData | None: - """Get the requested or the only modem_data value.""" - if CONF_HOST in config: - return self.modem_data.get(config[CONF_HOST]) - if len(self.modem_data) == 1: - return next(iter(self.modem_data.values())) - - return None - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up TP-Link LTE component.""" - if DATA_KEY not in hass.data: - websession = async_create_clientsession( - hass, cookie_jar=aiohttp.CookieJar(unsafe=True) - ) - hass.data[DATA_KEY] = LTEData(websession) - - domain_config = config.get(DOMAIN, []) - - tasks = [_setup_lte(hass, conf) for conf in domain_config] - if tasks: - await asyncio.gather(*tasks) - - for conf in domain_config: - for notify_conf in conf.get(CONF_NOTIFY, []): - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.NOTIFY, DOMAIN, notify_conf, config - ) - ) - + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "ghsa_url": "https://github.com/advisories/GHSA-h95x-26f3-88hr", + }, + ) return True - - -async def _setup_lte( - hass: HomeAssistant, lte_config: dict[str, Any], delay: int = 0 -) -> None: - """Set up a TP-Link LTE modem.""" - - host: str = lte_config[CONF_HOST] - password: str = lte_config[CONF_PASSWORD] - - lte_data: LTEData = hass.data[DATA_KEY] - modem = tp_connected.Modem(hostname=host, websession=lte_data.websession) - - modem_data = ModemData(host, modem) - - try: - await _login(hass, modem_data, password) - except tp_connected.Error: - retry_task = hass.loop.create_task(_retry_login(hass, modem_data, password)) - - @callback - def cleanup_retry(event: Event) -> None: - """Clean up retry task resources.""" - if not retry_task.done(): - retry_task.cancel() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) - - -async def _login(hass: HomeAssistant, modem_data: ModemData, password: str) -> None: - """Log in and complete setup.""" - await modem_data.modem.login(password=password) - modem_data.connected = True - lte_data: LTEData = hass.data[DATA_KEY] - lte_data.modem_data[modem_data.host] = modem_data - - async def cleanup(event: Event) -> None: - """Clean up resources.""" - await modem_data.modem.logout() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - - -async def _retry_login( - hass: HomeAssistant, modem_data: ModemData, password: str -) -> None: - """Sleep and retry setup.""" - - _LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) - - modem_data.connected = False - delay = 15 - - while not modem_data.connected: - await asyncio.sleep(delay) - - try: - await _login(hass, modem_data, password) - _LOGGER.warning("Connected to %s", modem_data.host) - except tp_connected.Error: - delay = min(2 * delay, 300) diff --git a/homeassistant/components/tplink_lte/manifest.json b/homeassistant/components/tplink_lte/manifest.json index a880594e683e42..1f9057c3ad73f1 100644 --- a/homeassistant/components/tplink_lte/manifest.json +++ b/homeassistant/components/tplink_lte/manifest.json @@ -4,7 +4,6 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/tplink_lte", "iot_class": "local_polling", - "loggers": ["tp_connected"], "quality_scale": "legacy", - "requirements": ["tp-connected==0.0.4"] + "requirements": [] } diff --git a/homeassistant/components/tplink_lte/notify.py b/homeassistant/components/tplink_lte/notify.py deleted file mode 100644 index 674f09efcd7f27..00000000000000 --- a/homeassistant/components/tplink_lte/notify.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Support for TP-Link LTE notifications.""" - -from __future__ import annotations - -import logging -from typing import Any - -import attr -import tp_connected - -from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import CONF_RECIPIENT -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from . import DATA_KEY, LTEData - -_LOGGER = logging.getLogger(__name__) - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> TplinkNotifyService | None: - """Get the notification service.""" - if discovery_info is None: - return None - return TplinkNotifyService(hass, discovery_info) - - -@attr.s -class TplinkNotifyService(BaseNotificationService): - """Implementation of a notification service.""" - - hass: HomeAssistant = attr.ib() - config: dict[str, Any] = attr.ib() - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a user.""" - - lte_data: LTEData = self.hass.data[DATA_KEY] - modem_data = lte_data.get_modem_data(self.config) - if not modem_data: - _LOGGER.error("No modem available") - return - - phone = self.config[CONF_RECIPIENT] - targets = kwargs.get(ATTR_TARGET, phone) - if targets and message: - for target in targets: - try: - await modem_data.modem.sms(target, message) - except tp_connected.Error: - _LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/tplink_lte/strings.json b/homeassistant/components/tplink_lte/strings.json new file mode 100644 index 00000000000000..d03b650746f5ee --- /dev/null +++ b/homeassistant/components/tplink_lte/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "integration_removed": { + "description": "The TP-Link LTE integration has been removed from Home Assistant.\n\nThe integration has not been working since Home Assistant 2023.6.0, has no maintainer, and its underlying library depends on a package with a [critical security vulnerability]({ghsa_url}).\n\nTo resolve this issue, remove the `tplink_lte` configuration from your `configuration.yaml` file and restart Home Assistant.", + "title": "The TP-Link LTE integration has been removed" + } + } +} diff --git a/requirements_all.txt b/requirements_all.txt index 14cf7528970f1b..ba45928ccbad0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3138,9 +3138,6 @@ toonapi==0.3.0 # homeassistant.components.totalconnect total-connect-client==2025.12.2 -# homeassistant.components.tplink_lte -tp-connected==0.0.4 - # homeassistant.components.tplink_omada tplink-omada-client==1.5.6 diff --git a/tests/components/tplink_lte/__init__.py b/tests/components/tplink_lte/__init__.py new file mode 100644 index 00000000000000..d487e0eba936ef --- /dev/null +++ b/tests/components/tplink_lte/__init__.py @@ -0,0 +1 @@ +"""Tests for the TP-Link LTE integration.""" diff --git a/tests/components/tplink_lte/test_init.py b/tests/components/tplink_lte/test_init.py new file mode 100644 index 00000000000000..76f65d83b8da5f --- /dev/null +++ b/tests/components/tplink_lte/test_init.py @@ -0,0 +1,23 @@ +"""Tests for the TP-Link LTE integration.""" + +from homeassistant.components.tplink_lte import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +async def test_tplink_lte_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the TP-Link LTE repair issue is created on setup.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: [{"host": "192.168.0.1", "password": "secret"}]}, + ) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + issue = issue_registry.async_get_issue(DOMAIN, DOMAIN) + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_key == "integration_removed" From 70aa58913daee2f3973437bdd2de1a5f51e3e8f5 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:52:37 +0100 Subject: [PATCH 0071/1707] Modernize demo/switch to async (#166619) --- homeassistant/components/demo/switch.py | 10 +++++----- tests/components/switch/test_light.py | 2 ++ tests/components/switch_as_x/test_cover.py | 12 ++++++++++++ tests/components/switch_as_x/test_fan.py | 12 ++++++++++++ tests/components/switch_as_x/test_light.py | 10 ++++++++++ tests/components/switch_as_x/test_lock.py | 10 ++++++++++ tests/components/switch_as_x/test_siren.py | 12 ++++++++++++ tests/components/switch_as_x/test_valve.py | 12 ++++++++++++ 8 files changed, 75 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index dd288f285af0c1..214f64e8a49409 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -61,12 +61,12 @@ def __init__( name=device_name, ) - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self._attr_is_on = True - self.schedule_update_ha_state() + self.async_write_ha_state() - def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" self._attr_is_on = False - self.schedule_update_ha_state() + self.async_write_ha_state() diff --git a/tests/components/switch/test_light.py b/tests/components/switch/test_light.py index e7681575ce4da2..4387151ce34bd9 100644 --- a/tests/components/switch/test_light.py +++ b/tests/components/switch/test_light.py @@ -62,11 +62,13 @@ async def test_light_service_calls(hass: HomeAssistant) -> None: assert hass.states.get("light.light_switch").state == "on" await common.async_toggle(hass, "light.light_switch") + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == "off" assert hass.states.get("light.light_switch").state == "off" await common.async_turn_on(hass, "light.light_switch") + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == "on" assert hass.states.get("light.light_switch").state == "on" diff --git a/tests/components/switch_as_x/test_cover.py b/tests/components/switch_as_x/test_cover.py index acb382a635a78e..5808489a1a9375 100644 --- a/tests/components/switch_as_x/test_cover.py +++ b/tests/components/switch_as_x/test_cover.py @@ -77,6 +77,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("cover.decorative_lights").state == CoverState.CLOSED @@ -87,6 +88,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("cover.decorative_lights").state == CoverState.OPEN @@ -97,6 +99,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("cover.decorative_lights").state == CoverState.CLOSED @@ -107,6 +110,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("cover.decorative_lights").state == CoverState.OPEN @@ -117,6 +121,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("cover.decorative_lights").state == CoverState.CLOSED @@ -127,6 +132,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("cover.decorative_lights").state == CoverState.OPEN @@ -160,6 +166,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("cover.decorative_lights").state == CoverState.OPEN @@ -170,6 +177,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("cover.decorative_lights").state == CoverState.OPEN @@ -180,6 +188,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("cover.decorative_lights").state == CoverState.CLOSED @@ -190,6 +199,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("cover.decorative_lights").state == CoverState.CLOSED @@ -200,6 +210,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("cover.decorative_lights").state == CoverState.OPEN @@ -210,6 +221,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("cover.decorative_lights").state == CoverState.CLOSED diff --git a/tests/components/switch_as_x/test_fan.py b/tests/components/switch_as_x/test_fan.py index a33490dab45d85..1b872f3dc228c9 100644 --- a/tests/components/switch_as_x/test_fan.py +++ b/tests/components/switch_as_x/test_fan.py @@ -75,6 +75,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("fan.decorative_lights").state == STATE_OFF @@ -85,6 +86,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("fan.decorative_lights").state == STATE_ON @@ -95,6 +97,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("fan.decorative_lights").state == STATE_OFF @@ -105,6 +108,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("fan.decorative_lights").state == STATE_ON @@ -115,6 +119,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("fan.decorative_lights").state == STATE_OFF @@ -125,6 +130,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("fan.decorative_lights").state == STATE_ON @@ -158,6 +164,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("fan.decorative_lights").state == STATE_OFF @@ -168,6 +175,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("fan.decorative_lights").state == STATE_ON @@ -178,6 +186,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("fan.decorative_lights").state == STATE_OFF @@ -188,6 +197,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("fan.decorative_lights").state == STATE_ON @@ -198,6 +208,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("fan.decorative_lights").state == STATE_OFF @@ -208,6 +219,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("fan.decorative_lights").state == STATE_ON diff --git a/tests/components/switch_as_x/test_light.py b/tests/components/switch_as_x/test_light.py index 5f724a2d7e72b4..ba752bd0840350 100644 --- a/tests/components/switch_as_x/test_light.py +++ b/tests/components/switch_as_x/test_light.py @@ -92,6 +92,7 @@ async def test_light_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "light.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("light.decorative_lights").state == STATE_OFF @@ -102,6 +103,7 @@ async def test_light_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "light.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("light.decorative_lights").state == STATE_ON @@ -116,6 +118,7 @@ async def test_light_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "light.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("light.decorative_lights").state == STATE_OFF @@ -149,6 +152,7 @@ async def test_switch_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("light.decorative_lights").state == STATE_OFF @@ -159,6 +163,7 @@ async def test_switch_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("light.decorative_lights").state == STATE_ON @@ -192,6 +197,7 @@ async def test_light_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "light.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("light.decorative_lights").state == STATE_OFF @@ -202,6 +208,7 @@ async def test_light_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "light.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("light.decorative_lights").state == STATE_ON @@ -216,6 +223,7 @@ async def test_light_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "light.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("light.decorative_lights").state == STATE_OFF @@ -249,6 +257,7 @@ async def test_switch_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("light.decorative_lights").state == STATE_OFF @@ -259,6 +268,7 @@ async def test_switch_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("light.decorative_lights").state == STATE_ON diff --git a/tests/components/switch_as_x/test_lock.py b/tests/components/switch_as_x/test_lock.py index c2a0806778d7b7..0f500c79f936a3 100644 --- a/tests/components/switch_as_x/test_lock.py +++ b/tests/components/switch_as_x/test_lock.py @@ -76,6 +76,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED @@ -86,6 +87,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED @@ -96,6 +98,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED @@ -106,6 +109,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED @@ -116,6 +120,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED @@ -149,6 +154,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED @@ -159,6 +165,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED @@ -169,6 +176,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED @@ -179,6 +187,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED @@ -189,6 +198,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED diff --git a/tests/components/switch_as_x/test_siren.py b/tests/components/switch_as_x/test_siren.py index 83daa86283043b..0b66f07cf60f33 100644 --- a/tests/components/switch_as_x/test_siren.py +++ b/tests/components/switch_as_x/test_siren.py @@ -75,6 +75,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("siren.decorative_lights").state == STATE_OFF @@ -85,6 +86,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("siren.decorative_lights").state == STATE_ON @@ -95,6 +97,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("siren.decorative_lights").state == STATE_OFF @@ -105,6 +108,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("siren.decorative_lights").state == STATE_ON @@ -115,6 +119,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("siren.decorative_lights").state == STATE_OFF @@ -125,6 +130,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("siren.decorative_lights").state == STATE_ON @@ -158,6 +164,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("siren.decorative_lights").state == STATE_OFF @@ -168,6 +175,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("siren.decorative_lights").state == STATE_ON @@ -178,6 +186,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("siren.decorative_lights").state == STATE_OFF @@ -188,6 +197,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("siren.decorative_lights").state == STATE_ON @@ -198,6 +208,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("siren.decorative_lights").state == STATE_OFF @@ -208,6 +219,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("siren.decorative_lights").state == STATE_ON diff --git a/tests/components/switch_as_x/test_valve.py b/tests/components/switch_as_x/test_valve.py index 6f6ef719ae1f93..30ce9fc5bd0fbc 100644 --- a/tests/components/switch_as_x/test_valve.py +++ b/tests/components/switch_as_x/test_valve.py @@ -77,6 +77,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "valve.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED @@ -87,6 +88,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "valve.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN @@ -97,6 +99,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "valve.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED @@ -107,6 +110,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN @@ -117,6 +121,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED @@ -127,6 +132,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN @@ -160,6 +166,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "valve.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN @@ -170,6 +177,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "valve.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN @@ -180,6 +188,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "valve.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED @@ -190,6 +199,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED @@ -200,6 +210,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN @@ -210,6 +221,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "switch.decorative_lights"}, blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED From 5544157d5e23635dd97dbdfde7a3a5cb11469e6e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 20:29:49 +0100 Subject: [PATCH 0072/1707] Fix override of state write in dlna_dmr (#166628) --- homeassistant/components/dlna_dmr/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 1c43d76ea0840a..e49679f5d5517b 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -353,10 +353,10 @@ async def async_config_update_listener( # Device was de/re-connected, state might have changed self.async_write_ha_state() - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state.""" self._attr_supported_features = self._supported_features() - super().async_write_ha_state() + super()._async_write_ha_state() async def _device_connect(self, location: str) -> None: """Connect to the device now that it's available.""" From c00a68383ce84894564d0769b439bf98a90f18cb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 20:39:43 +0100 Subject: [PATCH 0073/1707] Fix override of state write in radarr (#166630) --- homeassistant/components/radarr/calendar.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py index d3a30bf6ce9cf1..4bca75123e0416 100644 --- a/homeassistant/components/radarr/calendar.py +++ b/homeassistant/components/radarr/calendar.py @@ -56,7 +56,7 @@ async def async_get_events( # type: ignore[override] return await self.coordinator.async_get_events(start_date, end_date) @callback - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state to the state machine.""" if self.coordinator.event: self._attr_extra_state_attributes = { @@ -64,4 +64,4 @@ def async_write_ha_state(self) -> None: } else: self._attr_extra_state_attributes = {} - super().async_write_ha_state() + super()._async_write_ha_state() From f875c77af0641b5cce442ba313c01ef5c546951e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 26 Mar 2026 20:43:39 +0100 Subject: [PATCH 0074/1707] Update frontend to 20260325.1 (#166614) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index c7b933c5429370..d43174468c83dd 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.0"] + "requirements": ["home-assistant-frontend==20260325.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6ae73ed686dcc5..37e26f6f7162e9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.0 +home-assistant-frontend==20260325.1 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ba45928ccbad0c..867d4379f22cbb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.0 +home-assistant-frontend==20260325.1 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 505ca7ac7e3968..287345fae75039 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.0 +home-assistant-frontend==20260325.1 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From 5d7abae490c673b3afd01433252b8b7faebc3e32 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Thu, 26 Mar 2026 13:37:47 -0700 Subject: [PATCH 0075/1707] Handle Oauth2 ImplementationUnavailableError in aladdin_connect (#166631) Co-authored-by: Claude Sonnet 4.6 --- .../components/aladdin_connect/__init__.py | 17 ++++++++++++---- .../components/aladdin_connect/strings.json | 3 +++ tests/components/aladdin_connect/test_init.py | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 25e5426d23c476..5d82c8df682542 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -13,6 +13,9 @@ config_entry_oauth2_flow, device_registry as dr, ) +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from . import api from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN @@ -25,11 +28,17 @@ async def async_setup_entry( hass: HomeAssistant, entry: AladdinConnectConfigEntry ) -> bool: """Set up Aladdin Connect Genie from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index a04552108a2007..2e7cc6f6961af9 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -37,6 +37,9 @@ "close_door_failed": { "message": "Failed to close the garage door" }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, "open_door_failed": { "message": "Failed to open the garage door" } diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index d9f0675e228224..f3cd6042f32690 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -12,12 +12,32 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from . import init_integration from tests.common import MockConfigEntry, async_fire_time_changed +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_setup_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: From 8e5daeb7ddc15ca02f6b6eca7732847fcc28cca3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 21:56:23 +0100 Subject: [PATCH 0076/1707] Fix override of state write in fritzbox (#166629) --- homeassistant/components/fritzbox/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 693d8bac5665e6..8ba6fbd5f86422 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -97,7 +97,7 @@ def __init__( super().__init__(coordinator, ain) @callback - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state to the HASS state machine.""" if self.data.holiday_active: self._attr_supported_features = ClimateEntityFeature.PRESET_MODE @@ -109,7 +109,7 @@ def async_write_ha_state(self) -> None: self._attr_supported_features = SUPPORTED_FEATURES self._attr_hvac_modes = HVAC_MODES self._attr_preset_modes = PRESET_MODES - return super().async_write_ha_state() + return super()._async_write_ha_state() @property def current_temperature(self) -> float: From d76272d74a05b5eedb33f0a0cd83a04bd0e978b6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Mar 2026 22:00:25 +0100 Subject: [PATCH 0077/1707] Fix override of state write in camera base entity (#166626) --- homeassistant/components/camera/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 364c63c2c5fda1..fb7de2d8ebd23a 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -760,12 +760,12 @@ def camera_capabilities(self) -> CameraCapabilities: return CameraCapabilities(frontend_stream_types) @callback - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state to the state machine. Schedules async_refresh_providers if support of streams have changed. """ - super().async_write_ha_state() + super()._async_write_ha_state() if self.__supports_stream != ( supports_stream := self.supported_features & CameraEntityFeature.STREAM ): From ac65ba7d20f79dc258a1c2c2de2585f0b9ef2c0d Mon Sep 17 00:00:00 2001 From: Will Moss Date: Thu, 26 Mar 2026 20:43:23 -0700 Subject: [PATCH 0078/1707] Use error introduced in #154579 in fitbit integration (#166632) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/fitbit/__init__.py | 19 +++++++++++++----- homeassistant/components/fitbit/strings.json | 5 +++++ tests/components/fitbit/test_init.py | 21 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index f2378797d8d257..6b4e1cadbf345e 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -4,9 +4,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from . import api -from .const import FitbitScope +from .const import DOMAIN, FitbitScope from .coordinator import FitbitConfigEntry, FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import config_from_entry_data @@ -16,11 +19,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool: """Set up fitbit from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) fitbit_api = api.OAuthFitbitApi( hass, session, unit_system=entry.data.get("unit_system") diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 4d9060b998766f..49281a78560661 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -121,5 +121,10 @@ "name": "Water" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index 277965848c4ff1..be6b052a84049b 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus +from unittest.mock import patch import pytest @@ -12,6 +13,9 @@ ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from .conftest import ( CLIENT_ID, @@ -26,6 +30,23 @@ from tests.test_util.aiohttp import AiohttpClientMocker +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_setup( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], From ddfef18183e92db08fdd6c72a896324cb87b9d50 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Thu, 26 Mar 2026 20:45:04 -0700 Subject: [PATCH 0079/1707] Use error introduced in #154579 in google_photos integration (#166656) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- .../components/google_photos/__init__.py | 15 +++++++++++---- .../components/google_photos/strings.json | 3 +++ tests/components/google_photos/test_init.py | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index 08bdce9b359cde..115bd57f67c641 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -33,11 +33,18 @@ async def async_setup_entry( hass: HomeAssistant, entry: GooglePhotosConfigEntry ) -> bool: """Set up Google Photos from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + web_session = async_get_clientsession(hass) oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) auth = api.AsyncConfigEntryAuth(web_session, oauth_session) diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 63984ecc7c13a2..bb041da4a6330f 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -68,6 +68,9 @@ "no_access_to_path": { "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, "upload_error": { "message": "Failed to upload content: {message}" } diff --git a/tests/components/google_photos/test_init.py b/tests/components/google_photos/test_init.py index 80b051d092d5a4..b6fcc7d5c105d0 100644 --- a/tests/components/google_photos/test_init.py +++ b/tests/components/google_photos/test_init.py @@ -2,6 +2,7 @@ import http import time +from unittest.mock import patch from aiohttp import ClientError from google_photos_library_api.exceptions import GooglePhotosApiError @@ -10,6 +11,7 @@ from homeassistant.components.google_photos.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -118,3 +120,19 @@ async def test_coordinator_init_failure( ) -> None: """Test init failure to load albums.""" assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_implementation_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test setup entry when implementation is unavailable.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=config_entry_oauth2_flow.ImplementationUnavailableError, + ): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From cb43950ccf000432a1388af83e4c119f2728b0a8 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Thu, 26 Mar 2026 20:45:23 -0700 Subject: [PATCH 0080/1707] Use error introduced in #154579 in mcp integration (#166661) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/mcp/__init__.py | 10 +++++++++- homeassistant/components/mcp/strings.json | 5 +++++ tests/components/mcp/test_init.py | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index 642fa400213ff1..c4238017564ce0 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -7,6 +7,7 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, llm from .application_credentials import authorization_server_context @@ -42,7 +43,14 @@ async def _create_token_manager( hass: HomeAssistant, entry: ModelContextProtocolConfigEntry ) -> TokenManager | None: """Create a OAuth token manager for the config entry if the server requires authentication.""" - if not (implementation := await async_get_config_entry_implementation(hass, entry)): + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + if not implementation: return None session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 1dcc4400ceddc7..c5e84505b417fd 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -56,5 +56,10 @@ } } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 1f063e445eec9d..0a1f67e133637b 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -13,6 +13,9 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from .conftest import TEST_API_NAME @@ -342,3 +345,19 @@ async def test_convert_tool_schema_fails( ): await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: AsyncMock, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + with patch( + "homeassistant.components.mcp.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + await hass.async_block_till_done() + + assert config_entry_with_auth.state is ConfigEntryState.SETUP_RETRY From 8ca8c2191fb29ba3b79bac5345a7134162847924 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Fri, 27 Mar 2026 07:08:58 +0100 Subject: [PATCH 0081/1707] Modernize demo/remote to async (#166624) --- homeassistant/components/demo/remote.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index ffd6fd6e6096b1..b8354edaaea942 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -44,18 +44,18 @@ def extra_state_attributes(self) -> dict[str, Any] | None: return {"last_command_sent": self._last_command_sent} return None - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the remote on.""" self._attr_is_on = True - self.schedule_update_ha_state() + self.async_write_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the remote off.""" self._attr_is_on = False - self.schedule_update_ha_state() + self.async_write_ha_state() - def send_command(self, command: Iterable[str], **kwargs: Any) -> None: + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to a device.""" for com in command: self._last_command_sent = com - self.schedule_update_ha_state() + self.async_write_ha_state() From 4f897154539d1629f0adbb4f27507c293988be9c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 07:40:28 +0100 Subject: [PATCH 0082/1707] Fix override of state write in calendar base entity (#166625) --- homeassistant/components/calendar/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 032bd2fd36d454..db49440d449940 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -578,13 +578,13 @@ def state(self) -> str: return STATE_OFF @callback - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state to the state machine. This sets up listeners to handle state transitions for start or end of the current or upcoming event. """ - super().async_write_ha_state() + super()._async_write_ha_state() if self._alarm_unsubs is None: self._alarm_unsubs = [] _LOGGER.debug( From 8632420b8fb911a0a3db7d69fca797d6022e6843 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 07:48:14 +0100 Subject: [PATCH 0083/1707] Add weather support to humidity conditions (#166599) --- .../components/humidity/condition.py | 7 ++ .../components/humidity/conditions.yaml | 1 + tests/components/humidity/test_condition.py | 79 +++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/homeassistant/components/humidity/condition.py b/homeassistant/components/humidity/condition.py index 6a990837b0c338..101815a4009fec 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -11,6 +11,10 @@ DOMAIN as HUMIDIFIER_DOMAIN, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.components.weather import ( + ATTR_WEATHER_HUMIDITY, + DOMAIN as WEATHER_DOMAIN, +) from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec @@ -24,6 +28,9 @@ value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ), SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.HUMIDITY), + WEATHER_DOMAIN: DomainSpec( + value_source=ATTR_WEATHER_HUMIDITY, + ), } CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/humidity/conditions.yaml b/homeassistant/components/humidity/conditions.yaml index 2f518db77d849f..06818a57974ca9 100644 --- a/homeassistant/components/humidity/conditions.yaml +++ b/homeassistant/components/humidity/conditions.yaml @@ -19,6 +19,7 @@ is_value: device_class: humidity - domain: climate - domain: humidifier + - domain: weather fields: behavior: required: true diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index e71bbf7ded1350..f878dfe14a005a 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -11,6 +11,7 @@ from homeassistant.components.humidifier import ( ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ) +from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON from homeassistant.core import HomeAssistant @@ -48,6 +49,12 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "humidifier") +@pytest.fixture +async def target_weathers(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple weather entities associated with different targets.""" + return await target_entities(hass, "weather") + + @pytest.mark.parametrize( "condition", [ @@ -275,3 +282,75 @@ async def test_humidity_humidifier_condition_behavior_all( condition_options=condition_options, states=states, ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("weather"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_numerical_attribute_condition_above_below_any( + "humidity.is_value", + "sunny", + ATTR_WEATHER_HUMIDITY, + ), +) +async def test_humidity_weather_condition_behavior_any( + hass: HomeAssistant, + target_weathers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the humidity weather condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_weathers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("weather"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_numerical_attribute_condition_above_below_all( + "humidity.is_value", + "sunny", + ATTR_WEATHER_HUMIDITY, + ), +) +async def test_humidity_weather_condition_behavior_all( + hass: HomeAssistant, + target_weathers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the humidity weather condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_weathers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From 6153705b61f683b181e0f2a9cad80e208aeac6cc Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 27 Mar 2026 08:50:26 +0100 Subject: [PATCH 0084/1707] Improve Obihai tests and avoid dns lookups (#166510) --- tests/components/obihai/test_config_flow.py | 24 +++++++++++++-------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py index 4ad06f33cd1876..9b73cd2db2753f 100644 --- a/tests/components/obihai/test_config_flow.py +++ b/tests/components/obihai/test_config_flow.py @@ -28,7 +28,10 @@ async def test_user_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["step_id"] == "user" assert result["errors"] == {} - with patch(VALIDATE_AUTH_PATCH, return_value=MockPyObihai()): + with ( + patch(VALIDATE_AUTH_PATCH, return_value=MockPyObihai()), + patch("homeassistant.components.obihai.config_flow.gethostbyname"), + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -48,7 +51,10 @@ async def test_auth_failure(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch(VALIDATE_AUTH_PATCH, return_value=False): + with ( + patch(VALIDATE_AUTH_PATCH, return_value=False), + patch("homeassistant.components.obihai.config_flow.gethostbyname"), + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -81,9 +87,9 @@ async def test_connect_failure(hass: HomeAssistant, mock_gaierror: Generator) -> async def test_dhcp_flow(hass: HomeAssistant) -> None: """Test that DHCP discovery works.""" - with patch( - VALIDATE_AUTH_PATCH, - return_value=MockPyObihai(), + with ( + patch(VALIDATE_AUTH_PATCH, return_value=MockPyObihai()), + patch("homeassistant.components.obihai.config_flow.gethostbyname"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -120,9 +126,9 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: async def test_dhcp_flow_auth_failure(hass: HomeAssistant) -> None: """Test that DHCP fails if creds aren't default.""" - with patch( - VALIDATE_AUTH_PATCH, - return_value=False, + with ( + patch(VALIDATE_AUTH_PATCH, return_value=False), + patch("homeassistant.components.obihai.config_flow.gethostbyname"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -138,7 +144,7 @@ async def test_dhcp_flow_auth_failure(hass: HomeAssistant) -> None: == DHCP_SERVICE_INFO.ip ) - # Verify we get dropped into the normal user flow with non-default credentials + # patch_gethostbyname fixture is active result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ From 5b76fab646936a360d89a08788e9eafd9423f6df Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 27 Mar 2026 08:51:39 +0100 Subject: [PATCH 0085/1707] Bump aioamazondevices to 13.3.1 (#166658) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 033032928224dc..0401bb3828ecc8 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.3.0"] + "requirements": ["aioamazondevices==13.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 867d4379f22cbb..938b3031f2d237 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.0 +aioamazondevices==13.3.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 287345fae75039..60cc45ff15afcb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.0 +aioamazondevices==13.3.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From c5437432459578f75744b3b20ebc87da3cf4298b Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:51:50 +0100 Subject: [PATCH 0086/1707] Wait for device registry in entity registry loading (#166636) --- homeassistant/bootstrap.py | 1 + homeassistant/helpers/device_registry.py | 27 +++++++++++++- homeassistant/helpers/entity_registry.py | 4 +++ homeassistant/scripts/auth.py | 1 + homeassistant/scripts/check_config.py | 1 + tests/common.py | 2 ++ tests/helpers/test_device_registry.py | 13 +++++++ tests/helpers/test_entity_registry.py | 45 ++++++++++++++++++++++++ 8 files changed, 93 insertions(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 803ae756018f9a..ce411280772471 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -468,6 +468,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> bool: translation.async_setup(hass) recovery = hass.config.recovery_mode + device_registry.async_setup(hass) try: await asyncio.gather( create_eager_task(get_internal_store_manager(hass).async_initialize()), diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index c8b384189df681..dc2f083c90e3da 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections import defaultdict from collections.abc import Iterable, Mapping from datetime import datetime @@ -771,6 +772,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): devices: ActiveDeviceRegistryItems deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] _device_data: dict[str, DeviceEntry] + _loaded_event: asyncio.Event | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize the device registry.""" @@ -784,6 +786,11 @@ def __init__(self, hass: HomeAssistant) -> None: serialize_in_event_loop=False, ) + @callback + def async_setup(self) -> None: + """Set up the registry.""" + self._loaded_event = asyncio.Event() + @callback def async_get(self, device_id: str) -> DeviceEntry | None: """Get device. @@ -1463,6 +1470,9 @@ def async_remove_device(self, device_id: str) -> None: async def _async_load(self) -> None: """Load the device registry.""" + assert self._loaded_event is not None + assert not self._loaded_event.is_set() + async_setup_cleanup(self.hass, self) data = await self._store.async_load() @@ -1560,6 +1570,16 @@ def get_optional_enum[_EnumT: StrEnum]( self.deleted_devices = deleted_devices self._device_data = devices.data + self._loaded_event.set() + + async def async_wait_loaded(self) -> None: + """Wait until the device registry is fully loaded. + + Will only wait if the registry had already been set up. + """ + if self._loaded_event is not None: + await self._loaded_event.wait() + @callback def _data_to_save(self) -> dict[str, Any]: """Return data of device registry to store in a file.""" @@ -1706,9 +1726,14 @@ def async_get(hass: HomeAssistant) -> DeviceRegistry: return DeviceRegistry(hass) +def async_setup(hass: HomeAssistant) -> None: + """Set up device registry.""" + assert DATA_REGISTRY not in hass.data + async_get(hass).async_setup() + + async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: """Load device registry.""" - assert DATA_REGISTRY not in hass.data await async_get(hass).async_load(load_empty=load_empty) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 0cbcf22b8971e8..33276acfafd53c 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1944,6 +1944,10 @@ def async_update_entity_options( async def _async_load(self) -> None: """Load the entity registry.""" + # Device registry must be loaded before entity registry because + # migration and entity processing reference device names. + await dr.async_get(self.hass).async_wait_loaded() + _async_setup_cleanup(self.hass, self) _async_setup_entity_restore(self.hass, self) diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index b734d898b64b53..8ca2ef7fef11aa 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -55,6 +55,7 @@ def run(args: Sequence[str] | None) -> None: async def run_command(args: argparse.Namespace) -> None: """Run the command.""" hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) + dr.async_setup(hass) await asyncio.gather(dr.async_load(hass), er.async_load(hass)) hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) provider = hass.auth.auth_providers[0] diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index c5a77532822de2..ba883775f42d30 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -302,6 +302,7 @@ async def async_check_config(config_dir): hass = core.HomeAssistant(config_dir) loader.async_setup(hass) hass.config_entries = ConfigEntries(hass, {}) + dr.async_setup(hass) await ar.async_load(hass) await dr.async_load(hass) await er.async_load(hass) diff --git a/tests/common.py b/tests/common.py index 2e1a9f3fe14ee2..30f998034e193f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -305,6 +305,8 @@ def async_create_task_internal(coroutine, name=None, eager_start=True): hass ) if load_registries: + dr.async_setup(hass) + with ( patch.object(StoreWithoutWriteLoad, "async_load", return_value=None), patch( diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index a3490da95146b4..b184525c47f426 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -363,6 +363,7 @@ async def test_loading_from_storage( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) assert len(registry.devices) == 1 @@ -500,6 +501,7 @@ async def test_migration_from_1_1( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -654,6 +656,7 @@ async def test_migration_from_1_2( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -790,6 +793,7 @@ async def test_migration_fom_1_3( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -928,6 +932,7 @@ async def test_migration_from_1_4( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1068,6 +1073,7 @@ async def test_migration_from_1_5( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1210,6 +1216,7 @@ async def test_migration_from_1_6( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1354,6 +1361,7 @@ async def test_migration_from_1_7( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1496,6 +1504,7 @@ async def test_migration_from_1_10( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -1632,6 +1641,7 @@ async def test_migration_from_1_11( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) @@ -2627,6 +2637,7 @@ async def test_loading_saving_data( # Now load written data in new registry registry2 = dr.DeviceRegistry(hass) await flush_store(device_registry._store) + registry2.async_setup() await registry2.async_load() # Ensure same order @@ -3782,6 +3793,7 @@ async def test_cleanup_entity_registry_change( Don't pre-load the registries as the debouncer will then not be waiting for EVENT_ENTITY_REGISTRY_UPDATED events. """ + dr.async_setup(hass) await dr.async_load(hass) await er.async_load(hass) dev_reg = dr.async_get(hass) @@ -4943,6 +4955,7 @@ async def test_loading_invalid_configuration_url_from_storage( }, } + dr.async_setup(hass) await dr.async_load(hass) registry = dr.async_get(hass) assert len(registry.devices) == 1 diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 4ae9637a878e6c..b26a7d23d4703f 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1,5 +1,6 @@ """Tests for the Entity Registry.""" +import asyncio from datetime import datetime, timedelta from functools import partial from typing import Any @@ -504,6 +505,49 @@ async def test_loading_saving_data( assert new_entry2.unit_of_measurement == "initial-unit_of_measurement" +@pytest.mark.parametrize("load_registries", [False]) +async def test_entity_registry_loading_waits_for_device_registry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test entity registry waits for device registry when loaded concurrently. + + Both registries are loaded in parallel during bootstrap via asyncio.gather. + The entity registry accesses device registry during loading. This test delays + the device registry store load so entity registry attempts to load first. + """ + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "test.my_entity", + "device_id": "some-device", + "platform": "test_platform", + "unique_id": "unique-1", + }, + ] + }, + } + + original_load = dr.DeviceRegistryStore.async_load + + async def delayed_load(self: dr.DeviceRegistryStore) -> Any: + await asyncio.sleep(0) + return await original_load(self) + + dr.async_setup(hass) + + with patch.object(dr.DeviceRegistryStore, "async_load", delayed_load): + await asyncio.gather( + er.async_load(hass), + dr.async_load(hass), + ) + + registry = er.async_get(hass) + assert registry.async_get("test.my_entity") is not None + + def test_get_available_entity_id_considers_registered_entities( entity_registry: er.EntityRegistry, ) -> None: @@ -1547,6 +1591,7 @@ async def test_migration_1_20( "deleted_devices": [], }, } + dr.async_setup(hass) await dr.async_load(hass) # Entity registry data at version 1.20 From a953b697ce713ead06f2d8d5879b68eddeccfe1e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 10:22:31 +0100 Subject: [PATCH 0087/1707] Add valve conditions (#166634) --- .../components/automation/__init__.py | 1 + homeassistant/components/valve/condition.py | 20 +++ .../components/valve/conditions.yaml | 17 ++ homeassistant/components/valve/icons.json | 8 + homeassistant/components/valve/strings.json | 30 ++++ homeassistant/helpers/condition.py | 8 +- tests/components/valve/test_condition.py | 154 ++++++++++++++++++ 7 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/valve/condition.py create mode 100644 homeassistant/components/valve/conditions.yaml create mode 100644 tests/components/valve/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8d4fc2ebc1291a..bf768dba6a324c 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -147,6 +147,7 @@ "temperature", "text", "vacuum", + "valve", "water_heater", "window", } diff --git a/homeassistant/components/valve/condition.py b/homeassistant/components/valve/condition.py new file mode 100644 index 00000000000000..5ff94ee08ec600 --- /dev/null +++ b/homeassistant/components/valve/condition.py @@ -0,0 +1,20 @@ +"""Provides conditions for valves.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from . import ATTR_IS_CLOSED +from .const import DOMAIN + +VALVE_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED)} + +CONDITIONS: dict[str, type[Condition]] = { + "is_open": make_entity_state_condition(VALVE_DOMAIN_SPECS, False), + "is_closed": make_entity_state_condition(VALVE_DOMAIN_SPECS, True), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the valve conditions.""" + return CONDITIONS diff --git a/homeassistant/components/valve/conditions.yaml b/homeassistant/components/valve/conditions.yaml new file mode 100644 index 00000000000000..b639ae832e7b1a --- /dev/null +++ b/homeassistant/components/valve/conditions.yaml @@ -0,0 +1,17 @@ +.condition_common: &condition_common + target: + entity: + - domain: valve + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_open: *condition_common +is_closed: *condition_common diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json index c9c6b632dcbc6b..bc01ba7717570b 100644 --- a/homeassistant/components/valve/icons.json +++ b/homeassistant/components/valve/icons.json @@ -1,4 +1,12 @@ { + "conditions": { + "is_closed": { + "condition": "mdi:valve-closed" + }, + "is_open": { + "condition": "mdi:valve-open" + } + }, "entity_component": { "_": { "default": "mdi:valve-open", diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index 10e5e302ebab53..09bd02ba207980 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,4 +1,26 @@ { + "conditions": { + "is_closed": { + "description": "Tests if one or more valves are closed.", + "fields": { + "behavior": { + "description": "Whether the condition should pass when any or all targeted entities match.", + "name": "Behavior" + } + }, + "name": "Valve is closed" + }, + "is_open": { + "description": "Tests if one or more valves are open.", + "fields": { + "behavior": { + "description": "Whether the condition should pass when any or all targeted entities match.", + "name": "Behavior" + } + }, + "name": "Valve is open" + } + }, "entity_component": { "_": { "name": "[%key:component::valve::title%]", @@ -22,6 +44,14 @@ "name": "Water" } }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + } + }, "services": { "close_valve": { "description": "Closes a valve.", diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index e71dc1b991b8be..5cf8df5d36c76d 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -421,7 +421,7 @@ def test_state(**kwargs: Unpack[ConditionCheckParams]) -> bool: class EntityStateConditionBase(EntityConditionBase): """State condition.""" - _states: set[str] + _states: set[str | bool] def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected state(s).""" @@ -439,7 +439,7 @@ def _normalize_domain_specs( def make_entity_state_condition( domain_specs: Mapping[str, DomainSpec] | str, - states: str | set[str], + states: str | bool | set[str | bool], ) -> type[EntityStateConditionBase]: """Create a condition for entity state changes to specific state(s). @@ -448,8 +448,8 @@ def make_entity_state_condition( """ specs = _normalize_domain_specs(domain_specs) - if isinstance(states, str): - states_set = {states} + if isinstance(states, (str, bool)): + states_set: set[str | bool] = {states} else: states_set = states diff --git a/tests/components/valve/test_condition.py b/tests/components/valve/test_condition.py new file mode 100644 index 00000000000000..5ec78a90229636 --- /dev/null +++ b/tests/components/valve/test_condition.py @@ -0,0 +1,154 @@ +"""Test valve conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.valve import ATTR_IS_CLOSED +from homeassistant.components.valve.const import ValveState +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_valves(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple valve entities associated with different targets.""" + return await target_entities(hass, "valve") + + +@pytest.mark.parametrize( + "condition", + [ + "valve.is_open", + "valve.is_closed", + ], +) +async def test_valve_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the valve conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("valve"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="valve.is_open", + target_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + ), + *parametrize_condition_states_any( + condition="valve.is_closed", + target_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + ), + ], +) +async def test_valve_condition_behavior_any( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test valve condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_valves, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("valve"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="valve.is_open", + target_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + ), + *parametrize_condition_states_all( + condition="valve.is_closed", + target_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + ), + ], +) +async def test_valve_condition_behavior_all( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test valve condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_valves, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From 4fa4ba5ad0fd3d4f6c5809caf127368e0b32efc3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 10:48:20 +0100 Subject: [PATCH 0088/1707] Add select conditions (#166612) --- .../components/automation/__init__.py | 1 + homeassistant/components/select/condition.py | 55 ++++ .../components/select/conditions.yaml | 26 ++ homeassistant/components/select/icons.json | 5 + homeassistant/components/select/strings.json | 24 ++ tests/components/select/test_condition.py | 284 ++++++++++++++++++ 6 files changed, 395 insertions(+) create mode 100644 homeassistant/components/select/condition.py create mode 100644 homeassistant/components/select/conditions.yaml create mode 100644 tests/components/select/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index bf768dba6a324c..fcd881581d4de2 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -142,6 +142,7 @@ "person", "power", "schedule", + "select", "siren", "switch", "temperature", diff --git a/homeassistant/components/select/condition.py b/homeassistant/components/select/condition.py new file mode 100644 index 00000000000000..c04f9fc448413f --- /dev/null +++ b/homeassistant/components/select/condition.py @@ -0,0 +1,55 @@ +"""Provides conditions for selects.""" + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.components.input_select import DOMAIN as INPUT_SELECT_DOMAIN +from homeassistant.const import CONF_OPTIONS +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import ( + ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL, + Condition, + ConditionConfig, + EntityStateConditionBase, +) + +from .const import CONF_OPTION, DOMAIN + +IS_OPTION_SELECTED_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( + { + vol.Required(CONF_OPTIONS): { + vol.Required(CONF_OPTION): vol.All( + cv.ensure_list, vol.Length(min=1), [str] + ), + }, + } +) + +SELECT_DOMAIN_SPECS = {DOMAIN: DomainSpec(), INPUT_SELECT_DOMAIN: DomainSpec()} + + +class IsOptionSelectedCondition(EntityStateConditionBase): + """Condition for select option.""" + + _domain_specs = SELECT_DOMAIN_SPECS + _schema = IS_OPTION_SELECTED_SCHEMA + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Initialize the option selected condition.""" + super().__init__(hass, config) + if TYPE_CHECKING: + assert config.options is not None + self._states = set(config.options[CONF_OPTION]) + + +CONDITIONS: dict[str, type[Condition]] = { + "is_option_selected": IsOptionSelectedCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the select conditions.""" + return CONDITIONS diff --git a/homeassistant/components/select/conditions.yaml b/homeassistant/components/select/conditions.yaml new file mode 100644 index 00000000000000..bc1feaccbf4ca3 --- /dev/null +++ b/homeassistant/components/select/conditions.yaml @@ -0,0 +1,26 @@ +is_option_selected: + target: + entity: + - domain: select + - domain: input_select + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + option: + context: + filter_target: target + required: true + selector: + state: + attribute: options + hide_states: + - unavailable + - unknown + multiple: true diff --git a/homeassistant/components/select/icons.json b/homeassistant/components/select/icons.json index 84f61242bd2655..9a0502982403d6 100644 --- a/homeassistant/components/select/icons.json +++ b/homeassistant/components/select/icons.json @@ -1,4 +1,9 @@ { + "conditions": { + "is_option_selected": { + "condition": "mdi:format-list-bulleted" + } + }, "entity_component": { "_": { "default": "mdi:format-list-bulleted" diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index bf394a6c30d0ba..cac07327f53e1f 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -1,4 +1,20 @@ { + "conditions": { + "is_option_selected": { + "description": "Tests if one or more dropdowns have a specific option selected.", + "fields": { + "behavior": { + "description": "Whether the condition should pass when any or all targeted entities match.", + "name": "Behavior" + }, + "option": { + "description": "The options to check for.", + "name": "Option" + } + }, + "name": "Option is selected" + } + }, "device_automation": { "action_type": { "select_first": "Change {entity_name} to first option", @@ -36,6 +52,14 @@ "message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}." } }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + } + }, "services": { "select_first": { "description": "Selects the first option of a select.", diff --git a/tests/components/select/test_condition.py b/tests/components/select/test_condition.py new file mode 100644 index 00000000000000..edd97c41ee2698 --- /dev/null +++ b/tests/components/select/test_condition.py @@ -0,0 +1,284 @@ +"""Test select conditions.""" + +from contextlib import AbstractContextManager, nullcontext as does_not_raise +from typing import Any + +import pytest +import voluptuous as vol + +from homeassistant.components.select.condition import CONF_OPTION +from homeassistant.const import CONF_ENTITY_ID, CONF_OPTIONS, CONF_TARGET +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import async_validate_condition_config + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + create_target_condition, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_selects(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple select entities associated with different targets.""" + return await target_entities(hass, "select") + + +@pytest.fixture +async def target_input_selects(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple input_select entities associated with different targets.""" + return await target_entities(hass, "input_select") + + +@pytest.mark.parametrize( + "condition", + ["select.is_option_selected"], +) +async def test_select_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the select conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("select"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_condition_states_any( + condition="select.is_option_selected", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + target_states=["option_a", "option_b"], + other_states=["option_c"], + ), +) +async def test_select_condition_behavior_any( + hass: HomeAssistant, + target_selects: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the select condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_selects, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("select"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_condition_states_all( + condition="select.is_option_selected", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + target_states=["option_a", "option_b"], + other_states=["option_c"], + ), +) +async def test_select_condition_behavior_all( + hass: HomeAssistant, + target_selects: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the select condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_selects, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("input_select"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_condition_states_any( + condition="select.is_option_selected", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + target_states=["option_a", "option_b"], + other_states=["option_c"], + ), +) +async def test_input_select_condition_behavior_any( + hass: HomeAssistant, + target_input_selects: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the select condition with input_select entities and 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_input_selects, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("input_select"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + parametrize_condition_states_all( + condition="select.is_option_selected", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + target_states=["option_a", "option_b"], + other_states=["option_c"], + ), +) +async def test_input_select_condition_behavior_all( + hass: HomeAssistant, + target_input_selects: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the select condition with input_select entities and 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_input_selects, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +# --- Cross-domain test --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_select_condition_evaluates_both_domains( + hass: HomeAssistant, +) -> None: + """Test that the select condition evaluates both select and input_select entities.""" + entity_id_select = "select.test_select" + entity_id_input_select = "input_select.test_input_select" + + hass.states.async_set(entity_id_select, "option_a") + hass.states.async_set(entity_id_input_select, "option_a") + await hass.async_block_till_done() + + cond = await create_target_condition( + hass, + condition="select.is_option_selected", + target={CONF_ENTITY_ID: [entity_id_select, entity_id_input_select]}, + behavior="any", + condition_options={CONF_OPTION: ["option_a", "option_b"]}, + ) + + assert cond(hass) is True + + # Set one to a non-matching option - "any" behavior should still pass + hass.states.async_set(entity_id_select, "option_c") + await hass.async_block_till_done() + + assert cond(hass) is True + + # Set both to non-matching options + hass.states.async_set(entity_id_input_select, "option_c") + await hass.async_block_till_done() + + assert cond(hass) is False + + +# --- Schema validation tests --- + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition", "condition_options", "expected_result"), + [ + # Valid configurations + ( + "select.is_option_selected", + {CONF_OPTION: ["option_a", "option_b"]}, + does_not_raise(), + ), + ( + "select.is_option_selected", + {CONF_OPTION: "option_a"}, + does_not_raise(), + ), + # Invalid configurations + ( + "select.is_option_selected", + # Empty option list + {CONF_OPTION: []}, + pytest.raises(vol.Invalid), + ), + ( + "select.is_option_selected", + # Missing CONF_OPTION + {}, + pytest.raises(vol.Invalid), + ), + ], +) +async def test_select_is_option_selected_condition_validation( + hass: HomeAssistant, + condition: str, + condition_options: dict[str, Any], + expected_result: AbstractContextManager, +) -> None: + """Test select is_option_selected condition config validation.""" + with expected_result: + await async_validate_condition_config( + hass, + { + "condition": condition, + CONF_TARGET: {CONF_ENTITY_ID: "select.test"}, + CONF_OPTIONS: condition_options, + }, + ) From 8498e2a7151518ff5d1d15dce91a07293d97ec5e Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Fri, 27 Mar 2026 19:24:04 +0900 Subject: [PATCH 0089/1707] Bump thinqconnect to 1.0.11 (#166668) Co-authored-by: YunseonPark-LGE --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index ffe9c07e5415f4..fcc44ac10f6b01 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.9"] + "requirements": ["thinqconnect==1.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 938b3031f2d237..8925166b0c00ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3109,7 +3109,7 @@ thermopro-ble==1.1.3 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.9 +thinqconnect==1.0.11 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60cc45ff15afcb..9ffa7401f3ef96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2627,7 +2627,7 @@ thermobeacon-ble==0.10.0 thermopro-ble==1.1.3 # homeassistant.components.lg_thinq -thinqconnect==1.0.9 +thinqconnect==1.0.11 # homeassistant.components.tilt_ble tilt-ble==1.0.1 From ecd16d759af5476a103187429af82870e3d216d4 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Fri, 27 Mar 2026 03:27:58 -0700 Subject: [PATCH 0090/1707] Handle Oauth2 ImplementationUnavailableError in smappee (#166660) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/smappee/__init__.py | 15 ++++++--- homeassistant/components/smappee/strings.json | 5 +++ tests/components/smappee/test_init.py | 33 ++++++++++++++++++- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smappee/__init__.py b/homeassistant/components/smappee/__init__.py index 7fa30965aa8f24..372441ec586a83 100644 --- a/homeassistant/components/smappee/__init__.py +++ b/homeassistant/components/smappee/__init__.py @@ -11,6 +11,7 @@ CONF_PLATFORM, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -94,11 +95,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmappeeConfigEntry) -> b ) await hass.async_add_executor_job(smappee.load_local_service_location) else: - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err smappee_api = api.ConfigEntrySmappeeApi(hass, entry, implementation) diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index d1f333ffcdc0e7..0df1c68ecba0e4 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -43,5 +43,10 @@ "title": "Discovered Smappee device" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/smappee/test_init.py b/tests/components/smappee/test_init.py index cc752964a27f6d..b419662d0fc7e6 100644 --- a/tests/components/smappee/test_init.py +++ b/tests/components/smappee/test_init.py @@ -3,8 +3,11 @@ from unittest.mock import patch from homeassistant.components.smappee.const import DOMAIN -from homeassistant.config_entries import SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from tests.common import MockConfigEntry @@ -38,3 +41,31 @@ async def test_unload_config_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert not hass.data.get(DOMAIN) + + +async def test_oauth_implementation_not_available(hass: HomeAssistant) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="smappeeCloud", + source=SOURCE_USER, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": 9999999999, + "token_type": "Bearer", + }, + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 65cb9b8528708c55dc6a784bd24ad4cd9c8dc0e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 27 Mar 2026 11:05:58 +0000 Subject: [PATCH 0091/1707] Update idasen-ha to 2.6.5 (#166645) --- homeassistant/components/idasen_desk/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 9ed011498442ae..1acaf083485070 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -13,5 +13,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["idasen-ha==2.6.4"] + "requirements": ["idasen-ha==2.6.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8925166b0c00ce..f8e1d856aaf33d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1286,7 +1286,7 @@ icalendar==6.3.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.6.4 +idasen-ha==2.6.5 # homeassistant.components.idrive_e2 idrive-e2-client==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ffa7401f3ef96..c8e7eda5b50683 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1144,7 +1144,7 @@ icalendar==6.3.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.6.4 +idasen-ha==2.6.5 # homeassistant.components.idrive_e2 idrive-e2-client==0.1.1 From 53f64bff49a42aca2766b8509937c2d3e203aefc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 27 Mar 2026 05:51:24 -0700 Subject: [PATCH 0092/1707] Add client_id_metadata_document_supported to the OAuth Authorization Server Metadata (#166220) --- homeassistant/components/auth/login_flow.py | 7 +++++++ tests/components/auth/test_login_flow.py | 1 + 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 235d5b4c338f18..12d108f7942c25 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -142,6 +142,13 @@ async def get(self, request: web.Request) -> web.Response: "authorization_endpoint": f"{url_prefix}/auth/authorize", "token_endpoint": f"{url_prefix}/auth/token", "revocation_endpoint": f"{url_prefix}/auth/revoke", + # Home Assistant already accepts URL-based client_ids via + # IndieAuth without prior registration, which is compatible with + # draft-ietf-oauth-client-id-metadata-document. This flag + # advertises that support to encourage clients to use it. The + # metadata document is not actually fetched as IndieAuth doesn't + # require it. + "client_id_metadata_document_supported": True, "response_types_supported": ["code"], "service_documentation": ( "https://developers.home-assistant.io/docs/auth_api" diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index 4f36b70a13b143..5e1db02b5692c0 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -425,6 +425,7 @@ async def test_well_known_auth_info( "authorization_endpoint": f"{expected_url_prefix}/auth/authorize", "token_endpoint": f"{expected_url_prefix}/auth/token", "revocation_endpoint": f"{expected_url_prefix}/auth/revoke", + "client_id_metadata_document_supported": True, "response_types_supported": ["code"], "service_documentation": "https://developers.home-assistant.io/docs/auth_api", } From f5054d41e1401aa765ac1d41835acd0c5c8fe100 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 14:15:41 +0100 Subject: [PATCH 0093/1707] Add calendar conditions (#166643) --- .../components/automation/__init__.py | 1 + .../components/calendar/condition.py | 16 +++ .../components/calendar/conditions.yaml | 14 +++ homeassistant/components/calendar/icons.json | 5 + .../components/calendar/strings.json | 22 ++++ tests/components/calendar/test_condition.py | 114 ++++++++++++++++++ 6 files changed, 172 insertions(+) create mode 100644 homeassistant/components/calendar/condition.py create mode 100644 homeassistant/components/calendar/conditions.yaml create mode 100644 tests/components/calendar/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index fcd881581d4de2..01dcd6f2b3de15 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -122,6 +122,7 @@ "alarm_control_panel", "assist_satellite", "battery", + "calendar", "climate", "cover", "device_tracker", diff --git a/homeassistant/components/calendar/condition.py b/homeassistant/components/calendar/condition.py new file mode 100644 index 00000000000000..3055cb1d754964 --- /dev/null +++ b/homeassistant/components/calendar/condition.py @@ -0,0 +1,16 @@ +"""Provides conditions for calendars.""" + +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from .const import DOMAIN + +CONDITIONS: dict[str, type[Condition]] = { + "is_event_active": make_entity_state_condition(DOMAIN, STATE_ON), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the calendar conditions.""" + return CONDITIONS diff --git a/homeassistant/components/calendar/conditions.yaml b/homeassistant/components/calendar/conditions.yaml new file mode 100644 index 00000000000000..7452e7ec7fe230 --- /dev/null +++ b/homeassistant/components/calendar/conditions.yaml @@ -0,0 +1,14 @@ +is_event_active: + target: + entity: + - domain: calendar + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any diff --git a/homeassistant/components/calendar/icons.json b/homeassistant/components/calendar/icons.json index e2faf13658c70c..f9ae830d300297 100644 --- a/homeassistant/components/calendar/icons.json +++ b/homeassistant/components/calendar/icons.json @@ -1,4 +1,9 @@ { + "conditions": { + "is_event_active": { + "condition": "mdi:calendar-check" + } + }, "entity_component": { "_": { "default": "mdi:calendar", diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 0da24e4b97ea85..8cac1016e80687 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -1,4 +1,20 @@ { + "common": { + "condition_behavior_description": "How the state should match on the targeted calendars.", + "condition_behavior_name": "Behavior" + }, + "conditions": { + "is_event_active": { + "description": "Tests if one or more calendars have an active event.", + "fields": { + "behavior": { + "description": "[%key:component::calendar::common::condition_behavior_description%]", + "name": "[%key:component::calendar::common::condition_behavior_name%]" + } + }, + "name": "Calendar event is active" + } + }, "entity_component": { "_": { "name": "[%key:component::calendar::title%]", @@ -46,6 +62,12 @@ } }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "trigger_offset_type": { "options": { "after": "After", diff --git a/tests/components/calendar/test_condition.py b/tests/components/calendar/test_condition.py new file mode 100644 index 00000000000000..05b7c71131493b --- /dev/null +++ b/tests/components/calendar/test_condition.py @@ -0,0 +1,114 @@ +"""Test calendar conditions.""" + +from typing import Any + +import pytest + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_calendars(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple calendar entities associated with different targets.""" + return await target_entities(hass, "calendar") + + +@pytest.mark.parametrize( + "condition", + [ + "calendar.is_event_active", + ], +) +async def test_calendar_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the calendar conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("calendar"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="calendar.is_event_active", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_calendar_condition_behavior_any( + hass: HomeAssistant, + target_calendars: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test calendar condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_calendars, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("calendar"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="calendar.is_event_active", + target_states=[STATE_ON], + other_states=[STATE_OFF], + ), + ], +) +async def test_calendar_condition_behavior_all( + hass: HomeAssistant, + target_calendars: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test calendar condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_calendars, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From f82d21886a4df01ee9021e93d712a594980a6714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Fri, 27 Mar 2026 15:02:04 +0100 Subject: [PATCH 0094/1707] Add missing miele oven codes (#166690) --- homeassistant/components/miele/const.py | 3 +++ homeassistant/components/miele/strings.json | 2 ++ tests/components/miele/snapshots/test_sensor.ambr | 12 ++++++++++++ 3 files changed, 17 insertions(+) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 24e4b928059012..582f93b556657b 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -270,6 +270,7 @@ class ProgramPhaseOven(MieleEnum, missing_to_none=True): process_finished = 3078 searing = 3080 roasting = 3081 + cooling_down = 3083 energy_save = 3084 pre_heating = 3099 @@ -586,6 +587,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True): microwave_fan_grill = 23 conventional_heat = 24 top_heat = 25 + booster = 27 fan_grill = 29 bottom_heat = 31 moisture_plus_auto_roast = 35, 48 @@ -594,6 +596,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True): moisture_plus_conventional_heat = 51, 76 popcorn = 53 quick_microwave = 54 + airfry = 95 custom_program_1 = 97 custom_program_2 = 98 custom_program_3 = 99 diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index b0b0dd1239c5e4..1432e83d104daa 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -273,6 +273,7 @@ "program_id": { "name": "Program", "state": { + "airfry": "AirFry", "almond_macaroons_1_tray": "Almond macaroons (1 tray)", "almond_macaroons_2_trays": "Almond macaroons (2 trays)", "amaranth": "Amaranth", @@ -334,6 +335,7 @@ "blanching": "Blanching", "blueberry_muffins": "Blueberry muffins", "bologna_sausage": "Bologna sausage", + "booster": "Booster", "bottling": "Bottling", "bottling_hard": "Bottling (hard)", "bottling_medium": "Bottling (medium)", diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index afdbc97ccbdde2..4c139397dbe045 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -5243,6 +5243,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'airfry', 'almond_macaroons_1_tray', 'almond_macaroons_2_trays', 'amaranth', @@ -5294,6 +5295,7 @@ 'blanching', 'blueberry_muffins', 'bologna_sausage', + 'booster', 'bottling', 'bottling_hard', 'bottling_medium', @@ -5856,6 +5858,7 @@ 'device_class': 'enum', 'friendly_name': 'Oven Program', 'options': list([ + 'airfry', 'almond_macaroons_1_tray', 'almond_macaroons_2_trays', 'amaranth', @@ -5907,6 +5910,7 @@ 'blanching', 'blueberry_muffins', 'bologna_sausage', + 'booster', 'bottling', 'bottling_hard', 'bottling_medium', @@ -6449,6 +6453,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'cooling_down', 'energy_save', 'heating_up', 'not_running', @@ -6495,6 +6500,7 @@ 'device_class': 'enum', 'friendly_name': 'Oven Program phase', 'options': list([ + 'cooling_down', 'energy_save', 'heating_up', 'not_running', @@ -9041,6 +9047,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'airfry', 'almond_macaroons_1_tray', 'almond_macaroons_2_trays', 'amaranth', @@ -9092,6 +9099,7 @@ 'blanching', 'blueberry_muffins', 'bologna_sausage', + 'booster', 'bottling', 'bottling_hard', 'bottling_medium', @@ -9654,6 +9662,7 @@ 'device_class': 'enum', 'friendly_name': 'Oven Program', 'options': list([ + 'airfry', 'almond_macaroons_1_tray', 'almond_macaroons_2_trays', 'amaranth', @@ -9705,6 +9714,7 @@ 'blanching', 'blueberry_muffins', 'bologna_sausage', + 'booster', 'bottling', 'bottling_hard', 'bottling_medium', @@ -10247,6 +10257,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'cooling_down', 'energy_save', 'heating_up', 'not_running', @@ -10293,6 +10304,7 @@ 'device_class': 'enum', 'friendly_name': 'Oven Program phase', 'options': list([ + 'cooling_down', 'energy_save', 'heating_up', 'not_running', From 646f56d015b4292d83c2ad444d0ff19c7e60eb43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 27 Mar 2026 14:35:29 +0000 Subject: [PATCH 0095/1707] Reduce code duplication in todo triggers (#166640) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- homeassistant/components/todo/trigger.py | 158 +++++++++++------------ 1 file changed, 72 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/todo/trigger.py b/homeassistant/components/todo/trigger.py index c576aebde9c4d9..8387850f6e588a 100644 --- a/homeassistant/components/todo/trigger.py +++ b/homeassistant/components/todo/trigger.py @@ -167,13 +167,29 @@ def _handle_entities_updated(self, tracked_entities: set[str]) -> None: """Handle entities being added/removed from the target.""" -class ItemAddedTrigger(ItemTriggerBase): - """todo item added trigger.""" +class ItemChangeTriggerBase(ItemTriggerBase): + """todo item change trigger base class.""" - def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + def __init__( + self, hass: HomeAssistant, config: TriggerConfig, description: str + ) -> None: """Initialize trigger.""" super().__init__(hass, config) self._entity_item_ids: dict[str, set[str] | None] = {} + self._description = description + + @abc.abstractmethod + def _is_matching_item(self, item: TodoItem) -> bool: + """Return true if the item matches the trigger condition.""" + + @abc.abstractmethod + def _get_items_diff( + self, old_item_ids: set[str], current_item_ids: set[str] + ) -> set[str]: + """Return the set of item ids that should be reported for this trigger. + + The calculation is based on the previous and current matching item ids. + """ @override @callback @@ -187,23 +203,29 @@ def _handle_item_change( return old_item_ids = self._entity_item_ids.get(entity_id) - current_item_ids = {item.uid for item in event.items if item.uid is not None} + current_item_ids = { + item.uid + for item in event.items + if item.uid is not None and self._is_matching_item(item) + } self._entity_item_ids[entity_id] = current_item_ids if old_item_ids is None: # Entity just became available, so no old items to compare against return - added_item_ids = current_item_ids - old_item_ids - if added_item_ids: + + different_item_ids = self._get_items_diff(old_item_ids, current_item_ids) + if different_item_ids: _LOGGER.debug( - "Detected added items with ids %s for entity %s", - added_item_ids, + "Detected %s items with ids %s for entity %s", + self._description, + different_item_ids, entity_id, ) payload = { ATTR_ENTITY_ID: entity_id, - "item_ids": sorted(added_item_ids), + "item_ids": sorted(different_item_ids), } - run_action(payload, description="todo item added trigger") + run_action(payload, description=f"todo item {self._description} trigger") @override @callback @@ -213,100 +235,64 @@ def _handle_entities_updated(self, tracked_entities: set[str]) -> None: del self._entity_item_ids[entity_id] -class ItemRemovedTrigger(ItemTriggerBase): - """todo item removed trigger.""" +class ItemAddedTrigger(ItemChangeTriggerBase): + """todo item added trigger.""" def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" - super().__init__(hass, config) - self._entity_item_ids: dict[str, set[str] | None] = {} + super().__init__(hass, config, description="added") @override - @callback - def _handle_item_change( - self, event: TodoItemChangeEvent, run_action: TriggerActionRunner - ) -> None: - """Listen for todo item changes.""" - entity_id = event.entity_id - if event.items is None: - self._entity_item_ids[entity_id] = None - return + def _is_matching_item(self, item: TodoItem) -> bool: + """Return true if the item matches the trigger condition.""" + return True - old_item_ids = self._entity_item_ids.get(entity_id) - current_item_ids = {item.uid for item in event.items if item.uid is not None} - self._entity_item_ids[entity_id] = current_item_ids - if old_item_ids is None: - # Entity just became available, so no old items to compare against - return - removed_item_ids = old_item_ids - current_item_ids - if removed_item_ids: - _LOGGER.debug( - "Detected removed items with ids %s for entity %s", - removed_item_ids, - entity_id, - ) - payload = { - ATTR_ENTITY_ID: entity_id, - "item_ids": sorted(removed_item_ids), - } - run_action(payload, description="todo item removed trigger") + @override + def _get_items_diff( + self, old_item_ids: set[str], current_item_ids: set[str] + ) -> set[str]: + """Return the set of item ids that match added items.""" + return current_item_ids - old_item_ids + + +class ItemRemovedTrigger(ItemChangeTriggerBase): + """todo item removed trigger.""" + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize trigger.""" + super().__init__(hass, config, description="removed") @override - @callback - def _handle_entities_updated(self, tracked_entities: set[str]) -> None: - """Clear stale state for entities that left the tracked set.""" - for entity_id in set(self._entity_item_ids) - tracked_entities: - del self._entity_item_ids[entity_id] + def _is_matching_item(self, item: TodoItem) -> bool: + """Return true if the item matches the trigger condition.""" + return True + + @override + def _get_items_diff( + self, old_item_ids: set[str], current_item_ids: set[str] + ) -> set[str]: + """Return the set of item ids that match removed items.""" + return old_item_ids - current_item_ids -class ItemCompletedTrigger(ItemTriggerBase): +class ItemCompletedTrigger(ItemChangeTriggerBase): """todo item completed trigger.""" def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" - super().__init__(hass, config) - self._entity_completed_item_ids: dict[str, set[str] | None] = {} + super().__init__(hass, config, description="completed") @override - @callback - def _handle_item_change( - self, event: TodoItemChangeEvent, run_action: TriggerActionRunner - ) -> None: - """Listen for todo item changes.""" - entity_id = event.entity_id - if event.items is None: - self._entity_completed_item_ids[entity_id] = None - return - - old_item_ids = self._entity_completed_item_ids.get(entity_id) - current_item_ids = { - item.uid - for item in event.items - if item.uid is not None and item.status == TodoItemStatus.COMPLETED - } - self._entity_completed_item_ids[entity_id] = current_item_ids - if old_item_ids is None: - # Entity just became available, so no old items to compare against - return - new_completed_item_ids = current_item_ids - old_item_ids - if new_completed_item_ids: - _LOGGER.debug( - "Detected new completed items with ids %s for entity %s", - new_completed_item_ids, - entity_id, - ) - payload = { - ATTR_ENTITY_ID: entity_id, - "item_ids": sorted(new_completed_item_ids), - } - run_action(payload, description="todo item completed trigger") + def _is_matching_item(self, item: TodoItem) -> bool: + """Return true if the item matches the trigger condition.""" + return item.status == TodoItemStatus.COMPLETED @override - @callback - def _handle_entities_updated(self, tracked_entities: set[str]) -> None: - """Clear stale state for entities that left the tracked set.""" - for entity_id in set(self._entity_completed_item_ids) - tracked_entities: - del self._entity_completed_item_ids[entity_id] + def _get_items_diff( + self, old_item_ids: set[str], current_item_ids: set[str] + ) -> set[str]: + """Return the set of item ids that match completed items.""" + return current_item_ids - old_item_ids TRIGGERS: dict[str, type[Trigger]] = { From fbef3b27bd8c791986a14de3ea35cebb5698f93c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Mar 2026 15:39:10 +0100 Subject: [PATCH 0096/1707] Add timer conditions (#166641) Co-authored-by: Martin Hjelmare --- .../components/automation/__init__.py | 1 + homeassistant/components/timer/condition.py | 17 +++ .../components/timer/conditions.yaml | 18 +++ homeassistant/components/timer/icons.json | 11 ++ homeassistant/components/timer/strings.json | 44 ++++++ tests/components/timer/test_condition.py | 136 ++++++++++++++++++ 6 files changed, 227 insertions(+) create mode 100644 homeassistant/components/timer/condition.py create mode 100644 homeassistant/components/timer/conditions.yaml create mode 100644 tests/components/timer/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 01dcd6f2b3de15..3bdeae6ce109a2 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -148,6 +148,7 @@ "switch", "temperature", "text", + "timer", "vacuum", "valve", "water_heater", diff --git a/homeassistant/components/timer/condition.py b/homeassistant/components/timer/condition.py new file mode 100644 index 00000000000000..130114ca5d0ff8 --- /dev/null +++ b/homeassistant/components/timer/condition.py @@ -0,0 +1,17 @@ +"""Provides conditions for timers.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +from . import DOMAIN, STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED + +CONDITIONS: dict[str, type[Condition]] = { + "is_active": make_entity_state_condition(DOMAIN, STATUS_ACTIVE), + "is_paused": make_entity_state_condition(DOMAIN, STATUS_PAUSED), + "is_idle": make_entity_state_condition(DOMAIN, STATUS_IDLE), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the timer conditions.""" + return CONDITIONS diff --git a/homeassistant/components/timer/conditions.yaml b/homeassistant/components/timer/conditions.yaml new file mode 100644 index 00000000000000..a94cf6009336f8 --- /dev/null +++ b/homeassistant/components/timer/conditions.yaml @@ -0,0 +1,18 @@ +.condition_common: &condition_common + target: + entity: + - domain: timer + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + +is_active: *condition_common +is_paused: *condition_common +is_idle: *condition_common diff --git a/homeassistant/components/timer/icons.json b/homeassistant/components/timer/icons.json index d2a7160750baec..fcc398870aa714 100644 --- a/homeassistant/components/timer/icons.json +++ b/homeassistant/components/timer/icons.json @@ -1,4 +1,15 @@ { + "conditions": { + "is_active": { + "condition": "mdi:timer" + }, + "is_idle": { + "condition": "mdi:timer-off" + }, + "is_paused": { + "condition": "mdi:timer-pause" + } + }, "services": { "cancel": { "service": "mdi:cancel" diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index de8518212d5e57..b1373b4764eae6 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,4 +1,40 @@ { + "common": { + "condition_behavior_description": "How the state should match on the targeted timers.", + "condition_behavior_name": "Behavior" + }, + "conditions": { + "is_active": { + "description": "Tests if one or more timers are active.", + "fields": { + "behavior": { + "description": "[%key:component::timer::common::condition_behavior_description%]", + "name": "[%key:component::timer::common::condition_behavior_name%]" + } + }, + "name": "Timer is active" + }, + "is_idle": { + "description": "Tests if one or more timers are idle.", + "fields": { + "behavior": { + "description": "[%key:component::timer::common::condition_behavior_description%]", + "name": "[%key:component::timer::common::condition_behavior_name%]" + } + }, + "name": "Timer is idle" + }, + "is_paused": { + "description": "Tests if one or more timers are paused.", + "fields": { + "behavior": { + "description": "[%key:component::timer::common::condition_behavior_description%]", + "name": "[%key:component::timer::common::condition_behavior_name%]" + } + }, + "name": "Timer is paused" + } + }, "entity_component": { "_": { "name": "Timer", @@ -30,6 +66,14 @@ } } }, + "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + } + }, "services": { "cancel": { "description": "Resets a timer's duration to the last known initial value without firing the timer finished event.", diff --git a/tests/components/timer/test_condition.py b/tests/components/timer/test_condition.py new file mode 100644 index 00000000000000..3a60edca4c0c6e --- /dev/null +++ b/tests/components/timer/test_condition.py @@ -0,0 +1,136 @@ +"""Test timer conditions.""" + +from typing import Any + +import pytest + +from homeassistant.components.timer import STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_timers(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple timer entities associated with different targets.""" + return await target_entities(hass, "timer") + + +@pytest.mark.parametrize( + "condition", + [ + "timer.is_active", + "timer.is_paused", + "timer.is_idle", + ], +) +async def test_timer_conditions_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str +) -> None: + """Test the timer conditions are gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, condition) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("timer"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="timer.is_active", + target_states=[STATUS_ACTIVE], + other_states=[STATUS_IDLE, STATUS_PAUSED], + ), + *parametrize_condition_states_any( + condition="timer.is_paused", + target_states=[STATUS_PAUSED], + other_states=[STATUS_IDLE, STATUS_ACTIVE], + ), + *parametrize_condition_states_any( + condition="timer.is_idle", + target_states=[STATUS_IDLE], + other_states=[STATUS_ACTIVE, STATUS_PAUSED], + ), + ], +) +async def test_timer_condition_behavior_any( + hass: HomeAssistant, + target_timers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test timer condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_timers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("timer"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="timer.is_active", + target_states=[STATUS_ACTIVE], + other_states=[STATUS_IDLE, STATUS_PAUSED], + ), + *parametrize_condition_states_all( + condition="timer.is_paused", + target_states=[STATUS_PAUSED], + other_states=[STATUS_IDLE, STATUS_ACTIVE], + ), + *parametrize_condition_states_all( + condition="timer.is_idle", + target_states=[STATUS_IDLE], + other_states=[STATUS_ACTIVE, STATUS_PAUSED], + ), + ], +) +async def test_timer_condition_behavior_all( + hass: HomeAssistant, + target_timers: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test timer condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_timers, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From 78f6b934bb6bc6473df0f135d1adaa754cf06855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Fri, 27 Mar 2026 16:14:35 +0100 Subject: [PATCH 0097/1707] Add missing miele program_id code (#166685) --- homeassistant/components/miele/const.py | 1 + homeassistant/components/miele/strings.json | 1 + tests/components/miele/snapshots/test_sensor.ambr | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 582f93b556657b..2f8215767fcb48 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -453,6 +453,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True): proofing = 27, 10057 sportswear = 29, 10052 automatic_plus = 31 + table_linen = 33 outerwear = 37 pillows = 39 cool_air = 45 # washer-dryer diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 1432e83d104daa..8ce6cc1b81df64 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -883,6 +883,7 @@ "swiss_roll": "Swiss roll", "swiss_toffee_cream_100_ml": "Swiss toffee cream (100 ml)", "swiss_toffee_cream_150_ml": "Swiss toffee cream (150 ml)", + "table_linen": "Table linen", "tagliatelli_fresh": "Tagliatelli (fresh)", "tall_items": "Tall items", "tart_flambe": "Tart flambè", diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 4c139397dbe045..b1bc36daedd5c0 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -7630,6 +7630,7 @@ 'starch', 'steam_care', 'stuffed_toys', + 'table_linen', 'trainers', 'trainers_refresh', 'warm_air', @@ -7713,6 +7714,7 @@ 'starch', 'steam_care', 'stuffed_toys', + 'table_linen', 'trainers', 'trainers_refresh', 'warm_air', @@ -11434,6 +11436,7 @@ 'starch', 'steam_care', 'stuffed_toys', + 'table_linen', 'trainers', 'trainers_refresh', 'warm_air', @@ -11517,6 +11520,7 @@ 'starch', 'steam_care', 'stuffed_toys', + 'table_linen', 'trainers', 'trainers_refresh', 'warm_air', From 30ee28a0d334c2f79360b9d112ae6aaedbb5834a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 27 Mar 2026 16:43:51 +0100 Subject: [PATCH 0098/1707] Improve timer action naming consistency (#166682) --- homeassistant/components/timer/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index b1373b4764eae6..0b54a62f68b03f 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -77,7 +77,7 @@ "services": { "cancel": { "description": "Resets a timer's duration to the last known initial value without firing the timer finished event.", - "name": "Cancel" + "name": "Cancel timer" }, "change": { "description": "Changes a timer by adding or subtracting a given duration.", @@ -87,19 +87,19 @@ "name": "Duration" } }, - "name": "Change" + "name": "Change timer" }, "finish": { "description": "Finishes a running timer earlier than scheduled.", - "name": "Finish" + "name": "Finish timer" }, "pause": { "description": "Pauses a running timer, retaining the remaining duration for later continuation.", - "name": "[%key:common::action::pause%]" + "name": "Pause timer" }, "reload": { "description": "Reloads timers from the YAML-configuration.", - "name": "[%key:common::action::reload%]" + "name": "Reload timers" }, "start": { "description": "Starts a timer or restarts it with a provided duration.", @@ -109,7 +109,7 @@ "name": "Duration" } }, - "name": "[%key:common::action::start%]" + "name": "Start timer" } }, "title": "Timer" From e855b92b82e4a1984c017f90fb1ca38d3fea44f6 Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:19:30 +0000 Subject: [PATCH 0099/1707] Introduce a base entity for NINA (#166637) --- .../components/nina/binary_sensor.py | 32 +++++------------ homeassistant/components/nina/coordinator.py | 7 ++++ homeassistant/components/nina/entity.py | 36 +++++++++++++++++++ homeassistant/components/nina/strings.json | 7 ++++ .../nina/snapshots/test_binary_sensor.ambr | 10 +++--- 5 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/nina/entity.py diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index cfbdd87a0e2c98..621627833407df 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -8,11 +8,8 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_AFFECTED_AREAS, @@ -28,13 +25,13 @@ ATTR_WEB, CONF_MESSAGE_SLOTS, CONF_REGIONS, - DOMAIN, ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator +from .entity import NinaEntity async def async_setup_entry( - hass: HomeAssistant, + _: HomeAssistant, config_entry: NinaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: @@ -46,7 +43,7 @@ async def async_setup_entry( message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS] async_add_entities( - NINAMessage(coordinator, ent, regions[ent], i + 1, config_entry) + NINAMessage(coordinator, ent, regions[ent], i + 1) for ent in coordinator.data for i in range(message_slots) ) @@ -55,7 +52,7 @@ async def async_setup_entry( PARALLEL_UPDATES = 0 -class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity): +class NINAMessage(NinaEntity, BinarySensorEntity): """Representation of an NINA warning.""" _attr_device_class = BinarySensorDeviceClass.SAFETY @@ -67,31 +64,20 @@ def __init__( region: str, region_name: str, slot_id: int, - config_entry: ConfigEntry, ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(coordinator, region, region_name, slot_id) - self._region = region - self._warning_index = slot_id - 1 - - self._attr_name = f"Warning: {region_name} {slot_id}" + self._attr_translation_key = "warning" self._attr_unique_id = f"{region}-{slot_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, config_entry.entry_id)}, - manufacturer="NINA", - entry_type=DeviceEntryType.SERVICE, - ) @property def is_on(self) -> bool: """Return the state of the sensor.""" - if len(self.coordinator.data[self._region]) <= self._warning_index: + if self._get_active_warnings_count() <= self._warning_index: return False - data = self.coordinator.data[self._region][self._warning_index] - - return data.is_valid + return self._get_warning_data().is_valid @property def extra_state_attributes(self) -> dict[str, Any]: @@ -99,7 +85,7 @@ def extra_state_attributes(self) -> dict[str, Any]: if not self.is_on: return {} - data = self.coordinator.data[self._region][self._warning_index] + data = self._get_warning_data() return { ATTR_HEADLINE: data.headline, diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index 175b128fdba972..b41bcea55ae83e 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -64,6 +65,12 @@ def __init__( ] self.area_filter: str = config_entry.data[CONF_FILTERS][CONF_AREA_FILTER] + self.device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="NINA", + entry_type=DeviceEntryType.SERVICE, + ) + regions: dict[str, str] = config_entry.data[CONF_REGIONS] for region in regions: self._nina.add_region(region) diff --git a/homeassistant/components/nina/entity.py b/homeassistant/components/nina/entity.py new file mode 100644 index 00000000000000..97db7c90064ef0 --- /dev/null +++ b/homeassistant/components/nina/entity.py @@ -0,0 +1,36 @@ +"""NINA common entity.""" + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import NINADataUpdateCoordinator, NinaWarningData + + +class NinaEntity(CoordinatorEntity[NINADataUpdateCoordinator]): + """Base class for NINA entities.""" + + def __init__( + self, + coordinator: NINADataUpdateCoordinator, + region: str, + region_name: str, + slot_id: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._region = region + self._warning_index = slot_id - 1 + + self._attr_translation_placeholders = { + "region_name": region_name, + "slot_id": str(slot_id), + } + self._attr_device_info = coordinator.device_info + + def _get_active_warnings_count(self) -> int: + """Return the number of active warnings for the region.""" + return len(self.coordinator.data[self._region]) + + def _get_warning_data(self) -> NinaWarningData: + """Return warning data.""" + return self.coordinator.data[self._region][self._warning_index] diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index b022563f9df17b..711ca9d3715f7a 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -45,6 +45,13 @@ } } }, + "entity": { + "binary_sensor": { + "warning": { + "name": "Warning: {region_name} {slot_id}" + } + } + }, "options": { "abort": { "no_fetch": "[%key:component::nina::config::abort::no_fetch%]", diff --git a/tests/components/nina/snapshots/test_binary_sensor.ambr b/tests/components/nina/snapshots/test_binary_sensor.ambr index e2039756489a16..63adc9ac5f0773 100644 --- a/tests/components/nina/snapshots/test_binary_sensor.ambr +++ b/tests/components/nina/snapshots/test_binary_sensor.ambr @@ -31,7 +31,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'warning', 'unique_id': '095760000000-1', 'unit_of_measurement': None, }) @@ -93,7 +93,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'warning', 'unique_id': '095760000000-2', 'unit_of_measurement': None, }) @@ -144,7 +144,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'warning', 'unique_id': '095760000000-3', 'unit_of_measurement': None, }) @@ -195,7 +195,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'warning', 'unique_id': '095760000000-4', 'unit_of_measurement': None, }) @@ -246,7 +246,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'warning', 'unique_id': '095760000000-5', 'unit_of_measurement': None, }) From b39c83efd23db5190ebf93bf0bc34ac6858af528 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Fri, 27 Mar 2026 11:12:55 -0700 Subject: [PATCH 0100/1707] Handle Oauth2 ImplementationUnavailableError in google (#166647) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/google/__init__.py | 17 +++++++++++++---- homeassistant/components/google/strings.json | 5 +++++ tests/components/google/test_init.py | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 0f8be7a52e985f..edc7dc50967f76 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -24,6 +24,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.helpers.entity import generate_entity_id from .api import ApiAuthImpl, get_feature_access @@ -88,11 +91,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo _LOGGER.error("Configuration error in %s: %s", YAML_DEVICES, str(err)) return False - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) # Force a token refresh to fix a bug where tokens were persisted with # expires_in (relative time delta) and expires_at (absolute time) swapped. diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 2660848f8f2267..91fd097ef0d3b3 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -57,6 +57,11 @@ } } }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } + }, "options": { "step": { "init": { diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 02f9e1b48bdcfa..ee272c54c99e62 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -21,6 +21,9 @@ from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC, utcnow @@ -902,3 +905,20 @@ async def test_remove_entry( assert await hass.config_entries.async_remove(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 894e9bab0abcd45eecab7d38e076ea9105f7a59f Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:45:39 +0100 Subject: [PATCH 0101/1707] Use legacy naming for entities (#166696) --- homeassistant/components/diagnostics/util.py | 1 - homeassistant/helpers/entity_registry.py | 98 ++-- tests/components/zwave_js/test_init.py | 10 +- tests/helpers/test_entity_registry.py | 548 +++++-------------- tests/syrupy.py | 1 - 5 files changed, 174 insertions(+), 484 deletions(-) diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index c40b38c6de1125..9b07fbd2d14a38 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -51,7 +51,6 @@ def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool: return a.name not in ( "_cache", "compat_aliases", - "compat_name", "original_name_unprefixed", ) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 33276acfafd53c..851ab2c8990f30 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -80,7 +80,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 21 +STORAGE_VERSION_MINOR = 22 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -240,7 +240,6 @@ class RegistryEntry: # For backwards compatibility, should be removed in the future compat_aliases: list[str] = attr.ib(factory=list, eq=False) - compat_name: str | None = attr.ib(default=None, eq=False) # original_name_unprefixed is used to store the result of stripping # the device name prefix from the original_name, if possible. @@ -413,8 +412,7 @@ def as_storage_fragment(self) -> json_fragment: "has_entity_name": self.has_entity_name, "labels": list(self.labels), "modified_at": self.modified_at, - "name": self.compat_name, - "name_v2": self.name, + "name": self.name, "object_id_base": self.object_id_base, "options": self.options, "original_device_class": self.original_device_class, @@ -471,6 +469,7 @@ def _async_get_full_entity_name( original_name: str | None, original_name_unprefixed: str | None | UndefinedType = UNDEFINED, overridden_name: str | None = None, + use_legacy_naming: bool = False, ) -> str: """Get full name for an entity. @@ -480,7 +479,7 @@ def _async_get_full_entity_name( if name is None and overridden_name is not None: name = overridden_name - else: + elif not use_legacy_naming or name is None: device_name: str | None = None if ( device_id is not None @@ -533,6 +532,7 @@ def async_get_full_entity_name( name=entry.name, original_name=original_name, original_name_unprefixed=original_name_unprefixed, + use_legacy_naming=True, ) @@ -660,7 +660,6 @@ class DeletedRegistryEntry: # For backwards compatibility, should be removed in the future compat_aliases: list[str] = attr.ib(factory=list, eq=False) - compat_name: str | None = attr.ib(default=None, eq=False) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @@ -696,8 +695,7 @@ def as_storage_fragment(self) -> json_fragment: "id": self.id, "labels": list(self.labels), "modified_at": self.modified_at, - "name": self.compat_name, - "name_v2": self.name, + "name": self.name, "options": self.options if self.options is not UNDEFINED else {}, "options_undefined": self.options is UNDEFINED, "orphaned_timestamp": self.orphaned_timestamp, @@ -850,46 +848,37 @@ async def _async_migrate_func( # noqa: C901 for entity in data["entities"]: entity["object_id_base"] = entity["original_name"] - if old_minor_version < 21: - # Version 1.21 migrates the full name to include device name, - # even if entity name is overwritten by user. - # It also adds support for COMPUTED_NAME in aliases and starts preserving their order. - # To avoid a major version bump, we keep the old name and aliases as-is - # and use new name_v2 and aliases_v2 fields instead. + if old_minor_version == 21: + # Version 1.21 has been reverted. + # It migrated entity names to the new format stored in `name_v2` + # field, automatically stripping any device name prefix present. + # The old name was stored in `name` field for backwards compatibility. + # For users who already migrated to v1.21, we restore old names + # but try to preserve any user renames made since that migration. device_registry = dr.async_get(self.hass) for entity in data["entities"]: - alias_to_add: str | None = None + old_name = entity["name"] + name = entity.pop("name_v2") if ( - (name := entity["name"]) + (name != old_name) and (device_id := entity["device_id"]) is not None and (device := device_registry.async_get(device_id)) is not None and (device_name := device.name_by_user or device.name) ): - # Strip the device name prefix from the entity name if present, - # and add the full generated name as an alias. - # If the name doesn't have the device name prefix and the - # entity is exposed to a voice assistant, add the previous - # name as an alias instead to preserve backwards compatibility. - if ( - new_name := _async_strip_prefix_from_entity_name( - name, device_name - ) - ) is not None: - name = new_name - elif any( - entity.get("options", {}).get(key, {}).get("should_expose") - for key in ("conversation", "cloud.google_assistant") - ): - alias_to_add = name - - entity["name_v2"] = name - entity["aliases_v2"] = [alias_to_add, *entity["aliases"]] + name = f"{device_name} {name}" + + entity["name"] = name + + if old_minor_version < 22: + # Version 1.22 adds support for COMPUTED_NAME in aliases and starts preserving + # their order. + # To avoid a major version bump, we keep the old aliases as-is and use aliases_v2 + # field instead. + for entity in data["entities"]: + entity["aliases_v2"] = [None, *entity["aliases"]] for entity in data["deleted_entities"]: - # We don't know what the device name was, so the only thing we can do - # is to clear the overwritten name to not mislead users. - entity["name_v2"] = None entity["aliases_v2"] = [None, *entity["aliases"]] if old_major_version > 1: @@ -1363,7 +1352,6 @@ def async_get_or_create( area_id = deleted_entity.area_id categories = deleted_entity.categories compat_aliases = deleted_entity.compat_aliases - compat_name = deleted_entity.compat_name created_at = deleted_entity.created_at device_class = deleted_entity.device_class if deleted_entity.disabled_by is not UNDEFINED: @@ -1395,7 +1383,6 @@ def async_get_or_create( area_id = None categories = {} compat_aliases = [] - compat_name = None device_class = None icon = None labels = set() @@ -1443,7 +1430,6 @@ def none_if_undefined[_T](value: _T | UndefinedType) -> _T | None: categories=categories, capabilities=none_if_undefined(capabilities), compat_aliases=compat_aliases, - compat_name=compat_name, config_entry_id=none_if_undefined(config_entry_id), config_subentry_id=none_if_undefined(config_subentry_id), created_at=created_at, @@ -1506,7 +1492,6 @@ def async_remove(self, entity_id: str) -> None: area_id=entity.area_id, categories=entity.categories, compat_aliases=entity.compat_aliases, - compat_name=entity.compat_name, config_entry_id=config_entry_id, config_subentry_id=entity.config_subentry_id, created_at=entity.created_at, @@ -1620,14 +1605,27 @@ def async_device_modified( for entity in entities: if entity.has_entity_name: continue - name = ( - entity.original_name_unprefixed - if by_user and entity.name is None - else UNDEFINED - ) + + # When a user renames a device, update entity names to reflect + # the new device name. + # An empty name_unprefixed means the entity name equals + # the device name (e.g. a main sensor); a non-empty one + # is appended as a suffix. + name: str | None | UndefinedType = UNDEFINED + if ( + by_user + and entity.name is None + and (name_unprefixed := entity.original_name_unprefixed) is not None + ): + if not name_unprefixed: + name = device_name + elif device_name: + name = f"{device_name} {name_unprefixed}" + original_name_unprefixed = _async_strip_prefix_from_entity_name( entity.original_name, device_name ) + self._async_update_entity( entity.entity_id, name=name, @@ -1995,7 +1993,6 @@ async def _async_load(self) -> None: categories=entity["categories"], capabilities=entity["capabilities"], compat_aliases=entity["aliases"], - compat_name=entity["name"], config_entry_id=entity["config_entry_id"], config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), @@ -2016,7 +2013,7 @@ async def _async_load(self) -> None: has_entity_name=entity["has_entity_name"], labels=set(entity["labels"]), modified_at=datetime.fromisoformat(entity["modified_at"]), - name=entity["name_v2"], + name=entity["name"], object_id_base=entity.get("object_id_base"), options=entity["options"], original_device_class=entity["original_device_class"], @@ -2067,7 +2064,6 @@ def get_optional_enum[_EnumT: StrEnum]( area_id=entity["area_id"], categories=entity["categories"], compat_aliases=entity["aliases"], - compat_name=entity["name"], config_entry_id=entity["config_entry_id"], config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), @@ -2087,7 +2083,7 @@ def get_optional_enum[_EnumT: StrEnum]( id=entity["id"], labels=set(entity["labels"]), modified_at=datetime.fromisoformat(entity["modified_at"]), - name=entity["name_v2"], + name=entity["name"], options=entity["options"] if not entity["options_undefined"] else UNDEFINED, diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 95049d01a9ab01..035d657bbeb371 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -801,7 +801,7 @@ async def test_existing_node_not_replaced_when_not_ready( await hass.async_block_till_done() state = hass.states.get(custom_entity) assert state - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" assert not hass.states.get(motion_entity) node_state = deepcopy(zp3111_not_ready_state) @@ -835,7 +835,7 @@ async def test_existing_node_not_replaced_when_not_ready( state = hass.states.get(custom_entity) assert state - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" event = Event( type="ready", @@ -866,7 +866,7 @@ async def test_existing_node_not_replaced_when_not_ready( state = hass.states.get(custom_entity) assert state assert state.state != STATE_UNAVAILABLE - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" @pytest.mark.usefixtures("client") @@ -1857,7 +1857,7 @@ async def test_node_model_change( assert not hass.states.get(motion_entity) state = hass.states.get(custom_entity) assert state - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" # Unload the integration assert await hass.config_entries.async_unload(integration.entry_id) @@ -1887,7 +1887,7 @@ async def test_node_model_change( assert not hass.states.get(motion_entity) state = hass.states.get(custom_entity) assert state - assert state.name == "Custom Device Name Custom Entity Name" + assert state.name == "Custom Entity Name" @pytest.mark.usefixtures("zp3111", "integration") diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index b26a7d23d4703f..94f330f63e2f11 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -684,7 +684,6 @@ async def test_load_bad_data( "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, - "name_v2": None, "object_id_base": None, "options": None, "original_device_class": None, @@ -719,7 +718,6 @@ async def test_load_bad_data( "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, - "name_v2": None, "object_id_base": None, "options": None, "original_device_class": None, @@ -754,7 +752,6 @@ async def test_load_bad_data( "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, - "name_v2": None, "options": None, "options_undefined": False, "orphaned_timestamp": None, @@ -780,7 +777,6 @@ async def test_load_bad_data( "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, - "name_v2": None, "options": None, "options_undefined": False, "orphaned_timestamp": None, @@ -1135,7 +1131,6 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "object_id_base": None, "options": {}, "original_device_class": "best_class", @@ -1331,7 +1326,6 @@ async def test_migration_1_11( "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "object_id_base": None, "options": {}, "original_device_class": "best_class", @@ -1367,7 +1361,6 @@ async def test_migration_1_11( "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "options": {}, "options_undefined": True, "orphaned_timestamp": None, @@ -1500,7 +1493,6 @@ async def test_migration_1_18( "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "object_id_base": "Test Entity", "options": {}, "original_device_class": "best_class", @@ -1536,7 +1528,6 @@ async def test_migration_1_18( "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "name_v2": None, "options": {}, "options_undefined": False, "orphaned_timestamp": None, @@ -1554,10 +1545,14 @@ async def test_migration_1_18( @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_20( - hass: HomeAssistant, hass_storage: dict[str, Any] +async def test_migration_1_21( + hass: HomeAssistant, + hass_storage: dict[str, Any], ) -> None: - """Test migration from version 1.20.""" + """Test migration from version 1.21. + + Version 1.21 stored entity names in a new format, but was reverted. + """ hass_storage[dr.STORAGE_KEY] = { "version": dr.STORAGE_VERSION_MAJOR, "minor_version": dr.STORAGE_VERSION_MINOR, @@ -1565,24 +1560,25 @@ async def test_migration_1_20( "devices": [ { "area_id": None, - "config_entries": ["mock-config-entry"], - "config_entries_subentries": {"mock-config-entry": [None]}, + "config_entries": ["mock_entry"], + "config_entries_subentries": {"mock_entry": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, + "disabled_by_undefined": False, "entry_type": None, "hw_version": None, - "id": "device-1", - "identifiers": [["test", "device-1"]], + "id": "device_1234", + "identifiers": [["test", "device_1"]], "labels": [], "manufacturer": None, "model": None, "model_id": None, "modified_at": "1970-01-01T00:00:00+00:00", - "name": "My Device", "name_by_user": None, - "primary_config_entry": "mock-config-entry", + "name": "My Device", + "primary_config_entry": "mock_entry", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1591,238 +1587,121 @@ async def test_migration_1_20( "deleted_devices": [], }, } + dr.async_setup(hass) await dr.async_load(hass) - # Entity registry data at version 1.20 + entity_base = { + "aliases": [], + "area_id": None, + "capabilities": {}, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_id": "device_1234", + "disabled_by": None, + "entity_category": None, + "has_entity_name": False, + "hidden_by": None, + "icon": None, + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "object_id_base": "Temperature", + "options": {}, + "original_device_class": "temperature", + "original_icon": None, + "original_name": "Temperature", + "platform": "super_platform", + "previous_unique_id": None, + "suggested_object_id": None, + "supported_features": 0, + "translation_key": None, + "unit_of_measurement": None, + "device_class": None, + } hass_storage[er.STORAGE_KEY] = { "version": 1, - "minor_version": 20, + "minor_version": 21, "data": { "entities": [ { - # Entity with name=None - # name should be preserved - # should add None to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.entity_name_no_custom", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-1", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": None, - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-1", - "unit_of_measurement": None, - "device_class": None, - }, - { - # Entity with no device_id - # name should be preserved - # should add None to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": None, - "disabled_by": None, - "entity_category": None, - "entity_id": "test.no_device", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-2", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Standalone Sensor", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-2", - "unit_of_measurement": None, - "device_class": None, - }, - { - # Entity with name starting with device name - # name should be stripped to remove device name prefix - # should add None to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.name_with_device_prefix", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-3", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "My device temperature", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-3", - "unit_of_measurement": None, - "device_class": None, - }, - { - # Entity with custom name not starting with device name - # not exposed to any voice assistant - # name should be preserved - # should add None to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, + **entity_base, "entity_id": "test.custom_name", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-4", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Living Room Light", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-4", - "unit_of_measurement": None, - "device_class": None, + "id": "entity_custom_name", + "unique_id": "custom_name", + "name": "My Custom Name", + "name_v2": "My Custom Name", }, { - # Entity with custom name not starting with device name - # exposed to conversation assistant - # name should be preserved - # should add name to aliases - "aliases": [], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.custom_name_exposed", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-5", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Living Room Light", - "object_id_base": "Test entity", - "options": { - "conversation": {"should_expose": True}, - }, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-5", - "unit_of_measurement": None, - "device_class": None, + **entity_base, + "entity_id": "test.stripped", + "id": "entity_stripped", + "unique_id": "stripped", + "name": "My Device Temperature", + "name_v2": "Temperature", }, - ], - "deleted_entities": [ { - # Deleted entity - # name should be reset to None - # should add None to aliases - "aliases": ["deleted_alias"], - "area_id": None, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_class": None, - "disabled_by": None, - "disabled_by_undefined": False, - "entity_id": "test.deleted_entity", - "hidden_by": None, - "hidden_by_undefined": False, - "icon": None, - "id": "deleted-1", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Deleted Name", - "options": {}, - "options_undefined": False, - "orphaned_timestamp": None, - "platform": "test_platform", - "unique_id": "deleted-unique", - } + **entity_base, + "entity_id": "test.stripped_and_renamed", + "id": "entity_stripped_and_renamed", + "unique_id": "stripped_and_renamed", + "name": "My Device Temperature", + "name_v2": "Heat", + }, ], + "deleted_entities": [], }, } await er.async_load(hass) registry = er.async_get(hass) + entry = registry.async_get_or_create("test", "super_platform", "custom_name") + assert entry.name == "My Custom Name" + + entry = registry.async_get_or_create("test", "super_platform", "stripped") + assert entry.name == "My Device Temperature" + + entry = registry.async_get_or_create( + "test", "super_platform", "stripped_and_renamed" + ) + assert entry.name == "My Device Heat" + # Check migrated data await flush_store(registry._store) migrated_data = hass_storage[er.STORAGE_KEY] + + migrated_entity_base = { + "aliases": [], + "aliases_v2": [None], + "area_id": None, + "capabilities": {}, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_id": "device_1234", + "disabled_by": None, + "entity_category": None, + "has_entity_name": False, + "hidden_by": None, + "icon": None, + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "object_id_base": "Temperature", + "options": {}, + "original_device_class": "temperature", + "original_icon": None, + "original_name": "Temperature", + "platform": "super_platform", + "previous_unique_id": None, + "suggested_object_id": None, + "supported_features": 0, + "translation_key": None, + "unit_of_measurement": None, + "device_class": None, + } assert migrated_data == { "version": er.STORAGE_VERSION_MAJOR, "minor_version": er.STORAGE_VERSION_MINOR, @@ -1830,211 +1709,28 @@ async def test_migration_1_20( "data": { "entities": [ { - "aliases": [], - "aliases_v2": [None], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.entity_name_no_custom", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-1", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": None, - "name_v2": None, - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-1", - "unit_of_measurement": None, - "device_class": None, - }, - { - "aliases": [], - "aliases_v2": [None], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": None, - "disabled_by": None, - "entity_category": None, - "entity_id": "test.no_device", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-2", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Standalone Sensor", - "name_v2": "Standalone Sensor", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-2", - "unit_of_measurement": None, - "device_class": None, - }, - { - "aliases": [], - "aliases_v2": [None], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.name_with_device_prefix", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-3", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "My device temperature", - "name_v2": "Temperature", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-3", - "unit_of_measurement": None, - "device_class": None, - }, - { - "aliases": [], - "aliases_v2": [None], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, + **migrated_entity_base, "entity_id": "test.custom_name", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-4", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Living Room Light", - "name_v2": "Living Room Light", - "object_id_base": "Test entity", - "options": {}, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-4", - "unit_of_measurement": None, - "device_class": None, + "id": "entity_custom_name", + "unique_id": "custom_name", + "name": "My Custom Name", }, { - "aliases": [], - "aliases_v2": ["Living Room Light"], - "area_id": None, - "capabilities": {}, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_id": "device-1", - "disabled_by": None, - "entity_category": None, - "entity_id": "test.custom_name_exposed", - "has_entity_name": True, - "hidden_by": None, - "icon": None, - "id": "entity-5", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Living Room Light", - "name_v2": "Living Room Light", - "object_id_base": "Test entity", - "options": { - "conversation": {"should_expose": True}, - }, - "original_device_class": None, - "original_icon": None, - "original_name": "Test entity", - "platform": "test_platform", - "previous_unique_id": None, - "suggested_object_id": None, - "supported_features": 0, - "translation_key": None, - "unique_id": "unique-5", - "unit_of_measurement": None, - "device_class": None, + **migrated_entity_base, + "entity_id": "test.stripped", + "id": "entity_stripped", + "unique_id": "stripped", + "name": "My Device Temperature", }, - ], - "deleted_entities": [ { - "aliases": ["deleted_alias"], - "aliases_v2": [None, "deleted_alias"], - "area_id": None, - "categories": {}, - "config_entry_id": None, - "config_subentry_id": None, - "created_at": "1970-01-01T00:00:00+00:00", - "device_class": None, - "disabled_by": None, - "disabled_by_undefined": False, - "entity_id": "test.deleted_entity", - "hidden_by": None, - "hidden_by_undefined": False, - "icon": None, - "id": "deleted-1", - "labels": [], - "modified_at": "1970-01-01T00:00:00+00:00", - "name": "Deleted Name", - "name_v2": None, - "options": {}, - "options_undefined": False, - "orphaned_timestamp": None, - "platform": "test_platform", - "unique_id": "deleted-unique", + **migrated_entity_base, + "entity_id": "test.stripped_and_renamed", + "id": "entity_stripped_and_renamed", + "unique_id": "stripped_and_renamed", + "name": "My Device Heat", }, ], + "deleted_entities": [], }, } @@ -3353,7 +3049,7 @@ async def test_has_entity_name_false_device_name_changes( assert updated.original_name_unprefixed == "Light Temperature" updated2 = entity_registry.async_get(entry2.entity_id) - assert updated2.name == "Brightness" + assert updated2.name == "Hue Brightness" assert updated2.original_name_unprefixed is None updated3 = entity_registry.async_get(entry3.entity_id) diff --git a/tests/syrupy.py b/tests/syrupy.py index db1736028607e2..ec795151955d87 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -203,7 +203,6 @@ def _serializable_entity_registry_entry( ) serialized.pop("categories") serialized.pop("compat_aliases") - serialized.pop("compat_name") serialized.pop("original_name_unprefixed") serialized.pop("_cache") serialized["aliases"] = er._serialize_aliases(serialized["aliases"]) From 85c7bf1dff8b9e93addd7856689be2906f5a6923 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Sat, 28 Mar 2026 03:14:13 +0800 Subject: [PATCH 0102/1707] Add new Weather Station sensors to Switchbot Cloud (#165257) --- .../components/switchbot_cloud/__init__.py | 6 +- .../components/switchbot_cloud/sensor.py | 5 + .../snapshots/test_sensor.ambr | 330 ++++++++++++++++++ .../components/switchbot_cloud/test_sensor.py | 5 +- 4 files changed, 341 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index aa32576e8a276d..dd47f37e7e0cb7 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -315,7 +315,6 @@ async def make_device_data( ) devices_data.binary_sensors.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type == "AI Art Frame": coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id @@ -323,6 +322,11 @@ async def make_device_data( devices_data.buttons.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) devices_data.images.append((device, coordinator)) + if isinstance(device, Device) and device.device_type == "WeatherStation": + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.sensors.append((device, coordinator)) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 1b74756bbae2a3..11cb9f7bb577ae 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -257,6 +257,11 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): ), "Smart Radiator Thermostat": (BATTERY_DESCRIPTION,), "AI Art Frame": (BATTERY_DESCRIPTION,), + "WeatherStation": ( + BATTERY_DESCRIPTION, + TEMPERATURE_DESCRIPTION, + HUMIDITY_DESCRIPTION, + ), } diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index a7daee6c9bc476..5bdf5fda8364e9 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -1,4 +1,169 @@ # serializer version: 1 +# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_no_coordinator_data[Climate Panel][sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_no_coordinator_data[Meter][sensor.meter_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -402,3 +567,168 @@ 'state': 'unknown', }) # --- +# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_no_coordinator_data[WeatherStation][sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index c132c5d8ca47a7..78eec135ed5247 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -79,10 +79,7 @@ async def test_plug_mini_eu( @pytest.mark.parametrize( "device_model", - [ - "Meter", - "Plug Mini (EU)", - ], + ["Meter", "Plug Mini (EU)", "Climate Panel", "WeatherStation"], ) async def test_no_coordinator_data( hass: HomeAssistant, From 63ba49ce4cd2097f43c3c8897d9014a115d4621e Mon Sep 17 00:00:00 2001 From: reneboer Date: Fri, 27 Mar 2026 22:31:48 +0100 Subject: [PATCH 0103/1707] Add start_charge action to renault (#166701) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Norbert Rittel --- homeassistant/components/renault/icons.json | 3 ++ .../components/renault/renault_vehicle.py | 6 ++- homeassistant/components/renault/services.py | 21 ++++++++ .../components/renault/services.yaml | 12 +++++ homeassistant/components/renault/strings.json | 14 +++++ tests/components/renault/test_button.py | 4 +- tests/components/renault/test_services.py | 54 +++++++++++++++++++ 7 files changed, 110 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/renault/icons.json b/homeassistant/components/renault/icons.json index f1767dcfbf619a..00b607bea7a100 100644 --- a/homeassistant/components/renault/icons.json +++ b/homeassistant/components/renault/icons.json @@ -88,6 +88,9 @@ }, "charge_set_schedules": { "service": "mdi:calendar-clock" + }, + "charge_start": { + "service": "mdi:ev-station" } } } diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index e2acb1bc07d397..49b91c5cd38d8d 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -165,9 +165,11 @@ async def set_charge_mode( return await self._vehicle.set_charge_mode(charge_mode) @with_error_wrapping - async def set_charge_start(self) -> models.KamereonVehicleChargingStartActionData: + async def set_charge_start( + self, when: datetime | None = None + ) -> models.KamereonVehicleChargingStartActionData: """Start vehicle charge.""" - return await self._vehicle.set_charge_start() + return await self._vehicle.set_charge_start(when) @with_error_wrapping async def set_charge_stop(self) -> models.KamereonVehicleChargingStartActionData: diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index 03531924533c6a..a8811ff231bdd4 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -36,6 +36,11 @@ vol.Optional(ATTR_WHEN): cv.datetime, } ) +SERVICE_CHARGE_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Optional(ATTR_WHEN): cv.datetime, + } +) SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema( { vol.Required("startTime"): cv.string, @@ -113,6 +118,16 @@ async def ac_start(service_call: ServiceCall) -> None: LOGGER.debug("A/C start result: %s", result.raw_data) +async def charge_start(service_call: ServiceCall) -> None: + """Start Charging with optional delay.""" + when: datetime | None = service_call.data.get(ATTR_WHEN) + proxy = get_vehicle_proxy(service_call) + + LOGGER.debug("Charge start attempt, when: %s", when) + result = await proxy.set_charge_start(when) + LOGGER.debug("Charge start result: %s", result.raw_data) + + async def charge_set_schedules(service_call: ServiceCall) -> None: """Set charge schedules.""" schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] @@ -196,6 +211,12 @@ def async_setup_services(hass: HomeAssistant) -> None: ac_start, schema=SERVICE_AC_START_SCHEMA, ) + hass.services.async_register( + DOMAIN, + "charge_start", + charge_start, + schema=SERVICE_CHARGE_START_SCHEMA, + ) hass.services.async_register( DOMAIN, "charge_set_schedules", diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml index 835a57bd9c1bc4..c9f4351a68cf45 100644 --- a/homeassistant/components/renault/services.yaml +++ b/homeassistant/components/renault/services.yaml @@ -54,6 +54,18 @@ ac_set_schedules: selector: object: +charge_start: + fields: + vehicle: + required: true + selector: + device: + integration: renault + when: + example: "2026-03-01T17:45:00" + selector: + datetime: + charge_set_schedules: fields: vehicle: diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index a58575f68a3259..21e8dfff06e4d1 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -276,6 +276,20 @@ } }, "name": "Update charge schedule" + }, + "charge_start": { + "description": "Starts charging on vehicle.", + "fields": { + "vehicle": { + "description": "[%key:component::renault::services::ac_start::fields::vehicle::description%]", + "name": "Vehicle" + }, + "when": { + "description": "Timestamp for charging to start (optional - defaults to now).", + "name": "When" + } + }, + "name": "Start charging" } } } diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py index f8fdce7e24e64b..4bb019ce26a6b4 100644 --- a/tests/components/renault/test_button.py +++ b/tests/components/renault/test_button.py @@ -116,7 +116,7 @@ async def test_button_start_charge( with patch( "renault_api.renault_vehicle.RenaultVehicle.set_charge_start", return_value=( - schemas.KamereonVehicleHvacStartActionDataSchema.loads( + schemas.KamereonVehicleChargingStartActionDataSchema.loads( await async_load_fixture(hass, "action.set_charge_start.json", DOMAIN) ) ), @@ -125,7 +125,7 @@ async def test_button_start_charge( BUTTON_DOMAIN, SERVICE_PRESS, service_data=data, blocking=True ) assert len(mock_action.mock_calls) == 1 - assert mock_action.mock_calls[0][1] == () + assert mock_action.mock_calls[0][1] == (None,) @pytest.mark.usefixtures("fixtures_with_data") diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 7ce168623debc9..2e9bbfaac51ef7 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -132,6 +132,60 @@ async def test_service_set_ac_start_with_date( assert mock_action.mock_calls[0][1] == (temperature, when) +async def test_service_charge_start_simple( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Test that service invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_VEHICLE: get_device_id(hass), + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_start", + return_value=( + schemas.KamereonVehicleChargingStartActionDataSchema.loads( + await async_load_fixture(hass, "action.set_charge_start.json", DOMAIN) + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, "charge_start", service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (None,) + + +async def test_service_charge_start_with_date( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Test that service invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + when = datetime(2025, 8, 23, 17, 12, 45) + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_WHEN: when, + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_start", + return_value=( + schemas.KamereonVehicleChargingStartActionDataSchema.loads( + await async_load_fixture(hass, "action.set_charge_start.json", DOMAIN) + ) + ), + ) as mock_action: + await hass.services.async_call( + DOMAIN, "charge_start", service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == (when,) + + async def test_service_set_charge_schedule( hass: HomeAssistant, config_entry: ConfigEntry, snapshot: SnapshotAssertion ) -> None: From 79ec3ff4846134a4f386e89514102d86b4d57c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 27 Mar 2026 22:39:15 +0100 Subject: [PATCH 0104/1707] Add Matter Thermostat presets feature (#160885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Norbert Rittel Co-authored-by: TheJulianJES Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- homeassistant/components/matter/climate.py | 149 +++++++- homeassistant/components/matter/strings.json | 11 +- .../matter/fixtures/nodes/eve_thermo_v5.json | 2 +- .../matter/snapshots/test_climate.ambr | 40 ++- tests/components/matter/test_climate.py | 326 +++++++++++++++++- 5 files changed, 518 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 6f6f87cfc86cb0..a67d5dcc8ebc66 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -16,6 +16,10 @@ ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + PRESET_AWAY, + PRESET_HOME, + PRESET_NONE, + PRESET_SLEEP, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -42,6 +46,18 @@ HVACMode.FAN_ONLY: 7, } +# Map of Matter PresetScenarioEnum to HA standard preset constants or custom names +# This ensures presets are translated correctly using HA's translation system. +# kUserDefined scenarios always use device-provided names. +PRESET_SCENARIO_TO_HA_PRESET: dict[int, str] = { + clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied: PRESET_HOME, + clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied: PRESET_AWAY, + clusters.Thermostat.Enums.PresetScenarioEnum.kSleep: PRESET_SLEEP, + clusters.Thermostat.Enums.PresetScenarioEnum.kWake: "wake", + clusters.Thermostat.Enums.PresetScenarioEnum.kVacation: "vacation", + clusters.Thermostat.Enums.PresetScenarioEnum.kGoingToSleep: "going_to_sleep", +} + SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = { # Some devices only have a single setpoint while the matter spec # assumes that you need separate setpoints for heating and cooling. @@ -159,7 +175,6 @@ } SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum -ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum ThermostatFeature = clusters.Thermostat.Bitmaps.Feature @@ -195,10 +210,22 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF + _matter_presets: list[clusters.Thermostat.Structs.PresetStruct] + _attr_preset_mode: str | None = None + _attr_preset_modes: list[str] | None = None _feature_map: int | None = None _platform_translation_key = "thermostat" + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the climate entity.""" + # Initialize preset handle mapping as instance attribute before calling super().__init__() + # because MatterEntity.__init__() calls _update_from_device() which needs this attribute + self._matter_presets = [] + self._preset_handle_by_name: dict[str, bytes | None] = {} + self._preset_name_by_handle: dict[bytes | None, str] = {} + super().__init__(*args, **kwargs) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) @@ -243,6 +270,34 @@ async def async_set_temperature(self, **kwargs: Any) -> None: matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + preset_handle = self._preset_handle_by_name[preset_mode] + + command = clusters.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_handle + ) + await self.send_device_command(command) + + # Optimistic update is required because Matter devices usually confirm + # preset changes asynchronously via a later attribute subscription. + # Additionally, some devices based on connectedhomeip do not send a + # subscription report for ActivePresetHandle after SetActivePresetRequest + # because thermostat-server-presets.cpp/SetActivePreset() updates the + # value without notifying the reporting engine. Keep this optimistic + # update as a workaround for that SDK bug and for normal report delays. + # Reference: project-chip/connectedhomeip, + # src/app/clusters/thermostat-server/thermostat-server-presets.cpp. + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + # Keep the local ActivePresetHandle in sync until subscription update. + active_preset_path = create_attribute_path_from_attribute( + endpoint_id=self._endpoint.endpoint_id, + attribute=clusters.Thermostat.Attributes.ActivePresetHandle, + ) + self._endpoint.set_attribute_value(active_preset_path, preset_handle) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -267,10 +322,10 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() + self._attr_current_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.LocalTemperature ) - self._attr_current_humidity = ( int(raw_measured_humidity) / HUMIDITY_SCALING_FACTOR if ( @@ -282,6 +337,81 @@ def _update_from_device(self) -> None: else None ) + self._update_presets() + + self._update_hvac_mode_and_action() + self._update_target_temperatures() + self._update_temperature_limits() + + @callback + def _update_presets(self) -> None: + """Update preset modes and active preset.""" + # Check if the device supports presets feature before attempting to load. + # Use the already computed supported features instead of re-reading + # the FeatureMap attribute to keep a single source of truth and avoid + # casting None when the attribute is temporarily unavailable. + supported_features = self._attr_supported_features or 0 + if not (supported_features & ClimateEntityFeature.PRESET_MODE): + # Device does not support presets, skip preset update + self._preset_handle_by_name.clear() + self._preset_name_by_handle.clear() + self._attr_preset_modes = [] + self._attr_preset_mode = None + return + + self._matter_presets = ( + self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets) + or [] + ) + # Build preset mapping: use device-provided name if available, else generate unique name + self._preset_handle_by_name.clear() + self._preset_name_by_handle.clear() + if self._matter_presets: + used_names = set() + for i, preset in enumerate(self._matter_presets, start=1): + preset_translation = PRESET_SCENARIO_TO_HA_PRESET.get( + preset.presetScenario + ) + if preset_translation: + preset_name = preset_translation.lower() + else: + name = str(preset.name) if preset.name is not None else "" + name = name.strip() + if name: + preset_name = name + else: + # Ensure fallback name is unique + j = i + preset_name = f"Preset{j}" + while preset_name in used_names: + j += 1 + preset_name = f"Preset{j}" + used_names.add(preset_name) + preset_handle = ( + preset.presetHandle + if isinstance(preset.presetHandle, (bytes, type(None))) + else None + ) + self._preset_handle_by_name[preset_name] = preset_handle + self._preset_name_by_handle[preset_handle] = preset_name + + # Always include PRESET_NONE to allow users to clear the preset + self._preset_handle_by_name[PRESET_NONE] = None + self._preset_name_by_handle[None] = PRESET_NONE + + self._attr_preset_modes = list(self._preset_handle_by_name) + + # Update active preset mode + active_preset_handle = self.get_matter_attribute_value( + clusters.Thermostat.Attributes.ActivePresetHandle + ) + self._attr_preset_mode = self._preset_name_by_handle.get( + active_preset_handle, PRESET_NONE + ) + + @callback + def _update_hvac_mode_and_action(self) -> None: + """Update HVAC mode and action from device.""" if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False: # special case: the appliance has a dedicated Power switch on the OnOff cluster # if the mains power is off - treat it as if the HVAC mode is off @@ -333,7 +463,10 @@ def _update_from_device(self) -> None: self._attr_hvac_action = HVACAction.FAN else: self._attr_hvac_action = HVACAction.OFF - # update target temperature high/low + + @callback + def _update_target_temperatures(self) -> None: + """Update target temperature or temperature range.""" supports_range = ( self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -359,6 +492,9 @@ def _update_from_device(self) -> None: clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) + @callback + def _update_temperature_limits(self) -> None: + """Update min and max temperature limits.""" # update min_temp if self._attr_hvac_mode == HVACMode.COOL: attribute = clusters.Thermostat.Attributes.AbsMinCoolSetpointLimit @@ -398,6 +534,9 @@ def _calculate_features( self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF ) + if feature_map & ThermostatFeature.kPresets: + self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + # determine supported hvac modes if feature_map & ThermostatFeature.kHeating: self._attr_hvac_modes.append(HVACMode.HEAT) if feature_map & ThermostatFeature.kCooling: @@ -440,9 +579,13 @@ def _get_temperature_in_degrees( optional_attributes=( clusters.Thermostat.Attributes.FeatureMap, clusters.Thermostat.Attributes.ControlSequenceOfOperation, + clusters.Thermostat.Attributes.NumberOfPresets, clusters.Thermostat.Attributes.Occupancy, clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.Attributes.Presets, + clusters.Thermostat.Attributes.PresetTypes, + clusters.Thermostat.Attributes.ActivePresetHandle, clusters.Thermostat.Attributes.SystemMode, clusters.Thermostat.Attributes.ThermostatRunningMode, clusters.Thermostat.Attributes.ThermostatRunningState, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index b5e481c26e5751..b790c0b7213da9 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -145,7 +145,16 @@ }, "climate": { "thermostat": { - "name": "Thermostat" + "name": "Thermostat", + "state_attributes": { + "preset_mode": { + "state": { + "going_to_sleep": "Going to sleep", + "vacation": "Vacation", + "wake": "Wake" + } + } + } } }, "cover": { diff --git a/tests/components/matter/fixtures/nodes/eve_thermo_v5.json b/tests/components/matter/fixtures/nodes/eve_thermo_v5.json index 1d3c4f018fec79..01923529ed9598 100644 --- a/tests/components/matter/fixtures/nodes/eve_thermo_v5.json +++ b/tests/components/matter/fixtures/nodes/eve_thermo_v5.json @@ -501,7 +501,7 @@ } ], "1/513/74": 8, - "1/513/78": null, + "1/513/78": "AQ==", "1/513/80": [ { "0": "AQ==", diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 1bfa215b6ad94b..f203ff887dda4a 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -210,6 +210,16 @@ ]), 'max_temp': 30.0, 'min_temp': 10.0, + 'preset_modes': list([ + 'home', + 'away', + 'sleep', + 'wake', + 'vacation', + 'going_to_sleep', + 'Eco', + 'none', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -235,7 +245,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, @@ -252,7 +262,18 @@ ]), 'max_temp': 30.0, 'min_temp': 10.0, - 'supported_features': , + 'preset_mode': 'home', + 'preset_modes': list([ + 'home', + 'away', + 'sleep', + 'wake', + 'vacation', + 'going_to_sleep', + 'Eco', + 'none', + ]), + 'supported_features': , 'temperature': 17.5, }), 'context': , @@ -490,6 +511,11 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, + 'preset_modes': list([ + 'home', + 'away', + 'none', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -515,7 +541,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, @@ -535,7 +561,13 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, - 'supported_features': , + 'preset_mode': 'none', + 'preset_modes': list([ + 'home', + 'away', + 'none', + ]), + 'supported_features': , 'target_temp_high': 26.0, 'target_temp_low': 20.0, 'temperature': None, diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 8c133802c35bba..b0bf32461c42d8 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -8,9 +8,15 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.climate import ClimateEntityFeature, HVACAction, HVACMode +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from .common import ( @@ -316,6 +322,35 @@ async def test_thermostat_service_calls( ) matter_client.write_attribute.reset_mock() + # test changing only target_temp_high when target_temp_low stays the same + set_node_attribute(matter_node, 1, 513, 18, 1000) + set_node_attribute(matter_node, 1, 513, 17, 2500) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["target_temp_high"] == 25 + assert state.attributes["target_temp_low"] == 10 + + await hass.services.async_call( + "climate", + "set_temperature", + { + "entity_id": "climate.longan_link_hvac", + "target_temp_low": 10, # Same as current + "target_temp_high": 28, # Different from current + }, + blocking=True, + ) + + # Only target_temp_high should be written since target_temp_low hasn't changed + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path="1/513/17", + value=2800, + ) + matter_client.write_attribute.reset_mock() + # test change HAVC mode to heat await hass.services.async_call( "climate", @@ -419,3 +454,292 @@ async def test_room_airconditioner( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.room_airconditioner") assert state.attributes["supported_features"] & ClimateEntityFeature.TURN_ON + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) +async def test_eve_thermo_v5_presets( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Eve Thermo v5 thermostat presets attributes and state updates.""" + # test entity attributes + entity_id = "climate.eve_thermo_20ecd1701" + state = hass.states.get(entity_id) + assert state + + # test supported features correctly parsed + mask = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE + ) + assert state.attributes["supported_features"] & mask == mask + + # Test preset modes parsed correctly from Eve Thermo v5 + # Should use HA standard presets for known ones, original names for others + # PRESET_NONE is always included to allow users to clear the preset + assert state.attributes["preset_modes"] == [ + "home", + "away", + "sleep", + "wake", + "vacation", + "going_to_sleep", + "Eco", + PRESET_NONE, + ] + assert state.attributes["preset_mode"] == "home" + + # Get presets from the node for dynamic testing + presets_attribute = matter_node.endpoints[1].get_attribute_value( + 513, + clusters.Thermostat.Attributes.Presets.attribute_id, + ) + preset_by_name = {preset.name: preset.presetHandle for preset in presets_attribute} + + # test set_preset_mode with "home" preset (HA standard) + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "home", + }, + blocking=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.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_by_name["Home"] + ), + ) + # Verify preset_mode is optimistically updated + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "home" + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with "away" preset (HA standard) + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "away", + }, + blocking=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.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_by_name["Away"] + ), + ) + # Verify preset_mode is optimistically updated + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "away" + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with "eco" preset (custom, device-provided name) + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Eco", + }, + blocking=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.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_by_name["Eco"] + ), + ) + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with invalid preset mode + # The climate platform validates preset modes before calling our method + + # Get current state to derive expected modes + state = hass.states.get(entity_id) + assert state + expected_modes = ", ".join(state.attributes["preset_modes"]) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "InvalidPreset", + }, + blocking=True, + ) + + assert err.value.translation_key == "not_valid_preset_mode" + assert err.value.translation_placeholders == { + "mode": "InvalidPreset", + "modes": expected_modes, + } + + # Ensure no command was sent for invalid preset + assert matter_client.send_device_command.call_count == 0 + # Test that preset_mode is updated when ActivePresetHandle is set from device + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + preset_by_name["Home"], + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "home" + + # Test that preset_mode is updated when ActivePresetHandle changes to different preset + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + preset_by_name["Away"], + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "away" + + # Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + None, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == PRESET_NONE + + # Test that users can set preset_mode to PRESET_NONE to clear the active preset + matter_client.send_device_command.reset_mock() + # First set a preset so we have something to clear + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "home", + }, + blocking=True, + ) + matter_client.send_device_command.reset_mock() + + # Now call set_preset_mode with PRESET_NONE to clear it + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": PRESET_NONE, + }, + blocking=True, + ) + + # Verify the command was sent with null value to clear the preset + 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.Thermostat.Commands.SetActivePresetRequest(presetHandle=None), + ) + # Verify preset_mode is optimistically updated to PRESET_NONE + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == PRESET_NONE + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) +async def test_preset_mode_with_unnamed_preset( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test preset mode when a preset has no name or empty name. + + This tests the fallback preset naming case where a preset does not have + a mapped presetScenario and also has no device-provided name, requiring + the fallback Preset{i} naming pattern. + """ + entity_id = "climate.eve_thermo_20ecd1701" + + # Get current presets from the node + presets_attribute = matter_node.endpoints[1].get_attribute_value( + 513, + clusters.Thermostat.Attributes.Presets.attribute_id, + ) + + assert presets_attribute is not None + + # Add a new preset with unmapped scenario (e.g., 255) and no name + new_preset = clusters.Thermostat.Structs.PresetStruct( + presetHandle=b"\xff", + presetScenario=255, # Unmapped scenario + name="", # Empty name + ) + presets_attribute.append(new_preset) + + # Update the node with the new preset list + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.Presets.attribute_id, + presets_attribute, + ) + + # Trigger subscription callback to update entity + await trigger_subscription_callback(hass, matter_client) + + # Verify the preset was added with the fallback name "Preset8" + state = hass.states.get(entity_id) + assert state + assert "Preset8" in state.attributes["preset_modes"] + + # Test that the unnamed preset can be set as active + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Preset8", + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "Preset8" + + # Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + None, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == PRESET_NONE From b813aa213f93c58aa1ee37d9af8f039cd81a1ff4 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 27 Mar 2026 22:45:11 +0100 Subject: [PATCH 0105/1707] Update frontend to 20260325.2 (#166717) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d43174468c83dd..4c8256a82e6091 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.1"] + "requirements": ["home-assistant-frontend==20260325.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37e26f6f7162e9..13e943984c710e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.1 +home-assistant-frontend==20260325.2 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f8e1d856aaf33d..31b7cd6f817e0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.1 +home-assistant-frontend==20260325.2 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8e7eda5b50683..2bcac216716f2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.1 +home-assistant-frontend==20260325.2 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From 685b921fe7c3cea9be1c38e0bca227e0770ed0a2 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 27 Mar 2026 23:54:55 +0100 Subject: [PATCH 0106/1707] Update switchbot_cloud snapshots (#166720) --- .../snapshots/test_sensor.ambr | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index 5bdf5fda8364e9..51a40facefd9de 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -1,8 +1,9 @@ # serializer version: 1 # name: test_no_coordinator_data[Climate Panel][sensor.meter_1_battery-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': dict({ 'state_class': , @@ -55,8 +56,9 @@ # --- # name: test_no_coordinator_data[Climate Panel][sensor.meter_1_humidity-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': dict({ 'state_class': , @@ -109,8 +111,9 @@ # --- # name: test_no_coordinator_data[Climate Panel][sensor.meter_1_temperature-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': dict({ 'state_class': , @@ -569,8 +572,9 @@ # --- # name: test_no_coordinator_data[WeatherStation][sensor.meter_1_battery-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': dict({ 'state_class': , @@ -623,8 +627,9 @@ # --- # name: test_no_coordinator_data[WeatherStation][sensor.meter_1_humidity-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': dict({ 'state_class': , @@ -677,8 +682,9 @@ # --- # name: test_no_coordinator_data[WeatherStation][sensor.meter_1_temperature-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': dict({ 'state_class': , From 45def46a4512379586abf569e7ba8ebc0dd46004 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 28 Mar 2026 00:57:27 +0100 Subject: [PATCH 0107/1707] Bump gardena bluetooth to 2.3.0 (#166719) --- homeassistant/components/gardena_bluetooth/manifest.json | 2 +- homeassistant/components/husqvarna_automower_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 966a10bc9b0305..d9ffb7b25d2c21 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], - "requirements": ["gardena-bluetooth==2.1.0"] + "requirements": ["gardena-bluetooth==2.3.0"] } diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 3c9fb7d57c87af..9026532c00b315 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"] + "requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 31b7cd6f817e0b..7fb50712dd9897 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1032,7 +1032,7 @@ gTTS==2.5.3 # homeassistant.components.gardena_bluetooth # homeassistant.components.husqvarna_automower_ble -gardena-bluetooth==2.1.0 +gardena-bluetooth==2.3.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2bcac216716f2f..3bab1dc3ca56c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -914,7 +914,7 @@ gTTS==2.5.3 # homeassistant.components.gardena_bluetooth # homeassistant.components.husqvarna_automower_ble -gardena-bluetooth==2.1.0 +gardena-bluetooth==2.3.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.14 From 12b485b17e02f73f6d2255337dbbcbfff0154fe2 Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 28 Mar 2026 17:50:36 +1100 Subject: [PATCH 0108/1707] Add Remote platform to SMLIGHT Integration (#166728) --- homeassistant/components/smlight/__init__.py | 1 + homeassistant/components/smlight/remote.py | 70 ++++++++ homeassistant/components/smlight/strings.json | 8 + tests/components/smlight/test_remote.py | 157 ++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 homeassistant/components/smlight/remote.py create mode 100644 tests/components/smlight/test_remote.py diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index a6d7bbd14ea305..b815b4d74a28cf 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -19,6 +19,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, + Platform.REMOTE, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/smlight/remote.py b/homeassistant/components/smlight/remote.py new file mode 100644 index 00000000000000..4976c7688f24dc --- /dev/null +++ b/homeassistant/components/smlight/remote.py @@ -0,0 +1,70 @@ +"""Remote platform for SLZB-Ultima.""" + +import asyncio +from collections.abc import Iterable +from typing import Any + +from pysmlight.exceptions import SmlightError +from pysmlight.models import IRPayload + +from homeassistant.components.remote import ( + ATTR_DELAY_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + DEFAULT_NUM_REPEATS, + RemoteEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator +from .entity import SmEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize remote for SLZB-Ultima device.""" + coordinator = entry.runtime_data.data + + if coordinator.data.info.has_peripherals: + async_add_entities([SmRemoteEntity(coordinator)]) + + +class SmRemoteEntity(SmEntity, RemoteEntity): + """Representation of a SLZB-Ultima remote.""" + + _attr_translation_key = "remote" + _attr_is_on = True + + def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: + """Initialize the SLZB-Ultima remote.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.unique_id}-remote" + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a sequence of commands to a device.""" + num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS) + delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + + for _ in range(num_repeats): + for cmd in command: + try: + await self.coordinator.async_execute_command( + self.coordinator.client.actions.send_ir_code, + IRPayload(code=cmd), + ) + except SmlightError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_ir_code_failed", + translation_placeholders={"error": str(err)}, + ) from err + + await asyncio.sleep(delay_secs) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 6fbac239207976..10310d4c6ef406 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -84,6 +84,11 @@ "name": "Ambilight" } }, + "remote": { + "remote": { + "name": "IR Remote" + } + }, "sensor": { "core_temperature": { "name": "Core chip temp" @@ -159,6 +164,9 @@ }, "firmware_update_failed": { "message": "Firmware update failed for {device_name}." + }, + "send_ir_code_failed": { + "message": "Failed to send IR code: {error}." } }, "issues": { diff --git a/tests/components/smlight/test_remote.py b/tests/components/smlight/test_remote.py new file mode 100644 index 00000000000000..26382f0833726a --- /dev/null +++ b/tests/components/smlight/test_remote.py @@ -0,0 +1,157 @@ +"""Tests for SLZB-Ultima remote entity.""" + +from unittest.mock import MagicMock, patch + +from pysmlight import Info +from pysmlight.exceptions import SmlightError +from pysmlight.models import IRPayload +import pytest + +from homeassistant.components.remote import ( + ATTR_COMMAND, + ATTR_DELAY_SECS, + ATTR_NUM_REPEATS, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms, which should be loaded during the test.""" + return [Platform.REMOTE] + + +MOCK_ULTIMA = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-Ultima3", +) + + +async def test_remote_setup_ultima( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test remote entity is created for Ultima devices.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("remote.mock_title_ir_remote") + assert state is not None + + +async def test_remote_not_created_non_ultima( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test remote entity is not created for non-Ultima devices.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-MR1", + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("remote.mock_title_ir_remote") + assert state is None + + +async def test_remote_send_command( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test sending IR command.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "remote.mock_title_ir_remote" + state = hass.states.get(entity_id) + assert state is not None + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: entity_id, + ATTR_COMMAND: ["my_code", "another_code"], + ATTR_DELAY_SECS: 0, + }, + blocking=True, + ) + + assert mock_smlight_client.actions.send_ir_code.call_count == 2 + mock_smlight_client.actions.send_ir_code.assert_any_call(IRPayload(code="my_code")) + mock_smlight_client.actions.send_ir_code.assert_any_call( + IRPayload(code="another_code") + ) + + +async def test_remote_send_command_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test connection error handling.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "remote.mock_title_ir_remote" + state = hass.states.get(entity_id) + assert state is not None + + mock_smlight_client.actions.send_ir_code.side_effect = SmlightError("Failed") + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: ["my_code"]}, + blocking=True, + ) + assert exc_info.value.translation_key == "send_ir_code_failed" + + +@patch("homeassistant.components.smlight.remote.asyncio.sleep") +async def test_remote_send_command_repeats( + mock_sleep: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test sending IR command with repeats and delay.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "remote.mock_title_ir_remote" + state = hass.states.get(entity_id) + assert state is not None + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: entity_id, + ATTR_COMMAND: ["my_code", "another_code"], + ATTR_NUM_REPEATS: 2, + ATTR_DELAY_SECS: 0.5, + }, + blocking=True, + ) + + assert mock_smlight_client.actions.send_ir_code.call_count == 4 + assert mock_sleep.call_count == 5 + mock_sleep.assert_called_with(0.5) From 980772207756372fe6e25ca98dd3500043871259 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sat, 28 Mar 2026 05:15:29 -0500 Subject: [PATCH 0109/1707] Bump aiorussound to 4.9.1 (#166718) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 43683722a0c9b3..588f13960366fb 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.9.0"], + "requirements": ["aiorussound==4.9.1"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7fb50712dd9897..754a2a1143531e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -389,7 +389,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.9.0 +aiorussound==4.9.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bab1dc3ca56c1..c3fa853f22d19a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -374,7 +374,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.9.0 +aiorussound==4.9.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From a443060faa4d5788c1aca9e3de95d7d45fe9d508 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 28 Mar 2026 15:20:23 +0100 Subject: [PATCH 0110/1707] Improve comelit type handling (#166740) --- homeassistant/components/comelit/climate.py | 5 +-- .../components/comelit/humidifier.py | 5 +-- homeassistant/components/comelit/strings.json | 3 -- homeassistant/components/comelit/utils.py | 19 +++++----- tests/components/comelit/test_climate.py | 37 ------------------- tests/components/comelit/test_humidifier.py | 37 ------------------- 6 files changed, 14 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 84761a89722474..3f5a5268bb9371 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -9,7 +9,6 @@ from aiocomelit.const import CLIMATE from homeassistant.components.climate import ( - DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -92,7 +91,7 @@ async def async_setup_entry( entities: list[ClimateEntity] = [] for device in coordinator.data[CLIMATE].values(): - values = load_api_data(device, CLIMATE_DOMAIN) + values = load_api_data(device, "climate") if values[0] == 0 and values[4] == 0: # No climate data, device is only a humidifier/dehumidifier @@ -140,7 +139,7 @@ def __init__( def _update_attributes(self) -> None: """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] - values = load_api_data(device, CLIMATE_DOMAIN) + values = load_api_data(device, "climate") _active = values[1] _mode = values[2] # Values from API: "O", "L", "U" diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index 4a7361022ce7ee..b21682b6958924 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -9,7 +9,6 @@ from aiocomelit.const import CLIMATE from homeassistant.components.humidifier import ( - DOMAIN as HUMIDIFIER_DOMAIN, MODE_AUTO, MODE_NORMAL, HumidifierAction, @@ -68,7 +67,7 @@ async def async_setup_entry( entities: list[ComelitHumidifierEntity] = [] for device in coordinator.data[CLIMATE].values(): - values = load_api_data(device, HUMIDIFIER_DOMAIN) + values = load_api_data(device, "humidifier") if values[0] == 0 and values[4] == 0: # No humidity data, device is only a climate @@ -142,7 +141,7 @@ def __init__( def _update_attributes(self) -> None: """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] - values = load_api_data(device, HUMIDIFIER_DOMAIN) + values = load_api_data(device, "humidifier") _active = values[1] _mode = values[2] # Values from API: "O", "L", "U" diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 6d8e450c9e8c8c..d8d2605b172b4e 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -113,9 +113,6 @@ "humidity_while_off": { "message": "Cannot change humidity while off" }, - "invalid_clima_data": { - "message": "Invalid 'clima' data" - }, "update_failed": { "message": "Failed to update data: {error}" } diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py index 459b73a3ff950e..a2b05dda62ee98 100644 --- a/homeassistant/components/comelit/utils.py +++ b/homeassistant/components/comelit/utils.py @@ -2,13 +2,12 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import TYPE_CHECKING, Any, Concatenate +from typing import TYPE_CHECKING, Any, Concatenate, Literal from aiocomelit.api import ComelitSerialBridgeObject from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData from aiohttp import ClientSession, CookieJar -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -30,17 +29,19 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession: ) -def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]: +def load_api_data( + device: ComelitSerialBridgeObject, + domain: Literal["climate", "humidifier"], +) -> list[Any]: """Load data from the API.""" - # This function is called when the data is loaded from the API - if not isinstance(device.val, list): - raise HomeAssistantError( - translation_domain=domain, translation_key="invalid_clima_data" - ) + # This function is called when the data is loaded from the API. + # For climate and humidifier device.val is always a list. + if TYPE_CHECKING: + assert isinstance(device.val, list) # CLIMATE has a 2 item tuple: # - first for Clima # - second for Humidifier - return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1] + return device.val[0] if domain == "climate" else device.val[1] async def cleanup_stale_entity( diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py index 53a84fbc6b8c3e..54906c7e58d267 100644 --- a/tests/components/comelit/test_climate.py +++ b/tests/components/comelit/test_climate.py @@ -126,43 +126,6 @@ async def test_climate_data_update( assert state.attributes[ATTR_TEMPERATURE] == temp -async def test_climate_data_update_bad_data( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_serial_bridge: AsyncMock, - mock_serial_bridge_config_entry: MockConfigEntry, -) -> None: - """Test climate data update.""" - await setup_integration(hass, mock_serial_bridge_config_entry) - - assert (state := hass.states.get(ENTITY_ID)) - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 5.0 - - mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { - 0: ComelitSerialBridgeObject( - index=0, - name="Climate0", - status=0, - human_status="off", - type="climate", - val="bad_data", # type: ignore[arg-type] - protected=0, - zone="Living room", - power=0.0, - power_unit=WATT, - ), - } - - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert (state := hass.states.get(ENTITY_ID)) - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 5.0 - - async def test_climate_set_temperature( hass: HomeAssistant, mock_serial_bridge: AsyncMock, diff --git a/tests/components/comelit/test_humidifier.py b/tests/components/comelit/test_humidifier.py index 6530d33f09b2d8..8789d94fef3d37 100644 --- a/tests/components/comelit/test_humidifier.py +++ b/tests/components/comelit/test_humidifier.py @@ -126,43 +126,6 @@ async def test_humidifier_data_update( assert state.attributes[ATTR_HUMIDITY] == humidity -async def test_humidifier_data_update_bad_data( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_serial_bridge: AsyncMock, - mock_serial_bridge_config_entry: MockConfigEntry, -) -> None: - """Test humidifier data update.""" - await setup_integration(hass, mock_serial_bridge_config_entry) - - assert (state := hass.states.get(ENTITY_ID)) - assert state.state == STATE_ON - assert state.attributes[ATTR_HUMIDITY] == 50.0 - - mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { - 0: ComelitSerialBridgeObject( - index=0, - name="Climate0", - status=0, - human_status="off", - type="climate", - val="bad_data", # type: ignore[arg-type] - protected=0, - zone="Living room", - power=0.0, - power_unit=WATT, - ), - } - - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert (state := hass.states.get(ENTITY_ID)) - assert state.state == STATE_ON - assert state.attributes[ATTR_HUMIDITY] == 50.0 - - async def test_humidifier_set_humidity( hass: HomeAssistant, mock_serial_bridge: AsyncMock, From 0c0d6595d66d8ce090843007e79d21cba2ebdeb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sat, 28 Mar 2026 15:20:51 +0100 Subject: [PATCH 0111/1707] Add Matter range hood fixture (#166743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- tests/components/matter/common.py | 1 + .../fixtures/nodes/silabs_range_hood.json | 676 ++++++++++++++++++ .../matter/snapshots/test_button.ambr | 51 ++ .../components/matter/snapshots/test_fan.ambr | 63 ++ .../matter/snapshots/test_light.ambr | 59 ++ .../matter/snapshots/test_select.ambr | 63 ++ 6 files changed, 913 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/silabs_range_hood.json diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index c2e3c2fcec0c80..96b42d63fd09bf 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -99,6 +99,7 @@ "silabs_fan", "silabs_laundrywasher", "silabs_light_switch", + "silabs_range_hood", "silabs_refrigerator", "silabs_water_heater", "switchbot_k11_plus", diff --git a/tests/components/matter/fixtures/nodes/silabs_range_hood.json b/tests/components/matter/fixtures/nodes/silabs_range_hood.json new file mode 100644 index 00000000000000..8fb77679d28455 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs_range_hood.json @@ -0,0 +1,676 @@ +{ + "node_id": 114, + "date_commissioned": "2026-03-28T13:40:57.299000", + "last_interview": "2026-03-28T13:41:32.823000", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/65/0": [], + "0/65/65533": 1, + "0/65/65532": 0, + "0/65/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/65/65529": [], + "0/65/65528": [], + "0/64/0": [], + "0/64/65533": 1, + "0/64/65532": 0, + "0/64/65531": [0, 65532, 65533, 65528, 65529, 65531], + "0/64/65529": [], + "0/64/65528": [], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65533": 2, + "0/63/65532": 0, + "0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/63/65529": [0, 1, 3, 4], + "0/63/65528": [5, 2], + "0/62/0": [ + { + "1": "FTABAQUkAgE3AyQTAhgmBFdVeS8mBdeLJkQ3BiQVAiQRchgkBwEkCAEwCUEEJOQ9kvNA6n0D7h8CVIGwLHqkyK1e16SizjGFxQXnlTPYX+2sFmp2ElaeNjEOaTKlQAtmUQCr7MPKb4gQnY1LVjcKNQEoARgkAgE2AwQCBAEYMAQU8zOoH6iO/qli6VOfyCglAd3NQlUwBRRa34d1hFPuca7UFWclq9cFnlPhShgwC0AFLtdLkSZTnoRLjiHfLIzlYc+GZeYZpvBZqheLaytsm3XrRvPFDELtX5SWUAv1VuKXcLKFwcQ/y7beTutRbieGGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEAV5qZprx2HWOKSP2iCzsI7A0CHgZVtbwsQ/y4ssETfB9z00733STIN0AfD552Vi1h6fJSeEg0/pA82bJL/y0azcKNQEpARgkAmAwBBRa34d1hFPuca7UFWclq9cFnlPhSjAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQDD8OnB1NcHRxx387f9wZeFDYf32VZ3ZENQrlWBTQZqEKP+K6XjWmjTWttDEeW1kiNtB1T5ZBIaJUxVdqMuNQx8Y", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BG1FPaj8U/IZMJ0lkYRWnL7PNr67I7GmbKqrzOPKp92GWZkMLbxHskSpehAxMxW6nepQHWr+Eq9LLp7wy4CB7a4=", + "2": 4939, + "3": 2, + "4": 114, + "5": "ha", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBH2IWjEkBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQJ4lEwFc2hajQcqS2EERyUvKMaGClZUX11eBfZgrHewkwf3+xuFEUQ8duKOp0owZwSAiuYIJ8afw3R+dO6TGX54Y", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEbUU9qPxT8hkwnSWRhFacvs82vrsjsaZsqqvM48qn3YZZmQwtvEeyRKl6EDEzFbqd6lAdav4Sr0sunvDLgIHtrjcKNQEpARgkAmAwBBRvaChVdZwDuXcf/oyr6vKPKCnGWzAFFG9oKFV1nAO5dx/+jKvq8o8oKcZbGDALQLa3jnnqN0/o6VG8wM4V9FDzrgDfKPd5cn3BBz77K80Jzo/aNotaTNOa6zX//yIvOkBZfGyq1Dh1vXZ4g2NKcXoY" + ], + "0/62/5": 3, + "0/62/65533": 2, + "0/62/65532": 0, + "0/62/65531": [0, 1, 2, 3, 4, 5, 65532, 65533, 65528, 65529, 65531], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65528": [1, 3, 5, 8, 14], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65532": 0, + "0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531], + "0/60/65529": [0, 2], + "0/60/65528": [], + "0/52/0": [ + { + "0": 10, + "1": "UART", + "3": 336 + }, + { + "0": 9, + "1": "Bluetoot", + "3": 1224 + }, + { + "0": 8, + "1": "Bluetoot", + "3": 600 + }, + { + "0": 7, + "1": "Bluetoot", + "3": 380 + }, + { + "0": 6, + "1": "shell", + "3": 1280 + }, + { + "0": 5, + "1": "OT Seria", + "3": 3664 + }, + { + "0": 4, + "1": "RangeHoo", + "3": 3076 + }, + { + "0": 3, + "1": "Tmr Svc", + "3": 1060 + }, + { + "0": 2, + "1": "IDLE", + "3": 1064 + }, + { + "0": 1, + "1": "OT Stack", + "3": 2848 + }, + { + "0": 0, + "1": "CHIP", + "3": 3096 + } + ], + "0/52/1": 103512, + "0/52/2": 26832, + "0/52/65533": 1, + "0/52/65532": 1, + "0/52/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/52/65529": [0], + "0/52/65528": [], + "0/52/3": 36088, + "0/51/0": [ + { + "0": "MyHome", + "1": true, + "2": null, + "3": null, + "4": "XrM7nyFYrIQ=", + "5": [], + "6": [ + "/QANuACgAAAAAAD//gBsAA==", + "/VX8YmMnAAF4u+orFSYj+A==", + "/QANuACgAABiekSD+SLHxg==", + "/oAAAAAAAABcszufIVishA==" + ], + "7": 4 + } + ], + "0/51/1": 5, + "0/51/2": 118, + "0/51/3": 13, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65533": 2, + "0/51/65532": 0, + "0/51/65531": [ + 0, 1, 8, 3, 4, 5, 6, 7, 2, 65532, 65533, 65528, 65529, 65531 + ], + "0/51/65529": [0, 1], + "0/51/65528": [2], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65533": 2, + "0/48/65532": 0, + "0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/48/65529": [0, 2, 4], + "0/48/65528": [1, 3, 5], + "0/40/0": 19, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "SL-RangeHood", + "0/40/4": 32773, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/18": "CF23A858DDB010C9", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/24": 1, + "0/40/65533": 5, + "0/40/65532": 0, + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 11, 12, 13, 14, 15, 16, 18, + 65532, 65533, 65528, 65529, 65531 + ], + "0/40/65529": [], + "0/40/65528": [], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65533": 2, + "0/31/65532": 0, + "0/31/65531": [0, 2, 3, 4, 65532, 65533, 65528, 65529, 65531], + "0/31/65529": [], + "0/31/65528": [], + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [65, 64, 63, 62, 60, 52, 51, 48, 40, 31, 29, 49, 42, 53], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65533": 3, + "0/29/65532": 0, + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/29/65529": [], + "0/29/65528": [], + "0/49/0": 1, + "0/49/1": [ + { + "0": "p0jbsOzJRNw=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "p0jbsOzJRNw=", + "0/49/7": null, + "0/49/2": 10, + "0/49/3": 20, + "0/49/9": 10, + "0/49/10": 5, + "0/49/65533": 2, + "0/49/65532": 2, + "0/49/65531": [ + 0, 1, 4, 5, 6, 7, 2, 3, 9, 10, 65532, 65533, 65528, 65529, 65531 + ], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65528": [1, 5, 7], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65533": 1, + "0/42/65532": 0, + "0/42/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/42/65529": [0], + "0/42/65528": [], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "QP0ADbgAoAAA", + "0/53/7": [ + { + "0": 13438285129078731668, + "1": 21, + "2": 14336, + "3": 1004376, + "4": 210290, + "5": 1, + "6": -93, + "7": -91, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 2202349555917590819, + "1": 17, + "2": 25600, + "3": 168304, + "4": 58526, + "5": 3, + "6": -67, + "7": -67, + "8": 26, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 10563246212816049995, + "1": 11, + "2": 26624, + "3": 1951226, + "4": 177215, + "5": 3, + "6": -37, + "7": -33, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 18079024453762862237, + "1": 11, + "2": 31744, + "3": 3169619, + "4": 21445, + "5": 3, + "6": -39, + "7": -40, + "8": 11, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 14318601490803184919, + "1": 11, + "2": 33792, + "3": 3478972, + "4": 47143, + "5": 3, + "6": -41, + "7": -41, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 6498271992183290326, + "1": 8, + "2": 40960, + "3": 363662, + "4": 47746, + "5": 3, + "6": -55, + "7": -56, + "8": 25, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 4206032556233211940, + "1": 11, + "2": 53248, + "3": 936980, + "4": 213306, + "5": 2, + "6": -82, + "7": -85, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8265194500311707858, + "1": 20, + "2": 58368, + "3": 126887, + "4": 48540, + "5": 3, + "6": -63, + "7": -64, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 13438285129078731668, + "1": 14336, + "2": 14, + "3": 40, + "4": 2, + "5": 1, + "6": 2, + "7": 21, + "8": true, + "9": true + }, + { + "0": 2202349555917590819, + "1": 25600, + "2": 25, + "3": 40, + "4": 1, + "5": 3, + "6": 3, + "7": 17, + "8": true, + "9": true + }, + { + "0": 10563246212816049995, + "1": 26624, + "2": 26, + "3": 40, + "4": 1, + "5": 3, + "6": 3, + "7": 11, + "8": true, + "9": true + }, + { + "0": 6823863415041731716, + "1": 27648, + "2": 27, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + }, + { + "0": 18079024453762862237, + "1": 31744, + "2": 31, + "3": 40, + "4": 1, + "5": 3, + "6": 3, + "7": 11, + "8": true, + "9": true + }, + { + "0": 14318601490803184919, + "1": 33792, + "2": 33, + "3": 40, + "4": 1, + "5": 3, + "6": 3, + "7": 11, + "8": true, + "9": true + }, + { + "0": 6498271992183290326, + "1": 40960, + "2": 40, + "3": 31, + "4": 1, + "5": 3, + "6": 3, + "7": 9, + "8": true, + "9": true + }, + { + "0": 12602498247788046175, + "1": 47104, + "2": 46, + "3": 40, + "4": 1, + "5": 0, + "6": 0, + "7": 17, + "8": true, + "9": false + }, + { + "0": 4206032556233211940, + "1": 53248, + "2": 52, + "3": 40, + "4": 2, + "5": 2, + "6": 2, + "7": 11, + "8": true, + "9": true + }, + { + "0": 8265194500311707858, + "1": 58368, + "2": 57, + "3": 40, + "4": 1, + "5": 3, + "6": 3, + "7": 20, + "8": true, + "9": true + } + ], + "0/53/9": 1664395405, + "0/53/10": 68, + "0/53/11": 227, + "0/53/12": 140, + "0/53/13": 31, + "0/53/56": 65536, + "0/53/57": 0, + "0/53/58": 0, + "0/53/59": { + "0": 672, + "1": 143 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/63": null, + "0/53/64": null, + "0/53/65533": 3, + "0/53/65532": 15, + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65532, 65533, 65528, 65529, 65531 + ], + "0/53/65529": [0], + "0/53/65528": [], + "0/53/6": 0, + "0/53/14": 1, + "0/53/15": 1, + "0/53/16": 1, + "0/53/17": 0, + "0/53/18": 1, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 651, + "0/53/23": 637, + "0/53/24": 14, + "0/53/25": 637, + "0/53/26": 636, + "0/53/27": 14, + "0/53/28": 651, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 382, + "0/53/34": 1, + "0/53/35": 0, + "0/53/36": 113, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 406, + "0/53/40": 266, + "0/53/41": 112, + "0/53/42": 369, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 9, + "0/53/50": 0, + "0/53/51": 28, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "1/29/0": [ + { + "0": 122, + "1": 1 + } + ], + "1/29/1": [29, 514], + "1/29/2": [], + "1/29/3": [], + "1/29/65533": 3, + "1/29/65532": 0, + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/29/65529": [], + "1/29/65528": [], + "1/514/0": 0, + "1/514/1": 0, + "1/514/2": 0, + "1/514/3": 0, + "1/514/65533": 5, + "1/514/65532": 0, + "1/514/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/514/65529": [], + "1/514/65528": [], + "2/29/0": [ + { + "0": 256, + "1": 1 + } + ], + "2/29/1": [29, 3, 4, 6], + "2/29/2": [], + "2/29/3": [], + "2/29/65533": 3, + "2/29/65532": 0, + "2/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "2/29/65529": [], + "2/29/65528": [], + "2/3/0": 0, + "2/3/1": 2, + "2/3/65533": 6, + "2/3/65532": 0, + "2/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531], + "2/3/65529": [0, 64], + "2/3/65528": [], + "2/4/0": 128, + "2/4/65533": 4, + "2/4/65532": 1, + "2/4/65531": [0, 65532, 65533, 65528, 65529, 65531], + "2/4/65529": [0, 1, 2, 3, 4, 5], + "2/4/65528": [0, 1, 2, 3], + "2/6/0": false, + "2/6/65533": 6, + "2/6/65532": 1, + "2/6/65531": [0, 65532, 65533, 65528, 65529, 65531], + "2/6/65529": [0, 1, 2], + "2/6/65528": [], + "2/6/16384": false, + "2/6/16385": 0, + "2/6/16386": 0, + "2/6/16387": null + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 2802445a749338..818a021e21f103 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -4211,6 +4211,57 @@ 'state': 'unknown', }) # --- +# name: test_buttons[silabs_range_hood][button.sl_rangehood_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.sl_rangehood_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000072-MatterNodeDevice-2-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_range_hood][button.sl_rangehood_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'SL-RangeHood Identify', + }), + 'context': , + 'entity_id': 'button.sl_rangehood_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[silabs_refrigerator][button.refrigerator_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_fan.ambr b/tests/components/matter/snapshots/test_fan.ambr index b1b02733a257a5..cf5c8c568ffe06 100644 --- a/tests/components/matter/snapshots/test_fan.ambr +++ b/tests/components/matter/snapshots/test_fan.ambr @@ -413,3 +413,66 @@ 'state': 'on', }) # --- +# name: test_fans[silabs_range_hood][fan.sl_rangehood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.sl_rangehood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 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': 'fan', + 'unique_id': '00000000000004D2-0000000000000072-MatterNodeDevice-1-MatterFan-514-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_fans[silabs_range_hood][fan.sl_rangehood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SL-RangeHood', + 'preset_mode': None, + 'preset_modes': list([ + 'low', + 'medium', + 'high', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.sl_rangehood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/matter/snapshots/test_light.ambr b/tests/components/matter/snapshots/test_light.ambr index d00c8f628f50c7..b3f381e662a62a 100644 --- a/tests/components/matter/snapshots/test_light.ambr +++ b/tests/components/matter/snapshots/test_light.ambr @@ -803,3 +803,62 @@ 'state': 'off', }) # --- +# name: test_lights[silabs_range_hood][light.sl_rangehood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.sl_rangehood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 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': 0, + 'translation_key': 'light', + 'unique_id': '00000000000004D2-0000000000000072-MatterNodeDevice-2-MatterLight-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_lights[silabs_range_hood][light.sl_rangehood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'SL-RangeHood', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.sl_rangehood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index ec2eb274ce008e..0136173018a5e2 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -4503,6 +4503,69 @@ 'state': 'Colors', }) # --- +# name: test_selects[silabs_range_hood][select.sl_rangehood_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.sl_rangehood_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power-on behavior', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power-on behavior', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'startup_on_off', + 'unique_id': '00000000000004D2-0000000000000072-MatterNodeDevice-2-MatterStartUpOnOff-6-16387', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_range_hood][select.sl_rangehood_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SL-RangeHood Power-on behavior', + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'context': , + 'entity_id': 'select.sl_rangehood_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'previous', + }) +# --- # name: test_selects[silabs_refrigerator][select.refrigerator_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 16231d8d36883bd20892f70fa905523198148b70 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Sat, 28 Mar 2026 10:21:26 -0400 Subject: [PATCH 0112/1707] Bump kaleidescape dependency to 1.1.4 (#166744) --- homeassistant/components/kaleidescape/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kaleidescape/manifest.json b/homeassistant/components/kaleidescape/manifest.json index 7ad51d60c56f1c..6996b70bd2dca9 100644 --- a/homeassistant/components/kaleidescape/manifest.json +++ b/homeassistant/components/kaleidescape/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/kaleidescape", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pykaleidescape==1.1.3"], + "requirements": ["pykaleidescape==1.1.4"], "ssdp": [ { "deviceType": "schemas-upnp-org:device:Basic:1", diff --git a/requirements_all.txt b/requirements_all.txt index 754a2a1143531e..0eebdfa318b940 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2209,7 +2209,7 @@ pyituran==0.1.5 pyjvcprojector==2.0.3 # homeassistant.components.kaleidescape -pykaleidescape==1.1.3 +pykaleidescape==1.1.4 # homeassistant.components.kira pykira==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3fa853f22d19a..78d1e55e38d8ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1892,7 +1892,7 @@ pyituran==0.1.5 pyjvcprojector==2.0.3 # homeassistant.components.kaleidescape -pykaleidescape==1.1.3 +pykaleidescape==1.1.4 # homeassistant.components.kira pykira==0.1.1 From 30163fa2e7becdcfdbfa8518dcf06d33fd83df73 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Sat, 28 Mar 2026 15:26:35 +0100 Subject: [PATCH 0113/1707] Bump pyblu to 2.0.6 (#166738) --- homeassistant/components/bluesound/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 53109568fb5512..5e4e301d46b02b 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/bluesound", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pyblu==2.0.5"], + "requirements": ["pyblu==2.0.6"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 0eebdfa318b940..67cdff4077d51c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1989,7 +1989,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.5 +pyblu==2.0.6 # homeassistant.components.neato pybotvac==0.0.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78d1e55e38d8ea..b5d1a5ee17e7b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1723,7 +1723,7 @@ pybalboa==1.1.3 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.5 +pyblu==2.0.6 # homeassistant.components.neato pybotvac==0.0.28 From 3e5c29133894a337cbaffc8fd2fa17798786ffb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Sat, 28 Mar 2026 15:27:06 +0100 Subject: [PATCH 0114/1707] Add missing code for miele washing machine (#166731) --- homeassistant/components/miele/const.py | 1 + tests/components/miele/snapshots/test_sensor.ambr | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 2f8215767fcb48..52d728ef9db966 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -440,6 +440,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True): no_program = 0, -1 cottons = 1, 10001 + normal = 2 minimum_iron = 3 delicates = 4, 10022 woollens = 8, 10040 diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index b1bc36daedd5c0..1cdea6c5b80926 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -7613,6 +7613,7 @@ 'game_pieces', 'minimum_iron', 'no_program', + 'normal', 'outdoor_garments', 'outerwear', 'pillows', @@ -7697,6 +7698,7 @@ 'game_pieces', 'minimum_iron', 'no_program', + 'normal', 'outdoor_garments', 'outerwear', 'pillows', @@ -11419,6 +11421,7 @@ 'game_pieces', 'minimum_iron', 'no_program', + 'normal', 'outdoor_garments', 'outerwear', 'pillows', @@ -11503,6 +11506,7 @@ 'game_pieces', 'minimum_iron', 'no_program', + 'normal', 'outdoor_garments', 'outerwear', 'pillows', From 75782e6f17943efa88c234fe29a7e5d0c4cbe9e4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:38:59 -0400 Subject: [PATCH 0115/1707] Remove dispatcher pattern and use options properties in Vizio (#164711) Co-authored-by: Claude Opus 4.6 --- .../components/vizio/media_player.py | 49 ++++++------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 1a0b439b0e9dec..d7a3e481fbc749 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from pyvizio.api.apps import AppConfig, find_app_name from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP @@ -14,10 +16,6 @@ from homeassistant.const import CONF_DEVICE_CLASS, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -122,9 +120,7 @@ def __init__( self._available_inputs: list[str] = [] self._available_apps: list[str] = [] - self._volume_step = config_entry.options[CONF_VOLUME_STEP] self._all_apps = apps_coordinator.data if apps_coordinator else None - self._conf_apps = config_entry.options.get(CONF_APPS, {}) self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get( CONF_ADDITIONAL_CONFIGS, [] ) @@ -142,6 +138,16 @@ def __init__( self._attr_device_class = device_class self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) + @property + def _volume_step(self) -> int: + """Return the configured volume step.""" + return self._config_entry.options[CONF_VOLUME_STEP] + + @property + def _conf_apps(self) -> dict: + """Return the configured app filter options.""" + return self._config_entry.options.get(CONF_APPS, {}) + def _apps_list(self, apps: list[str]) -> list[str]: """Return process apps list based on configured filters.""" if self._conf_apps.get(CONF_INCLUDE): @@ -225,22 +231,6 @@ def _get_additional_app_names(self) -> list[str]: additional_app["name"] for additional_app in self._additional_app_configs ] - @staticmethod - async def _async_send_update_options_signal( - hass: HomeAssistant, config_entry: VizioConfigEntry - ) -> None: - """Send update event when Vizio config entry is updated.""" - # Move this method to component level if another entity ever gets added for a - # single config entry. - # See here: https://github.com/home-assistant/core/pull/30653#discussion_r366426121 - async_dispatcher_send(hass, config_entry.entry_id, config_entry) - - async def _async_update_options(self, config_entry: VizioConfigEntry) -> None: - """Update options if the update signal comes from this entity.""" - self._volume_step = config_entry.options[CONF_VOLUME_STEP] - # Update so that CONF_ADDITIONAL_CONFIGS gets retained for imports - self._conf_apps.update(config_entry.options.get(CONF_APPS, {})) - async def async_update_setting( self, setting_type: str, setting_name: str, new_value: int | str ) -> None: @@ -259,19 +249,10 @@ async def async_added_to_hass(self) -> None: # Process initial coordinator data self._handle_coordinator_update() - # Register callback for when config entry is updated. - self.async_on_remove( - self._config_entry.add_update_listener( - self._async_send_update_options_signal - ) - ) + async def _async_write_state(*_: Any) -> None: + self._handle_coordinator_update() - # Register callback for update event - self.async_on_remove( - async_dispatcher_connect( - self.hass, self._config_entry.entry_id, self._async_update_options - ) - ) + self.async_on_remove(self._config_entry.add_update_listener(_async_write_state)) if not (apps_coordinator := self._apps_coordinator): return From 25bfb16936e2fecc10754a4dbb98842e40112f78 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 28 Mar 2026 17:40:03 +0300 Subject: [PATCH 0116/1707] Exception translations for Anthropic integration (#166723) --- .../components/anthropic/__init__.py | 16 ++++++- homeassistant/components/anthropic/ai_task.py | 7 ++- homeassistant/components/anthropic/entity.py | 46 +++++++++++++++---- .../components/anthropic/quality_scale.yaml | 2 +- homeassistant/components/anthropic/repairs.py | 8 +++- .../components/anthropic/strings.json | 41 +++++++++++++++++ .../components/anthropic/test_conversation.py | 2 +- tests/components/anthropic/test_init.py | 2 +- 8 files changed, 105 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index e479c1836ec3e3..9011ad21e42e92 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -45,9 +45,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> try: await client.models.list(timeout=10.0) except anthropic.AuthenticationError as err: - raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_authentication_error", + translation_placeholders={"message": err.message}, + ) from err except anthropic.AnthropicError as err: - raise ConfigEntryNotReady(err) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={ + "message": err.message + if isinstance(err, anthropic.APIError) + else str(err) + }, + ) from err entry.runtime_data = client diff --git a/homeassistant/components/anthropic/ai_task.py b/homeassistant/components/anthropic/ai_task.py index 8701e28577eefa..5445b6543979e8 100644 --- a/homeassistant/components/anthropic/ai_task.py +++ b/homeassistant/components/anthropic/ai_task.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.json import json_loads +from .const import DOMAIN from .entity import AnthropicBaseLLMEntity if TYPE_CHECKING: @@ -60,7 +61,7 @@ async def _async_generate_data( if not isinstance(chat_log.content[-1], conversation.AssistantContent): raise HomeAssistantError( - "Last content in chat log is not an AssistantContent" + translation_domain=DOMAIN, translation_key="response_not_found" ) text = chat_log.content[-1].content or "" @@ -78,7 +79,9 @@ async def _async_generate_data( err, text, ) - raise HomeAssistantError("Error with Claude structured response") from err + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="json_parse_error" + ) from err return ai_task.GenDataTaskResult( conversation_id=chat_log.conversation_id, diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 38a99cc39d9486..94c8616d010758 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -401,7 +401,11 @@ def _convert_content( messages[-1]["content"] = messages[-1]["content"][0]["text"] else: # Note: We don't pass SystemContent here as it's passed to the API as the prompt - raise HomeAssistantError("Unexpected content type in chat log") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unexpected_chat_log_content", + translation_placeholders={"type": type(content).__name__}, + ) return messages, container_id @@ -443,7 +447,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have Each message could contain multiple blocks of the same type. """ if stream is None or not hasattr(stream, "__aiter__"): - raise HomeAssistantError("Expected a stream of messages") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="unexpected_stream_object" + ) current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None current_tool_args: str @@ -605,7 +611,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have chat_log.async_trace(_create_token_stats(input_usage, usage)) content_details.container = response.delta.container if response.delta.stop_reason == "refusal": - raise HomeAssistantError("Potential policy violation detected") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_refusal" + ) elif isinstance(response, RawMessageStopEvent): if content_details: content_details.delete_empty() @@ -664,7 +672,9 @@ async def _async_handle_chat_log( system = chat_log.content[0] if not isinstance(system, conversation.SystemContent): - raise HomeAssistantError("First message must be a system message") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="system_message_not_found" + ) # System prompt with caching enabled system_prompt: list[TextBlockParam] = [ @@ -754,7 +764,7 @@ async def _async_handle_chat_log( last_message = messages[-1] if last_message["role"] != "user": raise HomeAssistantError( - "Last message must be a user message to add attachments" + translation_domain=DOMAIN, translation_key="user_message_not_found" ) if isinstance(last_message["content"], str): last_message["content"] = [ @@ -859,11 +869,19 @@ async def _async_handle_chat_log( except anthropic.AuthenticationError as err: self.entry.async_start_reauth(self.hass) raise HomeAssistantError( - "Authentication error with Anthropic API, reauthentication required" + translation_domain=DOMAIN, + translation_key="api_authentication_error", + translation_placeholders={"message": err.message}, ) from err except anthropic.AnthropicError as err: raise HomeAssistantError( - f"Sorry, I had a problem talking to Anthropic: {err}" + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={ + "message": err.message + if isinstance(err, anthropic.APIError) + else str(err) + }, ) from err if not chat_log.unresponded_tool_results: @@ -883,15 +901,23 @@ def append_files_to_content() -> Iterable[ImageBlockParam | DocumentBlockParam]: for file_path, mime_type in files: if not file_path.exists(): - raise HomeAssistantError(f"`{file_path}` does not exist") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="wrong_file_path", + translation_placeholders={"file_path": file_path.as_posix()}, + ) if mime_type is None: mime_type = guess_file_type(file_path)[0] if not mime_type or not mime_type.startswith(("image/", "application/pdf")): raise HomeAssistantError( - "Only images and PDF are supported by the Anthropic API," - f"`{file_path}` is not an image file or PDF" + translation_domain=DOMAIN, + translation_key="wrong_file_type", + translation_placeholders={ + "file_path": file_path.as_posix(), + "mime_type": mime_type or "unknown", + }, ) if mime_type == "image/jpg": mime_type = "image/jpeg" diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml index 37f605b1532a88..142285df629057 100644 --- a/homeassistant/components/anthropic/quality_scale.yaml +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -88,7 +88,7 @@ rules: comment: | No entities disabled by default. entity-translations: todo - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: done repair-issues: done diff --git a/homeassistant/components/anthropic/repairs.py b/homeassistant/components/anthropic/repairs.py index 4594967d379570..ac78e690eba012 100644 --- a/homeassistant/components/anthropic/repairs.py +++ b/homeassistant/components/anthropic/repairs.py @@ -161,7 +161,9 @@ def _async_update_current_subentry(self, user_input: dict[str, str]) -> None: is None or (subentry := entry.subentries.get(self._current_subentry_id)) is None ): - raise HomeAssistantError("Subentry not found") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="subentry_not_found" + ) updated_data = { **subentry.data, @@ -190,4 +192,6 @@ async def async_create_fix_flow( """Create flow.""" if issue_id == "model_deprecated": return ModelDeprecatedRepairFlow() - raise HomeAssistantError("Unknown issue ID") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="unknown_issue_id" + ) diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 4e34085a09c7fc..72b15fbe2dd720 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -149,6 +149,47 @@ } } }, + "exceptions": { + "api_authentication_error": { + "message": "Authentication error with Anthropic API: {message}. Reauthentication required." + }, + "api_error": { + "message": "Anthropic API error: {message}." + }, + "api_refusal": { + "message": "Potential policy violation detected." + }, + "json_parse_error": { + "message": "Error with Claude structured response." + }, + "response_not_found": { + "message": "Last content in chat log is not an AssistantContent." + }, + "subentry_not_found": { + "message": "Subentry not found." + }, + "system_message_not_found": { + "message": "First message must be a system message." + }, + "unexpected_chat_log_content": { + "message": "Unexpected content type in chat log: {type}." + }, + "unexpected_stream_object": { + "message": "Expected a stream of messages." + }, + "unknown_issue_id": { + "message": "Unknown issue ID." + }, + "user_message_not_found": { + "message": "Last message must be a user message to add attachments." + }, + "wrong_file_path": { + "message": "`{file_path}` does not exist." + }, + "wrong_file_type": { + "message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF." + } + }, "issues": { "model_deprecated": { "fix_flow": { diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 3ff1ea5fb2d5af..c753adb5d3628f 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -645,7 +645,7 @@ async def test_double_system_messages( assert result.response.error_code == "unknown" assert ( result.response.speech["plain"]["speech"] - == "Unexpected content type in chat log" + == "Unexpected content type in chat log: SystemContent" ) diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 26dcc6d130c4dd..a004e0e1a575c0 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -43,7 +43,7 @@ ), body={"type": "error", "error": {"type": "invalid_request_error"}}, ), - "anthropic integration not ready yet: Your credit balance is too low to access the Claude API", + "Your credit balance is too low to access the Claude API", ), ], ) From 818cf41c228a12a8a713c09637d909dbd098302d Mon Sep 17 00:00:00 2001 From: Mattie <6250046+MattieGit@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:49:24 +0100 Subject: [PATCH 0117/1707] Bump python-qube-heatpump to 1.8.0 (#166713) --- homeassistant/components/hr_energy_qube/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hr_energy_qube/manifest.json b/homeassistant/components/hr_energy_qube/manifest.json index 7d42cb6d478148..4c279e0ee373cf 100644 --- a/homeassistant/components/hr_energy_qube/manifest.json +++ b/homeassistant/components/hr_energy_qube/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["python_qube_heatpump"], "quality_scale": "bronze", - "requirements": ["python-qube-heatpump==1.7.0"] + "requirements": ["python-qube-heatpump==1.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67cdff4077d51c..6849113d64111c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2654,7 +2654,7 @@ python-picnic-api2==1.3.1 python-pooldose==0.9.0 # homeassistant.components.hr_energy_qube -python-qube-heatpump==1.7.0 +python-qube-heatpump==1.8.0 # homeassistant.components.rabbitair python-rabbitair==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b5d1a5ee17e7b2..563c03eb3ac1b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2256,7 +2256,7 @@ python-picnic-api2==1.3.1 python-pooldose==0.9.0 # homeassistant.components.hr_energy_qube -python-qube-heatpump==1.7.0 +python-qube-heatpump==1.8.0 # homeassistant.components.rabbitair python-rabbitair==0.0.8 From de0efa1639a1218564c821ae54dc239f4b432bb2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:50:26 +0100 Subject: [PATCH 0118/1707] Bump aioimmich to 0.12.1 (#166746) --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 25ecc5cec1c8ed..2a0680e314ae84 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "platinum", - "requirements": ["aioimmich==0.12.0"] + "requirements": ["aioimmich==0.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6849113d64111c..8c35a232e54933 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -294,7 +294,7 @@ aiohue==4.8.0 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.12.0 +aioimmich==0.12.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 563c03eb3ac1b6..11defba2299dba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -282,7 +282,7 @@ aiohue==4.8.0 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.12.0 +aioimmich==0.12.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 3562a3800fc9bee20ac532cde7521f4e769f5979 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 28 Mar 2026 16:46:49 +0100 Subject: [PATCH 0119/1707] Improve energyid config flow tests (#166749) --- tests/components/energyid/test_config_flow.py | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/tests/components/energyid/test_config_flow.py b/tests/components/energyid/test_config_flow.py index 694f3ccb571a4c..4fb476e8929ef7 100644 --- a/tests/components/energyid/test_config_flow.py +++ b/tests/components/energyid/test_config_flow.py @@ -40,6 +40,15 @@ def mock_polling_interval_fixture() -> Generator[int]: yield polling_interval +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.energyid.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + async def test_config_flow_user_step_success_claimed(hass: HomeAssistant) -> None: """Test user step where device is already claimed.""" mock_client = MagicMock() @@ -105,7 +114,6 @@ def mock_webhook_client(*args, **kwargs): "homeassistant.components.energyid.config_flow.WebhookClient", side_effect=mock_webhook_client, ), - patch("homeassistant.components.energyid.config_flow.asyncio.sleep"), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -146,13 +154,13 @@ async def test_config_flow_claim_timeout(hass: HomeAssistant) -> None: "homeassistant.components.energyid.config_flow.WebhookClient", return_value=mock_unclaimed_client, ), - patch( - "homeassistant.components.energyid.config_flow.asyncio.sleep", - ) as mock_sleep, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + + assert mock_unclaimed_client.authenticate.call_count == 0 + result_external = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -160,8 +168,17 @@ async def test_config_flow_claim_timeout(hass: HomeAssistant) -> None: CONF_PROVISIONING_SECRET: TEST_PROVISIONING_SECRET, }, ) + assert result_external["type"] is FlowResultType.EXTERNAL_STEP + # Wait for the polling to time out. + await hass.async_block_till_done() + + # Verify polling actually ran the expected number of times + # +1 for the initial attempt before polling starts + assert mock_unclaimed_client.authenticate.call_count == MAX_POLLING_ATTEMPTS + 1 + mock_unclaimed_client.authenticate.reset_mock() + # Simulate polling timeout, then user continuing the flow result_after_timeout = await hass.config_entries.flow.async_configure( result_external["flow_id"] @@ -169,14 +186,10 @@ async def test_config_flow_claim_timeout(hass: HomeAssistant) -> None: await hass.async_block_till_done() # After timeout, polling stops and user continues - should see external step again + assert mock_unclaimed_client.authenticate.call_count == 1 assert result_after_timeout["type"] is FlowResultType.EXTERNAL_STEP assert result_after_timeout["step_id"] == "auth_and_claim" - # Verify polling actually ran the expected number of times - # Sleep happens at beginning of polling loop, so MAX_POLLING_ATTEMPTS + 1 sleeps - # but only MAX_POLLING_ATTEMPTS authentication attempts - assert mock_sleep.call_count == MAX_POLLING_ATTEMPTS + 1 - async def test_duplicate_unique_id_prevented(hass: HomeAssistant) -> None: """Test that duplicate device_id (unique_id) is detected and aborted.""" @@ -546,7 +559,6 @@ async def test_config_flow_reauth_needs_claim(hass: HomeAssistant) -> None: "homeassistant.components.energyid.config_flow.WebhookClient", return_value=mock_client, ), - patch("homeassistant.components.energyid.config_flow.asyncio.sleep"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -607,7 +619,6 @@ def mock_webhook_client(*args, **kwargs): "homeassistant.components.energyid.config_flow.WebhookClient", side_effect=mock_webhook_client, ), - patch("homeassistant.components.energyid.config_flow.asyncio.sleep"), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -655,7 +666,6 @@ def mock_webhook_client(*args, **kwargs): "homeassistant.components.energyid.config_flow.WebhookClient", side_effect=mock_webhook_client, ), - patch("homeassistant.components.energyid.config_flow.asyncio.sleep"), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -707,7 +717,6 @@ def mock_webhook_client(*args, **kwargs): "homeassistant.components.energyid.config_flow.WebhookClient", side_effect=mock_webhook_client, ), - patch("homeassistant.components.energyid.config_flow.asyncio.sleep"), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -815,7 +824,6 @@ async def auth_with_error(): "homeassistant.components.energyid.config_flow.WebhookClient", side_effect=mock_webhook_client, ), - patch("homeassistant.components.energyid.config_flow.asyncio.sleep"), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -888,7 +896,6 @@ async def auth_success(): "homeassistant.components.energyid.config_flow.WebhookClient", side_effect=mock_webhook_client, ), - patch("homeassistant.components.energyid.config_flow.asyncio.sleep"), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -911,25 +918,16 @@ async def auth_success(): await hass.async_block_till_done() # Verify polling made authentication attempt - # auth_call_count should be 1 (polling detected device is claimed) - assert auth_call_count >= 1 - claimed_auth_count = auth_call_count + assert auth_call_count == 2 # One for polling, one for the final check # User continues - device is already claimed, polling should be cancelled result_done = await hass.config_entries.flow.async_configure( result_external["flow_id"] ) - assert result_done["type"] is FlowResultType.EXTERNAL_STEP_DONE - - # Verify polling was cancelled - the auth count should only increase by 1 - # (for the manual check when user continues, not from polling) - assert auth_call_count == claimed_auth_count + 1 + assert result_done["type"] is FlowResultType.CREATE_ENTRY - # Final call to create entry - final_result = await hass.config_entries.flow.async_configure( - result_external["flow_id"] - ) - assert final_result["type"] is FlowResultType.CREATE_ENTRY + # Verify polling was cancelled - the auth count should not increase + assert auth_call_count == 2 # Wait a bit and verify no further authentication attempts from polling await hass.async_block_till_done() From 0ba3a94a3b125ececc057925fa02bc3d0baaf2d9 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:50:01 -0700 Subject: [PATCH 0120/1707] Handle Oauth2 ImplementationUnavailableError in google_tasks (#166657) Co-authored-by: Claude Sonnet 4.6 --- .../components/google_tasks/__init__.py | 14 ++++++++---- .../components/google_tasks/strings.json | 5 +++++ tests/components/google_tasks/test_init.py | 22 ++++++++++++++++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py index 2d570854ad4476..494295f69f2f64 100644 --- a/homeassistant/components/google_tasks/__init__.py +++ b/homeassistant/components/google_tasks/__init__.py @@ -25,11 +25,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleTasksConfigEntry) -> bool: """Set up Google Tasks from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) auth = api.AsyncConfigEntryAuth(hass, session) try: diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index d8ff18e0a7fb37..ea329c61bd1134 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -42,5 +42,10 @@ "title": "[%key:common::config_flow::title::reauth%]" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/google_tasks/test_init.py b/tests/components/google_tasks/test_init.py index e93e0d9c643d29..c1ed1cf609f059 100644 --- a/tests/components/google_tasks/test_init.py +++ b/tests/components/google_tasks/test_init.py @@ -5,7 +5,7 @@ from http import HTTPStatus import json import time -from unittest.mock import Mock +from unittest.mock import Mock, patch from aiohttp import ClientError from httplib2 import Response @@ -15,6 +15,9 @@ from homeassistant.components.google_tasks.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from .conftest import LIST_TASK_LIST_RESPONSE, LIST_TASKS_RESPONSE_WATER @@ -152,3 +155,20 @@ async def test_setup_error( assert not await integration_setup() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_tasks.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 0e1663f2596f529d35523dc7f8dedecdc6e6a5a9 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:51:09 -0700 Subject: [PATCH 0121/1707] Handle Oauth2 ImplementationUnavailableError in gentex_homelink (#166646) Co-authored-by: Claude Sonnet 4.6 --- .../components/gentex_homelink/__init__.py | 16 +++++++++----- .../components/gentex_homelink/strings.json | 5 +++++ tests/components/gentex_homelink/test_init.py | 21 +++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gentex_homelink/__init__.py b/homeassistant/components/gentex_homelink/__init__.py index cdd37a7920f266..68cf0dfac527c3 100644 --- a/homeassistant/components/gentex_homelink/__init__.py +++ b/homeassistant/components/gentex_homelink/__init__.py @@ -7,7 +7,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import oauth2 @@ -29,11 +29,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> hass, DOMAIN, auth_implementation ) - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) authenticated_session = oauth2.AsyncConfigEntryAuth( diff --git a/homeassistant/components/gentex_homelink/strings.json b/homeassistant/components/gentex_homelink/strings.json index 0a5fec312da32d..111fca492dab8c 100644 --- a/homeassistant/components/gentex_homelink/strings.json +++ b/homeassistant/components/gentex_homelink/strings.json @@ -49,5 +49,10 @@ } } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/gentex_homelink/test_init.py b/tests/components/gentex_homelink/test_init.py index 607430cdab0caa..d4003c3d0a1aff 100644 --- a/tests/components/gentex_homelink/test_init.py +++ b/tests/components/gentex_homelink/test_init.py @@ -8,6 +8,9 @@ from homeassistant.components.gentex_homelink.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) import homeassistant.helpers.device_registry as dr from . import setup_integration, update_callback @@ -72,3 +75,21 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("aioclient_mock_fixture") +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 6eb834946bb7195cbd39573695c88f7b1c666f3b Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:51:48 -0700 Subject: [PATCH 0122/1707] Handle Oauth2 ImplementationUnavailableError in lyric (#166655) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/lyric/__init__.py | 15 +++++--- homeassistant/components/lyric/strings.json | 5 +++ tests/components/lyric/test_init.py | 40 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 tests/components/lyric/test_init.py diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index c221b03a891157..95fb559491d325 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -6,6 +6,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, @@ -27,11 +28,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool: """Set up Honeywell Lyric from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err if not isinstance(implementation, LyricLocalOAuth2Implementation): raise TypeError("Unexpected auth implementation; can't find oauth client id") diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index c3bace886d4305..51f1cff5269aca 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -64,6 +64,11 @@ } } }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } + }, "services": { "set_hold_time": { "description": "Sets the time period to keep the temperature and override the schedule.", diff --git a/tests/components/lyric/test_init.py b/tests/components/lyric/test_init.py new file mode 100644 index 00000000000000..43316079fe1140 --- /dev/null +++ b/tests/components/lyric/test_init.py @@ -0,0 +1,40 @@ +"""Tests for the Honeywell Lyric integration.""" + +from unittest.mock import patch + +from homeassistant.components.lyric.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) + +from tests.common import MockConfigEntry + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": 9999999999, + "token_type": "Bearer", + }, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY From 09067a18b779ed0c42ac118e74be6d5f018961dd Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:52:42 -0700 Subject: [PATCH 0123/1707] Handle Oauth2 ImplementationUnavailableError in husqvarna_automower (#166633) Co-authored-by: Claude Sonnet 4.6 --- .../husqvarna_automower/__init__.py | 17 ++++++++++++---- .../husqvarna_automower/strings.json | 3 +++ .../husqvarna_automower/test_init.py | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 9ff6b1e06f8a04..c8260d4d825d95 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -11,6 +11,9 @@ config_entry_oauth2_flow, config_validation as cv, ) +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -42,11 +45,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool: """Set up this integration using UI.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) api_api = api.AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 912c6c3b51a7b5..b40d3666247add 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -491,6 +491,9 @@ "command_send_failed": { "message": "Failed to send command: {exception}" }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, "work_area_not_existing": { "message": "The selected work area does not exist." }, diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index e5d26400c37a40..14fa01fa938bc4 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -24,6 +24,9 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.util import dt as dt_util from . import setup_integration @@ -722,3 +725,20 @@ def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_automower_client.get_status.call_count == 2 + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 3757289c737ae2fa078c7f26ccb081c0b6bf78e2 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:53:20 -0700 Subject: [PATCH 0124/1707] Handle Oauth2 ImplementationUnavailableError in geocaching (#166648) Co-authored-by: Claude Sonnet 4.6 --- .../components/geocaching/__init__.py | 11 +++++++- .../components/geocaching/strings.json | 5 ++++ tests/components/geocaching/test_init.py | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/components/geocaching/test_init.py diff --git a/homeassistant/components/geocaching/__init__.py b/homeassistant/components/geocaching/__init__.py index 144249ac42fb06..be9e3c29b93b3e 100644 --- a/homeassistant/components/geocaching/__init__.py +++ b/homeassistant/components/geocaching/__init__.py @@ -2,11 +2,14 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) +from .const import DOMAIN from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -14,7 +17,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeocachingConfigEntry) -> bool: """Set up Geocaching from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err oauth_session = OAuth2Session(hass, entry, implementation) coordinator = GeocachingDataUpdateCoordinator( diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json index 4c31566e7b4609..896a239fb2cbb0 100644 --- a/homeassistant/components/geocaching/strings.json +++ b/homeassistant/components/geocaching/strings.json @@ -65,5 +65,10 @@ "unit_of_measurement": "souvenirs" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/geocaching/test_init.py b/tests/components/geocaching/test_init.py new file mode 100644 index 00000000000000..97d6753e11aa3c --- /dev/null +++ b/tests/components/geocaching/test_init.py @@ -0,0 +1,28 @@ +"""Tests for the Geocaching integration.""" + +from unittest.mock import patch + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) + +from tests.common import MockConfigEntry + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.geocaching.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From a2e60f84da904e6298d19b78fa6bea9d4e2f77cc Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:53:47 -0700 Subject: [PATCH 0125/1707] Handle Oauth2 ImplementationUnavailableError in google_sheets (#166651) Co-authored-by: Claude Sonnet 4.6 --- .../components/google_sheets/__init__.py | 9 ++++++++- .../components/google_sheets/strings.json | 5 +++++ tests/components/google_sheets/test_init.py | 20 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 9998134815177c..de88c6028b9d1f 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -15,6 +15,7 @@ ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) @@ -40,7 +41,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: GoogleSheetsConfigEntry ) -> bool: """Set up Google Sheets from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index c748aace6981a6..7dfe6bc36129c0 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -42,6 +42,11 @@ } } }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } + }, "services": { "append_sheet": { "description": "Appends data to a worksheet in Google Sheets.", diff --git a/tests/components/google_sheets/test_init.py b/tests/components/google_sheets/test_init.py index 7bb7369c7b5b56..d6782c2e446af7 100644 --- a/tests/components/google_sheets/test_init.py +++ b/tests/components/google_sheets/test_init.py @@ -35,6 +35,9 @@ OAuth2TokenRequestTransientError, ServiceValidationError, ) +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -558,3 +561,20 @@ async def test_get_sheet_invalid_worksheet( blocking=True, return_response=True, ) + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_sheets.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From f45c84b2a82b56de0800c46094f058e5999c1220 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:54:00 -0700 Subject: [PATCH 0126/1707] Handle Oauth2 ImplementationUnavailableError in iotty (#166652) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/iotty/__init__.py | 11 ++++++++++- homeassistant/components/iotty/strings.json | 5 +++++ tests/components/iotty/test_init.py | 22 ++++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iotty/__init__.py b/homeassistant/components/iotty/__init__.py index c9eb26393489fe..02e6912649258f 100644 --- a/homeassistant/components/iotty/__init__.py +++ b/homeassistant/components/iotty/__init__.py @@ -6,11 +6,14 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) +from .const import DOMAIN from .coordinator import ( IottyConfigEntry, IottyConfigEntryData, @@ -26,7 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: IottyConfigEntry) -> boo """Set up iotty from a config entry.""" _LOGGER.debug("async_setup_entry entry_id=%s", entry.entry_id) - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) data_update_coordinator = IottyDataUpdateCoordinator(hass, entry, session) diff --git a/homeassistant/components/iotty/strings.json b/homeassistant/components/iotty/strings.json index 33176be1235ca6..2e37d0c99f0bdb 100644 --- a/homeassistant/components/iotty/strings.json +++ b/homeassistant/components/iotty/strings.json @@ -25,5 +25,10 @@ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/iotty/test_init.py b/tests/components/iotty/test_init.py index ee8168fdf2ff1b..5616438e2d475c 100644 --- a/tests/components/iotty/test_init.py +++ b/tests/components/iotty/test_init.py @@ -1,11 +1,14 @@ """Tests for the iotty integration.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from homeassistant.components.iotty.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from tests.common import MockConfigEntry @@ -41,6 +44,23 @@ async def test_load_unload_coordinator_called( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iotty.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_load_unload_iottyproxy_called( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 3850bb0e57fa645c5596dcf3bfa28994a259418a Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:55:24 -0700 Subject: [PATCH 0127/1707] Handle Oauth2 ImplementationUnavailableError in google_mail (#166650) Co-authored-by: Claude Sonnet 4.6 --- .../components/google_mail/__init__.py | 10 ++++++++- .../components/google_mail/strings.json | 3 +++ tests/components/google_mail/test_init.py | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 534ce783cbc16a..844b5efb65eddc 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -5,8 +5,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) @@ -34,7 +36,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool: """Set up Google Mail from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth(hass, session) await auth.check_and_refresh_token() diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index e1c74a6553d8b8..d1e4472c208048 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -51,6 +51,9 @@ "exceptions": { "missing_from_for_alias": { "message": "Missing 'from' email when setting an alias to show. You have to provide a 'from' email" + }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" } }, "services": { diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index 91e7d4abe0a5b5..791ef6f8e88149 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -11,9 +11,13 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from .conftest import GOOGLE_TOKEN_URI, ComponentSetup +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -134,3 +138,20 @@ async def test_device_info( assert device.identifiers == {(DOMAIN, entry.entry_id)} assert device.manufacturer == "Google, Inc." assert device.name == "example@gmail.com" + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_mail.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 2399da93db84d02e526606f23a27d67011b42c0e Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:55:55 -0700 Subject: [PATCH 0128/1707] Handle Oauth2 ImplementationUnavailableError in google_assistant_sdk (#166649) Co-authored-by: Claude Sonnet 4.6 --- .../google_assistant_sdk/__init__.py | 9 ++++++++- .../google_assistant_sdk/strings.json | 3 +++ .../google_assistant_sdk/test_init.py | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 27573a68a27bf9..5df6ba19217b41 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -17,6 +17,7 @@ ) from homeassistant.helpers import config_validation as cv, discovery, intent from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) @@ -52,7 +53,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry ) -> bool: """Set up Google Assistant SDK from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 8a509430c53e86..b1997ff06b3a13 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -48,6 +48,9 @@ "grpc_error": { "message": "Failed to communicate with Google Assistant" }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, "reauth_required": { "message": "Credentials are invalid, re-authentication required" } diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index e45037a19bd053..d214723fcfc2ce 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -16,6 +16,9 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.setup import async_setup_component from .conftest import ComponentSetup, ExpectedCredentials @@ -492,3 +495,20 @@ async def test_conversation_agent_language_changed( mock_text_assistant.assert_has_calls([call(ExpectedCredentials(), "es-ES")]) mock_text_assistant.assert_has_calls([call().assist(text1)]) mock_text_assistant.assert_has_calls([call().assist(text2)]) + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_assistant_sdk.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 116fa57903aa59f4848627036bbb8551a5337c17 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 08:56:39 -0700 Subject: [PATCH 0129/1707] Handle Oauth2 ImplementationUnavailableError in monzo (#166653) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/monzo/__init__.py | 11 ++++++++++- homeassistant/components/monzo/strings.json | 5 +++++ tests/components/monzo/test_init.py | 22 ++++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py index e0aa3f3a8479ca..b0a516ae8ada50 100644 --- a/homeassistant/components/monzo/__init__.py +++ b/homeassistant/components/monzo/__init__.py @@ -6,13 +6,16 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, OAuth2Session, async_get_config_entry_implementation, ) from .api import AuthenticatedMonzoAPI +from .const import DOMAIN from .coordinator import MonzoConfigEntry, MonzoCoordinator _LOGGER = logging.getLogger(__name__) @@ -39,7 +42,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> b async def async_setup_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> bool: """Set up Monzo from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = OAuth2Session(hass, entry, implementation) diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json index edb1cd513f66a2..8f727194ab6212 100644 --- a/homeassistant/components/monzo/strings.json +++ b/homeassistant/components/monzo/strings.json @@ -50,5 +50,10 @@ "name": "Total balance" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/monzo/test_init.py b/tests/components/monzo/test_init.py index f255160f1ed722..11fe55f3cf1b64 100644 --- a/tests/components/monzo/test_init.py +++ b/tests/components/monzo/test_init.py @@ -7,8 +7,11 @@ from monzopy import AuthorisationExpiredError from homeassistant.components.monzo.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from . import setup_integration @@ -61,3 +64,20 @@ async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: assert entry.version == 1 assert entry.minor_version == 2 assert entry.unique_id == "600" + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + polling_config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + polling_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.monzo.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert polling_config_entry.state is ConfigEntryState.SETUP_RETRY From fbe4195ae0f195c614600d8309bd01071c95849c Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sat, 28 Mar 2026 16:06:11 +0000 Subject: [PATCH 0130/1707] Add event entity to Transmission (#166686) --- .../components/transmission/__init__.py | 2 +- .../components/transmission/const.py | 4 + .../components/transmission/coordinator.py | 113 ++++++++++++++---- .../components/transmission/event.py | 85 +++++++++++++ .../components/transmission/icons.json | 5 + .../components/transmission/strings.json | 14 +++ tests/components/transmission/test_event.py | 102 ++++++++++++++++ 7 files changed, 301 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/transmission/event.py create mode 100644 tests/components/transmission/test_event.py diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index d32c5e548bb47b..c9eff349f7ffac 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR, Platform.SWITCH] MIGRATION_NAME_TO_KEY = { # Sensors diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index b603b9dc0e3762..cde621249c3523 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -55,6 +55,10 @@ EVENT_REMOVED_TORRENT = "transmission_removed_torrent" EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent" +EVENT_TYPE_STARTED = "started" +EVENT_TYPE_REMOVED = "removed" +EVENT_TYPE_DOWNLOADED = "downloaded" + STATE_UP_DOWN = "up_down" STATE_SEEDING = "seeding" STATE_DOWNLOADING = "downloading" diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index e47b0171a5cbba..56f4f7666cdb7e 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -1,19 +1,24 @@ -"""Coordinator for transmssion integration.""" +"""Coordinator for transmission integration.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging import transmission_rpc from transmission_rpc.session import SessionStats from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ID, ATTR_NAME, CONF_HOST +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + ATTR_DOWNLOAD_PATH, + ATTR_LABELS, CONF_LIMIT, CONF_ORDER, DEFAULT_LIMIT, @@ -23,13 +28,28 @@ EVENT_DOWNLOADED_TORRENT, EVENT_REMOVED_TORRENT, EVENT_STARTED_TORRENT, + EVENT_TYPE_DOWNLOADED, + EVENT_TYPE_REMOVED, + EVENT_TYPE_STARTED, ) _LOGGER = logging.getLogger(__name__) +type EventCallback = Callable[[TransmissionEventData], None] type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator] +@dataclass +class TransmissionEventData: + """Data for a single event.""" + + event_type: str + name: str + id: int + download_path: str + labels: list[str] + + class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): """Transmission dataupdate coordinator class.""" @@ -49,6 +69,7 @@ def __init__( self._all_torrents: list[transmission_rpc.Torrent] = [] self._completed_torrents: list[transmission_rpc.Torrent] = [] self._started_torrents: list[transmission_rpc.Torrent] = [] + self._event_listeners: dict[str, EventCallback] = {} self.torrents: list[transmission_rpc.Torrent] = [] super().__init__( hass, @@ -68,9 +89,32 @@ def order(self) -> str: """Return order.""" return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) # type: ignore[no-any-return] + @callback + def async_add_event_listener( + self, update_callback: EventCallback, target_event_id: str + ) -> Callable[[], None]: + """Listen for updates.""" + self._event_listeners[target_event_id] = update_callback + return partial(self.__async_remove_listener_internal, target_event_id) + + def __async_remove_listener_internal(self, listener_id: str) -> None: + self._event_listeners.pop(listener_id, None) + + @callback + def _async_notify_event_listeners(self, event: TransmissionEventData) -> None: + """Notify event listeners in the event loop.""" + for listener in list(self._event_listeners.values()): + listener(event) + async def _async_update_data(self) -> SessionStats: """Update transmission data.""" - return await self.hass.async_add_executor_job(self.update) + data = await self.hass.async_add_executor_job(self.update) + + self.check_completed_torrent() + self.check_started_torrent() + self.check_removed_torrent() + + return data def update(self) -> SessionStats: """Get the latest data from Transmission instance.""" @@ -82,10 +126,6 @@ def update(self) -> SessionStats: except transmission_rpc.TransmissionError as err: raise UpdateFailed("Unable to connect to Transmission client") from err - self.check_completed_torrent() - self.check_started_torrent() - self.check_removed_torrent() - return data def init_torrent_list(self) -> None: @@ -108,15 +148,24 @@ def check_completed_torrent(self) -> None: for torrent in current_completed_torrents: if torrent.id not in old_completed_torrents: - self.hass.bus.fire( + # Once event triggers are out of labs we can remove the bus event + self.hass.bus.async_fire( EVENT_DOWNLOADED_TORRENT, { - "name": torrent.name, - "id": torrent.id, - "download_path": torrent.download_dir, - "labels": torrent.labels, + ATTR_NAME: torrent.name, + ATTR_ID: torrent.id, + ATTR_DOWNLOAD_PATH: torrent.download_dir, + ATTR_LABELS: torrent.labels, }, ) + event = TransmissionEventData( + event_type=EVENT_TYPE_DOWNLOADED, + name=torrent.name, + id=torrent.id, + download_path=torrent.download_dir or "", + labels=torrent.labels, + ) + self._async_notify_event_listeners(event) self._completed_torrents = current_completed_torrents @@ -130,15 +179,24 @@ def check_started_torrent(self) -> None: for torrent in current_started_torrents: if torrent.id not in old_started_torrents: - self.hass.bus.fire( + # Once event triggers are out of labs we can remove the bus event + self.hass.bus.async_fire( EVENT_STARTED_TORRENT, { - "name": torrent.name, - "id": torrent.id, - "download_path": torrent.download_dir, - "labels": torrent.labels, + ATTR_NAME: torrent.name, + ATTR_ID: torrent.id, + ATTR_DOWNLOAD_PATH: torrent.download_dir, + ATTR_LABELS: torrent.labels, }, ) + event = TransmissionEventData( + event_type=EVENT_TYPE_STARTED, + name=torrent.name, + id=torrent.id, + download_path=torrent.download_dir or "", + labels=torrent.labels, + ) + self._async_notify_event_listeners(event) self._started_torrents = current_started_torrents @@ -148,15 +206,24 @@ def check_removed_torrent(self) -> None: for torrent in self._all_torrents: if torrent.id not in current_torrents: - self.hass.bus.fire( + # Once event triggers are out of labs we can remove the bus event + self.hass.bus.async_fire( EVENT_REMOVED_TORRENT, { - "name": torrent.name, - "id": torrent.id, - "download_path": torrent.download_dir, - "labels": torrent.labels, + ATTR_NAME: torrent.name, + ATTR_ID: torrent.id, + ATTR_DOWNLOAD_PATH: torrent.download_dir, + ATTR_LABELS: torrent.labels, }, ) + event = TransmissionEventData( + event_type=EVENT_TYPE_REMOVED, + name=torrent.name, + id=torrent.id, + download_path=torrent.download_dir or "", + labels=torrent.labels, + ) + self._async_notify_event_listeners(event) self._all_torrents = self.torrents.copy() diff --git a/homeassistant/components/transmission/event.py b/homeassistant/components/transmission/event.py new file mode 100644 index 00000000000000..79cf21a5ffda7f --- /dev/null +++ b/homeassistant/components/transmission/event.py @@ -0,0 +1,85 @@ +"""Define events for the Transmission integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.const import ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + ATTR_DOWNLOAD_PATH, + ATTR_LABELS, + EVENT_TYPE_DOWNLOADED, + EVENT_TYPE_REMOVED, + EVENT_TYPE_STARTED, +) +from .coordinator import TransmissionConfigEntry, TransmissionEventData +from .entity import TransmissionEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TransmissionConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Transmission event platform.""" + coordinator = config_entry.runtime_data + + description = EventEntityDescription( + key="torrent", + translation_key="torrent", + event_types=[ + EVENT_TYPE_STARTED, + EVENT_TYPE_DOWNLOADED, + EVENT_TYPE_REMOVED, + ], + ) + + async_add_entities([TransmissionEvent(coordinator, description)]) + + +class TransmissionEvent(TransmissionEntity, EventEntity): + """Representation of a Transmission event entity.""" + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + if TYPE_CHECKING: + assert self._attr_unique_id + + self.async_on_remove( + self.coordinator.async_add_event_listener( + self._handle_event, self._attr_unique_id + ) + ) + + @callback + def _handle_event(self, event_data: TransmissionEventData) -> None: + """Handle the torrent events.""" + + event_type = event_data.event_type + + if event_type not in self.event_types: + _LOGGER.warning("Event type %s is not known", event_type) + return + + self._trigger_event( + event_type, + { + ATTR_NAME: event_data.name, + ATTR_ID: event_data.id, + ATTR_DOWNLOAD_PATH: event_data.download_path, + ATTR_LABELS: event_data.labels, + }, + ) + + self.async_write_ha_state() diff --git a/homeassistant/components/transmission/icons.json b/homeassistant/components/transmission/icons.json index 551ba07766f54d..1d5b149487e1ed 100644 --- a/homeassistant/components/transmission/icons.json +++ b/homeassistant/components/transmission/icons.json @@ -8,6 +8,11 @@ } } }, + "event": { + "torrent": { + "default": "mdi:folder-file-outline" + } + }, "sensor": { "active_torrents": { "default": "mdi:counter" diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 73dd986a8e09bf..c7ec6ea742670d 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -50,6 +50,20 @@ } } }, + "event": { + "torrent": { + "name": "Torrent", + "state_attributes": { + "event_type": { + "state": { + "downloaded": "Downloaded", + "removed": "Removed", + "started": "Started" + } + } + } + } + }, "sensor": { "active_torrents": { "name": "Active torrents", diff --git a/tests/components/transmission/test_event.py b/tests/components/transmission/test_event.py new file mode 100644 index 00000000000000..3b2c4349c3a439 --- /dev/null +++ b/tests/components/transmission/test_event.py @@ -0,0 +1,102 @@ +"""Tests for the Transmission event platform.""" + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES +from homeassistant.components.transmission.const import ( + DEFAULT_SCAN_INTERVAL, + EVENT_TYPE_DOWNLOADED, + EVENT_TYPE_REMOVED, + EVENT_TYPE_STARTED, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_event_entity_setup( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the event entity is created with expected capabilities.""" + with patch("homeassistant.components.transmission.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get("event.transmission_torrent") + assert state is not None + assert state.state == "unknown" + assert state.attributes[ATTR_EVENT_TYPE] is None + assert state.attributes[ATTR_EVENT_TYPES] == [ + EVENT_TYPE_STARTED, + EVENT_TYPE_DOWNLOADED, + EVENT_TYPE_REMOVED, + ] + + +@pytest.mark.parametrize( + ("hass_event", "expected_event_type"), + [ + (EVENT_TYPE_STARTED, EVENT_TYPE_STARTED), + (EVENT_TYPE_DOWNLOADED, EVENT_TYPE_DOWNLOADED), + (EVENT_TYPE_REMOVED, EVENT_TYPE_REMOVED), + ], +) +async def test_event_updates_state( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + hass_event: str, + expected_event_type: str, +) -> None: + """Test Transmission events update the entity state and attributes.""" + with patch("homeassistant.components.transmission.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + client = mock_transmission_client.return_value + torrent_status = { + EVENT_TYPE_STARTED: "downloading", + EVENT_TYPE_DOWNLOADED: "seeding", + EVENT_TYPE_REMOVED: "stopped", + }[hass_event] + torrent = SimpleNamespace( + id=1, + name="Test", + status=torrent_status, + download_dir="/downloads", + labels=[], + ) + + torrents_sequence = { + EVENT_TYPE_STARTED: [[torrent]], + EVENT_TYPE_DOWNLOADED: [[torrent]], + EVENT_TYPE_REMOVED: [[torrent], []], + }[hass_event] + + client.get_torrents.side_effect = torrents_sequence + + for _ in torrents_sequence: + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("event.transmission_torrent") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == expected_event_type + assert state.attributes["id"] == 1 + assert state.attributes["name"] == "Test" + assert state.attributes["download_path"] == "/downloads" + assert state.attributes["labels"] == [] + assert dt_util.parse_datetime(state.state) is not None From 4a0a400e228b73dee62871008f3a98a7a1424b84 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 28 Mar 2026 12:12:24 -0400 Subject: [PATCH 0131/1707] Bump pydrawise to 2026.3.0 (#166750) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 2ad8d8f36bdc57..069ca3ef500c24 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.9.0"] + "requirements": ["pydrawise==2026.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8c35a232e54933..390c4cbfa536cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2055,7 +2055,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.9.0 +pydrawise==2026.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11defba2299dba..b8ef8e88c8b248 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1768,7 +1768,7 @@ pydexcom==0.5.1 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.9.0 +pydrawise==2026.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 From a4a36b5cbd4713e59dd07bf57dff70a1bf3ab387 Mon Sep 17 00:00:00 2001 From: Will Moss Date: Sat, 28 Mar 2026 09:18:05 -0700 Subject: [PATCH 0132/1707] Handle Oauth2 ImplementationUnavailableError in microbees (#166654) Co-authored-by: Claude Sonnet 4.6 --- .../components/microbees/__init__.py | 16 +++++++++----- .../components/microbees/strings.json | 5 +++++ tests/components/microbees/test_init.py | 21 +++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/microbees/__init__.py b/homeassistant/components/microbees/__init__.py index af5d4aa32c782d..f0a39dc0352b36 100644 --- a/homeassistant/components/microbees/__init__.py +++ b/homeassistant/components/microbees/__init__.py @@ -13,7 +13,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow -from .const import PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import MicroBeesUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -50,11 +50,17 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) async def async_setup_entry(hass: HomeAssistant, entry: MicroBeesConfigEntry) -> bool: """Set up microBees from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) try: diff --git a/homeassistant/components/microbees/strings.json b/homeassistant/components/microbees/strings.json index 87c91b8c9ad18c..1ad8adbd63df39 100644 --- a/homeassistant/components/microbees/strings.json +++ b/homeassistant/components/microbees/strings.json @@ -35,5 +35,10 @@ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/tests/components/microbees/test_init.py b/tests/components/microbees/test_init.py index f70c83875724a9..83228d8babfbb2 100644 --- a/tests/components/microbees/test_init.py +++ b/tests/components/microbees/test_init.py @@ -3,7 +3,11 @@ from unittest.mock import patch from homeassistant.components.microbees.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from tests.common import MockConfigEntry @@ -33,3 +37,20 @@ async def test_migrate_entry_minor_version_1_2(hass: HomeAssistant) -> None: assert entry.version == 1 assert entry.minor_version == 2 assert entry.unique_id == "54321" + + +async def test_oauth_implementation_not_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 96891228c913cb4dd2f04a1330349ee24ecad647 Mon Sep 17 00:00:00 2001 From: Anis Kadri Date: Sat, 28 Mar 2026 09:19:49 -0700 Subject: [PATCH 0133/1707] Add select platform to UniFi Access integration (#166096) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: RaHehl --- .../components/unifi_access/__init__.py | 1 + .../components/unifi_access/coordinator.py | 31 +++ .../components/unifi_access/select.py | 102 ++++++++ .../components/unifi_access/strings.json | 16 ++ tests/components/unifi_access/conftest.py | 1 + .../unifi_access/snapshots/test_select.ambr | 127 +++++++++ tests/components/unifi_access/test_select.py | 243 ++++++++++++++++++ 7 files changed, 521 insertions(+) create mode 100644 homeassistant/components/unifi_access/select.py create mode 100644 tests/components/unifi_access/snapshots/test_select.ambr create mode 100644 tests/components/unifi_access/test_select.py diff --git a/homeassistant/components/unifi_access/__init__.py b/homeassistant/components/unifi_access/__init__.py index e92757ef11d996..b73b99fbce8cad 100644 --- a/homeassistant/components/unifi_access/__init__.py +++ b/homeassistant/components/unifi_access/__init__.py @@ -16,6 +16,7 @@ Platform.BUTTON, Platform.EVENT, Platform.IMAGE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index d9882c0ca8c01d..262af73590853d 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -15,7 +15,9 @@ ApiNotFoundError, Door, DoorLockRelayStatus, + DoorLockRule, DoorLockRuleStatus, + DoorLockRuleType, EmergencyStatus, UnifiAccessApiClient, WsMessageHandler, @@ -40,6 +42,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +DEFAULT_LOCK_RULE_INTERVAL = 10 type UnifiAccessConfigEntry = ConfigEntry[UnifiAccessCoordinator] @@ -102,6 +105,34 @@ def _unsubscribe() -> None: self._event_listeners.append(event_callback) return _unsubscribe + async def async_set_lock_rule(self, door_id: str, rule_type: str) -> None: + """Set a temporary lock rule for a door.""" + if not rule_type: + return + lock_rule_type = DoorLockRuleType(rule_type) + rule = DoorLockRule(type=lock_rule_type, interval=DEFAULT_LOCK_RULE_INTERVAL) + await self.client.set_door_lock_rule(door_id, rule) + if self.data is None or door_id not in self.data.doors: + return + new_status = DoorLockRuleStatus( + type=DoorLockRuleType.NONE + if lock_rule_type == DoorLockRuleType.RESET + else lock_rule_type + ) + updated_data = replace( + self.data, + door_lock_rules={ + **self.data.door_lock_rules, + door_id: new_status, + }, + ) + if self.last_update_success: + self.async_set_updated_data(updated_data) + else: + # Preserve coordinator error state while updating cached data + self.data = updated_data + self.async_update_listeners() + async def _async_setup(self) -> None: """Set up the WebSocket connection for push updates.""" handlers: dict[str, WsMessageHandler] = { diff --git a/homeassistant/components/unifi_access/select.py b/homeassistant/components/unifi_access/select.py new file mode 100644 index 00000000000000..4193a5a1d4f662 --- /dev/null +++ b/homeassistant/components/unifi_access/select.py @@ -0,0 +1,102 @@ +"""Select platform for the UniFi Access integration.""" + +from __future__ import annotations + +from unifi_access_api import Door, DoorLockRuleType, UnifiAccessError + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator +from .entity import UnifiAccessEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UnifiAccessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up UniFi Access select entities.""" + coordinator = entry.runtime_data + added_doors: set[str] = set() + + @callback + def _async_add_lock_rule_selects() -> None: + new_door_ids = sorted(coordinator.get_lock_rule_sensor_door_ids() - added_doors) + if not new_door_ids: + return + + async_add_entities( + UnifiAccessDoorLockRuleSelectEntity( + coordinator, coordinator.data.doors[door_id] + ) + for door_id in new_door_ids + if door_id in coordinator.data.doors + ) + added_doors.update(new_door_ids) + + _async_add_lock_rule_selects() + entry.async_on_unload(coordinator.async_add_listener(_async_add_lock_rule_selects)) + + +class UnifiAccessDoorLockRuleSelectEntity(UnifiAccessEntity, SelectEntity): + """Select entity for choosing the active temporary lock rule on a door.""" + + _attr_translation_key = "door_lock_rule" + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + door: Door, + ) -> None: + """Initialize the door lock rule select entity.""" + super().__init__(coordinator, door, "lock_rule_select") + + @property + def current_option(self) -> str | None: + """Return the currently active lock rule, or None if no rule is set.""" + rule_status = self.coordinator.get_lock_rule_status(self._door_id) + if rule_status is None or rule_status.type in ( + DoorLockRuleType.NONE, + DoorLockRuleType.RESET, + DoorLockRuleType.LOCK_NOW, + ): + return None + value = rule_status.type.value + return value if value in self.options else None + + @property + def options(self) -> list[str]: + """Return the available lock rule options.""" + opts = ["keep_lock", "keep_unlock", "custom", "reset"] + rule_status = self.coordinator.get_lock_rule_status(self._door_id) + if rule_status is not None and rule_status.type in ( + DoorLockRuleType.SCHEDULE, + DoorLockRuleType.LOCK_EARLY, + ): + opts.extend(["schedule", "lock_early"]) + return opts + + @property + def available(self) -> bool: + """Return whether the select should currently be shown as available.""" + return super().available and ( + self._door_id in self.coordinator.get_lock_rule_sensor_door_ids() + ) + + async def async_select_option(self, option: str) -> None: + """Apply the selected lock rule to the door.""" + if option == DoorLockRuleType.SCHEDULE.value: + return + try: + await self.coordinator.async_set_lock_rule(self._door_id, option) + except UnifiAccessError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="lock_rule_failed", + ) from err diff --git a/homeassistant/components/unifi_access/strings.json b/homeassistant/components/unifi_access/strings.json index d287b25bec012a..cd6e72bc9e5e12 100644 --- a/homeassistant/components/unifi_access/strings.json +++ b/homeassistant/components/unifi_access/strings.json @@ -67,6 +67,19 @@ "name": "Thumbnail" } }, + "select": { + "door_lock_rule": { + "name": "Lock rule", + "state": { + "custom": "Custom", + "keep_lock": "Keep locked", + "keep_unlock": "Keep unlocked", + "lock_early": "Lock early", + "reset": "Reset", + "schedule": "Schedule" + } + } + }, "sensor": { "door_lock_rule": { "name": "Lock rule", @@ -97,6 +110,9 @@ "emergency_failed": { "message": "Failed to set emergency status." }, + "lock_rule_failed": { + "message": "Failed to update the door lock rule." + }, "unlock_failed": { "message": "Failed to unlock the door." } diff --git a/tests/components/unifi_access/conftest.py b/tests/components/unifi_access/conftest.py index 7eb37552f35999..c28e628ab4d158 100644 --- a/tests/components/unifi_access/conftest.py +++ b/tests/components/unifi_access/conftest.py @@ -110,6 +110,7 @@ def mock_client() -> Generator[MagicMock]: return_value=EmergencyStatus(evacuation=False, lockdown=False) ) client.get_door_lock_rule = AsyncMock(return_value=DoorLockRuleStatus()) + client.set_door_lock_rule = AsyncMock() client.set_emergency_status = AsyncMock() client.unlock_door = AsyncMock() client.get_thumbnail = AsyncMock(return_value=b"") diff --git a/tests/components/unifi_access/snapshots/test_select.ambr b/tests/components/unifi_access/snapshots/test_select.ambr new file mode 100644 index 00000000000000..04954e99f7e10d --- /dev/null +++ b/tests/components/unifi_access/snapshots/test_select.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_select_entities[select.back_door_lock_rule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'keep_lock', + 'keep_unlock', + 'custom', + 'reset', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.back_door_lock_rule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lock rule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock rule', + 'platform': 'unifi_access', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_lock_rule', + 'unique_id': 'door-002-lock_rule_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.back_door_lock_rule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Back Door Lock rule', + 'options': list([ + 'keep_lock', + 'keep_unlock', + 'custom', + 'reset', + ]), + }), + 'context': , + 'entity_id': 'select.back_door_lock_rule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'keep_lock', + }) +# --- +# name: test_select_entities[select.front_door_lock_rule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'keep_lock', + 'keep_unlock', + 'custom', + 'reset', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.front_door_lock_rule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lock rule', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock rule', + 'platform': 'unifi_access', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'door_lock_rule', + 'unique_id': 'door-001-lock_rule_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.front_door_lock_rule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Front Door Lock rule', + 'options': list([ + 'keep_lock', + 'keep_unlock', + 'custom', + 'reset', + ]), + }), + 'context': , + 'entity_id': 'select.front_door_lock_rule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'keep_lock', + }) +# --- diff --git a/tests/components/unifi_access/test_select.py b/tests/components/unifi_access/test_select.py new file mode 100644 index 00000000000000..444123cafa0358 --- /dev/null +++ b/tests/components/unifi_access/test_select.py @@ -0,0 +1,243 @@ +"""Tests for the UniFi Access select platform.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from unifi_access_api import ( + ApiConnectionError, + ApiNotFoundError, + DoorLockRule, + DoorLockRuleStatus, + DoorLockRuleType, + UnifiAccessError, +) + +from homeassistant.components.unifi_access.const import DOMAIN +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +FRONT_DOOR_LOCK_RULE_SELECT_ENTITY = "select.front_door_lock_rule" +BACK_DOOR_LOCK_RULE_SELECT_ENTITY = "select.back_door_lock_rule" + + +@pytest.fixture(autouse=True) +def only_select_platform() -> Generator[None]: + """Limit setup to the select platform for select tests.""" + with patch("homeassistant.components.unifi_access.PLATFORMS", [Platform.SELECT]): + yield + + +async def test_select_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test select entities are created with expected state.""" + mock_client.get_door_lock_rule = AsyncMock( + return_value=DoorLockRuleStatus( + type=DoorLockRuleType.KEEP_LOCK, ended_time=1700000000 + ) + ) + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_current_option_no_rule( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test select reflects unknown state when no lock rule is active.""" + mock_client.get_door_lock_rule = AsyncMock(return_value=DoorLockRuleStatus()) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(FRONT_DOOR_LOCK_RULE_SELECT_ENTITY) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_select_current_option_active_rule( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test select reflects the current lock rule type.""" + mock_client.get_door_lock_rule = AsyncMock( + return_value=DoorLockRuleStatus(type=DoorLockRuleType.KEEP_LOCK) + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(FRONT_DOOR_LOCK_RULE_SELECT_ENTITY) + assert state is not None + assert state.state == "keep_lock" + + +async def test_select_option_calls_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test selecting an option calls set_door_lock_rule on the client.""" + mock_client.get_door_lock_rule = AsyncMock(return_value=DoorLockRuleStatus()) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + Platform.SELECT, + "select_option", + {"entity_id": FRONT_DOOR_LOCK_RULE_SELECT_ENTITY, "option": "keep_lock"}, + blocking=True, + ) + + mock_client.set_door_lock_rule.assert_awaited_once_with( + "door-001", DoorLockRule(type=DoorLockRuleType.KEEP_LOCK, interval=10) + ) + + +async def test_select_schedule_option_does_not_call_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test selecting schedule does not call the API.""" + mock_client.get_door_lock_rule = AsyncMock( + return_value=DoorLockRuleStatus(type=DoorLockRuleType.SCHEDULE) + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + Platform.SELECT, + "select_option", + {"entity_id": FRONT_DOOR_LOCK_RULE_SELECT_ENTITY, "option": "schedule"}, + blocking=True, + ) + + mock_client.set_door_lock_rule.assert_not_awaited() + + +async def test_select_not_created_when_lock_rules_unsupported( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that select entities are not created when lock rules are unsupported.""" + mock_client.get_door_lock_rule = AsyncMock(side_effect=ApiNotFoundError()) + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(FRONT_DOOR_LOCK_RULE_SELECT_ENTITY) is None + + +async def test_select_lock_early_option_shown_for_schedule_rule( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test lock_early appears in options when a schedule rule is active.""" + mock_client.get_door_lock_rule = AsyncMock( + return_value=DoorLockRuleStatus(type=DoorLockRuleType.SCHEDULE) + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(FRONT_DOOR_LOCK_RULE_SELECT_ENTITY) + assert state is not None + assert state.state == "schedule" + assert "schedule" in state.attributes["options"] + assert "lock_early" in state.attributes["options"] + + +async def test_select_lock_early_option_hidden_for_non_schedule_rule( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test lock_early is absent from options when no schedule rule is active.""" + mock_client.get_door_lock_rule = AsyncMock( + return_value=DoorLockRuleStatus(type=DoorLockRuleType.KEEP_LOCK) + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(FRONT_DOOR_LOCK_RULE_SELECT_ENTITY) + assert state is not None + assert "lock_early" not in state.attributes["options"] + + +async def test_select_created_for_supported_doors_only( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test select entities are created only for doors that support lock rules.""" + + async def mock_get_door_lock_rule(door_id: str) -> DoorLockRuleStatus: + if door_id == "door-001": + return DoorLockRuleStatus( + type=DoorLockRuleType.KEEP_LOCK, ended_time=1700000000 + ) + raise ApiNotFoundError + + mock_client.get_door_lock_rule = AsyncMock(side_effect=mock_get_door_lock_rule) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(FRONT_DOOR_LOCK_RULE_SELECT_ENTITY) is not None + assert hass.states.get(BACK_DOOR_LOCK_RULE_SELECT_ENTITY) is None + + +async def test_select_placeholder_created_for_transient_error_doors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test select placeholders are created for doors with transient fetch errors.""" + + async def mock_get_door_lock_rule(door_id: str) -> DoorLockRuleStatus: + if door_id == "door-001": + raise ApiConnectionError("Connection failed") + raise ApiNotFoundError + + mock_client.get_door_lock_rule = AsyncMock(side_effect=mock_get_door_lock_rule) + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(FRONT_DOOR_LOCK_RULE_SELECT_ENTITY) is not None + assert hass.states.get(FRONT_DOOR_LOCK_RULE_SELECT_ENTITY).state == STATE_UNKNOWN + assert hass.states.get(BACK_DOOR_LOCK_RULE_SELECT_ENTITY) is None + + +async def test_select_option_raises_on_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test HomeAssistantError is raised when set_door_lock_rule fails.""" + mock_client.get_door_lock_rule = AsyncMock(return_value=DoorLockRuleStatus()) + await setup_integration(hass, mock_config_entry) + + mock_client.set_door_lock_rule = AsyncMock( + side_effect=UnifiAccessError("API error") + ) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + Platform.SELECT, + "select_option", + { + "entity_id": FRONT_DOOR_LOCK_RULE_SELECT_ENTITY, + "option": "keep_lock", + }, + blocking=True, + ) + assert exc_info.value.translation_key == "lock_rule_failed" + assert exc_info.value.translation_domain == DOMAIN From beab473dccd9db8588cb07489fe269ec9c5168e5 Mon Sep 17 00:00:00 2001 From: crash0verride11 Date: Sat, 28 Mar 2026 12:23:57 -0400 Subject: [PATCH 0134/1707] Correct Musiccast sound mode name (#166644) Co-authored-by: crash0verride11 <3526616+crash0verride11@users.noreply.github.com> Co-authored-by: jtjart <80978647+jtjart@users.noreply.github.com> Co-authored-by: Joostlek --- homeassistant/components/yamaha_musiccast/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index dd6c86af6aa9c2..9768fa613f034a 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -81,8 +81,8 @@ "usa_a": "Hall in USA A", "usa_b": "Hall in USA B", "vienna": "Hall in Vienna", - "village_gate": "Village gate", - "village_vanguard": "Village vanguard", + "village_gate": "Village Gate", + "village_vanguard": "Village Vanguard", "warehouse_loft": "Warehouse loft" } } From f4544cf952952d65795018b5f4110f9e5ec590ac Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:26:18 +0200 Subject: [PATCH 0135/1707] Fix Huum test coverage and upgrade to silver (#166548) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/huum/manifest.json | 2 +- .../components/huum/quality_scale.yaml | 2 +- .../components/huum/snapshots/test_init.ambr | 32 +++++++ tests/components/huum/test_climate.py | 81 +++++++++++++++- tests/components/huum/test_init.py | 94 ++++++++++++++++--- 5 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 tests/components/huum/snapshots/test_init.ambr diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index ed392c8e637589..b7415cbbd724d7 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/huum", "integration_type": "device", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["huum==0.8.2"] } diff --git a/homeassistant/components/huum/quality_scale.yaml b/homeassistant/components/huum/quality_scale.yaml index d522814b9bad68..b08f4c054dee00 100644 --- a/homeassistant/components/huum/quality_scale.yaml +++ b/homeassistant/components/huum/quality_scale.yaml @@ -39,7 +39,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/tests/components/huum/snapshots/test_init.ambr b/tests/components/huum/snapshots/test_init.ambr new file mode 100644 index 00000000000000..eed66315bc3e7b --- /dev/null +++ b/tests/components/huum/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device_entry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'huum', + 'AABBCC112233', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Huum', + 'model': 'UKU WiFi', + 'model_id': None, + 'name': 'Huum sauna', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/huum/test_climate.py b/tests/components/huum/test_climate.py index c1d6bf516eba1d..61be0f5b6b9081 100644 --- a/tests/components/huum/test_climate.py +++ b/tests/components/huum/test_climate.py @@ -5,6 +5,8 @@ from freezegun.api import FrozenDateTimeFactory from huum.const import SaunaStatus +from huum.exceptions import SafetyException +from huum.schemas import SaunaConfig import pytest from syrupy.assertion import SnapshotAssertion @@ -21,6 +23,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -85,6 +88,62 @@ async def test_set_temperature( mock_huum_client.turn_on.assert_awaited_once_with(60) +@pytest.mark.usefixtures("init_integration") +async def test_set_hvac_mode_off( + hass: HomeAssistant, + mock_huum_client: AsyncMock, +) -> None: + """Test setting HVAC mode to off.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + mock_huum_client.turn_off.assert_awaited_once() + + +@pytest.mark.usefixtures("init_integration") +async def test_set_temperature_not_heating( + hass: HomeAssistant, + mock_huum_client: AsyncMock, +) -> None: + """Test setting temperature is ignored when not heating.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 60, + }, + blocking=True, + ) + + mock_huum_client.turn_on.assert_not_called() + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_safety_exception( + hass: HomeAssistant, + mock_huum_client: AsyncMock, +) -> None: + """Test that SafetyException is raised as HomeAssistantError.""" + mock_huum_client.turn_on.side_effect = SafetyException("Door is open") + mock_huum_client.status.return_value.status = SaunaStatus.ONLINE_HEATING + + with pytest.raises(HomeAssistantError, match="Unable to turn on sauna"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 60, + }, + blocking=True, + ) + + @pytest.mark.usefixtures("init_integration") async def test_temperature_range( hass: HomeAssistant, @@ -109,9 +168,27 @@ async def test_temperature_range( assert state.attributes["min_temp"] == CONFIG_DEFAULT_MIN_TEMP assert state.attributes["max_temp"] == CONFIG_DEFAULT_MAX_TEMP + # No sauna config should return default values. + mock_huum_client.status.return_value.sauna_config = None + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state.attributes["min_temp"] == CONFIG_DEFAULT_MIN_TEMP + assert state.attributes["max_temp"] == CONFIG_DEFAULT_MAX_TEMP + # Custom configured API response. - mock_huum_client.status.return_value.sauna_config.min_temp = 50 - mock_huum_client.status.return_value.sauna_config.max_temp = 80 + mock_huum_client.status.return_value.sauna_config = SaunaConfig( + child_lock="OFF", + max_heating_time=3, + min_heating_time=0, + max_temp=80, + min_temp=50, + max_timer=0, + min_timer=0, + ) freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) diff --git a/tests/components/huum/test_init.py b/tests/components/huum/test_init.py index e729e2e8386da2..d1f0bbd94f21bd 100644 --- a/tests/components/huum/test_init.py +++ b/tests/components/huum/test_init.py @@ -1,16 +1,19 @@ """Tests for the Huum __init__.""" +from datetime import timedelta from unittest.mock import AsyncMock -from huum.exceptions import Forbidden, NotAuthenticated +from freezegun.api import FrozenDateTimeFactory +from huum.exceptions import Forbidden, NotAuthenticated, RequestError import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant import config_entries from homeassistant.components.huum.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("init_integration") @@ -27,21 +30,88 @@ async def test_loading_and_unloading_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.parametrize("side_effect", [Forbidden, NotAuthenticated]) -async def test_auth_error_triggers_reauth( +@pytest.mark.parametrize( + "exception", + [ + Forbidden("Forbidden"), + NotAuthenticated("Not authenticated"), + ], +) +async def test_setup_entry_auth_error( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_huum_client: AsyncMock, - side_effect: type[Exception], + mock_config_entry: MockConfigEntry, + exception: Exception, ) -> None: - """Test that an auth error during coordinator refresh triggers reauth.""" + """Test setup triggers reauth on auth errors.""" mock_config_entry.add_to_hass(hass) - mock_huum_client.status.side_effect = side_effect + mock_huum_client.status.side_effect = exception await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR - assert any( - mock_config_entry.async_get_active_flows(hass, {config_entries.SOURCE_REAUTH}) + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + +async def test_setup_entry_connection_error( + hass: HomeAssistant, + mock_huum_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup retries on connection error.""" + mock_config_entry.add_to_hass(hass) + mock_huum_client.status.side_effect = RequestError("Request error") + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert hass.config_entries.flow.async_progress_by_handler(DOMAIN) == [] + + +@pytest.mark.usefixtures("init_integration") +async def test_device_entry( + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device registry entry.""" + assert ( + device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) ) + assert device_entry == snapshot + + +@pytest.mark.parametrize( + "side_effect", + [ + Forbidden("Forbidden"), + NotAuthenticated("Not authenticated"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_coordinator_update_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_huum_client: AsyncMock, + freezer: FrozenDateTimeFactory, + side_effect: Exception, +) -> None: + """Test that an auth error during coordinator refresh triggers reauth.""" + mock_huum_client.status.side_effect = side_effect + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH From b7bb185d503cecd26878f0807ca3b2708565318e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 28 Mar 2026 17:27:09 +0100 Subject: [PATCH 0136/1707] Add new OAuth exceptions to Neato (#166584) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/neato/__init__.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 2c273f9d158d24..318396d6a8a674 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -2,14 +2,19 @@ import logging -import aiohttp +from aiohttp import ClientError from pybotvac import Account from pybotvac.exceptions import NeatoException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -58,10 +63,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() - except aiohttp.ClientResponseError as ex: - _LOGGER.debug("API error: %s (%s)", ex.code, ex.message) - if ex.code in (401, 403): - raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + except OAuth2TokenRequestReauthError as ex: + raise ConfigEntryAuthFailed from ex + except (OAuth2TokenRequestError, ClientError) as ex: raise ConfigEntryNotReady from ex neato_session = api.ConfigEntryAuth(hass, entry, implementation) From 738b85c17d9d24bf6533bcc05e0dd2f1cb4fc383 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:39:21 +0100 Subject: [PATCH 0137/1707] Add event platform to HTML5 integration (#166577) --- homeassistant/components/html5/__init__.py | 2 +- homeassistant/components/html5/const.py | 6 + homeassistant/components/html5/entity.py | 73 +++++++++++ homeassistant/components/html5/event.py | 67 ++++++++++ homeassistant/components/html5/icons.json | 7 + homeassistant/components/html5/notify.py | 80 +++--------- homeassistant/components/html5/strings.json | 17 +++ .../html5/snapshots/test_event.ambr | 63 +++++++++ tests/components/html5/test_event.py | 120 ++++++++++++++++++ tests/components/html5/test_notify.py | 18 ++- 10 files changed, 388 insertions(+), 65 deletions(-) create mode 100644 homeassistant/components/html5/entity.py create mode 100644 homeassistant/components/html5/event.py create mode 100644 tests/components/html5/snapshots/test_event.ambr create mode 100644 tests/components/html5/test_event.py diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py index ed980a32ceeaf8..225379dfa1a914 100644 --- a/homeassistant/components/html5/__init__.py +++ b/homeassistant/components/html5/__init__.py @@ -9,7 +9,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.NOTIFY] +PLATFORMS = [Platform.EVENT, Platform.NOTIFY] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/html5/const.py b/homeassistant/components/html5/const.py index 75826ab90c91d8..a256241b0665f1 100644 --- a/homeassistant/components/html5/const.py +++ b/homeassistant/components/html5/const.py @@ -7,3 +7,9 @@ ATTR_VAPID_PUB_KEY = "vapid_pub_key" ATTR_VAPID_PRV_KEY = "vapid_prv_key" ATTR_VAPID_EMAIL = "vapid_email" + +REGISTRATIONS_FILE = "html5_push_registrations.conf" + +ATTR_ACTION = "action" +ATTR_DATA = "data" +ATTR_TAG = "tag" diff --git a/homeassistant/components/html5/entity.py b/homeassistant/components/html5/entity.py new file mode 100644 index 00000000000000..71b85208271001 --- /dev/null +++ b/homeassistant/components/html5/entity.py @@ -0,0 +1,73 @@ +"""Base entities for HTML5 integration.""" + +from __future__ import annotations + +from typing import NotRequired, TypedDict + +from aiohttp import ClientSession + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class Keys(TypedDict): + """Types for keys.""" + + p256dh: str + auth: str + + +class Subscription(TypedDict): + """Types for subscription.""" + + endpoint: str + expirationTime: int | None + keys: Keys + + +class Registration(TypedDict): + """Types for registration.""" + + subscription: Subscription + browser: str + name: NotRequired[str] + + +class HTML5Entity(Entity): + """Base entity for HTML5 integration.""" + + _attr_has_entity_name = True + _attr_name = None + _key: str + + def __init__( + self, + config_entry: ConfigEntry, + target: str, + registrations: dict[str, Registration], + session: ClientSession, + json_path: str, + ) -> None: + """Initialize the entity.""" + self.config_entry = config_entry + self.target = target + self.registrations = registrations + self.registration = registrations[target] + self.session = session + self.json_path = json_path + + self._attr_unique_id = f"{config_entry.entry_id}_{target}_{self._key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=target, + model=self.registration["browser"].capitalize(), + identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")}, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.target in self.registrations diff --git a/homeassistant/components/html5/event.py b/homeassistant/components/html5/event.py new file mode 100644 index 00000000000000..6f74d61d83d0a5 --- /dev/null +++ b/homeassistant/components/html5/event.py @@ -0,0 +1,67 @@ +"""Event platform for HTML5 integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.event import EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ATTR_ACTION, ATTR_DATA, ATTR_TAG, DOMAIN, REGISTRATIONS_FILE +from .entity import HTML5Entity +from .notify import _load_config + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the event entity platform.""" + + json_path = hass.config.path(REGISTRATIONS_FILE) + registrations = await hass.async_add_executor_job(_load_config, json_path) + + session = async_get_clientsession(hass) + async_add_entities( + HTML5EventEntity(config_entry, target, registrations, session, json_path) + for target in registrations + ) + + +class HTML5EventEntity(HTML5Entity, EventEntity): + """Representation of an event entity.""" + + _key = "event" + _attr_event_types = ["clicked", "received", "closed"] + _attr_translation_key = "event" + + @callback + def _async_handle_event( + self, target: str, event_type: str, event_data: dict[str, Any] + ) -> None: + """Handle the event.""" + + if target == self.target: + self._trigger_event( + event_type, + { + **event_data.get(ATTR_DATA, {}), + ATTR_ACTION: event_data.get(ATTR_ACTION), + ATTR_TAG: event_data.get(ATTR_TAG), + }, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register event callback.""" + + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event) + ) diff --git a/homeassistant/components/html5/icons.json b/homeassistant/components/html5/icons.json index d0a6013dd12524..4b3fd84b69ff57 100644 --- a/homeassistant/components/html5/icons.json +++ b/homeassistant/components/html5/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "event": { + "event": { + "default": "mdi:gesture-tap-button" + } + } + }, "services": { "dismiss": { "service": "mdi:bell-off" diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index a5e823ce629cba..21d57f7fb8ddd8 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -8,7 +8,7 @@ import json import logging import time -from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse import uuid @@ -38,7 +38,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -46,17 +46,19 @@ from homeassistant.util.json import load_json_object from .const import ( + ATTR_ACTION, + ATTR_TAG, ATTR_VAPID_EMAIL, ATTR_VAPID_PRV_KEY, ATTR_VAPID_PUB_KEY, DOMAIN, + REGISTRATIONS_FILE, SERVICE_DISMISS, ) +from .entity import HTML5Entity, Registration _LOGGER = logging.getLogger(__name__) -REGISTRATIONS_FILE = "html5_push_registrations.conf" - ATTR_SUBSCRIPTION = "subscription" ATTR_BROWSER = "browser" @@ -67,8 +69,6 @@ ATTR_P256DH = "p256dh" ATTR_EXPIRATIONTIME = "expirationTime" -ATTR_TAG = "tag" -ATTR_ACTION = "action" ATTR_ACTIONS = "actions" ATTR_TYPE = "type" ATTR_URL = "url" @@ -156,29 +156,6 @@ ) -class Keys(TypedDict): - """Types for keys.""" - - p256dh: str - auth: str - - -class Subscription(TypedDict): - """Types for subscription.""" - - endpoint: str - expirationTime: int | None - keys: Keys - - -class Registration(TypedDict): - """Types for registration.""" - - subscription: Subscription - browser: str - name: NotRequired[str] - - async def async_get_service( hass: HomeAssistant, config: ConfigType, @@ -419,7 +396,15 @@ async def post(self, request: web.Request) -> web.Response: ) event_name = f"{NOTIFY_CALLBACK_EVENT}.{event_payload[ATTR_TYPE]}" - request.app[KEY_HASS].bus.fire(event_name, event_payload) + hass = request.app[KEY_HASS] + hass.bus.fire(event_name, event_payload) + async_dispatcher_send( + hass, + DOMAIN, + event_payload[ATTR_TARGET], + event_payload[ATTR_TYPE], + event_payload, + ) return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]}) @@ -613,37 +598,11 @@ async def async_setup_entry( ) -class HTML5NotifyEntity(NotifyEntity): +class HTML5NotifyEntity(HTML5Entity, NotifyEntity): """Representation of a notification entity.""" - _attr_has_entity_name = True - _attr_name = None - _attr_supported_features = NotifyEntityFeature.TITLE - - def __init__( - self, - config_entry: ConfigEntry, - target: str, - registrations: dict[str, Registration], - session: ClientSession, - json_path: str, - ) -> None: - """Initialize the entity.""" - self.config_entry = config_entry - self.target = target - self.registrations = registrations - self.registration = registrations[target] - self.session = session - self.json_path = json_path - - self._attr_unique_id = f"{config_entry.entry_id}_{target}_device" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - name=target, - model=self.registration["browser"].capitalize(), - identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")}, - ) + _key = "device" async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message to a device.""" @@ -714,8 +673,3 @@ async def async_send_message(self, message: str, title: str | None = None) -> No translation_key="connection_error", translation_placeholders={"target": self.target}, ) from e - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self.target in self.registrations diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 81964a2af95007..e786f80a4561c8 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -20,6 +20,23 @@ } } }, + "entity": { + "event": { + "event": { + "state_attributes": { + "action": { "name": "Action" }, + "event_type": { + "state": { + "clicked": "Clicked", + "closed": "Closed", + "received": "Received" + } + }, + "tag": { "name": "Tag" } + } + } + } + }, "exceptions": { "channel_expired": { "message": "Notification channel for {target} has expired" diff --git a/tests/components/html5/snapshots/test_event.ambr b/tests/components/html5/snapshots/test_event.ambr new file mode 100644 index 00000000000000..3db7160da7fd74 --- /dev/null +++ b/tests/components/html5/snapshots/test_event.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_setup[event.my_desktop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'clicked', + 'received', + 'closed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.my_desktop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'html5', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'event', + 'unique_id': 'ABCDEFGHIJKLMNOPQRSTUVWXYZ_my-desktop_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.my_desktop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'clicked', + 'received', + 'closed', + ]), + 'friendly_name': 'my-desktop', + }), + 'context': , + 'entity_id': 'event.my_desktop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/html5/test_event.py b/tests/components/html5/test_event.py new file mode 100644 index 00000000000000..cd4be641f35540 --- /dev/null +++ b/tests/components/html5/test_event.py @@ -0,0 +1,120 @@ +"""Tests for the HTML5 event platform.""" + +from collections.abc import Generator +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock, patch + +from aiohttp.hdrs import AUTHORIZATION +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.html5.notify import ATTR_ACTION, ATTR_TAG, ATTR_TYPE +from homeassistant.components.notify import ATTR_DATA, ATTR_TARGET +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .test_notify import SUBSCRIPTION_1 + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + + +@pytest.fixture +def event_only() -> Generator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.html5.PLATFORMS", + [Platform.EVENT], + ): + yield + + +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid", "event_only") +@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + load_config: MagicMock, +) -> None: + """Snapshot test states of event platform.""" + load_config.return_value = {"my-desktop": SUBSCRIPTION_1} + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("event_payload"), + [ + { + ATTR_TARGET: "my-desktop", + ATTR_TYPE: "clicked", + ATTR_ACTION: "open_app", + ATTR_TAG: "1234", + ATTR_DATA: {"customKey": "Value"}, + }, + { + ATTR_TARGET: "my-desktop", + ATTR_TYPE: "received", + ATTR_TAG: "1234", + ATTR_DATA: {"customKey": "Value"}, + }, + { + ATTR_TARGET: "my-desktop", + ATTR_TYPE: "closed", + ATTR_TAG: "1234", + ATTR_DATA: {"customKey": "Value"}, + }, + ], +) +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z") +async def test_events( + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, + event_payload: dict[str, Any], + hass_client: ClientSessionGenerator, + mock_jwt: MagicMock, +) -> None: + """Test events.""" + load_config.return_value = {"my-desktop": SUBSCRIPTION_1} + await async_setup_component(hass, "http", {}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("event.my_desktop")) is not None + assert state.state == STATE_UNKNOWN + + client = await hass_client() + + mock_jwt.decode.return_value = {ATTR_TARGET: event_payload[ATTR_TARGET]} + + resp = await client.post( + "/api/notify.html5/callback", + json=event_payload, + headers={AUTHORIZATION: "Bearer JWT"}, + ) + + assert resp.status == HTTPStatus.OK + + assert (state := hass.states.get("event.my_desktop")) + assert state.state == "1970-01-01T00:00:00.000+00:00" + assert state.attributes.get("action") == event_payload.get(ATTR_ACTION) + assert state.attributes.get("tag") == event_payload[ATTR_TAG] + assert state.attributes.get("customKey") == event_payload[ATTR_DATA]["customKey"] diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index 7b6d3113b47c46..d7a83b2f66e692 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -1,5 +1,6 @@ """Test HTML5 notify platform.""" +from collections.abc import Generator from http import HTTPStatus import json from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch @@ -18,7 +19,12 @@ SERVICE_SEND_MESSAGE, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -89,6 +95,16 @@ } +@pytest.fixture(autouse=True) +def notify_only() -> Generator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.html5.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + async def test_get_service_with_no_json(hass: HomeAssistant) -> None: """Test empty json file.""" await async_setup_component(hass, "http", {}) From 2285db5bb1306eae3dad8876f3b530dc9b83952b Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Sat, 28 Mar 2026 12:48:22 -0400 Subject: [PATCH 0138/1707] Casper Glow - Add Select Options (#166553) Co-authored-by: Joost Lekkerkerker --- .../components/casper_glow/__init__.py | 7 +- homeassistant/components/casper_glow/const.py | 2 + .../components/casper_glow/coordinator.py | 11 +- .../components/casper_glow/icons.json | 5 + homeassistant/components/casper_glow/light.py | 2 + .../components/casper_glow/quality_scale.yaml | 6 +- .../components/casper_glow/select.py | 92 ++++++++ .../components/casper_glow/strings.json | 5 + tests/components/casper_glow/conftest.py | 17 +- .../casper_glow/snapshots/test_select.ambr | 67 ++++++ tests/components/casper_glow/test_light.py | 75 +++---- tests/components/casper_glow/test_select.py | 199 ++++++++++++++++++ 12 files changed, 432 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/casper_glow/select.py create mode 100644 tests/components/casper_glow/snapshots/test_select.ambr create mode 100644 tests/components/casper_glow/test_select.py diff --git a/homeassistant/components/casper_glow/__init__.py b/homeassistant/components/casper_glow/__init__.py index 4d1494d9d17370..216379cb4a012d 100644 --- a/homeassistant/components/casper_glow/__init__.py +++ b/homeassistant/components/casper_glow/__init__.py @@ -11,7 +11,12 @@ from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.LIGHT, + Platform.SELECT, +] async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool: diff --git a/homeassistant/components/casper_glow/const.py b/homeassistant/components/casper_glow/const.py index 37b5b7656ff249..c7e8d86729bb36 100644 --- a/homeassistant/components/casper_glow/const.py +++ b/homeassistant/components/casper_glow/const.py @@ -12,5 +12,7 @@ DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0] +DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES) + # Interval between periodic state polls to catch externally-triggered changes. STATE_POLL_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/casper_glow/coordinator.py b/homeassistant/components/casper_glow/coordinator.py index 6b363d0445bacc..576dfeda11e8e1 100644 --- a/homeassistant/components/casper_glow/coordinator.py +++ b/homeassistant/components/casper_glow/coordinator.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from .const import STATE_POLL_INTERVAL +from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -51,6 +51,15 @@ def __init__( ) self.title = title + # The device API couples brightness and dimming time into a + # single command (set_brightness_and_dimming_time), so both + # values must be tracked here for cross-entity use. + self.last_brightness_pct: int = ( + device.state.brightness_level + if device.state.brightness_level is not None + else SORTED_BRIGHTNESS_LEVELS[0] + ) + @callback def _needs_poll( self, diff --git a/homeassistant/components/casper_glow/icons.json b/homeassistant/components/casper_glow/icons.json index c291e1abc22345..6d8c1d8347450d 100644 --- a/homeassistant/components/casper_glow/icons.json +++ b/homeassistant/components/casper_glow/icons.json @@ -12,6 +12,11 @@ "resume": { "default": "mdi:play" } + }, + "select": { + "dimming_time": { + "default": "mdi:timer-outline" + } } } } diff --git a/homeassistant/components/casper_glow/light.py b/homeassistant/components/casper_glow/light.py index a8e29b2a7a3c24..686ccee4a7d77a 100644 --- a/homeassistant/components/casper_glow/light.py +++ b/homeassistant/components/casper_glow/light.py @@ -71,6 +71,7 @@ def _update_from_state(self, state: GlowState) -> None: self._attr_color_mode = ColorMode.BRIGHTNESS if state.brightness_level is not None: self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level) + self.coordinator.last_brightness_pct = state.brightness_level @callback def _async_handle_state_update(self, state: GlowState) -> None: @@ -97,6 +98,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) ) self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct) + self.coordinator.last_brightness_pct = brightness_pct async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index 7f73eb17602398..c139f7df4f8d62 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -52,8 +52,10 @@ rules: docs-troubleshooting: done docs-use-cases: todo dynamic-devices: todo - entity-category: todo - entity-device-class: todo + entity-category: done + entity-device-class: + status: exempt + comment: No applicable device classes for binary_sensor, button, light, or select entities. entity-disabled-by-default: todo entity-translations: done exception-translations: done diff --git a/homeassistant/components/casper_glow/select.py b/homeassistant/components/casper_glow/select.py new file mode 100644 index 00000000000000..61d1446a9d34cd --- /dev/null +++ b/homeassistant/components/casper_glow/select.py @@ -0,0 +1,92 @@ +"""Casper Glow integration select platform for dimming time.""" + +from __future__ import annotations + +from pycasperglow import GlowState + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DIMMING_TIME_OPTIONS +from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator +from .entity import CasperGlowEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CasperGlowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the select platform for Casper Glow.""" + async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)]) + + +class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity): + """Select entity for Casper Glow dimming time.""" + + _attr_translation_key = "dimming_time" + _attr_entity_category = EntityCategory.CONFIG + _attr_options = list(DIMMING_TIME_OPTIONS) + _attr_unit_of_measurement = UnitOfTime.MINUTES + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the dimming time select entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time" + + @property + def current_option(self) -> str | None: + """Return the currently selected dimming time from the coordinator.""" + if self.coordinator.last_dimming_time_minutes is None: + return None + return str(self.coordinator.last_dimming_time_minutes) + + async def async_added_to_hass(self) -> None: + """Restore last known dimming time and register state update callback.""" + await super().async_added_to_hass() + if self.coordinator.last_dimming_time_minutes is None and ( + last_state := await self.async_get_last_state() + ): + if last_state.state in DIMMING_TIME_OPTIONS: + self.coordinator.last_dimming_time_minutes = int(last_state.state) + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + if state.brightness_level is not None: + self.coordinator.last_brightness_pct = state.brightness_level + if ( + state.configured_dimming_time_minutes is not None + and self.coordinator.last_dimming_time_minutes is None + ): + self.coordinator.last_dimming_time_minutes = ( + state.configured_dimming_time_minutes + ) + # Dimming time is not part of the device state + # that is provided via BLE update. Therefore + # we need to trigger a state update for the select entity + # to update the current state. + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + """Set the dimming time.""" + await self._async_command( + self._device.set_brightness_and_dimming_time( + self.coordinator.last_brightness_pct, int(option) + ) + ) + self.coordinator.last_dimming_time_minutes = int(option) + # Dimming time is not part of the device state + # that is provided via BLE update. Therefore + # we need to trigger a state update for the select entity + # to update the current state. + self.async_write_ha_state() diff --git a/homeassistant/components/casper_glow/strings.json b/homeassistant/components/casper_glow/strings.json index a9d70090170015..27a25b6ed4f5cb 100644 --- a/homeassistant/components/casper_glow/strings.json +++ b/homeassistant/components/casper_glow/strings.json @@ -39,6 +39,11 @@ "resume": { "name": "Resume dimming" } + }, + "select": { + "dimming_time": { + "name": "Dimming time" + } } }, "exceptions": { diff --git a/tests/components/casper_glow/conftest.py b/tests/components/casper_glow/conftest.py index e41f85458ef345..a9e5bbf006c9a5 100644 --- a/tests/components/casper_glow/conftest.py +++ b/tests/components/casper_glow/conftest.py @@ -1,6 +1,6 @@ """Casper Glow session fixtures.""" -from collections.abc import Generator +from collections.abc import Callable, Generator from unittest.mock import MagicMock, patch from pycasperglow import GlowState @@ -51,6 +51,21 @@ def mock_casper_glow() -> Generator[MagicMock]: yield mock_device +@pytest.fixture +def fire_callbacks( + mock_casper_glow: MagicMock, +) -> Callable[[GlowState], None]: + """Return a helper that fires all registered device callbacks with a given state.""" + + def _fire(state: GlowState) -> None: + for cb in ( + call[0][0] for call in mock_casper_glow.register_callback.call_args_list + ): + cb(state) + + return _fire + + @pytest.fixture async def config_entry( hass: HomeAssistant, diff --git a/tests/components/casper_glow/snapshots/test_select.ambr b/tests/components/casper_glow/snapshots/test_select.ambr new file mode 100644 index 00000000000000..5cfba34c22b0ae --- /dev/null +++ b/tests/components/casper_glow/snapshots/test_select.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_entities[select.jar_dimming_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '15', + '30', + '45', + '60', + '90', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.jar_dimming_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dimming time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dimming time', + 'platform': 'casper_glow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dimming_time', + 'unique_id': 'aa:bb:cc:dd:ee:ff_dimming_time', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[select.jar_dimming_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Jar Dimming time', + 'options': list([ + '15', + '30', + '45', + '60', + '90', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.jar_dimming_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/casper_glow/test_light.py b/tests/components/casper_glow/test_light.py index b606d18dbbd14a..7375d2ed7f214d 100644 --- a/tests/components/casper_glow/test_light.py +++ b/tests/components/casper_glow/test_light.py @@ -1,5 +1,6 @@ """Test the Casper Glow light platform.""" +from collections.abc import Callable from unittest.mock import MagicMock, patch from pycasperglow import CasperGlowError, GlowState @@ -82,21 +83,19 @@ async def test_turn_off( async def test_state_update_via_callback( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that the entity updates state when the device fires a callback.""" state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_UNKNOWN - callback = mock_casper_glow.register_callback.call_args[0][0] - - callback(GlowState(is_on=True)) + fire_callbacks(GlowState(is_on=True)) state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_ON - callback(GlowState(is_on=False)) + fire_callbacks(GlowState(is_on=False)) state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_OFF @@ -115,25 +114,6 @@ async def test_color_mode( assert ColorMode.BRIGHTNESS in state.attributes["supported_color_modes"] -async def test_turn_on_with_brightness( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_casper_glow: MagicMock, -) -> None: - """Test turning on the light with brightness.""" - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 255}, - blocking=True, - ) - - mock_casper_glow.turn_on.assert_called_once_with() - mock_casper_glow.set_brightness_and_dimming_time.assert_called_once_with( - 100, DEFAULT_DIMMING_TIME_MINUTES - ) - - @pytest.mark.parametrize( ("ha_brightness", "device_pct"), [ @@ -169,11 +149,10 @@ async def test_brightness_snap_to_nearest( async def test_brightness_update_via_callback( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that brightness updates via device callback.""" - callback = mock_casper_glow.register_callback.call_args[0][0] - callback(GlowState(is_on=True, brightness_level=80)) + fire_callbacks(GlowState(is_on=True, brightness_level=80)) state = hass.states.get(ENTITY_ID) assert state is not None @@ -181,18 +160,29 @@ async def test_brightness_update_via_callback( assert state.attributes.get(ATTR_BRIGHTNESS) == 153 -async def test_turn_on_error( +@pytest.mark.usefixtures("config_entry") +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_TURN_ON, "turn_on"), + (SERVICE_TURN_OFF, "turn_off"), + ], +) +async def test_command_error( hass: HomeAssistant, - config_entry: MockConfigEntry, mock_casper_glow: MagicMock, + service: str, + mock_method: str, ) -> None: - """Test that a turn on error raises HomeAssistantError without marking entity unavailable.""" - mock_casper_glow.turn_on.side_effect = CasperGlowError("Connection failed") + """Test that a device error raises HomeAssistantError without marking entity unavailable.""" + getattr(mock_casper_glow, mock_method).side_effect = CasperGlowError( + "Connection failed" + ) with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) @@ -202,27 +192,11 @@ async def test_turn_on_error( assert state.state == STATE_UNKNOWN -async def test_turn_off_error( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_casper_glow: MagicMock, -) -> None: - """Test that a turn off error raises HomeAssistantError.""" - mock_casper_glow.turn_off.side_effect = CasperGlowError("Connection failed") - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_ID}, - blocking=True, - ) - - async def test_state_update_via_callback_after_command_failure( hass: HomeAssistant, config_entry: MockConfigEntry, mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that device callbacks correctly update state even after a command failure.""" mock_casper_glow.turn_on.side_effect = CasperGlowError("Connection failed") @@ -241,8 +215,7 @@ async def test_state_update_via_callback_after_command_failure( assert state.state == STATE_UNKNOWN # Device sends a push state update — entity reflects true device state - callback = mock_casper_glow.register_callback.call_args[0][0] - callback(GlowState(is_on=True)) + fire_callbacks(GlowState(is_on=True)) state = hass.states.get(ENTITY_ID) assert state is not None diff --git a/tests/components/casper_glow/test_select.py b/tests/components/casper_glow/test_select.py new file mode 100644 index 00000000000000..7878b12259d5a1 --- /dev/null +++ b/tests/components/casper_glow/test_select.py @@ -0,0 +1,199 @@ +"""Test the Casper Glow select platform.""" + +from collections.abc import Callable +from unittest.mock import MagicMock, patch + +from pycasperglow import CasperGlowError, GlowState +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.casper_glow.const import ( + DIMMING_TIME_OPTIONS, + SORTED_BRIGHTNESS_LEVELS, +) +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform + +ENTITY_ID = "select.jar_dimming_time" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all select entities match the snapshot.""" + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_state_from_callback( + hass: HomeAssistant, + config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test that the select entity shows dimming time reported by device callback.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + fire_callbacks( + GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[2])) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[2] + + +async def test_select_option( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test selecting a dimming time option.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: ENTITY_ID, "option": DIMMING_TIME_OPTIONS[1]}, + blocking=True, + ) + + mock_casper_glow.set_brightness_and_dimming_time.assert_called_once_with( + SORTED_BRIGHTNESS_LEVELS[0], int(DIMMING_TIME_OPTIONS[1]) + ) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[1] + + # A subsequent device callback must not overwrite the user's selection. + fire_callbacks( + GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[0])) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[1] + + +async def test_select_option_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, +) -> None: + """Test that a set_brightness_and_dimming_time error raises HomeAssistantError.""" + mock_casper_glow.set_brightness_and_dimming_time.side_effect = CasperGlowError( + "Connection failed" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: ENTITY_ID, "option": DIMMING_TIME_OPTIONS[1]}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_select_state_update_via_callback_after_command_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test that device callbacks correctly update state even after a command failure.""" + mock_casper_glow.set_brightness_and_dimming_time.side_effect = CasperGlowError( + "Connection failed" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: ENTITY_ID, "option": DIMMING_TIME_OPTIONS[1]}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + # Device sends a push state update — entity reflects true state + fire_callbacks( + GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[1])) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[1] + + +async def test_select_ignores_remaining_time_updates( + hass: HomeAssistant, + config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test that callbacks with only remaining time do not change the select state.""" + fire_callbacks(GlowState(dimming_time_minutes=44)) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_restore_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, +) -> None: + """Test that the dimming time is restored from the last known state on restart.""" + mock_restore_cache(hass, (State(ENTITY_ID, DIMMING_TIME_OPTIONS[3]),)) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[3] + + # Coordinator should be seeded with the restored value. + assert mock_config_entry.runtime_data.last_dimming_time_minutes == int( + DIMMING_TIME_OPTIONS[3] + ) + + +@pytest.mark.parametrize( + "restored_state", + [STATE_UNKNOWN, "invalid", "999"], +) +async def test_restore_state_ignores_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + restored_state: str, +) -> None: + """Test that invalid or unsupported restored states are ignored.""" + mock_restore_cache(hass, (State(ENTITY_ID, restored_state),)) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + assert mock_config_entry.runtime_data.last_dimming_time_minutes is None From 621874160297dfc47c00dc2c34295df164861912 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 28 Mar 2026 21:44:54 +0300 Subject: [PATCH 0139/1707] Document use cases for Anthropic integration (#166752) --- homeassistant/components/anthropic/quality_scale.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml index 142285df629057..39eb1fae8c0dcf 100644 --- a/homeassistant/components/anthropic/quality_scale.yaml +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -59,10 +59,7 @@ rules: status: exempt comment: | No data updates. - docs-examples: - status: todo - comment: | - To give examples of how people use the integration + docs-examples: done docs-known-limitations: done docs-supported-devices: status: todo From a18f3cba32b3a1dfc1a837f59f1b91d7b70f1fe3 Mon Sep 17 00:00:00 2001 From: jtjart <80978647+jtjart@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:58:00 +0100 Subject: [PATCH 0140/1707] Add config flow to pjlink (#166073) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- homeassistant/components/pjlink/__init__.py | 23 +- .../components/pjlink/config_flow.py | 100 +++++ homeassistant/components/pjlink/manifest.json | 3 +- .../components/pjlink/media_player.py | 91 +++-- homeassistant/components/pjlink/strings.json | 35 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- tests/components/pjlink/__init__.py | 18 + tests/components/pjlink/conftest.py | 43 ++ tests/components/pjlink/const.py | 21 + tests/components/pjlink/test_config_flow.py | 170 ++++++++ tests/components/pjlink/test_media_player.py | 384 ++++++++---------- 12 files changed, 648 insertions(+), 245 deletions(-) create mode 100644 homeassistant/components/pjlink/config_flow.py create mode 100644 homeassistant/components/pjlink/strings.json create mode 100644 tests/components/pjlink/conftest.py create mode 100644 tests/components/pjlink/const.py create mode 100644 tests/components/pjlink/test_config_flow.py diff --git a/homeassistant/components/pjlink/__init__.py b/homeassistant/components/pjlink/__init__.py index ab4d7fd377dd52..79a1f8f76fbfdf 100644 --- a/homeassistant/components/pjlink/__init__.py +++ b/homeassistant/components/pjlink/__init__.py @@ -1 +1,22 @@ -"""The pjlink component.""" +"""The PJLink integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +_PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up PJLink from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/pjlink/config_flow.py b/homeassistant/components/pjlink/config_flow.py new file mode 100644 index 00000000000000..c2cf722e598c68 --- /dev/null +++ b/homeassistant/components/pjlink/config_flow.py @@ -0,0 +1,100 @@ +"""Config flow for the PJLink integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pypjlink import Projector +from pypjlink.projector import ProjectorError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.helpers import config_validation as cv + +from .const import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PASSWORD): str, + } +) + + +def validate_projector_connection( + host: str, port: int | None, password: str | None +) -> str: + """Validate that we can connect to the projector.""" + with Projector.from_address(host, port) as projector: + projector.authenticate(password) + return projector.get_name() + + +class PJLinkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for PJLink.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + try: + projector_name = await self.hass.async_add_executor_job( + validate_projector_connection, + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input.get(CONF_PASSWORD), + ) + except TimeoutError, OSError: + errors["base"] = "cannot_connect" + except RuntimeError, ProjectorError: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=projector_name, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import( + self, import_config: dict[str, Any] + ) -> ConfigFlowResult: + """Import a config entry from configuration.yaml.""" + + host: str = import_config[CONF_HOST] + port: int = import_config.get(CONF_PORT, DEFAULT_PORT) + password: str | None = import_config.get(CONF_PASSWORD) + + self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) + try: + projector_name = await self.hass.async_add_executor_job( + validate_projector_connection, host, port, password + ) + except TimeoutError, OSError: + return self.async_abort(reason="cannot_connect") + except RuntimeError, ProjectorError: + return self.async_abort(reason="invalid_auth") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + import_data: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port} + if password: + import_data[CONF_PASSWORD] = password + return self.async_create_entry( + title=import_config.get(CONF_NAME, projector_name), data=import_data + ) diff --git a/homeassistant/components/pjlink/manifest.json b/homeassistant/components/pjlink/manifest.json index 787311b250a581..6b213592189ac2 100644 --- a/homeassistant/components/pjlink/manifest.json +++ b/homeassistant/components/pjlink/manifest.json @@ -2,9 +2,10 @@ "domain": "pjlink", "name": "PJLink", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/pjlink", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pypjlink"], - "quality_scale": "legacy", "requirements": ["pypjlink2==1.2.1"] } diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 1e035205f8f5a8..dea2f801db9066 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from pypjlink import MUTE_AUDIO, Projector from pypjlink.projector import ProjectorError import voluptuous as vol @@ -12,10 +14,15 @@ MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT -from homeassistant.core import HomeAssistant -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 .const import CONF_ENCODING, DEFAULT_ENCODING, DEFAULT_PORT, DOMAIN @@ -33,30 +40,60 @@ ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the PJLink platform.""" - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - name = config.get(CONF_NAME) - encoding = config.get(CONF_ENCODING) - password = config.get(CONF_PASSWORD) - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - hass_data = hass.data[DOMAIN] - - device_label = f"{host}:{port}" - if device_label in hass_data: + + 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.11.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": "PJLink", + }, + ) return - device = PjLinkDevice(host, port, name, encoding, password) - hass_data[device_label] = device - add_entities([device], True) + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2026.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "PJLink", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PJLink media player.""" + async_add_entities([PjLinkDevice(entry)], update_before_add=True) def format_input_source(input_source_name, input_source_number): @@ -74,20 +111,20 @@ class PjLinkDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, host, port, name, encoding, password): - """Iinitialize the PJLink device.""" - self._host = host - self._port = port - self._password = password - self._encoding = encoding - self._source_name_mapping = {} + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the PJLink device.""" + self._host = entry.data[CONF_HOST] + self._port = entry.data[CONF_PORT] + self._password = entry.data.get(CONF_PASSWORD) + self._source_name_mapping: dict[str, Any] = {} - self._attr_name = name + self._attr_name = entry.title self._attr_is_volume_muted = False self._attr_state = MediaPlayerState.OFF self._attr_source = None self._attr_source_list = [] self._attr_available = False + self._attr_unique_id = entry.entry_id def _force_off(self): self._attr_state = MediaPlayerState.OFF diff --git a/homeassistant/components/pjlink/strings.json b/homeassistant/components/pjlink/strings.json new file mode 100644 index 00000000000000..c151f16b4cddfe --- /dev/null +++ b/homeassistant/components/pjlink/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]" + }, + "deprecated_yaml_import_issue_invalid_auth": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, invalid authentication details were found. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]" + }, + "deprecated_yaml_import_issue_unknown": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unexpected exception occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a9abead1473dd9..bb6901c64603d7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -546,6 +546,7 @@ "pi_hole", "picnic", "ping", + "pjlink", "plaato", "playstation_network", "plex", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 79b8133084f49c..e43bb5959e679e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5277,8 +5277,8 @@ }, "pjlink": { "name": "PJLink", - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_polling" }, "plaato": { diff --git a/tests/components/pjlink/__init__.py b/tests/components/pjlink/__init__.py index 4a52b3c434d531..6d40ab8713b17c 100644 --- a/tests/components/pjlink/__init__.py +++ b/tests/components/pjlink/__init__.py @@ -1 +1,19 @@ """Test the pjlink integration.""" + +from homeassistant.components.pjlink.const import DEFAULT_PORT + +from tests.common import HomeAssistant, MockConfigEntry + + +async def setup_pjlink_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up PJLink integration via config entry.""" + entry = MockConfigEntry( + domain="pjlink", + data={"host": "127.0.0.1", "port": DEFAULT_PORT}, + title="test", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/pjlink/conftest.py b/tests/components/pjlink/conftest.py new file mode 100644 index 00000000000000..f2f156cafdd6e7 --- /dev/null +++ b/tests/components/pjlink/conftest.py @@ -0,0 +1,43 @@ +"""Common fixtures for the PJLink tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.pjlink.const import DOMAIN + +from .const import DEFAULT_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.pjlink.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + + return MockConfigEntry( + version=1, domain=DOMAIN, title="test name", data=DEFAULT_DATA + ) + + +@pytest.fixture +def mock_projector() -> Generator[MagicMock]: + """Mock the PJLink Projector in the config flow.""" + with patch( + "homeassistant.components.pjlink.config_flow.Projector", + autospec=True, + ) as mock_projector: + mock_instance = mock_projector.from_address.return_value + mock_instance.get_name.return_value = "test name" + mock_instance.__enter__.return_value = mock_instance + yield mock_projector diff --git a/tests/components/pjlink/const.py b/tests/components/pjlink/const.py new file mode 100644 index 00000000000000..eb7f6264911076 --- /dev/null +++ b/tests/components/pjlink/const.py @@ -0,0 +1,21 @@ +"""Constants for the PJLink tests.""" + +from homeassistant.components.pjlink.const import ( + CONF_ENCODING, + DEFAULT_ENCODING, + DEFAULT_PORT, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT + +DEFAULT_DATA = { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + CONF_PASSWORD: "test-password", +} +DEFAULT_DATA_WO_PORT = {CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password"} +DEFAULT_DATA_W_ENCODING = { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + CONF_PASSWORD: "test-password", + CONF_ENCODING: DEFAULT_ENCODING, +} diff --git a/tests/components/pjlink/test_config_flow.py b/tests/components/pjlink/test_config_flow.py new file mode 100644 index 00000000000000..9439a266b37267 --- /dev/null +++ b/tests/components/pjlink/test_config_flow.py @@ -0,0 +1,170 @@ +"""Test the PJLink config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.pjlink.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import DEFAULT_DATA, DEFAULT_DATA_W_ENCODING, DEFAULT_DATA_WO_PORT + +from tests.common import MockConfigEntry + + +async def test_user_flow_creates_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_projector: MagicMock, +) -> None: + """Test that the user flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], DEFAULT_DATA + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test name" + assert result["data"] == DEFAULT_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow_aborts_if_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_projector: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user flow aborts if already configured.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], DEFAULT_DATA + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "error_str"), + [ + (RuntimeError, "invalid_auth"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_invalid_inputs( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_projector: MagicMock, + side_effect: type[Exception], + error_str: str, +) -> None: + """Test we handle invalid inputs.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_instance = mock_projector.from_address.return_value + mock_instance.authenticate.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], DEFAULT_DATA + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_str} + + mock_instance.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], DEFAULT_DATA + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test name" + assert result["data"] == DEFAULT_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "import_data", + [DEFAULT_DATA, DEFAULT_DATA_WO_PORT, DEFAULT_DATA_W_ENCODING], +) +async def test_import_creates_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_projector: MagicMock, + import_data: dict[str, Any], +) -> None: + """Test importing a YAML config creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=import_data + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test name" + assert result["data"] == DEFAULT_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_aborts_if_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_projector: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test importing a YAML config aborts if already configured.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=DEFAULT_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "error_str"), + [ + (RuntimeError, "invalid_auth"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_import_invalid_inputs( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_projector: MagicMock, + side_effect: type[Exception], + error_str: str, +) -> None: + """Test we handle invalid inputs.""" + + mock_instance = mock_projector.from_address.return_value + mock_instance.authenticate.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=DEFAULT_DATA + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error_str + assert len(mock_setup_entry.mock_calls) == 0 + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 diff --git a/tests/components/pjlink/test_media_player.py b/tests/components/pjlink/test_media_player.py index b2f250103ad280..9ccddcff4eb7ba 100644 --- a/tests/components/pjlink/test_media_player.py +++ b/tests/components/pjlink/test_media_player.py @@ -2,7 +2,7 @@ from datetime import timedelta import socket -from unittest.mock import create_autospec, patch +from unittest.mock import MagicMock, create_autospec, patch import pypjlink from pypjlink import MUTE_AUDIO @@ -10,12 +10,40 @@ import pytest from homeassistant.components import media_player -from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant +from homeassistant.components.pjlink.const import ( + CONF_ENCODING, + DEFAULT_ENCODING, + DEFAULT_PORT, + DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + Platform, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from . import setup_pjlink_entry + +from tests.common import async_fire_time_changed + +_EXAMPLE_YAML_CONFIG = { + Platform.MEDIA_PLAYER: [ + { + CONF_PLATFORM: DOMAIN, + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + CONF_PASSWORD: "test-password", + CONF_ENCODING: DEFAULT_ENCODING, + } + ] +} @pytest.fixture(name="projector_from_address") @@ -24,211 +52,131 @@ def projector_from_address(): with patch("pypjlink.Projector.from_address") as from_address: constructor = create_autospec(pypjlink.Projector) - from_address.return_value = constructor.return_value + constructor.__enter__.return_value = constructor + from_address.return_value = constructor yield from_address @pytest.fixture(name="mocked_projector") -def mocked_projector(projector_from_address): +def mocked_projector(projector_from_address: MagicMock) -> MagicMock: """Create pjlink Projector instance mock.""" instance = projector_from_address.return_value - with instance as mocked_instance: - mocked_instance.get_name.return_value = "Test" - mocked_instance.get_power.return_value = "on" - mocked_instance.get_mute.return_value = [0, True] - mocked_instance.get_input.return_value = [0, 1] - mocked_instance.get_inputs.return_value = ( - ("HDMI", 1), - ("HDMI", 2), - ("VGA", 1), - ) + instance.get_name.return_value = "Test" + instance.get_power.return_value = "on" + instance.get_mute.return_value = [0, True] + instance.get_input.return_value = [0, 1] + instance.get_inputs.return_value = ( + ("HDMI", 1), + ("HDMI", 2), + ("VGA", 1), + ) + + instance.__enter__.return_value = instance - yield mocked_instance + return instance @pytest.mark.parametrize("side_effect", [socket.timeout, OSError]) async def test_offline_initialization( - projector_from_address, hass: HomeAssistant, side_effect + projector_from_address: MagicMock, hass: HomeAssistant, side_effect: type[Exception] ) -> None: """Test initialization of a device that is offline.""" - with assert_setup_component(1, media_player.DOMAIN): - projector_from_address.side_effect = side_effect + projector_from_address.side_effect = side_effect - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "name": "test_offline", - "host": "127.0.0.1", - } - }, - ) - await hass.async_block_till_done() + await setup_pjlink_entry(hass) - state = hass.states.get("media_player.test_offline") - assert state.state == "unavailable" + state = hass.states.get("media_player.test") + assert state.state == "unavailable" -async def test_initialization(projector_from_address, hass: HomeAssistant) -> None: +async def test_initialization( + projector_from_address: MagicMock, hass: HomeAssistant +) -> None: """Test a device that is available.""" - with assert_setup_component(1, media_player.DOMAIN): - instance = projector_from_address.return_value - - with instance as mocked_instance: - mocked_instance.get_name.return_value = "Test" - mocked_instance.get_inputs.return_value = ( - ("HDMI", 1), - ("HDMI", 2), - ("VGA", 1), - ) + mocked_instance = projector_from_address.return_value - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) - - await hass.async_block_till_done() + mocked_instance.get_name.return_value = "Test" + mocked_instance.get_inputs.return_value = ( + ("HDMI", 1), + ("HDMI", 2), + ("VGA", 1), + ) + await setup_pjlink_entry(hass) - state = hass.states.get("media_player.test") - assert state.state == "off" + state = hass.states.get("media_player.test") + assert state.state == "off" - assert "source_list" in state.attributes - source_list = state.attributes["source_list"] + assert "source_list" in state.attributes + source_list = state.attributes["source_list"] - assert set(source_list) == {"HDMI 1", "HDMI 2", "VGA 1"} + assert set(source_list) == {"HDMI 1", "HDMI 2", "VGA 1"} @pytest.mark.parametrize("power_state", ["on", "warm-up"]) async def test_on_state_init( - projector_from_address, hass: HomeAssistant, power_state + mocked_projector: MagicMock, hass: HomeAssistant, power_state: str ) -> None: """Test a device that is available.""" - with assert_setup_component(1, media_player.DOMAIN): - instance = projector_from_address.return_value + mocked_projector.get_power.return_value = power_state + mocked_projector.get_input.return_value = ("HDMI", 1) - with instance as mocked_instance: - mocked_instance.get_name.return_value = "Test" - mocked_instance.get_power.return_value = power_state - mocked_instance.get_inputs.return_value = (("HDMI", 1),) - mocked_instance.get_input.return_value = ("HDMI", 1) + await setup_pjlink_entry(hass) - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) - - await hass.async_block_till_done() - - state = hass.states.get("media_player.test") - assert state.state == "on" + state = hass.states.get("media_player.test") + assert state.state == "on" - assert state.attributes["source"] == "HDMI 1" + assert state.attributes["source"] == "HDMI 1" -async def test_api_error(projector_from_address, hass: HomeAssistant) -> None: +async def test_api_error(mocked_projector: MagicMock, hass: HomeAssistant) -> None: """Test invalid api responses.""" - with assert_setup_component(1, media_player.DOMAIN): - instance = projector_from_address.return_value + mocked_projector.get_power.side_effect = KeyError("OK") - with instance as mocked_instance: - mocked_instance.get_name.return_value = "Test" - mocked_instance.get_inputs.return_value = ( - ("HDMI", 1), - ("HDMI", 2), - ("VGA", 1), - ) - mocked_instance.get_power.side_effect = KeyError("OK") - - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) + await setup_pjlink_entry(hass) - await hass.async_block_till_done() - - state = hass.states.get("media_player.test") - assert state.state == "off" + state = hass.states.get("media_player.test") + assert state.state == "off" -async def test_update_unavailable(projector_from_address, hass: HomeAssistant) -> None: +async def test_update_unavailable( + projector_from_address: MagicMock, hass: HomeAssistant +) -> None: """Test update to a device that is unavailable.""" - with assert_setup_component(1, media_player.DOMAIN): - instance = projector_from_address.return_value + mocked_instance = projector_from_address.return_value - with instance as mocked_instance: - mocked_instance.get_name.return_value = "Test" - mocked_instance.get_inputs.return_value = ( - ("HDMI", 1), - ("HDMI", 2), - ("VGA", 1), - ) - - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) + mocked_instance.get_name.return_value = "Test" + mocked_instance.get_inputs.return_value = ( + ("HDMI", 1), + ("HDMI", 2), + ("VGA", 1), + ) - await hass.async_block_till_done() + await setup_pjlink_entry(hass) - state = hass.states.get("media_player.test") - assert state.state == "off" + state = hass.states.get("media_player.test") + assert state.state == "off" - projector_from_address.side_effect = socket.timeout - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done(wait_background_tasks=True) + projector_from_address.side_effect = socket.timeout + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("media_player.test") - assert state.state == "unavailable" + state = hass.states.get("media_player.test") + assert state.state == "unavailable" -async def test_unavailable_time(mocked_projector, hass: HomeAssistant) -> None: +async def test_unavailable_time( + mocked_projector: MagicMock, hass: HomeAssistant +) -> None: """Test unavailable time projector error.""" - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) - - await hass.async_block_till_done() + await setup_pjlink_entry(hass) state = hass.states.get("media_player.test") assert state.state == "on" @@ -245,21 +193,11 @@ async def test_unavailable_time(mocked_projector, hass: HomeAssistant) -> None: assert "is_volume_muted" not in state.attributes -async def test_turn_off(mocked_projector, hass: HomeAssistant) -> None: +async def test_turn_off(mocked_projector: MagicMock, hass: HomeAssistant) -> None: """Test turning off beamer.""" - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) + await setup_pjlink_entry(hass) - await hass.async_block_till_done() await hass.services.async_call( domain=media_player.DOMAIN, service="turn_off", @@ -270,21 +208,11 @@ async def test_turn_off(mocked_projector, hass: HomeAssistant) -> None: mocked_projector.set_power.assert_called_with("off") -async def test_turn_on(mocked_projector, hass: HomeAssistant) -> None: +async def test_turn_on(mocked_projector: MagicMock, hass: HomeAssistant) -> None: """Test turning on beamer.""" - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) + await setup_pjlink_entry(hass) - await hass.async_block_till_done() await hass.services.async_call( domain=media_player.DOMAIN, service="turn_on", @@ -295,21 +223,11 @@ async def test_turn_on(mocked_projector, hass: HomeAssistant) -> None: mocked_projector.set_power.assert_called_with("on") -async def test_mute(mocked_projector, hass: HomeAssistant) -> None: +async def test_mute(mocked_projector: MagicMock, hass: HomeAssistant) -> None: """Test muting beamer.""" - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) + await setup_pjlink_entry(hass) - await hass.async_block_till_done() await hass.services.async_call( domain=media_player.DOMAIN, service="volume_mute", @@ -320,21 +238,11 @@ async def test_mute(mocked_projector, hass: HomeAssistant) -> None: mocked_projector.set_mute.assert_called_with(MUTE_AUDIO, True) -async def test_unmute(mocked_projector, hass: HomeAssistant) -> None: +async def test_unmute(mocked_projector: MagicMock, hass: HomeAssistant) -> None: """Test unmuting beamer.""" - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) + await setup_pjlink_entry(hass) - await hass.async_block_till_done() await hass.services.async_call( domain=media_player.DOMAIN, service="volume_mute", @@ -345,21 +253,11 @@ async def test_unmute(mocked_projector, hass: HomeAssistant) -> None: mocked_projector.set_mute.assert_called_with(MUTE_AUDIO, False) -async def test_select_source(mocked_projector, hass: HomeAssistant) -> None: +async def test_select_source(mocked_projector: MagicMock, hass: HomeAssistant) -> None: """Test selecting source.""" - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - "platform": "pjlink", - "host": "127.0.0.1", - } - }, - ) + await setup_pjlink_entry(hass) - await hass.async_block_till_done() await hass.services.async_call( domain=media_player.DOMAIN, service="select_source", @@ -368,3 +266,61 @@ async def test_select_source(mocked_projector, hass: HomeAssistant) -> None: ) mocked_projector.set_input.assert_called_with("VGA", 1) + + +async def test_yaml_import( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mocked_projector: MagicMock, +) -> None: + """Test a YAML media player is imported and becomes an operational config entry.""" + assert await async_setup_component( + hass, Platform.MEDIA_PLAYER, _EXAMPLE_YAML_CONFIG + ) + await hass.async_block_till_done() + + # Verify the config entry was created + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + # Verify a warning was issued about YAML deprecation + assert issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + + +@pytest.mark.parametrize( + ("side_effect", "error_str"), + [ + (RuntimeError, "invalid_auth"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_failed_yaml_import( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mocked_projector: MagicMock, + caplog: pytest.LogCaptureFixture, + side_effect: type[Exception], + error_str: str, +) -> None: + """Test a YAML media player is imported and becomes an operational config entry.""" + + with patch("pypjlink.Projector.from_address", side_effect=side_effect): + assert await async_setup_component( + hass, Platform.MEDIA_PLAYER, _EXAMPLE_YAML_CONFIG + ) + await hass.async_block_till_done() + + # Verify the config entry was not created + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 + + # verify no flows still in progress + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + # Verify a warning was issued about YAML not being imported + assert issue_registry.async_get_issue( + DOMAIN, f"deprecated_yaml_import_issue_{error_str}" + ) From cfc58bd41504a53f8dd6d6efa1615e568cb7c2c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 10:22:30 -1000 Subject: [PATCH 0141/1707] Bump aiohttp to 3.13.4 (#166756) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 13e943984c710e..430e8d81f1cb52 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==4.0.0 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.3 +aiohttp==3.13.4 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index d6d32b7f622ae2..d76bc3610ef3ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # module level in `bootstrap.py` and its requirements thus need to be in # requirements.txt to ensure they are always installed "aiogithubapi==26.0.0", - "aiohttp==3.13.3", + "aiohttp==3.13.4", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index a8c09abd60b439..50c1319f8be083 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==4.0.0 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.3 +aiohttp==3.13.4 aiohttp_cors==0.8.1 aiozoneinfo==0.2.3 annotatedyaml==1.0.2 From ca511231154a3f37eb0ff7996cf8c3d4900814c1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 28 Mar 2026 21:59:36 +0100 Subject: [PATCH 0142/1707] Revert mqtt vacuum segments support (#166761) --- .../components/mqtt/abbreviations.py | 3 - homeassistant/components/mqtt/entity.py | 5 - homeassistant/components/mqtt/vacuum.py | 92 +---- tests/components/mqtt/test_vacuum.py | 371 +----------------- 4 files changed, 6 insertions(+), 465 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 9892384e804da1..4cc391e0ca7920 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -18,8 +18,6 @@ "bri_stat_t": "brightness_state_topic", "bri_tpl": "brightness_template", "bri_val_tpl": "brightness_value_template", - "cln_segmnts_cmd_t": "clean_segments_command_topic", - "cln_segmnts_cmd_tpl": "clean_segments_command_template", "clr_temp_cmd_tpl": "color_temp_command_template", "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", @@ -187,7 +185,6 @@ "rgbww_cmd_t": "rgbww_command_topic", "rgbww_stat_t": "rgbww_state_topic", "rgbww_val_tpl": "rgbww_value_template", - "segmnts": "segments", "send_cmd_t": "send_command_topic", "send_if_off": "send_if_off", "set_fan_spd_t": "set_fan_speed_topic", diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index a101612f793218..12b6aac94bf891 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -1484,7 +1484,6 @@ async def discovery_update(self, discovery_payload: MQTTDiscoveryPayload) -> Non self._config = config self._setup_from_config(self._config) self._setup_common_attributes_from_config(self._config) - self._process_entity_update() # Prepare MQTT subscriptions self.attributes_prepare_discovery_update(config) @@ -1587,10 +1586,6 @@ def _setup_common_attributes_from_config(self, config: ConfigType) -> None: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - @callback - def _process_entity_update(self) -> None: - """Process an entity discovery update.""" - @abstractmethod @callback def _prepare_subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 3ec8566029dfdb..6896d51ef93c6d 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -10,13 +10,12 @@ from homeassistant.components import vacuum from homeassistant.components.vacuum import ( ENTITY_ID_FORMAT, - Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -28,7 +27,7 @@ from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC from .entity import MqttEntity, async_setup_entity_entry_helper -from .models import MqttCommandTemplate, ReceiveMessage +from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic @@ -53,9 +52,6 @@ STATE_CLEANING: VacuumActivity.CLEANING, } -CONF_SEGMENTS = "segments" -CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic" -CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template" CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES CONF_PAYLOAD_TURN_ON = "payload_turn_on" CONF_PAYLOAD_TURN_OFF = "payload_turn_off" @@ -141,39 +137,8 @@ def services_to_strings( MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" -def validate_clean_area_config(config: ConfigType) -> ConfigType: - """Check for a valid configuration and check segments.""" - if (config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config) or ( - not config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config - ): - raise vol.Invalid( - f"Options `{CONF_SEGMENTS}` and " - f"`{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` must be defined together" - ) - segments: list[str] - if segments := config[CONF_SEGMENTS]: - if not config.get(CONF_UNIQUE_ID): - raise vol.Invalid( - f"Option `{CONF_SEGMENTS}` requires `{CONF_UNIQUE_ID}` to be configured" - ) - unique_segments: set[str] = set() - for segment in segments: - segment_id, _, _ = segment.partition(".") - if not segment_id or segment_id in unique_segments: - raise vol.Invalid( - f"The `{CONF_SEGMENTS}` option contains an invalid or non-" - f"unique segment ID '{segment_id}'. Got {segments}" - ) - unique_segments.add(segment_id) - - return config - - -_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { - vol.Optional(CONF_SEGMENTS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( cv.ensure_list, [cv.string] ), @@ -199,10 +164,7 @@ def validate_clean_area_config(config: ConfigType) -> ConfigType: } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config) -DISCOVERY_SCHEMA = vol.All( - _BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config -) +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA) async def async_setup_entry( @@ -229,11 +191,9 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED - _segments: list[Segment] _command_topic: str | None _set_fan_speed_topic: str | None _send_command_topic: str | None - _clean_segments_command_topic: str _payloads: dict[str, str | None] def __init__( @@ -269,23 +229,6 @@ def _strings_to_services( self._attr_supported_features = _strings_to_services( supported_feature_strings, STRING_TO_SERVICE ) - if config[CONF_SEGMENTS] and CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config: - self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA - segments: list[str] = config[CONF_SEGMENTS] - self._segments = [ - Segment(id=segment_id, name=name or segment_id) - for segment_id, _, name in [ - segment.partition(".") for segment in segments - ] - ] - self._clean_segments_command_topic = config[ - CONF_CLEAN_SEGMENTS_COMMAND_TOPIC - ] - self._clean_segments_command_template = MqttCommandTemplate( - config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE), - entity=self, - ).async_render - self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] self._command_topic = config.get(CONF_COMMAND_TOPIC) self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) @@ -303,20 +246,6 @@ def _strings_to_services( ) } - @callback - def _process_entity_update(self) -> None: - """Check vacuum segments with registry entry.""" - if ( - self._attr_supported_features & VacuumEntityFeature.CLEAN_AREA - and (last_seen := self.last_seen_segments) is not None - and {s.id: s for s in last_seen} != {s.id: s for s in self._segments} - ): - self.async_create_segments_issue() - - async def mqtt_async_added_to_hass(self) -> None: - """Check vacuum segments with registry entry.""" - self._process_entity_update() - def _update_state_attributes(self, payload: dict[str, Any]) -> None: """Update the entity state attributes.""" self._state_attrs.update(payload) @@ -348,19 +277,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) - async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: - """Perform an area clean.""" - await self.async_publish_with_config( - self._clean_segments_command_topic, - self._clean_segments_command_template( - json_dumps(segment_ids), {"value": segment_ids} - ), - ) - - async def async_get_segments(self) -> list[Segment]: - """Return the available segments.""" - return self._segments - async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: """Publish a command.""" if self._command_topic is None: diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index 1ddb30c404cb7d..ea5d9f8f8e7b85 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -3,7 +3,7 @@ from copy import deepcopy import json from typing import Any -from unittest.mock import call, patch +from unittest.mock import patch import pytest @@ -30,7 +30,6 @@ from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er, issue_registry as ir from .common import ( help_custom_config, @@ -64,11 +63,7 @@ from tests.common import async_fire_mqtt_message from tests.components.vacuum import common -from tests.typing import ( - MqttMockHAClientGenerator, - MqttMockPahoClient, - WebSocketGenerator, -) +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient COMMAND_TOPIC = "vacuum/command" SEND_COMMAND_TOPIC = "vacuum/send_command" @@ -87,27 +82,6 @@ } } -CONFIG_CLEAN_SEGMENTS_1 = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["Livingroom", "Kitchen"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } -} -CONFIG_CLEAN_SEGMENTS_2 = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["1.Livingroom", "2.Kitchen"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } -} - DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} CONFIG_ALL_SERVICES = help_custom_config( @@ -320,347 +294,6 @@ async def test_command_without_command_topic( mqtt_mock.async_publish.reset_mock() -@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_1]) -async def test_clean_segments_initial_setup_without_repair_issue( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test cleanable segments initial setup does not fire repair flow.""" - await mqtt_mock_entry() - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_1]) -async def test_clean_segments_command_without_id( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test cleanable segments without ID.""" - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - entity_registry.async_get_or_create( - vacuum.DOMAIN, - mqtt.DOMAIN, - "veryunique", - config_entry=config_entry, - suggested_object_id="test", - ) - entity_registry.async_update_entity_options( - "vacuum.test", - vacuum.DOMAIN, - { - "area_mapping": {"Nabu Casa": ["Kitchen", "Livingroom"]}, - "last_seen_segments": [ - {"id": "Livingroom", "name": "Livingroom"}, - {"id": "Kitchen", "name": "Kitchen"}, - ], - }, - ) - mqtt_mock = await mqtt_mock_entry() - await hass.async_block_till_done() - issue_registry = ir.async_get(hass) - # We do not expect a repair flow - assert len(issue_registry.issues) == 0 - - state = hass.states.get("vacuum.test") - assert state.state == STATE_UNKNOWN - await common.async_clean_area(hass, ["Nabu Casa"], entity_id="vacuum.test") - assert ( - call("vacuum/clean_segment", '["Kitchen","Livingroom"]', 0, False) - in mqtt_mock.async_publish.mock_calls - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"]["segments"] == [ - {"id": "Livingroom", "name": "Livingroom", "group": None}, - {"id": "Kitchen", "name": "Kitchen", "group": None}, - ] - - -@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS_2]) -async def test_clean_segments_command_with_id( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test cleanable segments with ID.""" - mqtt_mock = await mqtt_mock_entry() - # Set the area mapping - entity_registry.async_update_entity_options( - "vacuum.test", - vacuum.DOMAIN, - { - "area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]}, - "last_seen_segments": [ - {"id": "1", "name": "Livingroom"}, - {"id": "2", "name": "Kitchen"}, - ], - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.test") - assert state.state == STATE_UNKNOWN - await common.async_clean_area(hass, ["Kitchen"], entity_id="vacuum.test") - assert ( - call("vacuum/clean_segment", '["2"]', 0, False) - in mqtt_mock.async_publish.mock_calls - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"]["segments"] == [ - {"id": "1", "name": "Livingroom", "group": None}, - {"id": "2", "name": "Kitchen", "group": None}, - ] - - -async def test_clean_segments_command_update( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test cleanable segments update via discovery.""" - # Prepare original entity config entry - config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - entity_registry.async_get_or_create( - vacuum.DOMAIN, - mqtt.DOMAIN, - "veryunique", - config_entry=config_entry, - suggested_object_id="test", - ) - entity_registry.async_update_entity_options( - "vacuum.test", - vacuum.DOMAIN, - { - "area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]}, - "last_seen_segments": [ - {"id": "1", "name": "Livingroom"}, - {"id": "2", "name": "Kitchen"}, - ], - }, - ) - await mqtt_mock_entry() - # Do initial discovery - config1 = CONFIG_CLEAN_SEGMENTS_2[mqtt.DOMAIN][vacuum.DOMAIN] - payload1 = json.dumps(config1) - config_topic = "homeassistant/vacuum/bla/config" - async_fire_mqtt_message(hass, config_topic, payload1) - await hass.async_block_till_done() - state = hass.states.get("vacuum.test") - assert state.state == STATE_UNKNOWN - - issue_registry = ir.async_get(hass) - # We do not expect a repair flow - assert len(issue_registry.issues) == 0 - - # Update the segments - config2 = config1.copy() - config2["segments"] = ["1.Livingroom", "2.Kitchen", "3.Diningroom"] - payload2 = json.dumps(config2) - async_fire_mqtt_message(hass, config_topic, payload2) - await hass.async_block_till_done() - - # A repair flow should start - assert len(issue_registry.issues) == 1 - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"]["segments"] == [ - {"id": "1", "name": "Livingroom", "group": None}, - {"id": "2", "name": "Kitchen", "group": None}, - {"id": "3", "name": "Diningroom", "group": None}, - ] - - # Test update with a non-unique segment list fails - config3 = config1.copy() - config3["segments"] = ["1.Livingroom", "2.Kitchen", "2.Diningroom"] - payload3 = json.dumps(config3) - async_fire_mqtt_message(hass, config_topic, payload3) - await hass.async_block_till_done() - assert ( - "Error 'The `segments` option contains an invalid or non-unique segment ID '2'" - in caplog.text - ) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["Livingroom", "Kitchen", "Kitchen"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } - }, - { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["Livingroom", "Kitchen", ""], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } - }, - { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["1.Livingroom", "1.Kitchen"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } - }, - { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "unique_id": "veryunique", - "segments": ["1.Livingroom", "1.Kitchen", ".Diningroom"], - "clean_segments_command_topic": "vacuum/clean_segment", - } - } - }, - ], -) -async def test_non_unique_segments( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test with non-unique list of cleanable segments with valid segment IDs.""" - await mqtt_mock_entry() - assert ( - "The `segments` option contains an invalid or non-unique segment ID" - in caplog.text - ) - - -@pytest.mark.usefixtures("hass") -@pytest.mark.parametrize( - ("hass_config", "error_message"), - [ - ( - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ({"clean_segments_command_topic": "test-topic"},), - ), - "Options `segments` and " - "`clean_segments_command_topic` must be defined together", - ), - ( - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ({"segments": ["Livingroom"]},), - ), - "Options `segments` and " - "`clean_segments_command_topic` must be defined together", - ), - ( - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - "segments": ["Livingroom"], - "clean_segments_command_topic": "test-topic", - }, - ), - ), - "Option `segments` requires `unique_id` to be configured", - ), - ], -) -async def test_clean_segments_config_validation( - mqtt_mock_entry: MqttMockHAClientGenerator, - error_message: str, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test status clean segment config validation.""" - await mqtt_mock_entry() - assert error_message in caplog.text - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - CONFIG_CLEAN_SEGMENTS_2, - ({"clean_segments_command_template": "{{ ';'.join(value) }}"},), - ) - ], -) -async def test_clean_segments_command_with_id_and_command_template( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test clean segments with command template.""" - mqtt_mock = await mqtt_mock_entry() - entity_registry.async_update_entity_options( - "vacuum.test", - vacuum.DOMAIN, - { - "area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]}, - "last_seen_segments": [ - {"id": "1", "name": "Livingroom"}, - {"id": "2", "name": "Kitchen"}, - ], - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.test") - assert state.state == STATE_UNKNOWN - await common.async_clean_area( - hass, ["Livingroom", "Kitchen"], entity_id="vacuum.test" - ) - assert ( - call("vacuum/clean_segment", "1;2", 0, False) - in mqtt_mock.async_publish.mock_calls - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"]["segments"] == [ - {"id": "1", "name": "Livingroom", "group": None}, - {"id": "2", "name": "Kitchen", "group": None}, - ] - - @pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_status( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator From a6ec59d6a5ec7af271fd52d227c92fe47e0336e7 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:13:31 +0200 Subject: [PATCH 0143/1707] Bump habiticalib to 0.4.7 (#166772) --- homeassistant/components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 9649723f761084..d11d6fe557bd80 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.4.6"] + "requirements": ["habiticalib==0.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 390c4cbfa536cb..8b3855aa3f4188 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1173,7 +1173,7 @@ ha-philipsjs==3.2.4 ha-silabs-firmware-client==0.3.0 # homeassistant.components.habitica -habiticalib==0.4.6 +habiticalib==0.4.7 # homeassistant.components.bluetooth habluetooth==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b8ef8e88c8b248..ba0b386f4c4115 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1046,7 +1046,7 @@ ha-philipsjs==3.2.4 ha-silabs-firmware-client==0.3.0 # homeassistant.components.habitica -habiticalib==0.4.6 +habiticalib==0.4.7 # homeassistant.components.bluetooth habluetooth==5.11.1 From 3a761116e4fd90df962070c3e0633592d9915ece Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:14:19 +0200 Subject: [PATCH 0144/1707] Bump aiontfy to 0.8.3 (#166770) --- homeassistant/components/ntfy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index b327c1e2b93eed..dda80fef2574ca 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aiontfy"], "quality_scale": "platinum", - "requirements": ["aiontfy==0.8.1"] + "requirements": ["aiontfy==0.8.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b3855aa3f4188..f16c1e7d4e6adf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -339,7 +339,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.1 +aiontfy==0.8.3 # homeassistant.components.nut aionut==4.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba0b386f4c4115..28a41baa7a54cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -324,7 +324,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.1 +aiontfy==0.8.3 # homeassistant.components.nut aionut==4.3.4 From 99306a75d321ae0626c5ee12fa58f4278109b933 Mon Sep 17 00:00:00 2001 From: DevHugo Date: Sun, 29 Mar 2026 08:14:51 +0200 Subject: [PATCH 0145/1707] Bump youtubeaio to 2.1.2 (#166767) --- homeassistant/components/youtube/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json index 83734318bcd09a..a96293902b8fa8 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["youtubeaio==2.1.1"] + "requirements": ["youtubeaio==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f16c1e7d4e6adf..1d80e31a41cf2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3365,7 +3365,7 @@ yolink-api==0.6.3 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==2.1.1 +youtubeaio==2.1.2 # homeassistant.components.media_extractor yt-dlp[default]==2026.03.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28a41baa7a54cf..a07a5f396a26a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2850,7 +2850,7 @@ yolink-api==0.6.3 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==2.1.1 +youtubeaio==2.1.2 # homeassistant.components.media_extractor yt-dlp[default]==2026.03.17 From a93229bd323438616be0f41c2038ddc3f0578e34 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:17:01 +0200 Subject: [PATCH 0146/1707] Cancel wait_for_started task in Onkyo (#166762) --- homeassistant/components/onkyo/receiver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/onkyo/receiver.py b/homeassistant/components/onkyo/receiver.py index f7542e40bee7c9..ed2ec295bfa362 100644 --- a/homeassistant/components/onkyo/receiver.py +++ b/homeassistant/components/onkyo/receiver.py @@ -76,6 +76,8 @@ async def start(self) -> Awaitable[None] | None: if manager_task in done: # Something went wrong, so let's return the manager task, # so that it can be awaited to error out + wait_for_started_task.cancel() + await asyncio.wait((wait_for_started_task,)) return manager_task return None From 2b3a504a054b6e06d4283d9e36be06f4438ee78a Mon Sep 17 00:00:00 2001 From: mletenay Date: Sun, 29 Mar 2026 20:39:36 +0200 Subject: [PATCH 0147/1707] Update goodwe library to 0.4.10 (#166809) --- homeassistant/components/goodwe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index b658dbca636ba5..f66a656ba39d46 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.4.8"] + "requirements": ["goodwe==0.4.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1d80e31a41cf2a..c7fc84b5338edc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1084,7 +1084,7 @@ go2rtc-client==0.4.0 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.4.8 +goodwe==0.4.10 # homeassistant.components.google_mail # homeassistant.components.google_tasks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a07a5f396a26a8..41c20f1e2b678b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ go2rtc-client==0.4.0 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.4.8 +goodwe==0.4.10 # homeassistant.components.google_mail # homeassistant.components.google_tasks From e80caaa7cd46080dc35ecf624eb24cd8964566c6 Mon Sep 17 00:00:00 2001 From: Jeff Terrace Date: Sun, 29 Mar 2026 17:08:53 -0500 Subject: [PATCH 0148/1707] Remove hunterjm@ as an owner of onvif (#166823) --- CODEOWNERS | 4 ++-- homeassistant/components/onvif/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f73c4561383493..34bdca2205489b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1228,8 +1228,8 @@ build.json @home-assistant/supervisor /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 /tests/components/onkyo/ @arturpragacz @eclair4151 -/homeassistant/components/onvif/ @hunterjm @jterrace -/tests/components/onvif/ @hunterjm @jterrace +/homeassistant/components/onvif/ @jterrace +/tests/components/onvif/ @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck /homeassistant/components/open_router/ @joostlek diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 9d15ca0afe60f5..62b440b1c90073 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -1,7 +1,7 @@ { "domain": "onvif", "name": "ONVIF", - "codeowners": ["@hunterjm", "@jterrace"], + "codeowners": ["@jterrace"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [ From 31a24446a8a1aefa1f039b5dcf647dedcdf7f090 Mon Sep 17 00:00:00 2001 From: Jeff Terrace Date: Sun, 29 Mar 2026 19:05:05 -0500 Subject: [PATCH 0149/1707] Rename onvif event module to event_manager (#166830) --- homeassistant/components/onvif/device.py | 2 +- homeassistant/components/onvif/{event.py => event_manager.py} | 0 tests/components/onvif/__init__.py | 4 ++-- .../components/onvif/{test_event.py => test_event_manager.py} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename homeassistant/components/onvif/{event.py => event_manager.py} (100%) rename tests/components/onvif/{test_event.py => test_event_manager.py} (100%) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 47f23da7f99a4f..7bcdd33809b057 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -41,7 +41,7 @@ TILT_FACTOR, ZOOM_FACTOR, ) -from .event import EventManager +from .event_manager import EventManager from .models import PTZ, Capabilities, DeviceInfo, Profile, Resolution, Video diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event_manager.py similarity index 100% rename from homeassistant/components/onvif/event.py rename to homeassistant/components/onvif/event_manager.py diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index d31c84fcd4557e..ac68c021234093 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -13,7 +13,7 @@ from homeassistant import config_entries from homeassistant.components.onvif import config_flow from homeassistant.components.onvif.const import CONF_SNAPSHOT_AUTH -from homeassistant.components.onvif.event import EventManager +from homeassistant.components.onvif.event_manager import EventManager from homeassistant.components.onvif.models import ( Capabilities, DeviceInfo, @@ -253,7 +253,7 @@ async def mock_parse(topic, unique_id, msg): return event_by_topic.get(topic) with patch( - "homeassistant.components.onvif.event.onvif_parsers" + "homeassistant.components.onvif.event_manager.onvif_parsers" ) as mock_parsers: mock_parsers.parse = mock_parse mock_parsers.errors.UnknownTopicError = type( diff --git a/tests/components/onvif/test_event.py b/tests/components/onvif/test_event_manager.py similarity index 100% rename from tests/components/onvif/test_event.py rename to tests/components/onvif/test_event_manager.py From afc73fdcfd13a46ddaba505d415bfde60e45cb98 Mon Sep 17 00:00:00 2001 From: Mika Date: Mon, 30 Mar 2026 04:07:01 +0200 Subject: [PATCH 0150/1707] Bump aiosolaredge to 1.0.2 (#166763) --- homeassistant/components/solaredge/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json index 295d562778f59a..276ddaf0c821e7 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["aiosolaredge", "solaredge_web"], - "requirements": ["aiosolaredge==0.2.0", "solaredge-web==0.0.1"] + "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c7fc84b5338edc..2f805f0c5cbb3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -404,7 +404,7 @@ aioskybell==22.7.0 aioslimproto==3.0.0 # homeassistant.components.solaredge -aiosolaredge==0.2.0 +aiosolaredge==1.0.2 # homeassistant.components.steamist aiosteamist==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41c20f1e2b678b..1c8f2c70f53ea7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -389,7 +389,7 @@ aioskybell==22.7.0 aioslimproto==3.0.0 # homeassistant.components.solaredge -aiosolaredge==0.2.0 +aiosolaredge==1.0.2 # homeassistant.components.steamist aiosteamist==1.0.1 From 8978d197ca468722275ccefbb985e6f2072d0c9d Mon Sep 17 00:00:00 2001 From: Raman Varabets Date: Mon, 30 Mar 2026 10:41:11 +0800 Subject: [PATCH 0151/1707] Allow Matter thermostats with `null` `LocalTemperature` (#162973) Co-authored-by: TheJulianJES --- homeassistant/components/matter/climate.py | 1 + tests/components/matter/test_climate.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index a67d5dcc8ebc66..d1699beaa6060d 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -597,5 +597,6 @@ def _get_temperature_in_degrees( ), device_type=(device_types.Thermostat, device_types.RoomAirConditioner), allow_multi=True, # also used for sensor entity + allow_none_value=True, ), ] diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index b0bf32461c42d8..b6d7fa3e9027ab 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -743,3 +743,16 @@ async def test_preset_mode_with_unnamed_preset( state = hass.states.get(entity_id) assert state assert state.attributes["preset_mode"] == PRESET_NONE + + +@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"]) +@pytest.mark.parametrize("attributes", [{"1/513/0": None}]) +async def test_thermostat_with_null_local_temperature( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test thermostat is created when LocalTemperature is null.""" + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["current_temperature"] is None From d10197d5354c79d6823b96e00b502599aaf88623 Mon Sep 17 00:00:00 2001 From: pedroterzero <72469659+pedroterzero@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:12:57 +0200 Subject: [PATCH 0152/1707] Add fixture for Tuya D825A dehumidifier (#166822) --- .../tuya/fixtures/cs_biflejkeshx1sqig.json | 130 ++++++++++++++++++ tests/components/tuya/snapshots/test_fan.ambr | 57 ++++++++ .../tuya/snapshots/test_humidifier.ambr | 58 ++++++++ .../components/tuya/snapshots/test_init.ambr | 31 +++++ .../tuya/snapshots/test_select.ambr | 124 +++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 113 +++++++++++++++ .../tuya/snapshots/test_switch.ambr | 51 +++++++ 7 files changed, 564 insertions(+) create mode 100644 tests/components/tuya/fixtures/cs_biflejkeshx1sqig.json diff --git a/tests/components/tuya/fixtures/cs_biflejkeshx1sqig.json b/tests/components/tuya/fixtures/cs_biflejkeshx1sqig.json new file mode 100644 index 00000000000000..0123d41fe3536a --- /dev/null +++ b/tests/components/tuya/fixtures/cs_biflejkeshx1sqig.json @@ -0,0 +1,130 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "D825A I", + "category": "cs", + "product_id": "biflejkeshx1sqig", + "product_name": "D825A", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-11-21T16:23:08+00:00", + "create_time": "2024-11-21T16:23:08+00:00", + "update_time": "2024-11-21T16:23:08+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_enum": { + "type": "Enum", + "value": { + "range": ["40", "45", "50"] + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "mid", "high", "auto"] + } + }, + "swing": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_enum": { + "type": "Enum", + "value": { + "range": ["40", "45", "50"] + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "mid", "high", "auto"] + } + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_indoor": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "swing": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 540, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["C1", "C2", "P1", "P3", "P4", "water_full"] + } + } + }, + "status": { + "switch": true, + "dehumidify_set_enum": "45", + "fan_speed_enum": "auto", + "humidity_indoor": 76, + "temp_indoor": 24, + "swing": true, + "child_lock": false, + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 80816daee5a894..5cd7623d22bba8 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -234,6 +234,63 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[fan.d825a_i-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.d825a_i', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.giqs1xhsekjelfibsc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.d825a_i-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'D825A I', + 'percentage': 100, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.d825a_i', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[fan.dehumidifer-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index ba9cc22ef33e85..c2e0bb3e240bad 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -57,6 +57,64 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[humidifier.d825a_i-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.d825a_i', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.giqs1xhsekjelfibscswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.d825a_i-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 76, + 'device_class': 'dehumidifier', + 'friendly_name': 'D825A I', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.d825a_i', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[humidifier.dehumidifer-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index f70cb73d1d666e..99185f6bc74bff 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -4060,6 +4060,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[giqs1xhsekjelfibsc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'giqs1xhsekjelfibsc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'D825A', + 'model_id': 'biflejkeshx1sqig', + 'name': 'D825A I', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[gjnpc0eojd] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 96cc01db00b6ba..5db9bdcc14634c 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -2004,6 +2004,130 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[select.d825a_i_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.d825a_i_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Countdown', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.giqs1xhsekjelfibsccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.d825a_i_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'D825A I Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.d825a_i_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- +# name: test_platform_setup_and_discovery[select.d825a_i_target_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '40', + '45', + '50', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.d825a_i_target_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target humidity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_humidity', + 'unique_id': 'tuya.giqs1xhsekjelfibscdehumidify_set_enum', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.d825a_i_target_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'D825A I Target humidity', + 'options': list([ + '40', + '45', + '50', + ]), + }), + 'context': , + 'entity_id': 'select.d825a_i_target_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45', + }) +# --- # name: test_platform_setup_and_discovery[select.dehumidifer_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 10b542b445eba1..a695c77a682cf3 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -5090,6 +5090,119 @@ 'state': '241.6', }) # --- +# name: test_platform_setup_and_discovery[sensor.d825a_i_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.d825a_i_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.giqs1xhsekjelfibschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.d825a_i_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'D825A I Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.d825a_i_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '76.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.d825a_i_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.d825a_i_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.giqs1xhsekjelfibsctemp_indoor', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.d825a_i_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'D825A I Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.d825a_i_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.dehumidifer_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index d7fac631b9025d..7bb25523c7b13b 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -2973,6 +2973,57 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.d825a_i_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.d825a_i_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.giqs1xhsekjelfibscchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.d825a_i_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'D825A I Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.d825a_i_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.dehumidifer_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 067a9a0c2540b9195047655e302888f21d13d6dd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:51:50 +0200 Subject: [PATCH 0153/1707] Bump tuya-device-handlers to 0.0.16 (#166844) --- homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 031c6fa571733b..15d9402e2e982a 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -44,7 +44,7 @@ "iot_class": "cloud_push", "loggers": ["tuya_sharing"], "requirements": [ - "tuya-device-handlers==0.0.15", + "tuya-device-handlers==0.0.16", "tuya-device-sharing-sdk==0.2.8" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 2f805f0c5cbb3e..a3ec0182e93fb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3157,7 +3157,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.15 +tuya-device-handlers==0.0.16 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c8f2c70f53ea7..8f7c7b6ef4fc5c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2669,7 +2669,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.15 +tuya-device-handlers==0.0.16 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.8 From c509226d17cea6a3ad9154b9dc7894a00545f65d Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:03:37 +0200 Subject: [PATCH 0154/1707] Remove unused string from HTML5 integration (#166826) --- homeassistant/components/html5/strings.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index e786f80a4561c8..c419451dc2c239 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -48,12 +48,6 @@ "message": "Sending notification to {target} failed due to a request error" } }, - "issues": { - "deprecated_yaml_import_issue": { - "description": "Configuring HTML5 push notification using YAML has been deprecated. An automatic import of your existing configuration was attempted, but it failed.\n\nPlease remove the HTML5 push notification YAML configuration from your configuration.yaml file and reconfigure HTML5 push notification again manually.", - "title": "HTML5 YAML configuration import failed" - } - }, "services": { "dismiss": { "description": "Dismisses an HTML5 notification.", From 51131beaec2671d241c67d036410ced8110d7891 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 30 Mar 2026 10:44:16 +0200 Subject: [PATCH 0155/1707] Update knx-frontend to 2026.3.28.223133 (#166764) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a431ab98fefd69..c0a838b48c0c5d 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.15.0", "xknxproject==3.8.2", - "knx-frontend==2026.3.2.183756" + "knx-frontend==2026.3.28.223133" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index a3ec0182e93fb3..602f09afeae348 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1383,7 +1383,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.3.2.183756 +knx-frontend==2026.3.28.223133 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f7c7b6ef4fc5c..b899a8e525f868 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1223,7 +1223,7 @@ kegtron-ble==1.0.2 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.3.2.183756 +knx-frontend==2026.3.28.223133 # homeassistant.components.konnected konnected==1.2.0 From 970925141edd36ac4c509d891385227424a53525 Mon Sep 17 00:00:00 2001 From: Jeef Date: Mon, 30 Mar 2026 02:54:17 -0600 Subject: [PATCH 0156/1707] Bump weatherflow4py to 1.5.2 (#166773) --- homeassistant/components/weatherflow_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 38c73969bff9fd..b7d29a3e9d50d5 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.4.1"] + "requirements": ["weatherflow4py==1.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 602f09afeae348..8dd81b29b5a641 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3280,7 +3280,7 @@ waterfurnace==1.6.2 watergate-local-api==2025.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.4.1 +weatherflow4py==1.5.2 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b899a8e525f868..42a4b275aa301e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2777,7 +2777,7 @@ waterfurnace==1.6.2 watergate-local-api==2025.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.4.1 +weatherflow4py==1.5.2 # homeassistant.components.nasweb webio-api==0.1.12 From 119dfbddea66c2397ccacbfe5b75b8309dc63bb2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 30 Mar 2026 11:32:16 +0200 Subject: [PATCH 0157/1707] Update quality scale for Fritz (#166853) --- homeassistant/components/fritz/quality_scale.yaml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 547ef63ad22a5c..8818bc04cdb393 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -34,23 +34,17 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo + discovery-update-info: done discovery: done docs-data-update: done docs-examples: done docs-known-limitations: status: exempt comment: no known limitations, yet - docs-supported-devices: - status: todo - comment: add the known supported devices - docs-supported-functions: - status: todo - comment: need to be overhauled + docs-supported-devices: done + docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: - status: todo - comment: need to be overhauled + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done From 297e9e265a2185c4ea20397954369483a88aa0ae Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:06:43 +0200 Subject: [PATCH 0158/1707] Add `valve.opened` and `valve.closed` triggers (#165160) --- .../components/automation/__init__.py | 1 + homeassistant/components/valve/icons.json | 8 + homeassistant/components/valve/strings.json | 35 +++- homeassistant/components/valve/trigger.py | 24 +++ homeassistant/components/valve/triggers.yaml | 18 ++ homeassistant/helpers/trigger.py | 8 +- tests/components/valve/test_trigger.py | 161 ++++++++++++++++++ 7 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/valve/trigger.py create mode 100644 homeassistant/components/valve/triggers.yaml create mode 100644 tests/components/valve/test_trigger.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 3bdeae6ce109a2..7fa8c5afb6dfe6 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -193,6 +193,7 @@ "todo", "update", "vacuum", + "valve", "water_heater", "window", } diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json index bc01ba7717570b..c5bccd46b14b20 100644 --- a/homeassistant/components/valve/icons.json +++ b/homeassistant/components/valve/icons.json @@ -40,5 +40,13 @@ "toggle": { "service": "mdi:valve-open" } + }, + "triggers": { + "closed": { + "trigger": "mdi:valve-closed" + }, + "opened": { + "trigger": "mdi:valve-open" + } } } diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index 09bd02ba207980..cd01e3142cf407 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,4 +1,8 @@ { + "common": { + "trigger_behavior_description": "The behavior of the targeted valves to trigger on.", + "trigger_behavior_name": "Behavior" + }, "conditions": { "is_closed": { "description": "Tests if one or more valves are closed.", @@ -50,6 +54,13 @@ "all": "All", "any": "Any" } + }, + "trigger_behavior": { + "options": { + "any": "Any", + "first": "First", + "last": "Last" + } } }, "services": { @@ -80,5 +91,27 @@ "name": "Toggle valve" } }, - "title": "Valve" + "title": "Valve", + "triggers": { + "closed": { + "description": "Triggers after one or more valves close.", + "fields": { + "behavior": { + "description": "[%key:component::valve::common::trigger_behavior_description%]", + "name": "[%key:component::valve::common::trigger_behavior_name%]" + } + }, + "name": "Valve closed" + }, + "opened": { + "description": "Triggers after one or more valves open.", + "fields": { + "behavior": { + "description": "[%key:component::valve::common::trigger_behavior_description%]", + "name": "[%key:component::valve::common::trigger_behavior_name%]" + } + }, + "name": "Valve opened" + } + } } diff --git a/homeassistant/components/valve/trigger.py b/homeassistant/components/valve/trigger.py new file mode 100644 index 00000000000000..8459accd4ebeb6 --- /dev/null +++ b/homeassistant/components/valve/trigger.py @@ -0,0 +1,24 @@ +"""Provides triggers for valves.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger + +from . import ATTR_IS_CLOSED, DOMAIN + +VALVE_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED)} + + +TRIGGERS: dict[str, type[Trigger]] = { + "closed": make_entity_transition_trigger( + VALVE_DOMAIN_SPECS, from_states={False}, to_states={True} + ), + "opened": make_entity_transition_trigger( + VALVE_DOMAIN_SPECS, from_states={True}, to_states={False} + ), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for valves.""" + return TRIGGERS diff --git a/homeassistant/components/valve/triggers.yaml b/homeassistant/components/valve/triggers.yaml new file mode 100644 index 00000000000000..aaf09598d65aff --- /dev/null +++ b/homeassistant/components/valve/triggers.yaml @@ -0,0 +1,18 @@ +.trigger_common: &trigger_common + target: + entity: + domain: valve + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: trigger_behavior + options: + - first + - last + - any + +closed: *trigger_common +opened: *trigger_common diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 404051fd5fc0eb..99dd07ac75f738 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -482,8 +482,8 @@ def is_valid_state(self, state: State) -> bool: class EntityTransitionTriggerBase(EntityTriggerBase): """Trigger for entity state changes between specific states.""" - _from_states: set[str] - _to_states: set[str] + _from_states: set[str | bool] + _to_states: set[str | bool] def is_valid_transition(self, from_state: State, to_state: State) -> bool: """Check if the origin state matches the expected ones.""" @@ -838,8 +838,8 @@ class CustomTrigger(EntityTargetStateTriggerBase): def make_entity_transition_trigger( domain_specs: Mapping[str, DomainSpec] | str, *, - from_states: set[str], - to_states: set[str], + from_states: set[str | bool], + to_states: set[str | bool], ) -> type[EntityTransitionTriggerBase]: """Create a trigger for entity state changes between specific states. diff --git a/tests/components/valve/test_trigger.py b/tests/components/valve/test_trigger.py new file mode 100644 index 00000000000000..ae0a1f038e4a1c --- /dev/null +++ b/tests/components/valve/test_trigger.py @@ -0,0 +1,161 @@ +"""Test valve trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.valve import ATTR_IS_CLOSED, DOMAIN, ValveState +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_any, + assert_trigger_behavior_first, + assert_trigger_behavior_last, + assert_trigger_gated_by_labs_flag, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) + +TRIGGER_STATES = [ + *parametrize_trigger_states( + trigger="valve.closed", + target_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: True}), + (ValveState.OPENING, {ATTR_IS_CLOSED: True}), + ], + other_states=[ + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + ], + extra_invalid_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: None}), + (ValveState.OPEN, {}), + ], + ), + *parametrize_trigger_states( + trigger="valve.opened", + target_states=[ + (ValveState.OPEN, {ATTR_IS_CLOSED: False}), + (ValveState.OPENING, {ATTR_IS_CLOSED: False}), + (ValveState.CLOSING, {ATTR_IS_CLOSED: False}), + ], + other_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: True}), + ], + extra_invalid_states=[ + (ValveState.CLOSED, {ATTR_IS_CLOSED: None}), + (ValveState.CLOSED, {}), + ], + ), +] + + +@pytest.fixture +async def target_valves(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple valve entities associated with different targets.""" + return await target_entities(hass, DOMAIN) + + +@pytest.mark.parametrize( + "trigger_key", + [ + "valve.closed", + "valve.opened", + ], +) +async def test_valve_triggers_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str +) -> None: + """Test the valve triggers are gated by the labs flag.""" + await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES) +async def test_valve_state_trigger_behavior_any( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any] | None, + states: list[TriggerStateDescription], +) -> None: + """Test that the valve state trigger fires when any valve state changes to a specific state.""" + await assert_trigger_behavior_any( + hass, + target_entities=target_valves, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES) +async def test_valve_state_trigger_behavior_first( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + trigger_target_config: dict, + entities_in_target: int, + entity_id: str, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test that the valve state trigger fires when the first valve changes to a specific state.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_valves, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities(DOMAIN), +) +@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES) +async def test_valve_state_trigger_behavior_last( + hass: HomeAssistant, + target_valves: dict[str, list[str]], + trigger_target_config: dict, + entities_in_target: int, + entity_id: str, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test that the valve state trigger fires when the last valve changes to a specific state.""" + await assert_trigger_behavior_last( + hass, + target_entities=target_valves, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) From be3d65538dea0e59a3b7cb3e56683964c4da05e2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:32:27 +0200 Subject: [PATCH 0159/1707] Use runtime_data in motion_blinds integration (#166849) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/motion_blinds/__init__.py | 35 +++++-------------- .../components/motion_blinds/button.py | 10 +++--- .../components/motion_blinds/config_flow.py | 4 +-- .../components/motion_blinds/const.py | 1 - .../components/motion_blinds/coordinator.py | 28 +++++++++------ .../components/motion_blinds/cover.py | 10 +++--- .../components/motion_blinds/sensor.py | 9 +++-- 7 files changed, 40 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 9c4d1a97f00107..a13a73e6f9037d 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -2,11 +2,9 @@ import asyncio import logging -from typing import TYPE_CHECKING from motionblinds import AsyncMotionMulticast -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -14,32 +12,28 @@ from .const import ( CONF_BLIND_TYPE_LIST, CONF_INTERFACE, - CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, - DEFAULT_WAIT_FOR_PUSH, DOMAIN, - KEY_API_LOCK, - KEY_COORDINATOR, - KEY_GATEWAY, KEY_MULTICAST_LISTENER, KEY_SETUP_LOCK, KEY_UNSUB_STOP, PLATFORMS, ) -from .coordinator import DataUpdateCoordinatorMotionBlinds +from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry from .gateway import ConnectMotionGateway _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: MotionBlindsConfigEntry +) -> bool: """Set up the motion_blinds components from a config entry.""" hass.data.setdefault(DOMAIN, {}) setup_lock = hass.data[DOMAIN].setdefault(KEY_SETUP_LOCK, asyncio.Lock()) host = entry.data[CONF_HOST] key = entry.data[CONF_API_KEY] multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE) - wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH) blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST) # Create multicast Listener @@ -88,15 +82,9 @@ def stop_motion_multicast(event): ): raise ConfigEntryNotReady motion_gateway = connect_gateway_class.gateway_device - api_lock = asyncio.Lock() - coordinator_info = { - KEY_GATEWAY: motion_gateway, - KEY_API_LOCK: api_lock, - CONF_WAIT_FOR_PUSH: wait_for_push, - } coordinator = DataUpdateCoordinatorMotionBlinds( - hass, entry, _LOGGER, coordinator_info + hass, entry, _LOGGER, motion_gateway ) # store blind type list for next time @@ -110,20 +98,16 @@ def stop_motion_multicast(event): # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - KEY_GATEWAY: motion_gateway, - KEY_COORDINATOR: coordinator, - } - - if TYPE_CHECKING: - assert entry.unique_id is not None + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: MotionBlindsConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS @@ -132,7 +116,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok: multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST]) - hass.data[DOMAIN].pop(config_entry.entry_id) if not hass.config_entries.async_loaded_entries(DOMAIN): # No motion gateways left, stop Motion multicast diff --git a/homeassistant/components/motion_blinds/button.py b/homeassistant/components/motion_blinds/button.py index 09f29e09c705f5..f590f50694c731 100644 --- a/homeassistant/components/motion_blinds/button.py +++ b/homeassistant/components/motion_blinds/button.py @@ -5,25 +5,23 @@ from motionblinds.motion_blinds import LimitStatus, MotionBlind from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY -from .coordinator import DataUpdateCoordinatorMotionBlinds +from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry from .entity import MotionCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Motionblinds.""" entities: list[ButtonEntity] = [] - motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + coordinator = config_entry.runtime_data + motion_gateway = coordinator.gateway for blind in motion_gateway.device_list.values(): if blind.limit_status in ( diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index cd85de5c62787b..59a65aab0010bc 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -27,6 +26,7 @@ DEFAULT_WAIT_FOR_PUSH, DOMAIN, ) +from .coordinator import MotionBlindsConfigEntry from .gateway import ConnectMotionGateway _LOGGER = logging.getLogger(__name__) @@ -79,7 +79,7 @@ def __init__(self) -> None: @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, ) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 950fa3ab4c74e8..1d151a1e63bc1f 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -16,7 +16,6 @@ KEY_GATEWAY = "gateway" KEY_API_LOCK = "api_lock" -KEY_COORDINATOR = "coordinator" KEY_MULTICAST_LISTENER = "multicast_listener" KEY_SETUP_LOCK = "setup_lock" KEY_UNSUB_STOP = "unsub_stop" diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index 8de793c405fbfa..6614b666538b02 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -1,11 +1,12 @@ """DataUpdateCoordinator for Motionblinds integration.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging -from typing import Any -from motionblinds import DEVICE_TYPES_WIFI, ParseException +from motionblinds import DEVICE_TYPES_WIFI, MotionGateway, ParseException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -14,7 +15,7 @@ from .const import ( ATTR_AVAILABLE, CONF_WAIT_FOR_PUSH, - KEY_API_LOCK, + DEFAULT_WAIT_FOR_PUSH, KEY_GATEWAY, UPDATE_INTERVAL, UPDATE_INTERVAL_FAST, @@ -23,17 +24,20 @@ _LOGGER = logging.getLogger(__name__) +type MotionBlindsConfigEntry = ConfigEntry[DataUpdateCoordinatorMotionBlinds] + + class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): """Class to manage fetching data from single endpoint.""" - config_entry: ConfigEntry + config_entry: MotionBlindsConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, logger: logging.Logger, - coordinator_info: dict[str, Any], + gateway: MotionGateway, ) -> None: """Initialize global data updater.""" super().__init__( @@ -44,14 +48,16 @@ def __init__( update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - self.api_lock = coordinator_info[KEY_API_LOCK] - self._gateway = coordinator_info[KEY_GATEWAY] - self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH] + self.api_lock = asyncio.Lock() + self.gateway = gateway + self._wait_for_push = config_entry.options.get( + CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH + ) def update_gateway(self): """Fetch data from gateway.""" try: - self._gateway.Update() + self.gateway.Update() except TimeoutError, ParseException: # let the error be logged and handled by the motionblinds library return {ATTR_AVAILABLE: False} @@ -82,7 +88,7 @@ async def _async_update_data(self): self.update_gateway ) - for blind in self._gateway.device_list.values(): + for blind in self.gateway.device_list.values(): await asyncio.sleep(1.5) async with self.api_lock: data[blind.mac] = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index f1351af8bc21d0..342a00686d6c92 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -15,7 +15,6 @@ CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -25,12 +24,11 @@ ATTR_ABSOLUTE_POSITION, ATTR_AVAILABLE, ATTR_WIDTH, - DOMAIN, - KEY_COORDINATOR, KEY_GATEWAY, SERVICE_SET_ABSOLUTE_POSITION, UPDATE_DELAY_STOP, ) +from .coordinator import MotionBlindsConfigEntry from .entity import MotionCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -84,13 +82,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Motion Blind from a config entry.""" entities: list[MotionBaseDevice] = [] - motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + coordinator = config_entry.runtime_data + motion_gateway = coordinator.gateway for blind in motion_gateway.device_list.values(): if blind.type in POSITION_DEVICE_MAP: diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index eac89eccdd205f..673cbc5458e444 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -10,7 +10,6 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -19,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY +from .coordinator import MotionBlindsConfigEntry from .entity import MotionCoordinatorEntity ATTR_BATTERY_VOLTAGE = "battery_voltage" @@ -27,13 +26,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Motionblinds.""" entities: list[SensorEntity] = [] - motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + coordinator = config_entry.runtime_data + motion_gateway = coordinator.gateway for blind in motion_gateway.device_list.values(): entities.append(MotionSignalStrengthSensor(coordinator, blind)) From c6ad6da6aee1f5f2a7af82adbc8462c5fca3acc9 Mon Sep 17 00:00:00 2001 From: Florian Date: Mon, 30 Mar 2026 12:34:38 +0200 Subject: [PATCH 0160/1707] Clamp surepetcare battery percentage to 0-100 (#166824) Co-authored-by: Claude --- homeassistant/components/surepetcare/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 6f7dc6a33e9479..a34675eee74279 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -73,8 +73,8 @@ def _update_attr(self, surepy_entity: SurepyEntity) -> None: try: per_battery_voltage = state["battery"] / 4 voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW - self._attr_native_value = min( - int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100 + self._attr_native_value = max( + 0, min(int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100) ) except KeyError, TypeError: self._attr_native_value = None From 434f1dca2c091580d7a06835dea713c5d15494b3 Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Mon, 30 Mar 2026 06:38:28 -0400 Subject: [PATCH 0161/1707] Add diagnostics to Casper Glow (#166807) --- .../components/casper_glow/diagnostics.py | 31 ++++++++++++++++ .../components/casper_glow/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 36 +++++++++++++++++++ .../casper_glow/test_diagnostics.py | 33 +++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/casper_glow/diagnostics.py create mode 100644 tests/components/casper_glow/snapshots/test_diagnostics.ambr create mode 100644 tests/components/casper_glow/test_diagnostics.py diff --git a/homeassistant/components/casper_glow/diagnostics.py b/homeassistant/components/casper_glow/diagnostics.py new file mode 100644 index 00000000000000..d581b74808131a --- /dev/null +++ b/homeassistant/components/casper_glow/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for the Casper Glow integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components import bluetooth +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import CasperGlowConfigEntry + +SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"}) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: CasperGlowConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + service_info = bluetooth.async_last_service_info( + hass, coordinator.device.address, connectable=True + ) + + return { + "service_info": async_redact_data( + service_info.as_dict() if service_info else None, + SERVICE_INFO_TO_REDACT, + ), + } diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index c139f7df4f8d62..3d2cfceaf7c295 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -39,7 +39,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: No network discovery. diff --git a/tests/components/casper_glow/snapshots/test_diagnostics.ambr b/tests/components/casper_glow/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..4e15a7456afb93 --- /dev/null +++ b/tests/components/casper_glow/snapshots/test_diagnostics.ambr @@ -0,0 +1,36 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'service_info': dict({ + 'address': '**REDACTED**', + 'advertisement': list([ + 'Jar', + dict({ + }), + dict({ + }), + list([ + ]), + -127, + -60, + list([ + list([ + ]), + ]), + ]), + 'connectable': True, + 'device': '**REDACTED**', + 'manufacturer_data': dict({ + }), + 'name': '**REDACTED**', + 'raw': None, + 'rssi': -60, + 'service_data': dict({ + }), + 'service_uuids': list([ + ]), + 'source': '**REDACTED**', + 'tx_power': -127, + }), + }) +# --- diff --git a/tests/components/casper_glow/test_diagnostics.py b/tests/components/casper_glow/test_diagnostics.py new file mode 100644 index 00000000000000..7d5bda5b9f8c3d --- /dev/null +++ b/tests/components/casper_glow/test_diagnostics.py @@ -0,0 +1,33 @@ +"""Test the Casper Glow diagnostics.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert result == snapshot( + exclude=props("created_at", "modified_at", "entry_id", "time") + ) From d6458bc574c32e498905486d43ad54a4bb900e81 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:39:38 +0200 Subject: [PATCH 0162/1707] Add diagnostics support to UniFi Access integration (#166819) Co-authored-by: RaHehl Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/unifi_access/diagnostics.py | 41 ++++++++++++ .../unifi_access/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 64 +++++++++++++++++++ .../unifi_access/test_diagnostics.py | 29 +++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/unifi_access/diagnostics.py create mode 100644 tests/components/unifi_access/snapshots/test_diagnostics.ambr create mode 100644 tests/components/unifi_access/test_diagnostics.py diff --git a/homeassistant/components/unifi_access/diagnostics.py b/homeassistant/components/unifi_access/diagnostics.py new file mode 100644 index 00000000000000..903838dd6c62d0 --- /dev/null +++ b/homeassistant/components/unifi_access/diagnostics.py @@ -0,0 +1,41 @@ +"""Diagnostics support for UniFi Access.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import UnifiAccessConfigEntry + +TO_REDACT = {CONF_API_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: UnifiAccessConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = entry.runtime_data.data + return { + "entry_data": async_redact_data(dict(entry.data), TO_REDACT), + "coordinator_data": { + "doors": { + door_id: door.model_dump(mode="json") + for door_id, door in data.doors.items() + }, + "emergency": data.emergency.model_dump(mode="json"), + "door_lock_rules": { + door_id: rule.model_dump(mode="json") + for door_id, rule in data.door_lock_rules.items() + }, + "unconfirmed_lock_rule_doors": sorted(data.unconfirmed_lock_rule_doors), + "supports_lock_rules": data.supports_lock_rules, + "lock_rule_support_complete": data.lock_rule_support_complete, + "door_thumbnails": { + door_id: thumb.model_dump(mode="json") + for door_id, thumb in data.door_thumbnails.items() + }, + }, + } diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index d86686eb8165ce..01de812a0bb00e 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -41,7 +41,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: todo discovery: todo docs-data-update: todo diff --git a/tests/components/unifi_access/snapshots/test_diagnostics.ambr b/tests/components/unifi_access/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..f5125b7135713f --- /dev/null +++ b/tests/components/unifi_access/snapshots/test_diagnostics.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'coordinator_data': dict({ + 'door_lock_rules': dict({ + 'door-001': dict({ + 'ended_time': 0, + 'type': '', + }), + 'door-002': dict({ + 'ended_time': 0, + 'type': '', + }), + }), + 'door_thumbnails': dict({ + 'door-001': dict({ + 'door_thumbnail_last_update': 1700000000, + 'url': '/preview/front_door.png', + }), + }), + 'doors': dict({ + 'door-001': dict({ + 'door_lock_relay_status': 'lock', + 'door_position_status': 'close', + 'door_thumbnail': '/preview/front_door.png', + 'door_thumbnail_last_update': 1700000000, + 'floor_id': '', + 'full_name': '', + 'id': 'door-001', + 'is_bind_hub': False, + 'lock_rule_status': None, + 'name': 'Front Door', + 'type': 'door', + }), + 'door-002': dict({ + 'door_lock_relay_status': 'unlock', + 'door_position_status': 'open', + 'door_thumbnail': None, + 'door_thumbnail_last_update': None, + 'floor_id': '', + 'full_name': '', + 'id': 'door-002', + 'is_bind_hub': False, + 'lock_rule_status': None, + 'name': 'Back Door', + 'type': 'door', + }), + }), + 'emergency': dict({ + 'evacuation': False, + 'lockdown': False, + }), + 'lock_rule_support_complete': True, + 'supports_lock_rules': True, + 'unconfirmed_lock_rule_doors': list([ + ]), + }), + 'entry_data': dict({ + 'api_token': '**REDACTED**', + 'host': '192.168.1.1', + 'verify_ssl': False, + }), + }) +# --- diff --git a/tests/components/unifi_access/test_diagnostics.py b/tests/components/unifi_access/test_diagnostics.py new file mode 100644 index 00000000000000..e0ccf58ea319dc --- /dev/null +++ b/tests/components/unifi_access/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the UniFi Access integration.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 194485d863a080478e8fa0e5b1fa857146c11711 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:19:52 +0200 Subject: [PATCH 0163/1707] Fix shelly tests - mock async_unload_entry (#166851) --- tests/components/shelly/conftest.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index f3a583035e791c..137d5dc9f3e06e 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -793,10 +793,13 @@ def _initialize(): @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.shelly.async_setup_entry", return_value=True - ) as mock_setup_entry: + """Override async_setup_entry and async_unload_entry.""" + with ( + patch( + "homeassistant.components.shelly.async_setup_entry", return_value=True + ) as mock_setup_entry, + patch("homeassistant.components.shelly.async_unload_entry", return_value=True), + ): yield mock_setup_entry From b75af6d84a6bbdebd3aa3096021332ebfebb7eb6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Mar 2026 13:21:45 +0200 Subject: [PATCH 0164/1707] Mark Entity.async_write_ha_state as final (#166627) --- homeassistant/helpers/entity.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index d9aca3669cebd2..63e02627f71cb9 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1040,9 +1040,14 @@ def _async_write_ha_state_from_call_soon_threadsafe(self) -> None: self._async_verify_state_writable() self._async_write_ha_state() + @final @callback def async_write_ha_state(self) -> None: - """Write the state to the state machine.""" + """Write the state to the state machine. + + Note: Integrations which need to customize state write should + override _async_write_ha_state, not this method. + """ if not self.hass or not self._verified_state_writable: self._async_verify_state_writable() if self.hass.loop_thread_id != threading.get_ident(): From b6350478a5e29670f9b91898691928f2d747ea0f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:48:01 +0200 Subject: [PATCH 0165/1707] Migrate meteo_france to use runtime_data (#166852) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/meteo_france/__init__.py | 44 +++++++++---------- .../components/meteo_france/const.py | 3 -- .../components/meteo_france/coordinator.py | 27 +++++++++--- .../components/meteo_france/sensor.py | 25 +++-------- .../components/meteo_france/weather.py | 10 ++--- 5 files changed, 50 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 5d1274e085d7a6..023347a1a8de12 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -6,19 +6,14 @@ from meteofrance_api.helpers import is_valid_warning_department from requests import RequestException -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import ( - COORDINATOR_ALERT, - COORDINATOR_FORECAST, - COORDINATOR_RAIN, - DOMAIN, - PLATFORMS, -) +from .const import DOMAIN, PLATFORMS from .coordinator import ( MeteoFranceAlertUpdateCoordinator, + MeteoFranceConfigEntry, + MeteoFranceData, MeteoFranceForecastUpdateCoordinator, MeteoFranceRainUpdateCoordinator, ) @@ -26,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MeteoFranceConfigEntry) -> bool: """Set up a Meteo-France account from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -91,25 +86,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR_FORECAST: coordinator_forecast, - } - if coordinator_rain and coordinator_rain.last_update_success: - hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain - if coordinator_alert and coordinator_alert.last_update_success: - hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert + if coordinator_rain and not coordinator_rain.last_update_success: + coordinator_rain = None + if coordinator_alert and not coordinator_alert.last_update_success: + coordinator_alert = None + entry.runtime_data = MeteoFranceData( + forecast_coordinator=coordinator_forecast, + rain_coordinator=coordinator_rain, + alert_coordinator=coordinator_alert, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: MeteoFranceConfigEntry +) -> bool: """Unload a config entry.""" - if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]: - department = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR_FORECAST - ].data.position.get("dept") + if entry.runtime_data.alert_coordinator: + department = entry.runtime_data.forecast_coordinator.data.position.get("dept") hass.data[DOMAIN][department] = False _LOGGER.debug( ( @@ -121,13 +118,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: MeteoFranceConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 7a57048eb010dc..86819d825b7067 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -23,9 +23,6 @@ DOMAIN = "meteo_france" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] -COORDINATOR_FORECAST = "coordinator_forecast" -COORDINATOR_RAIN = "coordinator_rain" -COORDINATOR_ALERT = "coordinator_alert" ATTRIBUTION = "Data provided by Météo-France" MODEL = "Météo-France mobile API" MANUFACTURER = "Météo-France" diff --git a/homeassistant/components/meteo_france/coordinator.py b/homeassistant/components/meteo_france/coordinator.py index 8d09a21e12d5e4..8c4db6fd87bb8e 100644 --- a/homeassistant/components/meteo_france/coordinator.py +++ b/homeassistant/components/meteo_france/coordinator.py @@ -1,5 +1,8 @@ """Support for Meteo-France weather data.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import logging @@ -13,6 +16,18 @@ _LOGGER = logging.getLogger(__name__) +type MeteoFranceConfigEntry = ConfigEntry[MeteoFranceData] + + +@dataclass +class MeteoFranceData: + """Data for the Meteo-France integration.""" + + forecast_coordinator: MeteoFranceForecastUpdateCoordinator + rain_coordinator: MeteoFranceRainUpdateCoordinator | None + alert_coordinator: MeteoFranceAlertUpdateCoordinator | None + + SCAN_INTERVAL_RAIN = timedelta(minutes=5) SCAN_INTERVAL = timedelta(minutes=15) @@ -20,12 +35,12 @@ class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]): """Coordinator for Meteo-France forecast data.""" - config_entry: ConfigEntry + config_entry: MeteoFranceConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, client: MeteoFranceClient, ) -> None: """Initialize the coordinator.""" @@ -50,12 +65,12 @@ async def _async_update_data(self) -> Forecast: class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]): """Coordinator for Meteo-France rain data.""" - config_entry: ConfigEntry + config_entry: MeteoFranceConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, client: MeteoFranceClient, ) -> None: """Initialize the coordinator.""" @@ -80,12 +95,12 @@ async def _async_update_data(self) -> Rain: class MeteoFranceAlertUpdateCoordinator(DataUpdateCoordinator[CurrentPhenomenons]): """Coordinator for Meteo-France alert data.""" - config_entry: ConfigEntry + config_entry: MeteoFranceConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, client: MeteoFranceClient, department: str, ) -> None: diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 1af9ead3a64278..75876153d2d06c 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -19,7 +19,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UV_INDEX, @@ -41,18 +40,11 @@ ATTR_NEXT_RAIN_1_HOUR_FORECAST, ATTR_NEXT_RAIN_DT_REF, ATTRIBUTION, - COORDINATOR_ALERT, - COORDINATOR_FORECAST, - COORDINATOR_RAIN, DOMAIN, MANUFACTURER, MODEL, ) -from .coordinator import ( - MeteoFranceAlertUpdateCoordinator, - MeteoFranceForecastUpdateCoordinator, - MeteoFranceRainUpdateCoordinator, -) +from .coordinator import MeteoFranceAlertUpdateCoordinator, MeteoFranceConfigEntry @dataclass(frozen=True, kw_only=True) @@ -188,20 +180,13 @@ class MeteoFranceSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteo-France sensor platform.""" - data = hass.data[DOMAIN][entry.entry_id] - coordinator_forecast: MeteoFranceForecastUpdateCoordinator = data[ - COORDINATOR_FORECAST - ] - coordinator_rain: MeteoFranceRainUpdateCoordinator | None = data.get( - COORDINATOR_RAIN - ) - coordinator_alert: MeteoFranceAlertUpdateCoordinator | None = data.get( - COORDINATOR_ALERT - ) + coordinator_forecast = entry.runtime_data.forecast_coordinator + coordinator_rain = entry.runtime_data.rain_coordinator + coordinator_alert = entry.runtime_data.alert_coordinator entities: list[MeteoFranceSensor[Any]] = [ MeteoFranceSensor(coordinator_forecast, description) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index a4a137ded83af0..7076edb4f99ed9 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -18,7 +18,6 @@ WeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_MODE, UnitOfPrecipitationDepth, @@ -35,14 +34,13 @@ from .const import ( ATTRIBUTION, CONDITION_MAP, - COORDINATOR_FORECAST, DOMAIN, FORECAST_MODE_DAILY, FORECAST_MODE_HOURLY, MANUFACTURER, MODEL, ) -from .coordinator import MeteoFranceForecastUpdateCoordinator +from .coordinator import MeteoFranceConfigEntry, MeteoFranceForecastUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -58,13 +56,11 @@ def format_condition(condition: str, force_day: bool = False) -> str: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteo-France weather platform.""" - coordinator: MeteoFranceForecastUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ][COORDINATOR_FORECAST] + coordinator = entry.runtime_data.forecast_coordinator async_add_entities( [ From b38e41a34a506ebb4ff7a17fe011c3b64c1e7547 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:01:18 +0200 Subject: [PATCH 0166/1707] Refactor Tuya device diagnostics (#166846) --- homeassistant/components/tuya/diagnostics.py | 54 +++----------------- 1 file changed, 7 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index ff4b64e67cde3d..0d3dc9df8602d5 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -4,14 +4,13 @@ from typing import Any -from tuya_device_handlers.device_wrapper import DEVICE_WARNINGS +from tuya_device_handlers.helpers.diagnostics import customer_device_as_dict from tuya_sharing import CustomerDevice from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.util import dt as dt_util from . import TuyaConfigEntry from .const import DOMAIN, DPCode @@ -79,52 +78,13 @@ def _async_device_as_dict( ) -> dict[str, Any]: """Represent a Tuya device as a dictionary.""" - # Base device information, without sensitive information. - data = { - "id": device.id, - "name": device.name, - "category": device.category, - "product_id": device.product_id, - "product_name": device.product_name, - "online": device.online, - "sub": device.sub, - "time_zone": device.time_zone, - "active_time": dt_util.utc_from_timestamp(device.active_time).isoformat(), - "create_time": dt_util.utc_from_timestamp(device.create_time).isoformat(), - "update_time": dt_util.utc_from_timestamp(device.update_time).isoformat(), - "function": {}, - "status_range": {}, - "status": {}, - "home_assistant": {}, - "set_up": device.set_up, - "support_local": device.support_local, - "local_strategy": device.local_strategy, - "warnings": DEVICE_WARNINGS.get(device.id), - } - - # Gather Tuya states - for dpcode, value in device.status.items(): - # These statuses may contain sensitive information, redact these.. - if dpcode in _REDACTED_DPCODES: - data["status"][dpcode] = REDACTED - continue - - data["status"][dpcode] = value + # Base device information + data = customer_device_as_dict(device) - # Gather Tuya functions - for function in device.function.values(): - data["function"][function.code] = { - "type": function.type, - "value": function.values, - } - - # Gather Tuya status ranges - for status_range in device.status_range.values(): - data["status_range"][status_range.code] = { - "type": status_range.type, - "value": status_range.values, - "report_type": status_range.report_type, - } + # Redact sensitive information. + for key in data["status"]: + if key in _REDACTED_DPCODES: + data["status"][key] = REDACTED # Gather information how this Tuya device is represented in Home Assistant device_registry = dr.async_get(hass) From 174b5f55938e744d47932706778874c1959e6ebd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Mar 2026 14:29:25 +0200 Subject: [PATCH 0167/1707] Get list of analytics insights integrations from next environment (#166867) --- homeassistant/components/analytics_insights/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index 3691cab230069a..b0973956c4ee82 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from python_homeassistant_analytics import ( + Environment, HomeassistantAnalyticsClient, HomeassistantAnalyticsConnectionError, ) @@ -38,7 +39,7 @@ async def async_setup_entry( client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass)) try: - integrations = await client.get_integrations() + integrations = await client.get_integrations(Environment.NEXT) except HomeassistantAnalyticsConnectionError as ex: raise ConfigEntryNotReady("Could not fetch integration list") from ex From 49c3376c95aafdd45f6728ff276e632d7fa0ad23 Mon Sep 17 00:00:00 2001 From: Lorenzo Gasparini <112936414+Lorenzo-Gasparini@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:33:00 +0200 Subject: [PATCH 0168/1707] Bump fing_agent_api to 1.1.0 (#166855) --- homeassistant/components/fing/config_flow.py | 2 ++ homeassistant/components/fing/coordinator.py | 2 ++ homeassistant/components/fing/manifest.json | 2 +- homeassistant/components/fing/quality_scale.yaml | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fing/config_flow.py b/homeassistant/components/fing/config_flow.py index 0c99f7e34dbb87..10dd6bbb3f8522 100644 --- a/homeassistant/components/fing/config_flow.py +++ b/homeassistant/components/fing/config_flow.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT +from homeassistant.helpers.httpx_client import get_async_client from .const import DOMAIN, UPNP_AVAILABLE @@ -40,6 +41,7 @@ async def async_step_user( ip=user_input[CONF_IP_ADDRESS], port=int(user_input[CONF_PORT]), key=user_input[CONF_API_KEY], + client=get_async_client(self.hass), ) try: diff --git a/homeassistant/components/fing/coordinator.py b/homeassistant/components/fing/coordinator.py index 84d44ce5d73502..b2390f77317bed 100644 --- a/homeassistant/components/fing/coordinator.py +++ b/homeassistant/components/fing/coordinator.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, UPNP_AVAILABLE @@ -38,6 +39,7 @@ def __init__(self, hass: HomeAssistant, config_entry: FingConfigEntry) -> None: ip=config_entry.data[CONF_IP_ADDRESS], port=int(config_entry.data[CONF_PORT]), key=config_entry.data[CONF_API_KEY], + client=get_async_client(hass), ) self._upnp_available = config_entry.data[UPNP_AVAILABLE] update_interval = timedelta(seconds=30) diff --git a/homeassistant/components/fing/manifest.json b/homeassistant/components/fing/manifest.json index af2fb86703984d..32978274200ffb 100644 --- a/homeassistant/components/fing/manifest.json +++ b/homeassistant/components/fing/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["fing_agent_api==1.0.3"] + "requirements": ["fing_agent_api==1.1.0"] } diff --git a/homeassistant/components/fing/quality_scale.yaml b/homeassistant/components/fing/quality_scale.yaml index 273190261d7579..443ae2499c902c 100644 --- a/homeassistant/components/fing/quality_scale.yaml +++ b/homeassistant/components/fing/quality_scale.yaml @@ -68,5 +68,5 @@ rules: # Platinum async-dependency: todo - inject-websession: todo + inject-websession: done strict-typing: todo diff --git a/requirements_all.txt b/requirements_all.txt index 8dd81b29b5a641..0ee072655def06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -963,7 +963,7 @@ feedparser==6.0.12 file-read-backwards==2.0.0 # homeassistant.components.fing -fing_agent_api==1.0.3 +fing_agent_api==1.1.0 # homeassistant.components.fints fints==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42a4b275aa301e..faacac0a92b2a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -854,7 +854,7 @@ feedparser==6.0.12 file-read-backwards==2.0.0 # homeassistant.components.fing -fing_agent_api==1.0.3 +fing_agent_api==1.1.0 # homeassistant.components.fints fints==3.1.0 From 20b284d0e90b08a6392d1508b1443dc3481e2a5a Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:55:45 +0300 Subject: [PATCH 0169/1707] Fix Huum exception translations (#166778) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/huum/climate.py | 11 +++++------ homeassistant/components/huum/coordinator.py | 3 ++- homeassistant/components/huum/quality_scale.yaml | 2 +- homeassistant/components/huum/strings.json | 8 ++++++++ tests/components/huum/test_climate.py | 2 +- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index c3220d261e9465..319f2475ba7785 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from huum.const import SaunaStatus @@ -18,12 +17,10 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP +from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP, DOMAIN from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator from .entity import HuumBaseEntity -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 @@ -113,5 +110,7 @@ async def _turn_on(self, temperature: int) -> None: try: await self.coordinator.huum.turn_on(temperature) except (ValueError, SafetyException) as err: - _LOGGER.error(str(err)) - raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_turn_on", + ) from err diff --git a/homeassistant/components/huum/coordinator.py b/homeassistant/components/huum/coordinator.py index 532e78a81759aa..fac9f234ea8f13 100644 --- a/homeassistant/components/huum/coordinator.py +++ b/homeassistant/components/huum/coordinator.py @@ -56,5 +56,6 @@ async def _async_update_data(self) -> HuumStatusResponse: return await self.huum.status() except (Forbidden, NotAuthenticated) as err: raise ConfigEntryAuthFailed( - "Could not log in to Huum with given credentials" + translation_domain=DOMAIN, + translation_key="auth_failed", ) from err diff --git a/homeassistant/components/huum/quality_scale.yaml b/homeassistant/components/huum/quality_scale.yaml index b08f4c054dee00..fec8eea47a1ea4 100644 --- a/homeassistant/components/huum/quality_scale.yaml +++ b/homeassistant/components/huum/quality_scale.yaml @@ -62,7 +62,7 @@ rules: status: exempt comment: All entities are core functionality. entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json index 9ad89b5daaf443..8b50fcd5eeebfb 100644 --- a/homeassistant/components/huum/strings.json +++ b/homeassistant/components/huum/strings.json @@ -45,5 +45,13 @@ "name": "[%key:component::sensor::entity_component::humidity::name%]" } } + }, + "exceptions": { + "auth_failed": { + "message": "Could not log in to Huum with the given credentials." + }, + "unable_to_turn_on": { + "message": "Unable to turn on the sauna." + } } } diff --git a/tests/components/huum/test_climate.py b/tests/components/huum/test_climate.py index 61be0f5b6b9081..168a900417083f 100644 --- a/tests/components/huum/test_climate.py +++ b/tests/components/huum/test_climate.py @@ -132,7 +132,7 @@ async def test_turn_on_safety_exception( mock_huum_client.turn_on.side_effect = SafetyException("Door is open") mock_huum_client.status.return_value.status = SaunaStatus.ONLINE_HEATING - with pytest.raises(HomeAssistantError, match="Unable to turn on sauna"): + with pytest.raises(HomeAssistantError, match="Unable to turn on the sauna"): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, From 5be12a213dc3dd1bb0aec495634b8b242a118fde Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Mon, 30 Mar 2026 09:03:40 -0400 Subject: [PATCH 0170/1707] Bump pycasperglow to 1.2.0 (#166791) --- homeassistant/components/casper_glow/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/casper_glow/test_select.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/casper_glow/manifest.json b/homeassistant/components/casper_glow/manifest.json index 83b2a3a2f430cc..1e862beae69b27 100644 --- a/homeassistant/components/casper_glow/manifest.json +++ b/homeassistant/components/casper_glow/manifest.json @@ -15,5 +15,5 @@ "iot_class": "local_polling", "loggers": ["pycasperglow"], "quality_scale": "silver", - "requirements": ["pycasperglow==1.1.0"] + "requirements": ["pycasperglow==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0ee072655def06..58b53164f7a3ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2001,7 +2001,7 @@ pybravia==0.4.1 pycarwings2==2.14 # homeassistant.components.casper_glow -pycasperglow==1.1.0 +pycasperglow==1.2.0 # homeassistant.components.cloudflare pycfdns==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index faacac0a92b2a2..41fae869efec84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1732,7 +1732,7 @@ pybotvac==0.0.28 pybravia==0.4.1 # homeassistant.components.casper_glow -pycasperglow==1.1.0 +pycasperglow==1.2.0 # homeassistant.components.cloudflare pycfdns==3.0.0 diff --git a/tests/components/casper_glow/test_select.py b/tests/components/casper_glow/test_select.py index 7878b12259d5a1..ef72c092bb3029 100644 --- a/tests/components/casper_glow/test_select.py +++ b/tests/components/casper_glow/test_select.py @@ -153,7 +153,7 @@ async def test_select_ignores_remaining_time_updates( fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that callbacks with only remaining time do not change the select state.""" - fire_callbacks(GlowState(dimming_time_minutes=44)) + fire_callbacks(GlowState(dimming_time_remaining_ms=44)) state = hass.states.get(ENTITY_ID) assert state is not None From f0848edea983f2b2c4b6a04e511b5896bb831135 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 30 Mar 2026 15:23:49 +0200 Subject: [PATCH 0171/1707] Use translation key and icons.json for Synology DSM button entities (#166862) --- homeassistant/components/synology_dsm/button.py | 6 ++---- homeassistant/components/synology_dsm/icons.json | 5 +++++ homeassistant/components/synology_dsm/strings.json | 5 +++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 9c99f3a4c2a4ce..d762304543717c 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -34,15 +34,13 @@ class SynologyDSMbuttonDescription(ButtonEntityDescription): BUTTONS: Final = [ SynologyDSMbuttonDescription( key="reboot", - name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda syno_api: syno_api.async_reboot, ), SynologyDSMbuttonDescription( key="shutdown", - name="Shutdown", - icon="mdi:power", + translation_key="shutdown", entity_category=EntityCategory.CONFIG, press_action=lambda syno_api: syno_api.async_shutdown, ), @@ -63,6 +61,7 @@ class SynologyDSMButton(ButtonEntity): """Defines a Synology DSM button.""" entity_description: SynologyDSMbuttonDescription + _attr_has_entity_name = True def __init__( self, @@ -75,7 +74,6 @@ def __init__( if TYPE_CHECKING: assert api.network is not None assert api.information is not None - self._attr_name = f"{api.network.hostname} {description.name}" self._attr_unique_id = f"{api.information.serial}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, api.information.serial)} diff --git a/homeassistant/components/synology_dsm/icons.json b/homeassistant/components/synology_dsm/icons.json index 27c0198af5b6e6..44f69904365395 100644 --- a/homeassistant/components/synology_dsm/icons.json +++ b/homeassistant/components/synology_dsm/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "shutdown": { + "default": "mdi:power" + } + }, "sensor": { "cpu_15min_load": { "default": "mdi:chip" diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f80b892664881e..f31e7934420724 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -76,6 +76,11 @@ "name": "Security status" } }, + "button": { + "shutdown": { + "name": "Shutdown" + } + }, "sensor": { "cpu_15min_load": { "name": "CPU load average (15 min)" From 9fb0b69f0aea049fd2082bd024f39c4aec30fa66 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Mar 2026 15:42:31 +0200 Subject: [PATCH 0172/1707] Improve text action naming consistency (#166523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/text/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index 0c159a8809a598..e7fea2f230ed53 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -60,14 +60,14 @@ }, "services": { "set_value": { - "description": "Sets the value.", + "description": "Sets the value of a text entity.", "fields": { "value": { "description": "Enter your text.", "name": "Value" } }, - "name": "Set value" + "name": "Set text value" } }, "title": "Text", From d1ccda18f711109875b450d41306febd8d0958b9 Mon Sep 17 00:00:00 2001 From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:52:11 +0200 Subject: [PATCH 0173/1707] Skip unchanged connection check on reconfigure flow for Satel Integra (#166695) Co-authored-by: Joost Lekkerkerker --- .../components/satel_integra/config_flow.py | 15 +++-- .../satel_integra/test_config_flow.py | 61 ++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/satel_integra/config_flow.py b/homeassistant/components/satel_integra/config_flow.py index b70beaf6ba9c1f..59c91ec5f8d189 100644 --- a/homeassistant/components/satel_integra/config_flow.py +++ b/homeassistant/components/satel_integra/config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, @@ -163,7 +164,16 @@ async def async_step_reconfigure( if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]): + if ( + reconfigure_entry.state is not ConfigEntryState.LOADED + or reconfigure_entry.data != user_input + ): + if not await self.test_connection( + user_input[CONF_HOST], user_input[CONF_PORT] + ): + errors["base"] = "cannot_connect" + + if not errors: return self.async_update_reload_and_abort( reconfigure_entry, data_updates={ @@ -171,11 +181,8 @@ async def async_step_reconfigure( CONF_PORT: user_input[CONF_PORT], }, title=user_input[CONF_HOST], - reload_even_if_entry_is_unchanged=False, ) - errors["base"] = "cannot_connect" - suggested_values: dict[str, Any] = { **reconfigure_entry.data, **(user_input or {}), diff --git a/tests/components/satel_integra/test_config_flow.py b/tests/components/satel_integra/test_config_flow.py index e5f48cbaea46ab..e43c05a88ccd7e 100644 --- a/tests/components/satel_integra/test_config_flow.py +++ b/tests/components/satel_integra/test_config_flow.py @@ -16,7 +16,12 @@ DEFAULT_PORT, DOMAIN, ) -from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER, ConfigSubentry +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigEntryState, + ConfigSubentry, +) from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -363,6 +368,60 @@ async def test_reconfigure_flow_success( assert mock_setup_entry.call_count == 1 +async def test_reconfigure_flow_config_unchanged_loaded( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure skips connection testing if loaded config is unchanged.""" + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert mock_config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], dict(mock_config_entry.data) + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == MOCK_CONFIG_DATA + assert mock_satel.connect.call_count == 0 + + await hass.async_block_till_done() + assert mock_setup_entry.call_count == 1 + + +async def test_reconfigure_flow_config_unchanged_not_loaded( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure validates unchanged config if the entry is not loaded.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], dict(mock_config_entry.data) + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == MOCK_CONFIG_DATA + assert mock_satel.connect.call_count == 1 + assert mock_setup_entry.call_count == 1 + + async def test_reconfigure_connection_failed( hass: HomeAssistant, mock_satel: AsyncMock, From cda52af178a63654fa4246a77c31e0931f4f9caf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:56:08 +0200 Subject: [PATCH 0174/1707] Migrate motioneye to use runtime_data (#166848) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/motioneye/__init__.py | 26 +++++++++--------- homeassistant/components/motioneye/camera.py | 8 +++--- .../components/motioneye/config_flow.py | 4 +-- .../components/motioneye/coordinator.py | 7 +++-- .../components/motioneye/media_source.py | 27 ++++++++++--------- homeassistant/components/motioneye/sensor.py | 9 +++---- homeassistant/components/motioneye/switch.py | 9 +++---- 7 files changed, 46 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 5f3799abb1f90e..37ffe9bbd01088 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -45,7 +45,7 @@ async_register as webhook_register, async_unregister as webhook_unregister, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, CONF_URL, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -80,7 +80,7 @@ WEB_HOOK_SENTINEL_KEY, WEB_HOOK_SENTINEL_VALUE, ) -from .coordinator import MotionEyeUpdateCoordinator +from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN] @@ -134,7 +134,7 @@ def is_acceptable_camera(camera: dict[str, Any] | None) -> bool: @callback def listen_for_new_cameras( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, add_func: Callable, ) -> None: """Listen for new cameras.""" @@ -168,7 +168,7 @@ def _add_camera( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: MotionEyeClient, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, camera_id: int, camera: dict[str, Any], device_identifier: tuple[str, str], @@ -274,9 +274,8 @@ def _build_url( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool: """Set up motionEye from a config entry.""" - hass.data.setdefault(DOMAIN, {}) client = create_motioneye_client( entry.data[CONF_URL], @@ -306,7 +305,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = MotionEyeUpdateCoordinator(hass, entry, client) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator current_cameras: set[tuple[str, str]] = set() device_registry = dr.async_get(hass) @@ -362,14 +361,13 @@ def _async_process_motioneye_cameras() -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool: """Unload a config entry.""" webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - coordinator = hass.data[DOMAIN].pop(entry.entry_id) - await coordinator.client.async_client_close() + await entry.runtime_data.client.async_client_close() return unload_ok @@ -438,10 +436,14 @@ def _get_media_event_data( event_file_type: int, ) -> dict[str, str]: config_entry_id = next(iter(device.config_entries), None) - if not config_entry_id or config_entry_id not in hass.data[DOMAIN]: + if ( + not config_entry_id + or not (entry := hass.config_entries.async_get_entry(config_entry_id)) + or entry.state != ConfigEntryState.LOADED + ): return {} - coordinator = hass.data[DOMAIN][config_entry_id] + coordinator: MotionEyeUpdateCoordinator = entry.runtime_data client = coordinator.client for identifier in device.identifiers: diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 65baa163e0a715..f18891c1d8c148 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -30,7 +30,6 @@ CONF_STILL_IMAGE_URL, MjpegCamera, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -50,14 +49,13 @@ CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, - DOMAIN, MOTIONEYE_MANUFACTURER, SERVICE_ACTION, SERVICE_SET_TEXT_OVERLAY, SERVICE_SNAPSHOT, TYPE_MOTIONEYE_MJPEG_CAMERA, ) -from .coordinator import MotionEyeUpdateCoordinator +from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator from .entity import MotionEyeEntity PLATFORMS = [Platform.CAMERA] @@ -92,11 +90,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data @callback def camera_add(camera: dict[str, Any]) -> None: diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 7ca6d9dfcebfee..d8036f8758f2ea 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -39,6 +38,7 @@ DEFAULT_WEBHOOK_SET_OVERWRITE, DOMAIN, ) +from .coordinator import MotionEyeConfigEntry class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): @@ -180,7 +180,7 @@ async def async_step_hassio_confirm( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: MotionEyeConfigEntry, ) -> MotionEyeOptionsFlow: """Get the Hyperion Options flow.""" return MotionEyeOptionsFlow() diff --git a/homeassistant/components/motioneye/coordinator.py b/homeassistant/components/motioneye/coordinator.py index 6e330d5d27bb66..601b132da12bd1 100644 --- a/homeassistant/components/motioneye/coordinator.py +++ b/homeassistant/components/motioneye/coordinator.py @@ -16,13 +16,16 @@ _LOGGER = logging.getLogger(__name__) +type MotionEyeConfigEntry = ConfigEntry[MotionEyeUpdateCoordinator] + + class MotionEyeUpdateCoordinator(DataUpdateCoordinator[dict[str, Any] | None]): """Coordinator for motionEye data.""" - config_entry: ConfigEntry + config_entry: MotionEyeConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, client: MotionEyeClient + self, hass: HomeAssistant, entry: MotionEyeConfigEntry, client: MotionEyeClient ) -> None: """Initialize the coordinator.""" super().__init__( diff --git a/homeassistant/components/motioneye/media_source.py b/homeassistant/components/motioneye/media_source.py index 52d4ca04530639..26674a6b6277d7 100644 --- a/homeassistant/components/motioneye/media_source.py +++ b/homeassistant/components/motioneye/media_source.py @@ -17,12 +17,13 @@ PlayMedia, Unresolvable, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from . import get_media_url, split_motioneye_device_identifier from .const import DOMAIN +from .coordinator import MotionEyeConfigEntry MIME_TYPE_MAP = { "movies": "video/mp4", @@ -74,7 +75,7 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: self._verify_kind_or_raise(kind) url = get_media_url( - self.hass.data[DOMAIN][config.entry_id].client, + config.runtime_data.client, self._get_camera_id_or_raise(config, device), self._get_path_or_raise(path), kind == "images", @@ -120,10 +121,10 @@ async def async_browse_media( return self._build_media_devices(config) return self._build_media_configs() - def _get_config_or_raise(self, config_id: str) -> ConfigEntry: + def _get_config_or_raise(self, config_id: str) -> MotionEyeConfigEntry: """Get a config entry from a URL.""" entry = self.hass.config_entries.async_get_entry(config_id) - if not entry: + if not entry or entry.state != ConfigEntryState.LOADED: raise MediaSourceError(f"Unable to find config entry with id: {config_id}") return entry @@ -154,7 +155,7 @@ def _get_path_or_raise(cls, path: str | None) -> str: @classmethod def _get_camera_id_or_raise( - cls, config: ConfigEntry, device: dr.DeviceEntry + cls, config: MotionEyeConfigEntry, device: dr.DeviceEntry ) -> int: """Get a config entry from a URL.""" for identifier in device.identifiers: @@ -164,7 +165,7 @@ def _get_camera_id_or_raise( raise MediaSourceError(f"Could not find camera id for device id: {device.id}") @classmethod - def _build_media_config(cls, config: ConfigEntry) -> BrowseMediaSource: + def _build_media_config(cls, config: MotionEyeConfigEntry) -> BrowseMediaSource: return BrowseMediaSource( domain=DOMAIN, identifier=config.entry_id, @@ -196,7 +197,7 @@ def _build_media_configs(self) -> BrowseMediaSource: @classmethod def _build_media_device( cls, - config: ConfigEntry, + config: MotionEyeConfigEntry, device: dr.DeviceEntry, full_title: bool = True, ) -> BrowseMediaSource: @@ -211,7 +212,7 @@ def _build_media_device( children_media_class=MediaClass.DIRECTORY, ) - def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource: + def _build_media_devices(self, config: MotionEyeConfigEntry) -> BrowseMediaSource: """Build the media sources for device entries.""" device_registry = dr.async_get(self.hass) devices = dr.async_entries_for_config_entry(device_registry, config.entry_id) @@ -226,7 +227,7 @@ def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource: @classmethod def _build_media_kind( cls, - config: ConfigEntry, + config: MotionEyeConfigEntry, device: dr.DeviceEntry, kind: str, full_title: bool = True, @@ -251,7 +252,7 @@ def _build_media_kind( ) def _build_media_kinds( - self, config: ConfigEntry, device: dr.DeviceEntry + self, config: MotionEyeConfigEntry, device: dr.DeviceEntry ) -> BrowseMediaSource: base = self._build_media_device(config, device) base.children = [ @@ -262,7 +263,7 @@ def _build_media_kinds( async def _build_media_path( self, - config: ConfigEntry, + config: MotionEyeConfigEntry, device: dr.DeviceEntry, kind: str, path: str, @@ -276,7 +277,7 @@ async def _build_media_path( base.children = [] - client = self.hass.data[DOMAIN][config.entry_id].client + client = config.runtime_data.client camera_id = self._get_camera_id_or_raise(config, device) if kind == "movies": @@ -286,7 +287,7 @@ async def _build_media_path( sub_dirs: set[str] = set() parts = parsed_path.parts - media_list = resp.get(KEY_MEDIA_LIST, []) + media_list = resp.get(KEY_MEDIA_LIST, []) if resp else [] def get_media_sort_key(media: dict) -> str: """Get media sort key.""" diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index be3644451015bb..a8b14017de6971 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -9,24 +9,23 @@ from motioneye_client.const import KEY_ACTIONS from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import get_camera_from_cameras, listen_for_new_cameras -from .const import DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR -from .coordinator import MotionEyeUpdateCoordinator +from .const import TYPE_MOTIONEYE_ACTION_SENSOR +from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator from .entity import MotionEyeEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data @callback def camera_add(camera: dict[str, Any]) -> None: diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 4acaf54ae2077e..09aea463838e11 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -16,14 +16,13 @@ ) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import get_camera_from_cameras, listen_for_new_cameras -from .const import DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE -from .coordinator import MotionEyeUpdateCoordinator +from .const import TYPE_MOTIONEYE_SWITCH_BASE +from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator from .entity import MotionEyeEntity MOTIONEYE_SWITCHES = [ @@ -68,11 +67,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data @callback def camera_add(camera: dict[str, Any]) -> None: From 8c07348a3d140c0a92540e8901cf20102f37901c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:03:43 +0200 Subject: [PATCH 0175/1707] Migrate neato to use runtime_data (#166854) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/neato/__init__.py | 18 ++++++--------- homeassistant/components/neato/button.py | 7 +++--- homeassistant/components/neato/camera.py | 17 ++++++-------- homeassistant/components/neato/const.py | 4 ---- homeassistant/components/neato/hub.py | 16 ++++++++----- homeassistant/components/neato/sensor.py | 10 ++++----- homeassistant/components/neato/switch.py | 12 +++++----- homeassistant/components/neato/vacuum.py | 26 +++++++--------------- 8 files changed, 46 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 318396d6a8a674..9ba9164bdbea6a 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -24,12 +24,14 @@ from homeassistant.helpers.typing import ConfigType from . import api -from .const import DOMAIN, NEATO_LOGIN +from .const import DOMAIN from .hub import NeatoHub from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +type NeatoConfigEntry = ConfigEntry[NeatoHub] + CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BUTTON, @@ -46,9 +48,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool: """Set up config entry.""" - hass.data.setdefault(DOMAIN, {}) if CONF_TOKEN not in entry.data: raise ConfigEntryAuthFailed @@ -69,7 +70,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex neato_session = api.ConfigEntryAuth(hass, entry, implementation) - hass.data[DOMAIN][entry.entry_id] = neato_session hub = NeatoHub(hass, Account(neato_session)) await hub.async_update_entry_unique_id(entry) @@ -80,17 +80,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Failed to connect to Neato API") raise ConfigEntryNotReady from ex - hass.data[NEATO_LOGIN] = hub + entry.runtime_data = hub await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool: """Unload config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/neato/button.py b/homeassistant/components/neato/button.py index 8658dfd1b1b213..2afaca890007c4 100644 --- a/homeassistant/components/neato/button.py +++ b/homeassistant/components/neato/button.py @@ -5,22 +5,21 @@ from pybotvac import Robot from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import NEATO_ROBOTS +from . import NeatoConfigEntry from .entity import NeatoEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato button from config entry.""" - entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]] + entities = [NeatoDismissAlertButton(robot) for robot in entry.runtime_data.robots] async_add_entities(entities, True) diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index 42278a3a48f6a9..4234867be99b37 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -11,11 +11,11 @@ from urllib3.response import HTTPResponse from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from . import NeatoConfigEntry +from .const import SCAN_INTERVAL_MINUTES from .entity import NeatoEntity from .hub import NeatoHub @@ -27,15 +27,14 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato camera with config entry.""" - neato: NeatoHub = hass.data[NEATO_LOGIN] - mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA) + hub = entry.runtime_data dev = [ - NeatoCleaningMap(neato, robot, mapdata) - for robot in hass.data[NEATO_ROBOTS] + NeatoCleaningMap(hub, robot, hub.map_data) + for robot in hub.robots if "maps" in robot.traits ] @@ -51,9 +50,7 @@ class NeatoCleaningMap(NeatoEntity, Camera): _attr_translation_key = "cleaning_map" - def __init__( - self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None - ) -> None: + def __init__(self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any]) -> None: """Initialize Neato cleaning map.""" super().__init__(robot) Camera.__init__(self) diff --git a/homeassistant/components/neato/const.py b/homeassistant/components/neato/const.py index 2237096282c486..f875d9086cfbc4 100644 --- a/homeassistant/components/neato/const.py +++ b/homeassistant/components/neato/const.py @@ -3,10 +3,6 @@ DOMAIN = "neato" CONF_VENDOR = "vendor" -NEATO_LOGIN = "neato_login" -NEATO_MAP_DATA = "neato_map_data" -NEATO_PERSISTENT_MAPS = "neato_persistent_maps" -NEATO_ROBOTS = "neato_robots" SCAN_INTERVAL_MINUTES = 1 diff --git a/homeassistant/components/neato/hub.py b/homeassistant/components/neato/hub.py index fd5f045c30f21c..9410e60ad0936c 100644 --- a/homeassistant/components/neato/hub.py +++ b/homeassistant/components/neato/hub.py @@ -1,7 +1,10 @@ """Support for Neato botvac connected vacuum cleaners.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pybotvac import Account from urllib3.response import HTTPResponse @@ -10,8 +13,6 @@ from homeassistant.core import HomeAssistant from homeassistant.util import Throttle -from .const import NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS - _LOGGER = logging.getLogger(__name__) @@ -22,14 +23,17 @@ def __init__(self, hass: HomeAssistant, neato: Account) -> None: """Initialize the Neato hub.""" self._hass = hass self.my_neato: Account = neato + self.robots: set[Any] = set() + self.persistent_maps: dict[str, Any] = {} + self.map_data: dict[str, Any] = {} @Throttle(timedelta(minutes=1)) def update_robots(self) -> None: """Update the robot states.""" - _LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS)) - self._hass.data[NEATO_ROBOTS] = self.my_neato.robots - self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps - self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps + _LOGGER.debug("Running HUB.update_robots %s", self.robots) + self.robots = self.my_neato.robots + self.persistent_maps = self.my_neato.persistent_maps + self.map_data = self.my_neato.maps def download_map(self, url: str) -> HTTPResponse: """Download a new map image.""" diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 4be02fe1ef73b4..6ec28dba7fe9c9 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -10,12 +10,12 @@ from pybotvac.robot import Robot from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from . import NeatoConfigEntry +from .const import SCAN_INTERVAL_MINUTES from .entity import NeatoEntity from .hub import NeatoHub @@ -28,12 +28,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Neato sensor using config entry.""" - neato: NeatoHub = hass.data[NEATO_LOGIN] - dev = [NeatoSensor(neato, robot) for robot in hass.data[NEATO_ROBOTS]] + hub = entry.runtime_data + dev = [NeatoSensor(hub, robot) for robot in hub.robots] if not dev: return diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index 1ae06fef44cf4b..df0aba9787ed23 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -10,12 +10,12 @@ from pybotvac.robot import Robot from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from . import NeatoConfigEntry +from .const import SCAN_INTERVAL_MINUTES from .entity import NeatoEntity from .hub import NeatoHub @@ -30,14 +30,14 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato switch with config entry.""" - neato: NeatoHub = hass.data[NEATO_LOGIN] + hub = entry.runtime_data dev = [ - NeatoConnectedSwitch(neato, robot, type_name) - for robot in hass.data[NEATO_ROBOTS] + NeatoConnectedSwitch(hub, robot, type_name) + for robot in hub.robots for type_name in SWITCH_TYPES ] diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 571eb25df6c10c..02d2e40b4db39a 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -15,22 +15,12 @@ VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - ACTION, - ALERTS, - ERRORS, - MODE, - NEATO_LOGIN, - NEATO_MAP_DATA, - NEATO_PERSISTENT_MAPS, - NEATO_ROBOTS, - SCAN_INTERVAL_MINUTES, -) +from . import NeatoConfigEntry +from .const import ACTION, ALERTS, ERRORS, MODE, SCAN_INTERVAL_MINUTES from .entity import NeatoEntity from .hub import NeatoHub @@ -52,16 +42,16 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato vacuum with config entry.""" - neato: NeatoHub = hass.data[NEATO_LOGIN] - mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA) - persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS) + hub = entry.runtime_data dev = [ - NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps) - for robot in hass.data[NEATO_ROBOTS] + NeatoConnectedVacuum( + hub, robot, hub.map_data or None, hub.persistent_maps or None + ) + for robot in hub.robots ] if not dev: From 607462028b3554516ca6655ea1ccea4ad8ee0bb1 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 16:08:03 +0200 Subject: [PATCH 0176/1707] Rename component to integration in Thomson (#166880) --- homeassistant/components/thomson/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/thomson/__init__.py b/homeassistant/components/thomson/__init__.py index 3c1ce045f39d2d..5547707976611d 100644 --- a/homeassistant/components/thomson/__init__.py +++ b/homeassistant/components/thomson/__init__.py @@ -1 +1 @@ -"""The thomson component.""" +"""The Thomson integration.""" From 14b9915914264b013c573fae6b4b7f750dde465a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 30 Mar 2026 16:16:31 +0200 Subject: [PATCH 0177/1707] Add repair flow when MQTT YAML config is present but the broker is not set up correctly (#165090) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- homeassistant/components/mqtt/__init__.py | 13 +++++++ homeassistant/components/mqtt/strings.json | 4 ++ tests/components/mqtt/test_init.py | 45 +++++++++++++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index d6f14828050144..fb3d84041be086 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -311,6 +311,19 @@ def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Plat async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the actions and websocket API for the MQTT component.""" + if config.get(DOMAIN) and not mqtt_config_entry_enabled(hass): + issue_registry = ir.async_get(hass) + issue_registry.async_get_or_create( + DOMAIN, + "yaml_setup_without_active_setup", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + learn_more_url="https://www.home-assistant.io/integrations/mqtt/" + "#configuration", + translation_key="yaml_setup_without_active_setup", + ) + websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_mqtt_info) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index a50c39aa5ea258..4b09d3558b395e 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1141,6 +1141,10 @@ } }, "title": "MQTT device \"{name}\" subentry migration to YAML" + }, + "yaml_setup_without_active_setup": { + "description": "Home Assistant detected manually configured MQTT items, but these items cannot be loaded because MQTT is not set up correctly. Make sure the MQTT broker is set up correctly, or remove the MQTT configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.", + "title": "MQTT is not set up correctly" } }, "options": { diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index a9896ee318ccfe..93ff1d4a9555bd 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -33,7 +33,12 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import device_registry as dr, entity_registry as er, template +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, + template, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType @@ -2304,3 +2309,41 @@ async def test_multi_platform_discovery( async def test_mqtt_integration_level_imports(attr: str) -> None: """Test mqtt integration level public published imports are available.""" assert hasattr(mqtt, attr) + + +@pytest.mark.usefixtures("mqtt_client_mock") +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {"sensor": {"state_topic": "test-topic"}}}] +) +async def test_yaml_config_without_entry( + hass: HomeAssistant, hass_config: ConfigType, issue_registry: ir.IssueRegistry +) -> None: + """Test a repair issue is created for YAML setup without an active config entry.""" + await async_setup_component(hass, mqtt.DOMAIN, hass_config) + issue = issue_registry.async_get_issue( + mqtt.DOMAIN, "yaml_setup_without_active_setup" + ) + assert issue is not None + assert ( + issue.learn_more_url == "https://www.home-assistant.io/integrations/mqtt/" + "#configuration" + ) + + +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {"sensor": {"state_topic": "test-topic"}}}] +) +async def test_yaml_config_with_active_mqtt_config_entry( + hass: HomeAssistant, + hass_config: ConfigType, + mqtt_mock_entry: MqttMockHAClientGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test no repair issue is created for YAML setup with an active config entry.""" + await mqtt_mock_entry() + issue = issue_registry.async_get_issue( + mqtt.DOMAIN, "yaml_setup_without_active_setup" + ) + state = hass.states.get("sensor.mqtt_sensor") + assert state is not None + assert issue is None From 9348948afab8944b9cd0fcc0f8ed97c3a1dd5f8c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 30 Mar 2026 16:21:02 +0200 Subject: [PATCH 0178/1707] Add attribute `group_entities` to the list of blocked MQTT entity attributes (#165360) --- homeassistant/components/mqtt/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 12b6aac94bf891..86fd7cd582445d 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -140,6 +140,7 @@ "entity_registry_enabled_default", "extra_state_attributes", "force_update", + "group_entities", "icon", "friendly_name", "should_poll", From 1aa380fdfade0c64198c2fde94cfdc49ffd3c02e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:25:10 +0200 Subject: [PATCH 0179/1707] Add tr4nt0r as codeowner to html5 integration (#166771) --- CODEOWNERS | 4 ++-- homeassistant/components/html5/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 34bdca2205489b..2d5ee30b0710b0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -741,8 +741,8 @@ build.json @home-assistant/supervisor /tests/components/honeywell/ @rdfurman @mkmer /homeassistant/components/hr_energy_qube/ @MattieGit /tests/components/hr_energy_qube/ @MattieGit -/homeassistant/components/html5/ @alexyao2015 -/tests/components/html5/ @alexyao2015 +/homeassistant/components/html5/ @alexyao2015 @tr4nt0r +/tests/components/html5/ @alexyao2015 @tr4nt0r /homeassistant/components/http/ @home-assistant/core /tests/components/http/ @home-assistant/core /homeassistant/components/huawei_lte/ @scop @fphammerle diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json index 1ef261d201d860..b958ab46461e30 100644 --- a/homeassistant/components/html5/manifest.json +++ b/homeassistant/components/html5/manifest.json @@ -1,7 +1,7 @@ { "domain": "html5", "name": "HTML5 Push Notifications", - "codeowners": ["@alexyao2015"], + "codeowners": ["@alexyao2015", "@tr4nt0r"], "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/html5", From 157362f225ed21167e8a699ce3db54e18778fa53 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 30 Mar 2026 17:27:39 +0300 Subject: [PATCH 0180/1707] Fix OpenAI image generation with reasoning (#166827) --- .../components/openai_conversation/entity.py | 8 +++++++- .../components/openai_conversation/test_ai_task.py | 13 ++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 399da7ce4d85e8..50a4f6f8f7e906 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -346,7 +346,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have id=event.item.id, tool_name="web_search_call", tool_args={ - "action": event.item.action.to_dict(), + "action": event.item.action.to_dict() + if event.item.action + else None, }, external=True, ) @@ -360,6 +362,10 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have } last_role = "tool_result" elif isinstance(event.item, ImageGenerationCall): + if last_summary_index is not None: + yield {"role": "assistant"} + last_role = "assistant" + last_summary_index = None yield {"native": event.item} last_summary_index = -1 # Trigger new assistant message on next turn elif isinstance(event, ResponseTextDeltaEvent): diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index 783efcee40c9b8..990c4322a04625 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir, selector -from . import create_image_gen_call_item, create_message_item +from . import create_image_gen_call_item, create_message_item, create_reasoning_item from tests.common import MockConfigEntry @@ -247,8 +247,15 @@ async def test_generate_image( # Mock the OpenAI response stream mock_create_stream.return_value = [ - create_image_gen_call_item(id="ig_A", output_index=0), - create_message_item(id="msg_A", text="", output_index=1), + ( + *create_reasoning_item( + id="rs_A", + output_index=0, + reasoning_summary=[["The user asks me to generate an image"]], + ), + *create_image_gen_call_item(id="ig_A", output_index=1), + *create_message_item(id="msg_A", text="", output_index=2), + ) ] with patch.object( From 0d14bdab24f1c88e44d9d90c13b73ed7ed2c10b1 Mon Sep 17 00:00:00 2001 From: hanwg Date: Mon, 30 Mar 2026 22:29:28 +0800 Subject: [PATCH 0181/1707] Fix webhook leak for Telegram bot (#166776) --- homeassistant/components/telegram_bot/webhooks.py | 4 ++-- tests/components/telegram_bot/test_webhooks.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index c31227d0a635f1..31255dee71ca04 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -37,8 +37,6 @@ async def async_setup_bot_platform( pushbot = PushBot(hass, bot, config, secret_token) - await pushbot.start_application() - webhook_registered = await pushbot.register_webhook() if not webhook_registered: raise RuntimeError("Failed to register webhook with Telegram") @@ -49,6 +47,8 @@ async def async_setup_bot_platform( get_base_url(bot), ) + await pushbot.start_application() + hass.http.register_view( PushBotView( hass, diff --git a/tests/components/telegram_bot/test_webhooks.py b/tests/components/telegram_bot/test_webhooks.py index 25ed17fd3a10ed..ed9e1c0369688d 100644 --- a/tests/components/telegram_bot/test_webhooks.py +++ b/tests/components/telegram_bot/test_webhooks.py @@ -1,7 +1,7 @@ """Tests for webhooks.""" from ipaddress import IPv4Network -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from telegram.error import TimedOut @@ -31,6 +31,10 @@ async def test_set_webhooks_failed( patch( "homeassistant.components.telegram_bot.webhooks.Bot.set_webhook", ) as mock_set_webhook, + patch( + "homeassistant.components.telegram_bot.webhooks.Application.start", + AsyncMock(), + ) as mock_start, ): mock_set_webhook.side_effect = [TimedOut("mock timeout"), False] @@ -58,6 +62,8 @@ async def test_set_webhooks_failed( assert mock_webhooks_config_entry.state is ConfigEntryState.SETUP_ERROR await hass.async_block_till_done() + assert mock_start.call_count == 0 + async def test_set_webhooks( hass: HomeAssistant, @@ -68,11 +74,16 @@ async def test_set_webhooks( ) -> None: """Test set webhooks success.""" mock_webhooks_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_webhooks_config_entry.entry_id) + + with patch( + "homeassistant.components.telegram_bot.webhooks.Application.start", AsyncMock() + ) as mock_start: + await hass.config_entries.async_setup(mock_webhooks_config_entry.entry_id) await hass.async_block_till_done() assert mock_webhooks_config_entry.state is ConfigEntryState.LOADED + mock_start.assert_called_once() async def test_webhooks_update_invalid_json( From 4ad73da7ec314988a8489a363449899d82a6ae9e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:36:07 +0200 Subject: [PATCH 0182/1707] Add strict typing to UniFi Access integration (#166787) --- .strict-typing | 1 + homeassistant/components/unifi_access/coordinator.py | 2 +- .../components/unifi_access/quality_scale.yaml | 2 +- mypy.ini | 10 ++++++++++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 87e2e85eeb8ad9..5e1549256616c9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -579,6 +579,7 @@ homeassistant.components.trmnl.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifi.* +homeassistant.components.unifi_access.* homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index 262af73590853d..af29b9e2ae4bac 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -333,7 +333,7 @@ def _process_door_update( async def _handle_setting_update(self, msg: WebsocketMessage) -> None: """Handle settings update messages (evacuation/lockdown).""" if self.data is None: - return + return # type: ignore[unreachable] update = cast(SettingUpdate, msg) self.async_set_updated_data( replace( diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index 01de812a0bb00e..a593584d940894 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -65,4 +65,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/mypy.ini b/mypy.ini index e0f08ce3787b47..5b59dbdc476361 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5548,6 +5548,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.unifi_access.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.unifiprotect.*] check_untyped_defs = true disallow_incomplete_defs = true From 42c3610685eb76d29f4d641208e31a144a3aa7ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 30 Mar 2026 15:41:08 +0100 Subject: [PATCH 0183/1707] Add counter purpose-specific condition (#166879) --- .../components/automation/__init__.py | 1 + homeassistant/components/counter/condition.py | 15 ++ .../components/counter/conditions.yaml | 25 +++ homeassistant/components/counter/icons.json | 5 + homeassistant/components/counter/strings.json | 22 +++ tests/components/counter/test_condition.py | 177 ++++++++++++++++++ 6 files changed, 245 insertions(+) create mode 100644 homeassistant/components/counter/condition.py create mode 100644 homeassistant/components/counter/conditions.yaml create mode 100644 tests/components/counter/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 7fa8c5afb6dfe6..8887674dcdbaef 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -124,6 +124,7 @@ "battery", "calendar", "climate", + "counter", "cover", "device_tracker", "door", diff --git a/homeassistant/components/counter/condition.py b/homeassistant/components/counter/condition.py new file mode 100644 index 00000000000000..ce5aa6b3916e4f --- /dev/null +++ b/homeassistant/components/counter/condition.py @@ -0,0 +1,15 @@ +"""Provides conditions for counters.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.condition import Condition, make_entity_numerical_condition + +DOMAIN = "counter" + +CONDITIONS: dict[str, type[Condition]] = { + "is_value": make_entity_numerical_condition(DOMAIN), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for counters.""" + return CONDITIONS diff --git a/homeassistant/components/counter/conditions.yaml b/homeassistant/components/counter/conditions.yaml new file mode 100644 index 00000000000000..6a00235d287108 --- /dev/null +++ b/homeassistant/components/counter/conditions.yaml @@ -0,0 +1,25 @@ +is_value: + target: + entity: + - domain: counter + fields: + behavior: + required: true + default: any + selector: + select: + translation_key: condition_behavior + options: + - all + - any + threshold: + required: true + selector: + numeric_threshold: + entity: + - domain: counter + - domain: input_number + - domain: number + mode: is + number: + mode: box diff --git a/homeassistant/components/counter/icons.json b/homeassistant/components/counter/icons.json index fef5b876c73182..c2dd1d0afc3aaa 100644 --- a/homeassistant/components/counter/icons.json +++ b/homeassistant/components/counter/icons.json @@ -1,4 +1,9 @@ { + "conditions": { + "is_value": { + "condition": "mdi:counter" + } + }, "services": { "decrement": { "service": "mdi:numeric-negative-1" diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index e09fd1ba9fdcb2..5bede3a676b4e1 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -3,6 +3,22 @@ "trigger_behavior_description": "The behavior of the targeted counters to trigger on.", "trigger_behavior_name": "Behavior" }, + "conditions": { + "is_value": { + "description": "Tests the value of one or more counters.", + "fields": { + "behavior": { + "description": "How the state should match on the targeted counters.", + "name": "Behavior" + }, + "threshold": { + "description": "What to test for and threshold values.", + "name": "Threshold" + } + }, + "name": "Counter value" + } + }, "entity_component": { "_": { "name": "[%key:component::counter::title%]", @@ -30,6 +46,12 @@ } }, "selector": { + "condition_behavior": { + "options": { + "all": "All", + "any": "Any" + } + }, "trigger_behavior": { "options": { "any": "Any", diff --git a/tests/components/counter/test_condition.py b/tests/components/counter/test_condition.py new file mode 100644 index 00000000000000..c25695edbfb6e0 --- /dev/null +++ b/tests/components/counter/test_condition.py @@ -0,0 +1,177 @@ +"""Test counter conditions.""" + +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_gated_by_labs_flag, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_counters(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple counter entities associated with different targets.""" + return await target_entities(hass, "counter") + + +async def test_counter_condition_gated_by_labs_flag( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the counter condition is gated by the labs flag.""" + await assert_condition_gated_by_labs_flag(hass, caplog, "counter.is_value") + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("counter"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="counter.is_value", + condition_options={ + "threshold": {"type": "above", "value": {"number": 20}}, + }, + target_states=["21", "50", "100"], + other_states=["0", "10", "20"], + ), + *parametrize_condition_states_any( + condition="counter.is_value", + condition_options={ + "threshold": {"type": "below", "value": {"number": 20}}, + }, + target_states=["0", "10", "19"], + other_states=["20", "50", "100"], + ), + *parametrize_condition_states_any( + condition="counter.is_value", + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 10}, + "value_max": {"number": 30}, + }, + }, + target_states=["11", "20", "29"], + other_states=["0", "10", "30", "100"], + ), + *parametrize_condition_states_any( + condition="counter.is_value", + condition_options={ + "threshold": { + "type": "outside", + "value_min": {"number": 10}, + "value_max": {"number": 30}, + }, + }, + target_states=["0", "10", "30", "100"], + other_states=["11", "20", "29"], + ), + ], +) +async def test_counter_is_value_condition_behavior_any( + hass: HomeAssistant, + target_counters: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the counter is_value condition with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_counters, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("counter"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="counter.is_value", + condition_options={ + "threshold": {"type": "above", "value": {"number": 20}}, + }, + target_states=["21", "50", "100"], + other_states=["0", "10", "20"], + ), + *parametrize_condition_states_all( + condition="counter.is_value", + condition_options={ + "threshold": {"type": "below", "value": {"number": 20}}, + }, + target_states=["0", "10", "19"], + other_states=["20", "50", "100"], + ), + *parametrize_condition_states_all( + condition="counter.is_value", + condition_options={ + "threshold": { + "type": "between", + "value_min": {"number": 10}, + "value_max": {"number": 30}, + }, + }, + target_states=["11", "20", "29"], + other_states=["0", "10", "30", "100"], + ), + *parametrize_condition_states_all( + condition="counter.is_value", + condition_options={ + "threshold": { + "type": "outside", + "value_min": {"number": 10}, + "value_max": {"number": 30}, + }, + }, + target_states=["0", "10", "30", "100"], + other_states=["11", "20", "29"], + ), + ], +) +async def test_counter_is_value_condition_behavior_all( + hass: HomeAssistant, + target_counters: dict[str, list[str]], + condition_target_config: dict[str, Any], + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test the counter is_value condition with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_counters, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) From 0a05993a4e47ec06a090e87eb671565937d7abcb Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:44:12 +0200 Subject: [PATCH 0184/1707] Unifi Access add reconfiguration flow and refactor validation logic (#166812) Co-authored-by: RaHehl --- .../components/unifi_access/config_flow.py | 104 ++++++++----- .../unifi_access/quality_scale.yaml | 2 +- .../components/unifi_access/strings.json | 16 +- .../unifi_access/test_config_flow.py | 145 ++++++++++++++++++ 4 files changed, 227 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/unifi_access/config_flow.py b/homeassistant/components/unifi_access/config_flow.py index b6474936dfe9ca..87acc7a84ed327 100644 --- a/homeassistant/components/unifi_access/config_flow.py +++ b/homeassistant/components/unifi_access/config_flow.py @@ -24,6 +24,29 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: + """Validate user input and return errors dict.""" + errors: dict[str, str] = {} + session = async_get_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ) + client = UnifiAccessApiClient( + host=user_input[CONF_HOST], + api_token=user_input[CONF_API_TOKEN], + session=session, + verify_ssl=user_input[CONF_VERIFY_SSL], + ) + try: + await client.authenticate() + except ApiAuthError: + errors["base"] = "invalid_auth" + except ApiConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -31,26 +54,9 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: - session = async_get_clientsession( - self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] - ) - client = UnifiAccessApiClient( - host=user_input[CONF_HOST], - api_token=user_input[CONF_API_TOKEN], - session=session, - verify_ssl=user_input[CONF_VERIFY_SSL], - ) - try: - await client.authenticate() - except ApiAuthError: - errors["base"] = "invalid_auth" - except ApiConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + errors = await self._validate_input(user_input) + if not errors: return self.async_create_entry( title="UniFi Access", data=user_input, @@ -68,6 +74,40 @@ async def async_step_user( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST]}, + ) + errors = await self._validate_input(user_input) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + + suggested_values = user_input or dict(reconfigure_entry.data) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_VERIFY_SSL): bool, + } + ), + suggested_values, + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -82,25 +122,13 @@ async def async_step_reauth_confirm( reauth_entry = self._get_reauth_entry() if user_input is not None: - session = async_get_clientsession( - self.hass, verify_ssl=reauth_entry.data[CONF_VERIFY_SSL] - ) - client = UnifiAccessApiClient( - host=reauth_entry.data[CONF_HOST], - api_token=user_input[CONF_API_TOKEN], - session=session, - verify_ssl=reauth_entry.data[CONF_VERIFY_SSL], + errors = await self._validate_input( + { + **reauth_entry.data, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + } ) - try: - await client.authenticate() - except ApiAuthError: - errors["base"] = "invalid_auth" - except ApiConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not errors: return self.async_update_reload_and_abort( reauth_entry, data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}, diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index a593584d940894..42a8ac4cfca7b2 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -58,7 +58,7 @@ rules: entity-translations: todo exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/unifi_access/strings.json b/homeassistant/components/unifi_access/strings.json index cd6e72bc9e5e12..44cf6dd921b7e6 100644 --- a/homeassistant/components/unifi_access/strings.json +++ b/homeassistant/components/unifi_access/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,6 +20,19 @@ }, "description": "The API token for UniFi Access at {host} is invalid. Please provide a new token." }, + "reconfigure": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "host": "[%key:common::config_flow::data::host%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "api_token": "[%key:component::unifi_access::config::step::user::data_description::api_token%]", + "host": "[%key:component::unifi_access::config::step::user::data_description::host%]", + "verify_ssl": "[%key:component::unifi_access::config::step::user::data_description::verify_ssl%]" + }, + "description": "Update the connection settings of this UniFi Access controller." + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", diff --git a/tests/components/unifi_access/test_config_flow.py b/tests/components/unifi_access/test_config_flow.py index 5a25620ecb040c..4c6c77b5f0b5ff 100644 --- a/tests/components/unifi_access/test_config_flow.py +++ b/tests/components/unifi_access/test_config_flow.py @@ -216,3 +216,148 @@ async def test_reauth_flow_errors( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reconfiguration flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "10.0.0.1" + assert mock_config_entry.data[CONF_API_TOKEN] == "new-api-token" + assert mock_config_entry.data[CONF_VERIFY_SSL] is True + + +async def test_reconfigure_flow_same_host_new_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow with same host and new API token.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == MOCK_HOST + assert mock_config_entry.data[CONF_API_TOKEN] == "new-api-token" + + +async def test_reconfigure_flow_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow aborts when host already configured.""" + mock_config_entry.add_to_hass(hass) + + other_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "other-token", + CONF_VERIFY_SSL: False, + }, + ) + other_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ApiConnectionError("Connection failed"), "cannot_connect"), + (ApiAuthError(), "invalid_auth"), + (RuntimeError("boom"), "unknown"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test reconfiguration flow errors and recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_client.authenticate.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_client.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "new-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" From 732b170190ed4917a7b2f1b66ed2561d773ebece Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 30 Mar 2026 16:48:18 +0200 Subject: [PATCH 0185/1707] Introduce per-source DataUpdateCoordinator for UniFi polling data sources (#166806) --- homeassistant/components/unifi/coordinator.py | 45 +++++++++++++++ homeassistant/components/unifi/entity.py | 19 ++++--- .../components/unifi/hub/entity_loader.py | 57 ++++++++++--------- homeassistant/components/unifi/hub/hub.py | 2 +- homeassistant/components/unifi/switch.py | 14 +++-- tests/components/unifi/test_switch.py | 4 +- 6 files changed, 97 insertions(+), 44 deletions(-) create mode 100644 homeassistant/components/unifi/coordinator.py diff --git a/homeassistant/components/unifi/coordinator.py b/homeassistant/components/unifi/coordinator.py new file mode 100644 index 00000000000000..9b840d77132677 --- /dev/null +++ b/homeassistant/components/unifi/coordinator.py @@ -0,0 +1,45 @@ +"""UniFi Network data update coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from aiounifi.interfaces.api_handlers import APIHandler + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + +if TYPE_CHECKING: + from .hub.hub import UnifiHub + +POLL_INTERVAL = timedelta(seconds=10) + + +class UnifiDataUpdateCoordinator[HandlerT: APIHandler](DataUpdateCoordinator[None]): + """Coordinator managing polling for a single UniFi API data source.""" + + def __init__( + self, + hub: UnifiHub, + handler: HandlerT, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hub.hass, + LOGGER, + name=f"UniFi {type(handler).__name__}", + config_entry=hub.config.entry, + update_interval=POLL_INTERVAL, + ) + self._handler = handler + + @property + def handler(self) -> HandlerT: + """Return the aiounifi handler managed by this coordinator.""" + return self._handler + + async def _async_update_data(self) -> None: + """Update data from the API handler.""" + await self._handler.update() diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 4b68287ce10f0f..03fae17f689864 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -94,16 +94,14 @@ def async_client_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: @dataclass(frozen=True, kw_only=True) -class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( - EntityDescription -): +class UnifiEntityDescription[HandlerT: APIHandler, ItemT: ApiItem](EntityDescription): """UniFi Entity Description.""" api_handler_fn: Callable[[aiounifi.Controller], HandlerT] """Provide api_handler from api.""" device_info_fn: Callable[[UnifiHub, str], DeviceInfo | None] """Provide device info object based on hub and obj_id.""" - object_fn: Callable[[aiounifi.Controller, str], ApiItemT] + object_fn: Callable[[aiounifi.Controller, str], ItemT] """Retrieve object based on api and obj_id.""" unique_id_fn: Callable[[UnifiHub, str], str] """Provide a unique ID based on hub and obj_id.""" @@ -113,7 +111,7 @@ class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( """Determine if config entry options allow creation of entity.""" available_fn: Callable[[UnifiHub, str], bool] = lambda hub, obj_id: hub.available """Determine if entity is available, default is if connection is working.""" - name_fn: Callable[[ApiItemT], str | None] = lambda obj: None + name_fn: Callable[[ItemT], str | None] = lambda obj: None """Entity name function, can be used to extend entity name beyond device name.""" supported_fn: Callable[[UnifiHub, str], bool] = lambda hub, obj_id: True """Determine if UniFi object supports providing relevant data for entity.""" @@ -129,17 +127,17 @@ class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( """If entity needs to do regular checks on state.""" -class UnifiEntity[HandlerT: APIHandler, ApiItemT: ApiItem](Entity): +class UnifiEntity[HandlerT: APIHandler, ItemT: ApiItem](Entity): """Representation of a UniFi entity.""" - entity_description: UnifiEntityDescription[HandlerT, ApiItemT] + entity_description: UnifiEntityDescription[HandlerT, ItemT] _attr_unique_id: str def __init__( self, obj_id: str, hub: UnifiHub, - description: UnifiEntityDescription[HandlerT, ApiItemT], + description: UnifiEntityDescription[HandlerT, ItemT], ) -> None: """Set up UniFi switch entity.""" self._obj_id = obj_id @@ -258,6 +256,11 @@ def async_initiate_state(self) -> None: """ self.async_update_state(ItemEvent.ADDED, self._obj_id) + @callback + def get_object(self) -> ItemT: + """Return the latest object for this entity.""" + return self.entity_description.object_fn(self.api, self._obj_id) + @callback @abstractmethod def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 4fd3d34a51dc26..3400e707ba2526 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -12,30 +12,28 @@ from functools import partial from typing import TYPE_CHECKING, Any -from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from homeassistant.const import Platform from homeassistant.core import callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS +from ..coordinator import UnifiDataUpdateCoordinator from ..entity import UnifiEntity, UnifiEntityDescription if TYPE_CHECKING: - from .. import UnifiConfigEntry from .hub import UnifiHub CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) -POLL_INTERVAL = timedelta(seconds=10) class UnifiEntityLoader: """UniFi Network integration handling platforms for entity registration.""" - def __init__(self, hub: UnifiHub, config_entry: UnifiConfigEntry) -> None: + def __init__(self, hub: UnifiHub) -> None: """Initialize the UniFi entity loader.""" self.hub = hub self.api_updaters = ( @@ -48,28 +46,20 @@ def __init__(self, hub: UnifiHub, config_entry: UnifiConfigEntry) -> None: hub.api.sites.update, hub.api.system_information.update, hub.api.firewall_policies.update, - hub.api.traffic_rules.update, - hub.api.traffic_routes.update, hub.api.wlans.update, ) - self.polling_api_updaters = ( - hub.api.traffic_rules.update, - hub.api.traffic_routes.update, - ) self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] - self._data_update_coordinator = DataUpdateCoordinator( - hub.hass, - LOGGER, - name="Unifi entity poller", - config_entry=config_entry, - update_method=self._update_pollable_api_data, - update_interval=POLL_INTERVAL, - ) - - self._update_listener = self._data_update_coordinator.async_add_listener( - update_callback=lambda: None - ) + self._polling_coordinators: dict[int, UnifiDataUpdateCoordinator] = { + id(hub.api.traffic_rules): UnifiDataUpdateCoordinator( + hub, hub.api.traffic_rules + ), + id(hub.api.traffic_routes): UnifiDataUpdateCoordinator( + hub, hub.api.traffic_routes + ), + } + for coordinator in self._polling_coordinators.values(): + coordinator.async_add_listener(lambda: None) self.platforms: list[ tuple[ @@ -85,7 +75,15 @@ def __init__(self, hub: UnifiHub, config_entry: UnifiConfigEntry) -> None: async def initialize(self) -> None: """Initialize API data and extra client support.""" - await self._refresh_api_data() + await asyncio.gather( + self._refresh_api_data(), + self._refresh_data( + [ + coordinator.async_refresh + for coordinator in self._polling_coordinators.values() + ] + ), + ) self._restore_inactive_clients() self.wireless_clients.update_clients(set(self.hub.api.clients.values())) @@ -100,10 +98,6 @@ async def _refresh_data( if result is not None: LOGGER.warning("Exception on update %s", result) - async def _update_pollable_api_data(self) -> None: - """Refresh API data for pollable updaters.""" - await self._refresh_data(self.polling_api_updaters) - async def _refresh_api_data(self) -> None: """Refresh API data from network application.""" await self._refresh_data(self.api_updaters) @@ -165,6 +159,13 @@ def _should_add_entity( and description.supported_fn(self.hub, obj_id) ) + @callback + def get_data_update_coordinator( + self, handler: APIHandler + ) -> UnifiDataUpdateCoordinator | None: + """Return the polling coordinator for a handler, if available.""" + return self._polling_coordinators.get(id(handler)) + @callback def _load_entities( self, diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py index 9ea887bdb29a71..6cf8825a26cd58 100644 --- a/homeassistant/components/unifi/hub/hub.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -39,7 +39,7 @@ def __init__( self.hass = hass self.api = api self.config = UnifiConfig.from_config_entry(config_entry) - self.entity_loader = UnifiEntityLoader(self, config_entry) + self.entity_loader = UnifiEntityLoader(self) self._entity_helper = UnifiEntityHelper(hass, api) self.websocket = UnifiWebsocket(hass, api, self.signal_reachable) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index b9fbf48cf49140..b39020204a5aa2 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -208,8 +208,6 @@ async def async_traffic_rule_control_fn( """Control traffic rule state.""" traffic_rule = hub.api.traffic_rules[obj_id].raw await hub.api.request(TrafficRuleEnableRequest.create(traffic_rule, target)) - # Update the traffic rules so the UI is updated appropriately - await hub.api.traffic_rules.update() async def async_traffic_route_control_fn( @@ -218,8 +216,6 @@ async def async_traffic_route_control_fn( """Control traffic route state.""" traffic_route = hub.api.traffic_routes[obj_id].raw await hub.api.request(TrafficRouteSaveRequest.create(traffic_route, target)) - # Update the traffic routes so the UI is updated appropriately - await hub.api.traffic_routes.update() async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: @@ -447,10 +443,18 @@ def async_initiate_state(self) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" await self.entity_description.control_fn(self.hub, self._obj_id, True) + if coordinator := self.hub.entity_loader.get_data_update_coordinator( + self.entity_description.api_handler_fn(self.api) + ): + await coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" await self.entity_description.control_fn(self.hub, self._obj_id, False) + if coordinator := self.hub.entity_loader.get_data_update_coordinator( + self.entity_description.api_handler_fn(self.api) + ): + await coordinator.async_request_refresh() @callback def async_update_state( @@ -464,7 +468,7 @@ def async_update_state( return description = self.entity_description - obj = description.object_fn(self.api, self._obj_id) + obj = self.get_object() if (is_on := description.is_on_fn(self.hub, obj)) != self.is_on: self._attr_is_on = is_on diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index d95a87d61f91d5..33b23d421f3dc8 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1223,7 +1223,7 @@ async def test_traffic_rules( expected_enable_call = deepcopy(traffic_rule) expected_enable_call["enabled"] = True - assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.call_count == call_count + 1 assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call @@ -1277,7 +1277,7 @@ async def test_traffic_routes( expected_enable_call = deepcopy(traffic_route) expected_enable_call["enabled"] = True - assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.call_count == call_count + 1 assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call From e78bb97e8466ac98eb7aacd16464fac53a6d29c8 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Mon, 30 Mar 2026 10:58:11 -0400 Subject: [PATCH 0186/1707] Support vacation mode in Econet (#166659) --- .../components/econet/water_heater.py | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index 876d9270bc914f..450c2b5eaa7a30 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -45,6 +45,13 @@ ) +def _operation_mode_to_ha(mode: WaterHeaterOperationMode | None) -> str: + """Translate an EcoNet operation mode to a Home Assistant state.""" + if mode in (None, WaterHeaterOperationMode.VACATION): + return STATE_OFF + return ECONET_STATE_TO_HA[mode] + + async def async_setup_entry( hass: HomeAssistant, entry: EconetConfigEntry, @@ -80,26 +87,22 @@ def is_away_mode_on(self) -> bool: @property def current_operation(self) -> str: """Return current operation.""" - econet_mode = self.water_heater.mode - _current_op = STATE_OFF - if econet_mode is not None: - _current_op = ECONET_STATE_TO_HA[econet_mode] - - return _current_op + return _operation_mode_to_ha(self.water_heater.mode) @property def operation_list(self) -> list[str]: """List of available operation modes.""" - econet_modes = self.water_heater.modes - operation_modes = set() - for mode in econet_modes: - if ( - mode is not WaterHeaterOperationMode.UNKNOWN - and mode is not WaterHeaterOperationMode.VACATION - ): - ha_mode = ECONET_STATE_TO_HA[mode] - operation_modes.add(ha_mode) - return list(operation_modes) + return list( + dict.fromkeys( + ECONET_STATE_TO_HA[mode] + for mode in self.water_heater.modes + if mode + not in ( + WaterHeaterOperationMode.UNKNOWN, + WaterHeaterOperationMode.VACATION, + ) + ) + ) @property def supported_features(self) -> WaterHeaterEntityFeature: From 70cea66e5b8c06a59d3e0db4c716a6ec87db41d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Mon, 30 Mar 2026 17:03:21 +0200 Subject: [PATCH 0187/1707] Skip unavailable sensors in LaCrosse View (#166859) --- .../components/lacrosse_view/coordinator.py | 56 ++++++++++++------- tests/components/lacrosse_view/test_sensor.py | 50 ++++++++++++++++- 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index c6f3c2312c0740..3d5e3bf4ce0220 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -73,31 +73,45 @@ async def _async_update_data(self) -> list[Sensor]: except HTTPError as error: raise UpdateFailed from error - try: - # Fetch last hour of data - for sensor in self.devices: + # Fetch last hour of data + for sensor in self.devices: + try: data = await self.api.get_sensor_status( sensor=sensor, tz=self.hass.config.time_zone, ) - _LOGGER.debug("Got data: %s", data) - - if data_error := data.get("error"): - if data_error == "no_readings": - sensor.data = None - _LOGGER.debug("No readings for %s", sensor.name) - continue - _LOGGER.debug("Error: %s", data_error) - raise UpdateFailed( - translation_domain=DOMAIN, translation_key="update_error" - ) - - sensor.data = data["data"]["current"] - - except HTTPError as error: - raise UpdateFailed( - translation_domain=DOMAIN, translation_key="update_error" - ) from error + except HTTPError as error: + error_data = error.args[1] if len(error.args) > 1 else None + if ( + isinstance(error_data, dict) + and error_data.get("error") == "no_readings" + ): + sensor.data = None + _LOGGER.debug("No readings for %s", sensor.name) + continue + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" + ) from error + + _LOGGER.debug("Got data: %s", data) + + if data_error := data.get("error"): + if data_error == "no_readings": + sensor.data = None + _LOGGER.debug("No readings for %s", sensor.name) + continue + _LOGGER.debug("Error: %s", data_error) + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" + ) + + current_data = data.get("data", {}).get("current") + if current_data is None: + sensor.data = None + _LOGGER.debug("No current data payload for %s", sensor.name) + continue + + sensor.data = current_data # Verify that we have permission to read the sensors for sensor in self.devices: diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index f0860f47b01514..361bb9cf1ce627 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import patch -from lacrosse_view import Sensor +from lacrosse_view import HTTPError, Sensor import pytest from homeassistant.components.lacrosse_view.const import DOMAIN @@ -230,6 +230,54 @@ async def test_no_readings(hass: HomeAssistant) -> None: assert hass.states.get("sensor.test_temperature").state == "unavailable" +async def test_mixed_readings(hass: HomeAssistant) -> None: + """Test a device without readings does not fail setup for the whole entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + working_sensor = TEST_SENSOR.model_copy( + update={"name": "Working", "sensor_id": "working", "device_id": "working"} + ) + no_readings_sensor = TEST_NO_READINGS_SENSOR.model_copy( + update={ + "name": "No readings", + "sensor_id": "no_readings", + "device_id": "no_readings", + } + ) + working_status = working_sensor.data + no_readings_status = no_readings_sensor.data + working_sensor.data = None + no_readings_sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[working_sensor, no_readings_sensor], + ), + patch( + "lacrosse_view.LaCrosse.get_sensor_status", + side_effect=[ + working_status, + HTTPError( + "Failed to get sensor status, status code: 404", + no_readings_status, + ), + ], + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert hass.states.get("sensor.working_temperature").state == "2" + assert hass.states.get("sensor.no_readings_temperature").state == "unavailable" + + async def test_other_error(hass: HomeAssistant) -> None: """Test behavior when there is an error.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) From 91099ea489d2e73f3f66b37ae04933465de0d4e3 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:19:07 +0200 Subject: [PATCH 0188/1707] Update UniFi Access quality scale: mark fulfilled Gold rules (#166789) Co-authored-by: RaHehl Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .../components/unifi_access/quality_scale.yaml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index 42a8ac4cfca7b2..f666252ce50dcd 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -52,14 +52,18 @@ rules: docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo - entity-translations: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: All entities provide essential data and should be enabled by default. + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: + status: exempt + comment: Integration raises ConfigEntryAuthFailed and relies on Home Assistant core to surface reauth/repair issues, no custom repairs are defined. stale-devices: todo # Platinum From 2c013777db1403365a36fcf6193ed48213535482 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 17:43:56 +0200 Subject: [PATCH 0189/1707] Rename component to integration in Opple (#166891) --- homeassistant/components/opple/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/opple/__init__.py b/homeassistant/components/opple/__init__.py index 41ef2b0fdd8de4..17b462d3ac3604 100644 --- a/homeassistant/components/opple/__init__.py +++ b/homeassistant/components/opple/__init__.py @@ -1 +1 @@ -"""The opple component.""" +"""The Opple integration.""" From 13709b1c90683c6aaa39aac05bac65f58566c2e4 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 17:45:18 +0200 Subject: [PATCH 0190/1707] Rename component to integration in Sky Hub (#166888) --- homeassistant/components/sky_hub/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sky_hub/__init__.py b/homeassistant/components/sky_hub/__init__.py index a5b8969018f034..3c465305acdb61 100644 --- a/homeassistant/components/sky_hub/__init__.py +++ b/homeassistant/components/sky_hub/__init__.py @@ -1 +1 @@ -"""The sky_hub component.""" +"""The Sky Hub integration.""" From 69b55c295d57f6418810571c5fbe61eb8b1fc9ae Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 17:47:38 +0200 Subject: [PATCH 0191/1707] Rename component to integration in OhmConnect (#166881) --- homeassistant/components/ohmconnect/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ohmconnect/__init__.py b/homeassistant/components/ohmconnect/__init__.py index 1713f82a59b358..3d3c9ca34479a3 100644 --- a/homeassistant/components/ohmconnect/__init__.py +++ b/homeassistant/components/ohmconnect/__init__.py @@ -1 +1 @@ -"""The ohmconnect component.""" +"""The OhmConnect integration.""" From ca2099b1656954941f0092269e70d6767d9b151e Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 18:13:17 +0200 Subject: [PATCH 0192/1707] Rename component to integration in Panasonic Blu-Ray (#166890) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/panasonic_bluray/__init__.py | 2 +- homeassistant/components/panasonic_bluray/media_player.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/panasonic_bluray/__init__.py b/homeassistant/components/panasonic_bluray/__init__.py index a39b070b3c5f25..53c222ffc1f5d0 100644 --- a/homeassistant/components/panasonic_bluray/__init__.py +++ b/homeassistant/components/panasonic_bluray/__init__.py @@ -1 +1 @@ -"""The panasonic_bluray component.""" +"""The Panasonic Blu-Ray Player integration.""" diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py index 0a5e5d24b682fd..0547e5f1b235bd 100644 --- a/homeassistant/components/panasonic_bluray/media_player.py +++ b/homeassistant/components/panasonic_bluray/media_player.py @@ -39,7 +39,7 @@ def setup_platform( add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Panasonic Blu-ray platform.""" + """Set up the Panasonic Blu-ray media player platform.""" conf = discovery_info or config # Register configured device with Home Assistant. @@ -59,7 +59,7 @@ class PanasonicBluRay(MediaPlayerEntity): ) def __init__(self, ip, name): - """Initialize the Panasonic Blue-ray device.""" + """Initialize the Panasonic Blu-ray device.""" self._device = PanasonicBD(ip) self._attr_name = name self._attr_state = MediaPlayerState.OFF From 501b4e6efb8aa47868e5d27167872e6af2a2ae38 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Mon, 30 Mar 2026 19:17:05 +0200 Subject: [PATCH 0193/1707] Convert Z-Wave Opening state to separate Open/Closed and Tilted sensors (#166635) Co-authored-by: Martin Hjelmare Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/zwave_js/binary_sensor.py | 305 +++++++++- .../components/zwave_js/strings.json | 8 + .../components/zwave_js/test_binary_sensor.py | 540 +++++++++++++++++- tests/components/zwave_js/test_sensor.py | 132 ----- 4 files changed, 826 insertions(+), 159 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index c0df675a25dabe..9ec546be756299 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -17,18 +17,28 @@ SmokeAlarmNotificationEvent, ) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.value import Value as ZwaveValue +from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) +from homeassistant.helpers.start import async_at_started from .const import DOMAIN from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity @@ -72,8 +82,7 @@ ACCESS_CONTROL_DOOR_STATE_OPEN_TILT = 5633 -# Numeric State values used by the "Opening state" notification variable. -# This is only needed temporarily until the legacy Access Control door state binary sensors are removed. +# Numeric State values used by the Opening state notification variable. class OpeningState(IntEnum): """Opening state values exposed by Access Control notifications.""" @@ -82,23 +91,23 @@ class OpeningState(IntEnum): TILTED = 2 -# parse_opening_state helpers for the DEPRECATED legacy Access Control binary sensors. -def _legacy_is_closed(opening_state: OpeningState) -> bool: +# parse_opening_state helpers. +def _opening_state_is_closed(opening_state: OpeningState) -> bool: """Return if Opening state represents closed.""" return opening_state is OpeningState.CLOSED -def _legacy_is_open(opening_state: OpeningState) -> bool: +def _opening_state_is_open(opening_state: OpeningState) -> bool: """Return if Opening state represents open.""" return opening_state is OpeningState.OPEN -def _legacy_is_open_or_tilted(opening_state: OpeningState) -> bool: +def _opening_state_is_open_or_tilted(opening_state: OpeningState) -> bool: """Return if Opening state represents open or tilted.""" return opening_state in (OpeningState.OPEN, OpeningState.TILTED) -def _legacy_is_tilted(opening_state: OpeningState) -> bool: +def _opening_state_is_tilted(opening_state: OpeningState) -> bool: """Return if Opening state represents tilted.""" return opening_state is OpeningState.TILTED @@ -127,12 +136,51 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription): @dataclass(frozen=True, kw_only=True) class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription): - """Describe a legacy Access Control binary sensor that derives state from Opening state.""" + """Describe an Access Control binary sensor that derives state from Opening state.""" state_key: int parse_opening_state: Callable[[OpeningState], bool] +@dataclass(frozen=True, kw_only=True) +class LegacyDoorStateRepairDescription: + """Describe how a legacy door state entity should be migrated.""" + + issue_translation_key: str + replacement_state_key: OpeningState + + +LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS: dict[str, LegacyDoorStateRepairDescription] = { + "legacy_access_control_door_state_simple_open": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open_regular": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open_tilt": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_tilt_state", + replacement_state_key=OpeningState.TILTED, + ), + "legacy_access_control_door_tilt_state_tilted": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_tilt_state", + replacement_state_key=OpeningState.TILTED, + ), +} + +LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS = frozenset( + { + description.issue_translation_key + for description in LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.values() + } +) + + # Mappings for Notification sensors # https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx # @@ -389,6 +437,9 @@ class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription): } +# This can likely be removed once the legacy notification binary sensor +# discovery path is gone and Opening state is handled only by the dedicated +# discovery schemas below. @callback def is_valid_notification_binary_sensor( info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, @@ -396,13 +447,111 @@ def is_valid_notification_binary_sensor( """Return if the notification CC Value is valid as binary sensor.""" if not info.primary_value.metadata.states: return False - # Access Control - Opening state is exposed as a single enum sensor instead - # of fanning out one binary sensor per state. + # Opening state is handled by dedicated discovery schemas if is_opening_state_notification_value(info.primary_value): return False return len(info.primary_value.metadata.states) > 1 +@callback +def _async_delete_legacy_entity_repairs(hass: HomeAssistant, entity_id: str) -> None: + """Delete all stale legacy door state repair issues for an entity.""" + for issue_key in LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS: + async_delete_issue(hass, DOMAIN, f"{issue_key}.{entity_id}") + + +@callback +def _async_check_legacy_entity_repair( + hass: HomeAssistant, + driver: Driver, + entity: ZWaveLegacyDoorStateBinarySensor, +) -> None: + """Schedule a repair issue check once HA has fully started.""" + + @callback + def _async_do_check(hass: HomeAssistant) -> None: + """Create or delete a repair issue for a deprecated legacy door state entity.""" + ent_reg = er.async_get(hass) + if entity.unique_id is None: + return + entity_id = ent_reg.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, entity.unique_id + ) + if entity_id is None: + return + + repair_description = LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.get( + entity.entity_description.key + ) + if repair_description is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + entity_entry = ent_reg.async_get(entity_id) + if entity_entry is None or entity_entry.disabled: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + entity_automations = automations_with_entity(hass, entity_id) + entity_scripts = scripts_with_entity(hass, entity_id) + if not entity_automations and not entity_scripts: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + opening_state_value = get_opening_state_notification_value( + entity.info.node, entity.info.primary_value.endpoint + ) + if opening_state_value is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + replacement_unique_id = ( + f"{driver.controller.home_id}.{opening_state_value.value_id}." + f"{repair_description.replacement_state_key}" + ) + replacement_entity_id = ent_reg.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, replacement_unique_id + ) + if replacement_entity_id is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + items = [] + for domain, entity_ids in ( + ("automation", entity_automations), + ("script", entity_scripts), + ): + for eid in entity_ids: + item = ent_reg.async_get(eid) + if item: + items.append( + f"- [{item.name or item.original_name or eid}]" + f"(/config/{domain}/edit/{item.unique_id})" + ) + else: + items.append(f"- {eid}") + + async_create_issue( + hass, + DOMAIN, + f"{repair_description.issue_translation_key}.{entity_id}", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key=repair_description.issue_translation_key, + translation_placeholders={ + "entity_id": entity_id, + "entity_name": ( + entity_entry.name or entity_entry.original_name or entity_id + ), + "replacement_entity_id": replacement_entity_id, + "items": "\n".join(items), + }, + ) + + async_at_started(hass, _async_do_check) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, @@ -442,13 +591,21 @@ def async_add_binary_sensor( and info.entity_class is ZWaveBooleanBinarySensor ): entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info)) + elif ( + isinstance(info, NewZwaveDiscoveryInfo) + and info.entity_class is ZWaveOpeningStateBinarySensor + and isinstance( + info.entity_description, OpeningStateZWaveJSEntityDescription + ) + ): + entities.append(ZWaveOpeningStateBinarySensor(config_entry, driver, info)) elif ( isinstance(info, NewZwaveDiscoveryInfo) and info.entity_class is ZWaveLegacyDoorStateBinarySensor ): - entities.append( - ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) - ) + entity = ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) + entities.append(entity) + _async_check_legacy_entity_repair(hass, driver, entity) elif isinstance(info, NewZwaveDiscoveryInfo): pass # other entity classes are not migrated yet elif info.platform_hint == "notification": @@ -632,6 +789,69 @@ def is_on(self) -> bool | None: return None +class ZWaveOpeningStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity): + """Representation of a binary sensor derived from Opening state.""" + + entity_description: OpeningStateZWaveJSEntityDescription + _known_states: set[str] + + def __init__( + self, + config_entry: ZwaveJSConfigEntry, + driver: Driver, + info: NewZwaveDiscoveryInfo, + ) -> None: + """Initialize an Opening state binary sensor entity.""" + super().__init__(config_entry, driver, info) + self._known_states = set(info.primary_value.metadata.states or ()) + self._attr_unique_id = ( + f"{self._attr_unique_id}.{self.entity_description.state_key}" + ) + + @callback + def should_rediscover_on_metadata_update(self) -> bool: + """Check if metadata states require adding the Tilt entity.""" + return ( + # Open and Tilt entities share the same underlying Opening state value. + # Only let the main Open entity trigger rediscovery when Tilt first + # appears so we can add the missing sibling without recreating the + # main entity and losing its registry customizations. + str(OpeningState.TILTED) not in self._known_states + and str(OpeningState.TILTED) + in set(self.info.primary_value.metadata.states or ()) + and self.entity_description.state_key == OpeningState.OPEN + ) + + async def _async_remove_and_rediscover(self, value: ZwaveValue) -> None: + """Trigger re-discovery while preserving the main Opening state entity.""" + assert self.device_entry is not None + controller_events = ( + self.config_entry.runtime_data.driver_events.controller_events + ) + + # Unlike the base implementation, keep this entity in place so its + # registry entry and user customizations survive metadata rediscovery. + controller_events.discovered_value_ids[self.device_entry.id].discard( + value.value_id + ) + node_events = controller_events.node_events + value_updates_disc_info = node_events.value_updates_disc_info[ + value.node.node_id + ] + node_events.async_on_value_added(value_updates_disc_info, value) + + @property + def is_on(self) -> bool | None: + """Return if the sensor is on or off.""" + value = self.info.primary_value.value + if value is None: + return None + try: + return self.entity_description.parse_opening_state(OpeningState(int(value))) + except TypeError, ValueError: + return None + + class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Representation of a Z-Wave binary_sensor from a property.""" @@ -730,11 +950,54 @@ def __init__( ), entity_class=ZWaveNotificationBinarySensor, ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Opening state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.TILTED}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + # Also derive the main binary sensor from the same value ID + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="access_control_opening_state_tilted", + name="Tilt", + state_key=OpeningState.TILTED, + parse_opening_state=_opening_state_is_tilted, + ), + entity_class=ZWaveOpeningStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Opening state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.OPEN}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + entity_description=OpeningStateZWaveJSEntityDescription( + key="access_control_opening_state_open", + state_key=OpeningState.OPEN, + parse_opening_state=_opening_state_is_open_or_tilted, + device_class=BinarySensorDeviceClass.DOOR, + ), + entity_class=ZWaveOpeningStateBinarySensor, + ), # ------------------------------------------------------------------- # DEPRECATED legacy Access Control door/window binary sensors. # These schemas exist only for backwards compatibility with users who # already have these entities registered. New integrations should use - # the Opening state enum sensor instead. Do not add new schemas here. + # the dedicated Opening state binary sensors instead. Do not add new + # schemas here. # All schemas below use ZWaveLegacyDoorStateBinarySensor and are # disabled by default (entity_registry_enabled_default=False). # ------------------------------------------------------------------- @@ -758,7 +1021,7 @@ def __init__( key="legacy_access_control_door_state_simple_open", name="Window/door is open", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, - parse_opening_state=_legacy_is_open_or_tilted, + parse_opening_state=_opening_state_is_open_or_tilted, device_class=BinarySensorDeviceClass.DOOR, entity_registry_enabled_default=False, ), @@ -784,7 +1047,7 @@ def __init__( key="legacy_access_control_door_state_simple_closed", name="Window/door is closed", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, - parse_opening_state=_legacy_is_closed, + parse_opening_state=_opening_state_is_closed, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -809,7 +1072,7 @@ def __init__( key="legacy_access_control_door_state_open", name="Window/door is open", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, - parse_opening_state=_legacy_is_open, + parse_opening_state=_opening_state_is_open, device_class=BinarySensorDeviceClass.DOOR, entity_registry_enabled_default=False, ), @@ -835,7 +1098,7 @@ def __init__( key="legacy_access_control_door_state_closed", name="Window/door is closed", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, - parse_opening_state=_legacy_is_closed, + parse_opening_state=_opening_state_is_closed, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -858,7 +1121,7 @@ def __init__( key="legacy_access_control_door_state_open_regular", name="Window/door is open in regular position", state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR, - parse_opening_state=_legacy_is_open, + parse_opening_state=_opening_state_is_open, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -881,7 +1144,7 @@ def __init__( key="legacy_access_control_door_state_open_tilt", name="Window/door is open in tilt position", state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_TILT, - parse_opening_state=_legacy_is_tilted, + parse_opening_state=_opening_state_is_tilted, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -904,7 +1167,7 @@ def __init__( key="legacy_access_control_door_tilt_state_tilted", name="Window/door is tilted", state_key=OpeningState.OPEN, - parse_opening_state=_legacy_is_tilted, + parse_opening_state=_opening_state_is_tilted, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index dbaefc4f1cf8c3..cc933386d1363e 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -303,6 +303,14 @@ } }, "issues": { + "deprecated_legacy_door_open_state": { + "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on when the door or window is open or tilted.", + "title": "Deprecation: {entity_name}" + }, + "deprecated_legacy_door_tilt_state": { + "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on only when the door or window is tilted.", + "title": "Deprecation: {entity_name}" + }, "device_config_file_changed": { "fix_flow": { "abort": { diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index ad7db02950c29d..dd8001d4cfd329 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -9,7 +9,12 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components import automation +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -20,7 +25,9 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import ( @@ -144,6 +151,22 @@ def _add_lock_state_notification_states(node_state: dict[str, Any]) -> dict[str, return updated_state +def _set_opening_state_metadata_states( + node_state: dict[str, Any], states: dict[str, str] +) -> dict[str, Any]: + """Return a node state with updated Opening state metadata states.""" + updated_state = copy.deepcopy(node_state) + for value_data in updated_state["values"]: + if ( + value_data.get("commandClass") == 113 + and value_data.get("property") == "Access Control" + and value_data.get("propertyKey") == "Opening state" + ): + value_data["metadata"]["states"] = states + break + return updated_state + + @pytest.fixture def platforms() -> list[str]: """Fixture to specify platforms to test.""" @@ -418,12 +441,12 @@ async def test_property_sensor_door_status( assert state.state == STATE_UNKNOWN -async def test_opening_state_notification_does_not_create_binary_sensors( +async def test_opening_state_creates_open_binary_sensor( hass: HomeAssistant, client, hoppe_ehandle_connectsense_state, ) -> None: - """Test Opening state does not fan out into per-state binary sensors.""" + """Test Opening state creates the Open binary sensor.""" # The eHandle fixture has a Binary Sensor CC value for tilt, which we # want to ignore in the assertion below state = copy.deepcopy(hoppe_ehandle_connectsense_state) @@ -440,7 +463,12 @@ async def test_opening_state_notification_does_not_create_binary_sensors( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert not hass.states.async_all("binary_sensor") + open_state = hass.states.get("binary_sensor.ehandle_connectsense") + assert open_state is not None + assert open_state.state == STATE_OFF + assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + + assert hass.states.get("binary_sensor.ehandle_connectsense_tilt") is None async def test_opening_state_disables_legacy_window_door_notification_sensors( @@ -476,7 +504,7 @@ async def test_opening_state_disables_legacy_window_door_notification_sensors( } or ( entry.original_name == "Window/door is tilted" - and entry.original_device_class != BinarySensorDeviceClass.WINDOW + and entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION ) ) ] @@ -488,6 +516,162 @@ async def test_opening_state_disables_legacy_window_door_notification_sensors( ) assert all(hass.states.get(entry.entity_id) is None for entry in legacy_entries) + open_state = hass.states.get("binary_sensor.ehandle_connectsense") + assert open_state is not None + assert open_state.state == STATE_OFF + assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + + +async def test_opening_state_binary_sensors_with_tilted( + hass: HomeAssistant, + client, + hoppe_ehandle_connectsense_state, +) -> None: + """Test Opening state creates Open and Tilt binary sensors when supported.""" + node = Node( + client, + _set_opening_state_metadata_states( + hoppe_ehandle_connectsense_state, + {"0": "Closed", "1": "Open", "2": "Tilted"}, + ), + ) + client.driver.controller.nodes[node.node_id] = node + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + open_entity_id = "binary_sensor.ehandle_connectsense" + tilted_entity_id = "binary_sensor.ehandle_connectsense_tilt" + + open_state = hass.states.get(open_entity_id) + tilted_state = hass.states.get(tilted_entity_id) + assert open_state is not None + assert tilted_state is not None + assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + assert ATTR_DEVICE_CLASS not in tilted_state.attributes + assert open_state.state == STATE_OFF + assert tilted_state.state == STATE_OFF + + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "newValue": 1, + "prevValue": 0, + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + }, + }, + ) + ) + await hass.async_block_till_done() + + assert hass.states.get(open_entity_id).state == STATE_ON + assert hass.states.get(tilted_entity_id).state == STATE_OFF + + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "newValue": 2, + "prevValue": 1, + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + }, + }, + ) + ) + await hass.async_block_till_done() + + assert hass.states.get(open_entity_id).state == STATE_ON + assert hass.states.get(tilted_entity_id).state == STATE_ON + + +async def test_opening_state_tilted_appears_via_metadata_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + hoppe_ehandle_connectsense_state, +) -> None: + """Test tilt binary sensor is added without recreating the main entity.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + open_entity_id = "binary_sensor.ehandle_connectsense" + tilted_entity_id = "binary_sensor.ehandle_connectsense_tilt" + open_entry = entity_registry.async_get(open_entity_id) + assert open_entry is not None + + assert hass.states.get(open_entity_id) is not None + assert hass.states.get(tilted_entity_id) is None + + node.receive_event( + Event( + "metadata updated", + { + "source": "node", + "event": "metadata updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Opening state", + "ccSpecific": {"notificationType": 6}, + "min": 0, + "max": 255, + "states": { + "0": "Closed", + "1": "Open", + "2": "Tilted", + }, + "stateful": True, + "secret": False, + }, + }, + }, + ) + ) + await hass.async_block_till_done() + + assert hass.states.get(open_entity_id) is not None + tilted_state = hass.states.get(tilted_entity_id) + assert tilted_state is not None + assert entity_registry.async_get(open_entity_id) == open_entry + async def test_reenabled_legacy_door_state_entity_follows_opening_state( hass: HomeAssistant, @@ -983,3 +1167,347 @@ async def test_hoppe_ehandle_connectsense( assert entry.original_name == "Window/door is tilted" assert entry.original_device_class == BinarySensorDeviceClass.WINDOW assert entry.disabled_by is None, "Entity should be enabled by default" + + +async def test_legacy_door_open_state_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test an open-state legacy entity creates the open-state repair issue.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.22", + suggested_object_id="ehandle_connectsense_window_door_is_open", + original_name="Window/door is open", + ) + entity_id = entity_entry.entity_id + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + assert issue is not None + assert issue.translation_key == "deprecated_legacy_door_open_state" + assert issue.translation_placeholders["entity_id"] == entity_id + assert issue.translation_placeholders["entity_name"] == "Window/door is open" + assert ( + issue.translation_placeholders["replacement_entity_id"] + == "binary_sensor.ehandle_connectsense" + ) + assert "test" in issue.translation_placeholders["items"] + + +async def test_legacy_door_tilt_state_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test a tilt-state legacy entity creates the tilt-state repair issue.""" + node = Node( + client, + _set_opening_state_metadata_states( + hoppe_ehandle_connectsense_state, + {"0": "Closed", "1": "Open", "2": "Tilted"}, + ), + ) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.5633", + suggested_object_id="ehandle_connectsense_window_door_is_open_in_tilt_position", + original_name="Window/door is open in tilt position", + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}" + ) + assert issue is not None + assert issue.translation_key == "deprecated_legacy_door_tilt_state" + assert issue.translation_placeholders["entity_id"] == entity_id + assert ( + issue.translation_placeholders["entity_name"] + == "Window/door is open in tilt position" + ) + assert ( + issue.translation_placeholders["replacement_entity_id"] + == "binary_sensor.ehandle_connectsense_tilt" + ) + assert "test" in issue.translation_placeholders["items"] + + +async def test_legacy_door_open_state_no_repair_issue_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test no repair issue is created when the legacy entity is disabled.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.22", + suggested_object_id="ehandle_connectsense_window_door_is_open", + original_name="Window/door is open", + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) + + +async def test_legacy_closed_door_state_does_not_create_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test closed-state legacy entities are excluded from repair issues.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.23", + suggested_object_id="ehandle_connectsense_window_door_is_closed", + original_name="Window/door is closed", + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}" + ) + is None + ) + + +async def test_hoppe_custom_tilt_sensor_no_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test no repair issue for the custom Binary Sensor CC tilt entity.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-48-0-Tilt", + suggested_object_id="ehandle_connectsense_window_door_is_tilted", + original_name="Window/door is tilted", + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}" + ) + is None + ) + + +async def test_legacy_door_open_state_stale_repair_issue_cleaned_up( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test stale open-state repair issues are deleted when no references remain.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.22", + suggested_object_id="ehandle_connectsense_window_door_is_open", + original_name="Window/door is open", + ) + entity_id = entity_entry.entity_id + + async_create_issue( + hass, + DOMAIN, + f"deprecated_legacy_door_open_state.{entity_id}", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_legacy_door_open_state", + translation_placeholders={ + "entity_id": entity_id, + "entity_name": "Window/door is open", + "replacement_entity_id": "binary_sensor.ehandle_connectsense", + "items": "- [test](/config/automation/edit/test_automation)", + }, + ) + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is not None + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index e111d6aed91162..e5b7d40f712407 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -11,7 +11,6 @@ from zwave_js_server.model.node import Node from homeassistant.components.sensor import ( - ATTR_OPTIONS, ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, @@ -895,137 +894,6 @@ async def test_new_sensor_invalid_scale( mock_schedule_reload.assert_called_once_with(integration.entry_id) -async def test_opening_state_sensor( - hass: HomeAssistant, - client, - hoppe_ehandle_connectsense_state, -) -> None: - """Test Opening state is exposed as an enum sensor.""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("sensor.ehandle_connectsense_opening_state") - assert state - assert state.state == "Closed" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] - assert state.attributes[ATTR_VALUE] == 0 - - # Make sure we're not accidentally creating enum sensors for legacy - # Door/Window notification variables. - legacy_sensor_ids = [ - "sensor.ehandle_connectsense_door_state", - "sensor.ehandle_connectsense_door_state_simple", - ] - for entity_id in legacy_sensor_ids: - assert hass.states.get(entity_id) is None - - -async def test_opening_state_sensor_metadata_options_change( - hass: HomeAssistant, - hoppe_ehandle_connectsense: Node, - integration: MockConfigEntry, -) -> None: - """Test Opening state sensor is rediscovered when metadata options change.""" - entity_id = "sensor.ehandle_connectsense_opening_state" - node = hoppe_ehandle_connectsense - - # Verify initial state with 2 options - state = hass.states.get(entity_id) - assert state - assert state.state == "Closed" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] - - # Simulate metadata update adding "Tilted" state - event = Event( - "metadata updated", - { - "source": "node", - "event": "metadata updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Notification", - "commandClass": 113, - "endpoint": 0, - "property": "Access Control", - "propertyKey": "Opening state", - "propertyName": "Access Control", - "propertyKeyName": "Opening state", - "metadata": { - "type": "number", - "readable": True, - "writeable": False, - "label": "Opening state", - "ccSpecific": {"notificationType": 6}, - "min": 0, - "max": 255, - "states": { - "0": "Closed", - "1": "Open", - "2": "Tilted", - }, - "stateful": True, - "secret": False, - }, - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - # Entity should be rediscovered with 3 options - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open", "Tilted"] - - # Simulate metadata update removing "Tilted" state - event = Event( - "metadata updated", - { - "source": "node", - "event": "metadata updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Notification", - "commandClass": 113, - "endpoint": 0, - "property": "Access Control", - "propertyKey": "Opening state", - "propertyName": "Access Control", - "propertyKeyName": "Opening state", - "metadata": { - "type": "number", - "readable": True, - "writeable": False, - "label": "Opening state", - "ccSpecific": {"notificationType": 6}, - "min": 0, - "max": 255, - "states": { - "0": "Closed", - "1": "Open", - }, - "stateful": True, - "secret": False, - }, - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - # Entity should be rediscovered with 2 options again - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] - - CONTROLLER_STATISTICS_ENTITY_PREFIX = "sensor.z_stick_gen5_usb_controller_" # controller statistics with initial state of 0 CONTROLLER_STATISTICS_SUFFIXES = { From c42b50418ed2c2a8d52dbbca99a611da16a96978 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:19:20 +0200 Subject: [PATCH 0194/1707] Add stale device removal support to UniFi Access (#166792) Co-authored-by: RaHehl --- .../components/unifi_access/coordinator.py | 21 +++++++ .../unifi_access/quality_scale.yaml | 2 +- tests/components/unifi_access/test_init.py | 56 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index af29b9e2ae4bac..480b5f81902200 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -37,6 +37,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -194,6 +195,9 @@ async def _async_update_data(self) -> UnifiAccessData: supports_lock_rules = bool(door_lock_rules) or bool(unconfirmed_lock_rule_doors) + current_ids = {door.id for door in doors} | {self.config_entry.entry_id} + self._remove_stale_devices(current_ids) + return UnifiAccessData( doors={door.id: door for door in doors}, emergency=emergency, @@ -221,6 +225,23 @@ async def _async_get_door_lock_rule( except ApiNotFoundError: return None + @callback + def _remove_stale_devices(self, current_ids: set[str]) -> None: + """Remove devices for doors that no longer exist on the hub.""" + device_registry = dr.async_get(self.hass) + for device in dr.async_entries_for_config_entry( + device_registry, self.config_entry.entry_id + ): + if any( + identifier[0] == DOMAIN and identifier[1] in current_ids + for identifier in device.identifiers + ): + continue + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + def _on_ws_connect(self) -> None: """Handle WebSocket connection established.""" _LOGGER.debug("WebSocket connected to UniFi Access") diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index f666252ce50dcd..66dc50f69d2ce9 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -64,7 +64,7 @@ rules: repair-issues: status: exempt comment: Integration raises ConfigEntryAuthFailed and relies on Home Assistant core to surface reauth/repair issues, no custom repairs are defined. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/tests/components/unifi_access/test_init.py b/tests/components/unifi_access/test_init.py index 889fad4a32ca76..733a72be5f98f2 100644 --- a/tests/components/unifi_access/test_init.py +++ b/tests/components/unifi_access/test_init.py @@ -23,8 +23,10 @@ WebsocketMessage, ) +from homeassistant.components.unifi_access.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -353,3 +355,57 @@ async def test_ws_location_update_thumbnail_only_no_state( # Door state unchanged, thumbnail updated assert hass.states.get(FRONT_DOOR_BINARY_SENSOR).state == state_before assert hass.states.get(FRONT_DOOR_IMAGE).state != image_state_before + + +async def test_stale_device_removed_on_refresh( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that stale devices are automatically removed on data refresh.""" + # Verify both doors exist after initial setup + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-001")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-002")}) + + # Simulate door-002 being removed from the hub + mock_client.get_doors.return_value = [ + door for door in mock_client.get_doors.return_value if door.id != "door-002" + ] + + # Trigger natural refresh via WebSocket reconnect + on_disconnect = mock_client.start_websocket.call_args[1]["on_disconnect"] + on_connect = mock_client.start_websocket.call_args[1]["on_connect"] + on_disconnect() + await hass.async_block_till_done() + on_connect() + await hass.async_block_till_done() + + # door-001 still exists, door-002 was removed + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-001")}) + assert not device_registry.async_get_device(identifiers={(DOMAIN, "door-002")}) + + +async def test_stale_device_removed_on_startup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test stale devices present before setup are removed on initial refresh.""" + mock_config_entry.add_to_hass(hass) + + # Create a stale door device that no longer exists on the hub + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "door-003")}, + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-003")}) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Valid doors from the hub should exist, stale device should be removed + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-001")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-002")}) + assert not device_registry.async_get_device(identifiers={(DOMAIN, "door-003")}) From 14cb42349a267e64368d08dd43b6bb043fa21b0a Mon Sep 17 00:00:00 2001 From: Chase <43818313+ab3lson@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:40:02 -0400 Subject: [PATCH 0195/1707] OpenRouter: Add WebSearch Support (#164293) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- CODEOWNERS | 4 +- .../components/open_router/__init__.py | 31 +++- .../components/open_router/config_flow.py | 24 +++- homeassistant/components/open_router/const.py | 4 + .../components/open_router/entity.py | 18 ++- .../components/open_router/manifest.json | 2 +- .../components/open_router/strings.json | 18 ++- tests/components/open_router/conftest.py | 24 +++- tests/components/open_router/test_ai_task.py | 29 ++++ .../open_router/test_config_flow.py | 118 ++++++++++++++- .../open_router/test_conversation.py | 60 ++++++++ tests/components/open_router/test_init.py | 136 ++++++++++++++++++ 12 files changed, 443 insertions(+), 25 deletions(-) create mode 100644 tests/components/open_router/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 2d5ee30b0710b0..78374a8d180b8d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1232,8 +1232,8 @@ build.json @home-assistant/supervisor /tests/components/onvif/ @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck -/homeassistant/components/open_router/ @joostlek -/tests/components/open_router/ @joostlek +/homeassistant/components/open_router/ @joostlek @ab3lson +/tests/components/open_router/ @joostlek @ab3lson /homeassistant/components/opendisplay/ @g4bri3lDev /tests/components/opendisplay/ @g4bri3lDev /homeassistant/components/openerz/ @misialq diff --git a/homeassistant/components/open_router/__init__.py b/homeassistant/components/open_router/__init__.py index 9850f72f71d1d4..57b23c796dbce8 100644 --- a/homeassistant/components/open_router/__init__.py +++ b/homeassistant/components/open_router/__init__.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client -from .const import LOGGER +from .const import CONF_WEB_SEARCH, LOGGER PLATFORMS = [Platform.AI_TASK, Platform.CONVERSATION] @@ -56,3 +56,32 @@ async def _async_update_listener( async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: """Unload OpenRouter.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, entry: OpenRouterConfigEntry +) -> bool: + """Migrate config entry.""" + LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) + + if entry.version > 1 or (entry.version == 1 and entry.minor_version > 2): + return False + + if entry.version == 1 and entry.minor_version < 2: + for subentry in entry.subentries.values(): + if CONF_WEB_SEARCH in subentry.data: + continue + + updated_data = {**subentry.data, CONF_WEB_SEARCH: False} + + hass.config_entries.async_update_subentry( + entry, subentry, data=updated_data + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.info( + "Migration to version %s.%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py index db9af4c0f26ad8..85ae4ca3744768 100644 --- a/homeassistant/components/open_router/config_flow.py +++ b/homeassistant/components/open_router/config_flow.py @@ -27,6 +27,7 @@ from homeassistant.helpers import llm from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( + BooleanSelector, SelectOptionDict, SelectSelector, SelectSelectorConfig, @@ -34,7 +35,12 @@ TemplateSelector, ) -from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS +from .const import ( + CONF_PROMPT, + CONF_WEB_SEARCH, + DOMAIN, + RECOMMENDED_CONVERSATION_OPTIONS, +) _LOGGER = logging.getLogger(__name__) @@ -43,6 +49,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenRouter.""" VERSION = 1 + MINOR_VERSION = 2 @classmethod @callback @@ -66,7 +73,7 @@ async def async_step_user( user_input[CONF_API_KEY], async_get_clientsession(self.hass) ) try: - await client.get_key_data() + key_data = await client.get_key_data() except OpenRouterError: errors["base"] = "cannot_connect" except Exception: @@ -74,7 +81,7 @@ async def async_step_user( errors["base"] = "unknown" else: return self.async_create_entry( - title="OpenRouter", + title=key_data.label, data=user_input, ) return self.async_show_form( @@ -106,7 +113,7 @@ async def _get_models(self) -> None: class ConversationFlowHandler(OpenRouterSubentryFlowHandler): - """Handle subentry flow.""" + """Handle conversation subentry flow.""" def __init__(self) -> None: """Initialize the subentry flow.""" @@ -208,13 +215,20 @@ async def async_step_init( ): SelectSelector( SelectSelectorConfig(options=hass_apis, multiple=True) ), + vol.Optional( + CONF_WEB_SEARCH, + default=self.options.get( + CONF_WEB_SEARCH, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_WEB_SEARCH], + ), + ): BooleanSelector(), } ), ) class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler): - """Handle subentry flow.""" + """Handle AI task subentry flow.""" def __init__(self) -> None: """Initialize the subentry flow.""" diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py index 7316d45c3e5f51..1664f98add2b20 100644 --- a/homeassistant/components/open_router/const.py +++ b/homeassistant/components/open_router/const.py @@ -9,9 +9,13 @@ LOGGER = logging.getLogger(__package__) CONF_RECOMMENDED = "recommended" +CONF_WEB_SEARCH = "web_search" + +RECOMMENDED_WEB_SEARCH = False RECOMMENDED_CONVERSATION_OPTIONS = { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + CONF_WEB_SEARCH: RECOMMENDED_WEB_SEARCH, } diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py index 0a2f62f9c94da3..0bf9fd38ec73a7 100644 --- a/homeassistant/components/open_router/entity.py +++ b/homeassistant/components/open_router/entity.py @@ -37,9 +37,8 @@ from homeassistant.helpers.json import json_dumps from . import OpenRouterConfigEntry -from .const import DOMAIN, LOGGER +from .const import CONF_WEB_SEARCH, DOMAIN, LOGGER -# Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 @@ -52,7 +51,6 @@ def _adjust_schema(schema: dict[str, Any]) -> None: if "required" not in schema: schema["required"] = [] - # Ensure all properties are required for prop, prop_info in schema["properties"].items(): _adjust_schema(prop_info) if prop not in schema["required"]: @@ -233,14 +231,20 @@ async def _async_handle_chat_log( ) -> None: """Generate an answer for the chat log.""" + model = self.model + if self.subentry.data.get(CONF_WEB_SEARCH): + model = f"{model}:online" + + extra_body: dict[str, Any] = {"require_parameters": True} + model_args = { - "model": self.model, + "model": model, "user": chat_log.conversation_id, "extra_headers": { "X-Title": "Home Assistant", "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", }, - "extra_body": {"require_parameters": True}, + "extra_body": extra_body, } tools: list[ChatCompletionFunctionToolParam] | None = None @@ -296,6 +300,10 @@ async def _async_handle_chat_log( LOGGER.error("Error talking to API: %s", err) raise HomeAssistantError("Error talking to API") from err + if not result.choices: + LOGGER.error("API returned empty choices") + raise HomeAssistantError("API returned empty response") + result_message = result.choices[0].message model_args["messages"].extend( diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 1a48eb5b44d1bd..5be81a48a75fe6 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -2,7 +2,7 @@ "domain": "open_router", "name": "OpenRouter", "after_dependencies": ["assist_pipeline", "intent"], - "codeowners": ["@joostlek"], + "codeowners": ["@joostlek", "@ab3lson"], "config_flow": true, "dependencies": ["conversation"], "documentation": "https://www.home-assistant.io/integrations/open_router", diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index ab99c3cec1d97d..caad20f5d4a85c 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -23,19 +23,18 @@ "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "entry_type": "AI task", "initiate_flow": { + "reconfigure": "Reconfigure AI task", "user": "Add AI task" }, "step": { "init": { "data": { - "model": "[%key:component::open_router::config_subentries::conversation::step::init::data::model%]" - }, - "data_description": { - "model": "The model to use for the AI task" + "model": "[%key:common::generic::model%]" }, "description": "Configure the AI task" } @@ -45,22 +44,27 @@ "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "entry_not_loaded": "[%key:component::open_router::config_subentries::ai_task_data::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "entry_type": "Conversation agent", "initiate_flow": { + "reconfigure": "Reconfigure conversation agent", "user": "Add conversation agent" }, "step": { "init": { "data": { "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "model": "Model", - "prompt": "[%key:common::config_flow::data::prompt%]" + "model": "[%key:common::generic::model%]", + "prompt": "[%key:common::config_flow::data::prompt%]", + "web_search": "Enable web search" }, "data_description": { + "llm_hass_api": "Select which tools the model can use to interact with your devices and entities.", "model": "The model to use for the conversation agent", - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "web_search": "Allow the model to search the web for answers" }, "description": "Configure the conversation agent" } diff --git a/tests/components/open_router/conftest.py b/tests/components/open_router/conftest.py index 33ca4d790c9bb5..b0af668f2352aa 100644 --- a/tests/components/open_router/conftest.py +++ b/tests/components/open_router/conftest.py @@ -9,9 +9,13 @@ from openai.types.chat import ChatCompletion, ChatCompletionMessage from openai.types.chat.chat_completion import Choice import pytest -from python_open_router import ModelsDataWrapper +from python_open_router import KeyData, ModelsDataWrapper -from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN +from homeassistant.components.open_router.const import ( + CONF_PROMPT, + CONF_WEB_SEARCH, + DOMAIN, +) from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import HomeAssistant @@ -38,11 +42,18 @@ def enable_assist() -> bool: @pytest.fixture -def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: +def web_search() -> bool: + """Mock web search setting.""" + return False + + +@pytest.fixture +def conversation_subentry_data(enable_assist: bool, web_search: bool) -> dict[str, Any]: """Mock conversation subentry data.""" res: dict[str, Any] = { CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "You are a helpful assistant.", + CONF_WEB_SEARCH: web_search, } if enable_assist: res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] @@ -137,6 +148,13 @@ async def mock_open_router_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMo autospec=True, ) as mock_client: client = mock_client.return_value + client.get_key_data.return_value = KeyData( + label="Test account", + usage=0, + is_provisioning_key=False, + limit_remaining=None, + is_free_tier=True, + ) models = await async_load_fixture(hass, "models.json", DOMAIN) client.get_models.return_value = ModelsDataWrapper.from_json(models).data yield client diff --git a/tests/components/open_router/test_ai_task.py b/tests/components/open_router/test_ai_task.py index ac7db878af04fb..acd76db974561a 100644 --- a/tests/components/open_router/test_ai_task.py +++ b/tests/components/open_router/test_ai_task.py @@ -211,6 +211,35 @@ async def test_generate_invalid_structured_data( ) +async def test_generate_data_empty_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, +) -> None: + """Test AI Task raises HomeAssistantError when API returns empty choices.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[], + created=1700000000, + model="x-ai/grok-3", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8), + ) + ) + + with pytest.raises(HomeAssistantError, match="API returned empty response"): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.gemini_1_5_pro", + instructions="Generate test data", + ) + + async def test_generate_data_with_attachments( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py index 523ef69aa3b8bc..9534028cadf996 100644 --- a/tests/components/open_router/test_config_flow.py +++ b/tests/components/open_router/test_config_flow.py @@ -5,7 +5,11 @@ import pytest from python_open_router import OpenRouterError -from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN +from homeassistant.components.open_router.const import ( + CONF_PROMPT, + CONF_WEB_SEARCH, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import HomeAssistant @@ -35,9 +39,33 @@ async def test_full_flow( ) assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test account" assert result["data"] == {CONF_API_KEY: "bla"} +async def test_second_account( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a second account with a different API key can be added.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "different_key"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test account" + assert result["data"] == {CONF_API_KEY: "different_key"} + + @pytest.mark.parametrize( ("exception", "error"), [ @@ -131,6 +159,7 @@ async def test_create_conversation_agent( CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", CONF_LLM_HASS_API: ["assist"], + CONF_WEB_SEARCH: False, }, ) @@ -139,6 +168,7 @@ async def test_create_conversation_agent( CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", CONF_LLM_HASS_API: ["assist"], + CONF_WEB_SEARCH: False, } @@ -170,6 +200,7 @@ async def test_create_conversation_agent_no_control( CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", CONF_LLM_HASS_API: [], + CONF_WEB_SEARCH: False, }, ) @@ -177,6 +208,7 @@ async def test_create_conversation_agent_no_control( assert result["data"] == { CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", + CONF_WEB_SEARCH: False, } @@ -263,12 +295,19 @@ async def test_reconfigure_conversation_agent( CONF_MODEL: "openai/gpt-4", CONF_PROMPT: "updated prompt", CONF_LLM_HASS_API: ["assist"], + CONF_WEB_SEARCH: True, }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" + subentry = mock_config_entry.subentries[subentry_id] + assert subentry.data[CONF_MODEL] == "openai/gpt-4" + assert subentry.data[CONF_PROMPT] == "updated prompt" + assert subentry.data[CONF_LLM_HASS_API] == ["assist"] + assert subentry.data[CONF_WEB_SEARCH] is True + async def test_reconfigure_ai_task( hass: HomeAssistant, @@ -367,6 +406,83 @@ async def test_reconfigure_ai_task_abort( assert result["reason"] == reason +@pytest.mark.parametrize( + ("web_search", "expected_web_search"), + [(True, True), (False, False)], + indirect=["web_search"], +) +async def test_create_conversation_agent_web_search( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + web_search: bool, + expected_web_search: bool, +) -> None: + """Test creating a conversation agent with web search enabled/disabled.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Verify web_search field is present in schema with correct default + schema = result["data_schema"].schema + key = next(k for k in schema if k == CONF_WEB_SEARCH) + assert key.default() is False + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "openai/gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + CONF_WEB_SEARCH: expected_web_search, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_WEB_SEARCH] is expected_web_search + + +@pytest.mark.parametrize( + ("current_web_search", "expected_default"), + [(True, True), (False, False)], +) +async def test_reconfigure_conversation_subentry_web_search_default( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + current_web_search: bool, + expected_default: bool, +) -> None: + """Test web_search field default reflects existing value when reconfiguring.""" + await setup_integration(hass, mock_config_entry) + + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={**subentry.data, CONF_WEB_SEARCH: current_web_search}, + ) + await hass.async_block_till_done() + + result = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + schema = result["data_schema"].schema + key = next(k for k in schema if k == CONF_WEB_SEARCH) + assert key.default() is expected_default + + @pytest.mark.parametrize( ("current_llm_apis", "suggested_llm_apis", "expected_options"), [ diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py index 6bd4a513171033..4292e11896adc1 100644 --- a/tests/components/open_router/test_conversation.py +++ b/tests/components/open_router/test_conversation.py @@ -79,6 +79,66 @@ async def test_default_prompt( } +@pytest.mark.parametrize( + ("web_search", "expected_model_suffix"), + [(True, ":online"), (False, "")], + ids=["web_search_enabled", "web_search_disabled"], +) +async def test_web_search( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 + web_search: bool, + expected_model_suffix: str, +) -> None: + """Test that web search adds :online suffix to model.""" + await setup_integration(hass, mock_config_entry) + await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + call = mock_openai_client.chat.completions.create.call_args_list[0][1] + expected_model = f"openai/gpt-3.5-turbo{expected_model_suffix}" + assert call["model"] == expected_model + + +async def test_empty_api_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that an empty choices response raises HomeAssistantError.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8), + ) + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + + @pytest.mark.parametrize("enable_assist", [True]) async def test_function_call( hass: HomeAssistant, diff --git a/tests/components/open_router/test_init.py b/tests/components/open_router/test_init.py new file mode 100644 index 00000000000000..75a0fdebfad08a --- /dev/null +++ b/tests/components/open_router/test_init.py @@ -0,0 +1,136 @@ +"""Tests for the OpenRouter integration.""" + +from unittest.mock import patch + +from homeassistant.components.open_router.const import ( + CONF_PROMPT, + CONF_WEB_SEARCH, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm + +from tests.common import MockConfigEntry + + +async def test_migrate_entry_from_v1_1_to_v1_2( + hass: HomeAssistant, +) -> None: + """Test migration from version 1.1 to 1.2.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "bla", + }, + version=1, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data={ + CONF_MODEL: "openai/gpt-3.5-turbo", + CONF_PROMPT: "You are a helpful assistant.", + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + }, + subentry_id="conversation_subentry", + subentry_type="conversation", + title="GPT-3.5 Turbo", + unique_id=None, + ), + ConfigSubentryData( + data={ + CONF_MODEL: "openai/gpt-4", + }, + subentry_id="ai_task_subentry", + subentry_type="ai_task_data", + title="GPT-4", + unique_id=None, + ), + ], + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.open_router.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 1 + assert entry.minor_version == 2 + + conversation_subentry = entry.subentries["conversation_subentry"] + assert conversation_subentry.data[CONF_MODEL] == "openai/gpt-3.5-turbo" + assert conversation_subentry.data[CONF_PROMPT] == "You are a helpful assistant." + assert conversation_subentry.data[CONF_LLM_HASS_API] == [llm.LLM_API_ASSIST] + assert conversation_subentry.data[CONF_WEB_SEARCH] is False + + ai_task_subentry = entry.subentries["ai_task_subentry"] + assert ai_task_subentry.data[CONF_MODEL] == "openai/gpt-4" + assert ai_task_subentry.data[CONF_WEB_SEARCH] is False + + +async def test_migrate_entry_already_migrated( + hass: HomeAssistant, +) -> None: + """Test migration is skipped when already on version 1.2.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "bla", + }, + version=1, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data={ + CONF_MODEL: "openai/gpt-3.5-turbo", + CONF_PROMPT: "You are a helpful assistant.", + CONF_WEB_SEARCH: True, + }, + subentry_id="conversation_subentry", + subentry_type="conversation", + title="GPT-3.5 Turbo", + unique_id=None, + ), + ], + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.open_router.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 1 + assert entry.minor_version == 2 + + conversation_subentry = entry.subentries["conversation_subentry"] + assert conversation_subentry.data[CONF_MODEL] == "openai/gpt-3.5-turbo" + assert conversation_subentry.data[CONF_WEB_SEARCH] is True + + +async def test_migrate_entry_from_future_version_fails( + hass: HomeAssistant, +) -> None: + """Test migration fails for future versions.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "bla", + }, + version=100, + minor_version=99, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 100 + assert entry.minor_version == 99 + assert entry.state is ConfigEntryState.MIGRATION_ERROR From dc111a475e89f82b4d3420a139f4e78020a233b3 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 30 Mar 2026 20:40:56 +0300 Subject: [PATCH 0196/1707] Add support for web search dynamic filtering for Anthropic (#164116) --- homeassistant/components/anthropic/const.py | 10 + homeassistant/components/anthropic/entity.py | 70 +++-- tests/components/anthropic/__init__.py | 72 +++-- tests/components/anthropic/conftest.py | 6 +- .../snapshots/test_conversation.ambr | 283 ++++++++++++++++++ .../components/anthropic/test_conversation.py | 246 ++++++++++++++- 6 files changed, 631 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 138f704aa0cce2..8c88d8f47654dc 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -71,6 +71,16 @@ "claude-3-haiku", ] +PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [ + "claude-haiku-4-5", + "claude-opus-4-1", + "claude-opus-4-0", + "claude-opus-4-20250514", + "claude-sonnet-4-0", + "claude-sonnet-4-20250514", + "claude-3-haiku", +] + DEPRECATED_MODELS = [ "claude-3", ] diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 94c8616d010758..021fc727a7558c 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -19,6 +19,8 @@ CitationsWebSearchResultLocation, CitationWebSearchResultLocationParam, CodeExecutionTool20250825Param, + CodeExecutionToolResultBlock, + CodeExecutionToolResultBlockParamContentParam, Container, ContentBlockParam, DocumentBlockParam, @@ -61,15 +63,16 @@ ToolUseBlockParam, Usage, WebSearchTool20250305Param, + WebSearchTool20260209Param, WebSearchToolResultBlock, WebSearchToolResultBlockParamContentParam, ) from anthropic.types.bash_code_execution_tool_result_block_param import ( - Content as BashCodeExecutionToolResultContentParam, + Content as BashCodeExecutionToolResultBlockParamContentParam, ) from anthropic.types.message_create_params import MessageCreateParamsStreaming from anthropic.types.text_editor_code_execution_tool_result_block_param import ( - Content as TextEditorCodeExecutionToolResultContentParam, + Content as TextEditorCodeExecutionToolResultBlockParamContentParam, ) import voluptuous as vol from voluptuous_openapi import convert @@ -105,6 +108,7 @@ MIN_THINKING_BUDGET, NON_ADAPTIVE_THINKING_MODELS, NON_THINKING_MODELS, + PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS, UNSUPPORTED_STRUCTURED_OUTPUT_MODELS, ) @@ -224,12 +228,22 @@ def _convert_content( }, ), } + elif content.tool_name == "code_execution": + tool_result_block = { + "type": "code_execution_tool_result", + "tool_use_id": content.tool_call_id, + "content": cast( + CodeExecutionToolResultBlockParamContentParam, + content.tool_result, + ), + } elif content.tool_name == "bash_code_execution": tool_result_block = { "type": "bash_code_execution_tool_result", "tool_use_id": content.tool_call_id, "content": cast( - BashCodeExecutionToolResultContentParam, content.tool_result + BashCodeExecutionToolResultBlockParamContentParam, + content.tool_result, ), } elif content.tool_name == "text_editor_code_execution": @@ -237,7 +251,7 @@ def _convert_content( "type": "text_editor_code_execution_tool_result", "tool_use_id": content.tool_call_id, "content": cast( - TextEditorCodeExecutionToolResultContentParam, + TextEditorCodeExecutionToolResultBlockParamContentParam, content.tool_result, ), } @@ -368,6 +382,7 @@ def _convert_content( name=cast( Literal[ "web_search", + "code_execution", "bash_code_execution", "text_editor_code_execution", ], @@ -379,6 +394,7 @@ def _convert_content( and tool_call.tool_name in [ "web_search", + "code_execution", "bash_code_execution", "text_editor_code_execution", ] @@ -470,7 +486,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have type="tool_use", id=response.content_block.id, name=response.content_block.name, - input={}, + input=response.content_block.input or {}, ) current_tool_args = "" if response.content_block.name == output_tool: @@ -532,13 +548,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have type="server_tool_use", id=response.content_block.id, name=response.content_block.name, - input={}, + input=response.content_block.input or {}, ) current_tool_args = "" elif isinstance( response.content_block, ( WebSearchToolResultBlock, + CodeExecutionToolResultBlock, BashCodeExecutionToolResultBlock, TextEditorCodeExecutionToolResultBlock, ), @@ -594,13 +611,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have current_tool_block = None continue tool_args = json.loads(current_tool_args) if current_tool_args else {} - current_tool_block["input"] = tool_args + current_tool_block["input"] |= tool_args yield { "tool_calls": [ llm.ToolInput( id=current_tool_block["id"], tool_name=current_tool_block["name"], - tool_args=tool_args, + tool_args=current_tool_block["input"], external=current_tool_block["type"] == "server_tool_use", ) ] @@ -735,19 +752,34 @@ async def _async_handle_chat_log( ] if options.get(CONF_CODE_EXECUTION): - tools.append( - CodeExecutionTool20250825Param( - name="code_execution", - type="code_execution_20250825", - ), - ) + # The `web_search_20260209` tool automatically enables `code_execution_20260120` tool + if model.startswith( + tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS) + ) or not options.get(CONF_WEB_SEARCH): + tools.append( + CodeExecutionTool20250825Param( + name="code_execution", + type="code_execution_20250825", + ), + ) if options.get(CONF_WEB_SEARCH): - web_search = WebSearchTool20250305Param( - name="web_search", - type="web_search_20250305", - max_uses=options.get(CONF_WEB_SEARCH_MAX_USES), - ) + if model.startswith( + tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS) + ) or not options.get(CONF_CODE_EXECUTION): + web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = ( + WebSearchTool20250305Param( + name="web_search", + type="web_search_20250305", + max_uses=options.get(CONF_WEB_SEARCH_MAX_USES), + ) + ) + else: + web_search = WebSearchTool20260209Param( + name="web_search", + type="web_search_20260209", + max_uses=options.get(CONF_WEB_SEARCH_MAX_USES), + ) if options.get(CONF_WEB_SEARCH_USER_LOCATION): web_search["user_location"] = { "type": "approximate", diff --git a/tests/components/anthropic/__init__.py b/tests/components/anthropic/__init__.py index 14af09158fd24f..5cda33064129ae 100644 --- a/tests/components/anthropic/__init__.py +++ b/tests/components/anthropic/__init__.py @@ -1,5 +1,7 @@ """Tests for the Anthropic integration.""" +from typing import Any + from anthropic.types import ( BashCodeExecutionOutputBlock, BashCodeExecutionResultBlock, @@ -7,6 +9,9 @@ BashCodeExecutionToolResultError, BashCodeExecutionToolResultErrorCode, CitationsDelta, + CodeExecutionToolResultBlock, + CodeExecutionToolResultBlockContent, + DirectCaller, InputJSONDelta, RawContentBlockDeltaEvent, RawContentBlockStartEvent, @@ -24,7 +29,9 @@ ToolUseBlock, WebSearchResultBlock, WebSearchToolResultBlock, + WebSearchToolResultError, ) +from anthropic.types.server_tool_use_block import Caller from anthropic.types.text_editor_code_execution_tool_result_block import ( Content as TextEditorCodeExecutionToolResultBlockContent, ) @@ -138,45 +145,58 @@ def create_tool_use_block( def create_server_tool_use_block( - index: int, id: str, name: str, args_parts: list[str] + index: int, + id: str, + name: str, + input_parts: list[str] | dict[str, Any], + caller: Caller | None = None, ) -> list[RawMessageStreamEvent]: """Create a server tool use block.""" + if caller is None: + caller = DirectCaller(type="direct") + return [ RawContentBlockStartEvent( type="content_block_start", content_block=ServerToolUseBlock( - type="server_tool_use", id=id, input={}, name=name + type="server_tool_use", + id=id, + input=input_parts if isinstance(input_parts, dict) else {}, + name=name, + caller=caller, ), index=index, ), *[ RawContentBlockDeltaEvent( - delta=InputJSONDelta(type="input_json_delta", partial_json=args_part), + delta=InputJSONDelta(type="input_json_delta", partial_json=input_part), index=index, type="content_block_delta", ) - for args_part in args_parts + for input_part in (input_parts if isinstance(input_parts, list) else []) ], RawContentBlockStopEvent(index=index, type="content_block_stop"), ] -def create_web_search_block( - index: int, id: str, query_parts: list[str] -) -> list[RawMessageStreamEvent]: - """Create a server tool use block for web search.""" - return create_server_tool_use_block(index, id, "web_search", query_parts) - - def create_web_search_result_block( - index: int, id: str, results: list[WebSearchResultBlock] + index: int, + id: str, + results: list[WebSearchResultBlock] | WebSearchToolResultError, + caller: Caller | None = None, ) -> list[RawMessageStreamEvent]: """Create a server tool result block for web search results.""" + if caller is None: + caller = DirectCaller(type="direct") + return [ RawContentBlockStartEvent( type="content_block_start", content_block=WebSearchToolResultBlock( - type="web_search_tool_result", tool_use_id=id, content=results + type="web_search_tool_result", + tool_use_id=id, + content=results, + caller=caller, ), index=index, ), @@ -184,11 +204,20 @@ def create_web_search_result_block( ] -def create_bash_code_execution_block( - index: int, id: str, command_parts: list[str] +def create_code_execution_result_block( + index: int, id: str, content: CodeExecutionToolResultBlockContent ) -> list[RawMessageStreamEvent]: - """Create a server tool use block for bash code execution.""" - return create_server_tool_use_block(index, id, "bash_code_execution", command_parts) + """Create a server tool result block for code execution results.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=CodeExecutionToolResultBlock( + type="code_execution_tool_result", tool_use_id=id, content=content + ), + index=index, + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] def create_bash_code_execution_result_block( @@ -226,15 +255,6 @@ def create_bash_code_execution_result_block( ] -def create_text_editor_code_execution_block( - index: int, id: str, command_parts: list[str] -) -> list[RawMessageStreamEvent]: - """Create a server tool use block for text editor code execution.""" - return create_server_tool_use_block( - index, id, "text_editor_code_execution", command_parts - ) - - def create_text_editor_code_execution_result_block( index: int, id: str, diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index c6cfb733554cac..94c04c3a01c915 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -215,7 +215,11 @@ async def mock_generator(events: Iterable[RawMessageStreamEvent], **kwargs): isinstance(event, RawContentBlockStartEvent) and isinstance(event.content_block, ServerToolUseBlock) and event.content_block.name - in ["bash_code_execution", "text_editor_code_execution"] + in [ + "code_execution", + "bash_code_execution", + "text_editor_code_execution", + ] ): container = Container( id=kwargs.get("container_id", "container_1234567890ABCDEFGHIJKLMN"), diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 705225cbec2ce5..581a3ea73c649e 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1508,3 +1508,286 @@ }), ]) # --- +# name: test_web_search_dynamic_filtering + list([ + dict({ + 'attachments': None, + 'content': 'Who won the Nobel for Chemistry in 2025?', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': None, + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + ]), + 'container': None, + 'redacted_thinking': None, + 'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + }), + 'role': 'assistant', + 'thinking_content': 'Let me search for this information.', + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT', + 'tool_args': dict({ + 'code': ''' + + import json + result = await web_search({"query": "Nobel Prize chemistry 2025 winner"}) + parsed = json.loads(result) + for r in parsed[:3]: + print(r.get("title", "")) + print(r.get("content", "")[:300]) + print("---") + + ''', + }), + 'tool_name': 'code_execution', + }), + dict({ + 'external': True, + 'id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4', + 'tool_args': dict({ + 'query': 'Nobel Prize chemistry 2025 winner', + }), + 'tool_name': 'web_search', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4', + 'tool_name': 'web_search', + 'tool_result': dict({ + 'content': list([ + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': None, + 'title': 'Press release: Nobel Prize in Chemistry 2025 - Example.com', + 'type': 'web_search_result', + 'url': 'https://www.example.com/prizes/chemistry/2025/press-release/', + }), + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': None, + 'title': 'Nobel Prize in Chemistry 2025 - NewsSite.com', + 'type': 'web_search_result', + 'url': 'https://www.newssite.com/prizes/chemistry/2025/summary/', + }), + ]), + }), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT', + 'tool_name': 'code_execution', + 'tool_result': dict({ + 'content': list([ + ]), + 'encrypted_stdout': 'EuQJCioIDRgCIiRj', + 'return_code': 0, + 'stderr': '', + 'type': 'encrypted_code_execution_result', + }), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'The 2025 Nobel Prize in Chemistry was awarded jointly to **Susumu Kitagawa**, **Richard Robson**, and **Omar M. Yaghi** "for the development of metal–organic frameworks."', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + ]), + 'container': Container(id='container_1234567890ABCDEFGHIJKLMN', expires_at=HAFakeDatetime(2025, 10, 31, 12, 5, tzinfo=datetime.timezone.utc)), + 'redacted_thinking': None, + 'thinking_signature': None, + }), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_web_search_dynamic_filtering.1 + list([ + dict({ + 'content': 'Who won the Nobel for Chemistry in 2025?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': 'Let me search for this information.', + 'type': 'thinking', + }), + dict({ + 'id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT', + 'input': dict({ + 'code': ''' + + import json + result = await web_search({"query": "Nobel Prize chemistry 2025 winner"}) + parsed = json.loads(result) + for r in parsed[:3]: + print(r.get("title", "")) + print(r.get("content", "")[:300]) + print("---") + + ''', + }), + 'name': 'code_execution', + 'type': 'server_tool_use', + }), + dict({ + 'id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4', + 'input': dict({ + 'query': 'Nobel Prize chemistry 2025 winner', + }), + 'name': 'web_search', + 'type': 'server_tool_use', + }), + dict({ + 'content': list([ + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': None, + 'title': 'Press release: Nobel Prize in Chemistry 2025 - Example.com', + 'type': 'web_search_result', + 'url': 'https://www.example.com/prizes/chemistry/2025/press-release/', + }), + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': None, + 'title': 'Nobel Prize in Chemistry 2025 - NewsSite.com', + 'type': 'web_search_result', + 'url': 'https://www.newssite.com/prizes/chemistry/2025/summary/', + }), + ]), + 'tool_use_id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4', + 'type': 'web_search_tool_result', + }), + dict({ + 'content': dict({ + 'content': list([ + ]), + 'encrypted_stdout': 'EuQJCioIDRgCIiRj', + 'return_code': 0, + 'stderr': '', + 'type': 'encrypted_code_execution_result', + }), + 'tool_use_id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT', + 'type': 'code_execution_tool_result', + }), + dict({ + 'text': 'The 2025 Nobel Prize in Chemistry was awarded jointly to **Susumu Kitagawa**, **Richard Robson**, and **Omar M. Yaghi** "for the development of metal–organic frameworks."', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_web_search_error + list([ + dict({ + 'attachments': None, + 'content': "What's on the news today?", + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': "To get today's news, I'll perform a web search", + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + ]), + 'container': None, + 'redacted_thinking': None, + 'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + }), + 'role': 'assistant', + 'thinking_content': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.", + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'srvtoolu_12345ABC', + 'tool_args': dict({ + 'query': "today's news", + }), + 'tool_name': 'web_search', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'srvtoolu_12345ABC', + 'tool_name': 'web_search', + 'tool_result': dict({ + 'error_code': 'too_many_requests', + 'type': 'web_search_tool_result_error', + }), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'I am unable to perform the web search at this time.', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_web_search_error.1 + list([ + dict({ + 'content': "What's on the news today?", + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.", + 'type': 'thinking', + }), + dict({ + 'text': "To get today's news, I'll perform a web search", + 'type': 'text', + }), + dict({ + 'id': 'srvtoolu_12345ABC', + 'input': dict({ + 'query': "today's news", + }), + 'name': 'web_search', + 'type': 'server_tool_use', + }), + dict({ + 'content': dict({ + 'error_code': 'too_many_requests', + 'type': 'web_search_tool_result_error', + }), + 'tool_use_id': 'srvtoolu_12345ABC', + 'type': 'web_search_tool_result', + }), + dict({ + 'text': 'I am unable to perform the web search at this time.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index c753adb5d3628f..eb696b3c953e9c 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -8,7 +8,9 @@ from anthropic.types import ( CitationsWebSearchResultLocation, CitationWebSearchResultLocationParam, + EncryptedCodeExecutionResultBlock, Message, + ServerToolCaller20260120, TextBlock, TextEditorCodeExecutionCreateResultBlock, TextEditorCodeExecutionStrReplaceResultBlock, @@ -16,6 +18,7 @@ TextEditorCodeExecutionViewResultBlock, Usage, WebSearchResultBlock, + WebSearchToolResultError, ) from anthropic.types.text_editor_code_execution_tool_result_block import ( Content as TextEditorCodeExecutionToolResultBlockContent, @@ -51,15 +54,14 @@ from homeassistant.util import ulid as ulid_util from . import ( - create_bash_code_execution_block, create_bash_code_execution_result_block, + create_code_execution_result_block, create_content_block, create_redacted_thinking_block, - create_text_editor_code_execution_block, + create_server_tool_use_block, create_text_editor_code_execution_result_block, create_thinking_block, create_tool_use_block, - create_web_search_block, create_web_search_result_block, ) @@ -864,7 +866,7 @@ async def test_web_search( next(iter(mock_config_entry.subentries.values())), data={ CONF_LLM_HASS_API: llm.LLM_API_ASSIST, - CONF_CHAT_MODEL: "claude-sonnet-4-5", + CONF_CHAT_MODEL: "claude-sonnet-4-0", CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: True, @@ -909,9 +911,10 @@ async def test_web_search( *create_content_block( 1, ["To get today's news, I'll perform a web search"] ), - *create_web_search_block( + *create_server_tool_use_block( 2, "srvtoolu_12345ABC", + "web_search", ["", '{"que', 'ry"', ": \"today's", ' news"}'], ), *create_web_search_result_block(3, "srvtoolu_12345ABC", web_search_results), @@ -984,6 +987,226 @@ async def test_web_search( assert mock_create_stream.call_args.kwargs["messages"] == snapshot +@freeze_time("2025-10-31 12:00:00") +async def test_web_search_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test web search error.""" + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_CHAT_MODEL: "claude-sonnet-4-0", + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_MAX_USES: 5, + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ) + + web_search_results = WebSearchToolResultError( + type="web_search_tool_result_error", + error_code="too_many_requests", + ) + mock_create_stream.return_value = [ + ( + *create_thinking_block( + 0, + [ + "The user is", + " asking about today's news, which", + " requires current, real-time information", + ". This is clearly something that requires recent", + " information beyond my knowledge cutoff.", + " I should use the web", + "_search tool to fin", + "d today's news.", + ], + ), + *create_content_block( + 1, ["To get today's news, I'll perform a web search"] + ), + *create_server_tool_use_block( + 2, + "srvtoolu_12345ABC", + "web_search", + ["", '{"que', 'ry"', ": \"today's", ' news"}'], + ), + *create_web_search_result_block(3, "srvtoolu_12345ABC", web_search_results), + *create_content_block( + 4, + ["I am unable to perform the web search at this time."], + ), + ) + ] + + result = await conversation.async_converse( + hass, + "What's on the news today?", + None, + Context(), + agent_id="conversation.claude_conversation", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot + assert mock_create_stream.call_args.kwargs["messages"] == snapshot + + +@freeze_time("2025-10-31 12:00:00") +async def test_web_search_dynamic_filtering( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test web search with dynamic filtering of the results.""" + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_CHAT_MODEL: "claude-opus-4-6", + CONF_CODE_EXECUTION: True, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_MAX_USES: 5, + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ) + + web_search_results = [ + WebSearchResultBlock( + type="web_search_result", + title="Press release: Nobel Prize in Chemistry 2025 - Example.com", + url="https://www.example.com/prizes/chemistry/2025/press-release/", + page_age=None, + encrypted_content="ABCDEFG", + ), + WebSearchResultBlock( + type="web_search_result", + title="Nobel Prize in Chemistry 2025 - NewsSite.com", + url="https://www.newssite.com/prizes/chemistry/2025/summary/", + page_age=None, + encrypted_content="ABCDEFG", + ), + ] + + content = EncryptedCodeExecutionResultBlock( + type="encrypted_code_execution_result", + content=[], + encrypted_stdout="EuQJCioIDRgCIiRj", + return_code=0, + stderr="", + ) + + mock_create_stream.return_value = [ + ( + *create_thinking_block( + 0, ["Let", " me search", " for this", " information.", ""] + ), + *create_server_tool_use_block( + 1, + "srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT", + "code_execution", + [ + "", + '{"code": "\\nimport', + " json", + "\\nresult", + " = await", + " web", + '_search({\\"', + 'query\\": \\"Nobel Prize chemistry', + " 2025 ", + 'winner\\"})\\nparsed', + " = json.loads(result)", + "\\nfor", + " r", + " in parsed[:", + "3", + "]:\\n print(r.", + 'get(\\"title', + '\\", \\"\\"))', + '\\n print(r.get(\\"', + "content", + '\\", \\"\\")', + "[:300", + '])\\n print(\\"---\\")', + "\\n", + '"}', + ], + ), + *create_server_tool_use_block( + 2, + "srvtoolu_016vjte6G4Lj6yzLc2ak1vY4", + "web_search", + {"query": "Nobel Prize chemistry 2025 winner"}, + caller=ServerToolCaller20260120( + type="code_execution_20260120", + tool_id="srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT", + ), + ), + *create_web_search_result_block( + 3, + "srvtoolu_016vjte6G4Lj6yzLc2ak1vY4", + web_search_results, + caller=ServerToolCaller20260120( + type="code_execution_20260120", + tool_id="srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT", + ), + ), + *create_code_execution_result_block( + 4, "srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT", content + ), + *create_content_block( + 5, + [ + "The ", + "2025 Nobel Prize in Chemistry was", + " awarded jointly to **", + "Susumu Kitagawa**,", + " **", + "Richard Robson**, and **Omar", + ' M. Yaghi** "', + "for the development of metal–organic frameworks", + '."', + ], + ), + ) + ] + + result = await conversation.async_converse( + hass, + "Who won the Nobel for Chemistry in 2025?", + None, + Context(), + agent_id="conversation.claude_conversation", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot + assert mock_create_stream.call_args.kwargs["messages"] == snapshot + + @freeze_time("2025-10-31 12:00:00") async def test_bash_code_execution( hass: HomeAssistant, @@ -1014,9 +1237,10 @@ async def test_bash_code_execution( "tmp/number.txt'.", ], ), - *create_bash_code_execution_block( + *create_server_tool_use_block( 1, "srvtoolu_12345ABC", + "bash_code_execution", [ "", '{"c', @@ -1093,9 +1317,10 @@ async def test_bash_code_execution_error( "tmp/number.txt'.", ], ), - *create_bash_code_execution_block( + *create_server_tool_use_block( 1, "srvtoolu_12345ABC", + "bash_code_execution", [ "", '{"c', @@ -1252,8 +1477,8 @@ async def test_text_editor_code_execution( mock_create_stream.return_value = [ ( *create_content_block(0, ["I'll do it", "."]), - *create_text_editor_code_execution_block( - 1, "srvtoolu_12345ABC", args_parts + *create_server_tool_use_block( + 1, "srvtoolu_12345ABC", "text_editor_code_execution", args_parts ), *create_text_editor_code_execution_result_block( 2, "srvtoolu_12345ABC", content=content @@ -1287,9 +1512,10 @@ async def test_container_reused( """Test that container is reused.""" mock_create_stream.return_value = [ ( - *create_bash_code_execution_block( + *create_server_tool_use_block( 0, "srvtoolu_12345ABC", + "bash_code_execution", ['{"command": "echo $RANDOM"}'], ), *create_bash_code_execution_result_block( From 52af74c3b6cb61d6c94e17d1517ad04955c3ec98 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:49:59 +0200 Subject: [PATCH 0197/1707] Add entity action `html5.send_message` to HTML5 integration (#166349) Co-authored-by: Joostlek --- homeassistant/components/html5/__init__.py | 9 + homeassistant/components/html5/const.py | 13 ++ homeassistant/components/html5/icons.json | 3 + homeassistant/components/html5/issue.py | 31 +++ homeassistant/components/html5/notify.py | 73 ++++-- homeassistant/components/html5/services.py | 82 +++++++ homeassistant/components/html5/services.yaml | 134 +++++++++++ homeassistant/components/html5/strings.json | 112 ++++++++++ tests/components/html5/test_notify.py | 222 +++++++++++++++++-- 9 files changed, 641 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/html5/issue.py create mode 100644 homeassistant/components/html5/services.py diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py index 225379dfa1a914..5cd10a98a273b4 100644 --- a/homeassistant/components/html5/__init__.py +++ b/homeassistant/components/html5/__init__.py @@ -4,14 +4,23 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.EVENT, Platform.NOTIFY] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the HTML5 services.""" + + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up HTML5 from a config entry.""" hass.async_create_task( diff --git a/homeassistant/components/html5/const.py b/homeassistant/components/html5/const.py index a256241b0665f1..cc0aeb282c7ce6 100644 --- a/homeassistant/components/html5/const.py +++ b/homeassistant/components/html5/const.py @@ -11,5 +11,18 @@ REGISTRATIONS_FILE = "html5_push_registrations.conf" ATTR_ACTION = "action" +ATTR_ACTIONS = "actions" +ATTR_BADGE = "badge" ATTR_DATA = "data" +ATTR_DIR = "dir" +ATTR_ICON = "icon" +ATTR_IMAGE = "image" +ATTR_LANG = "lang" +ATTR_RENOTIFY = "renotify" +ATTR_REQUIRE_INTERACTION = "require_interaction" +ATTR_SILENT = "silent" ATTR_TAG = "tag" +ATTR_TIMESTAMP = "timestamp" +ATTR_TTL = "ttl" +ATTR_URGENCY = "urgency" +ATTR_VIBRATE = "vibrate" diff --git a/homeassistant/components/html5/icons.json b/homeassistant/components/html5/icons.json index 4b3fd84b69ff57..e4b738f22b3704 100644 --- a/homeassistant/components/html5/icons.json +++ b/homeassistant/components/html5/icons.json @@ -9,6 +9,9 @@ "services": { "dismiss": { "service": "mdi:bell-off" + }, + "send_message": { + "service": "mdi:message-arrow-right" } } } diff --git a/homeassistant/components/html5/issue.py b/homeassistant/components/html5/issue.py new file mode 100644 index 00000000000000..a12c5e9217d8a5 --- /dev/null +++ b/homeassistant/components/html5/issue.py @@ -0,0 +1,31 @@ +"""Issues for HTML5 integration.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.util import slugify + +from .const import DOMAIN + + +@callback +def deprecated_notify_action_call( + hass: HomeAssistant, target: list[str] | None +) -> None: + """Deprecated action call.""" + + action = ( + f"notify.html5_{slugify(target[0])}" + if target and len(target) == 1 + else "notify.html5" + ) + + async_create_issue( + hass, + DOMAIN, + f"deprecated_notify_action_{action}", + breaks_in_ha_version="2026.11.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_notify_action", + translation_placeholders={"action": action}, + ) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 21d57f7fb8ddd8..5d7989a129ec0e 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -47,7 +47,11 @@ from .const import ( ATTR_ACTION, + ATTR_ACTIONS, + ATTR_REQUIRE_INTERACTION, ATTR_TAG, + ATTR_TIMESTAMP, + ATTR_TTL, ATTR_VAPID_EMAIL, ATTR_VAPID_PRV_KEY, ATTR_VAPID_PUB_KEY, @@ -56,6 +60,7 @@ SERVICE_DISMISS, ) from .entity import HTML5Entity, Registration +from .issue import deprecated_notify_action_call _LOGGER = logging.getLogger(__name__) @@ -69,13 +74,11 @@ ATTR_P256DH = "p256dh" ATTR_EXPIRATIONTIME = "expirationTime" -ATTR_ACTIONS = "actions" ATTR_TYPE = "type" ATTR_URL = "url" ATTR_DISMISS = "dismiss" ATTR_PRIORITY = "priority" DEFAULT_PRIORITY = "normal" -ATTR_TTL = "ttl" DEFAULT_TTL = 86400 DEFAULT_BADGE = "/static/images/notification-badge.png" @@ -465,6 +468,9 @@ async def async_dismiss(self, **kwargs: Any) -> None: async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" + + deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET)) + tag = str(uuid.uuid4()) payload: dict[str, Any] = { "badge": DEFAULT_BADGE, @@ -605,32 +611,53 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity): _key = "device" async def async_send_message(self, message: str, title: str | None = None) -> None: - """Send a message to a device.""" - timestamp = int(time.time()) - tag = str(uuid.uuid4()) + """Send a message to a device via notify.send_message action.""" + await self._webpush( + title=title or ATTR_TITLE_DEFAULT, + message=message, + badge=DEFAULT_BADGE, + icon=DEFAULT_ICON, + ) - payload: dict[str, Any] = { - "badge": DEFAULT_BADGE, - "body": message, - "icon": DEFAULT_ICON, - ATTR_TAG: tag, - ATTR_TITLE: title or ATTR_TITLE_DEFAULT, - "timestamp": timestamp * 1000, - ATTR_DATA: { - ATTR_JWT: add_jwt( - timestamp, - self.target, - tag, - self.registration["subscription"]["keys"]["auth"], - ) - }, - } + async def send_push_notification(self, **kwargs: Any) -> None: + """Send a message to a device via html5.send_message action.""" + await self._webpush(**kwargs) + self._async_record_notification() + + async def _webpush( + self, + message: str | None = None, + timestamp: datetime | None = None, + ttl: timedelta | None = None, + urgency: str | None = None, + **kwargs: Any, + ) -> None: + """Shared internal helper to push messages.""" + payload: dict[str, Any] = kwargs + + if message is not None: + payload["body"] = message + + payload.setdefault(ATTR_TAG, str(uuid.uuid4())) + ts = int(timestamp.timestamp()) if timestamp else int(time.time()) + payload[ATTR_TIMESTAMP] = ts * 1000 + + if ATTR_REQUIRE_INTERACTION in payload: + payload["requireInteraction"] = payload.pop(ATTR_REQUIRE_INTERACTION) + + payload.setdefault(ATTR_DATA, {}) + payload[ATTR_DATA][ATTR_JWT] = add_jwt( + ts, + self.target, + payload[ATTR_TAG], + self.registration["subscription"]["keys"]["auth"], + ) endpoint = urlparse(self.registration["subscription"]["endpoint"]) vapid_claims = { "sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}", "aud": f"{endpoint.scheme}://{endpoint.netloc}", - "exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60), + "exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60), } try: @@ -639,6 +666,8 @@ async def async_send_message(self, message: str, title: str | None = None) -> No json.dumps(payload), self.config_entry.data[ATTR_VAPID_PRV_KEY], vapid_claims, + ttl=int(ttl.total_seconds()) if ttl is not None else DEFAULT_TTL, + headers={"Urgency": urgency} if urgency else None, aiohttp_session=self.session, ) cast(ClientResponse, response).raise_for_status() diff --git a/homeassistant/components/html5/services.py b/homeassistant/components/html5/services.py new file mode 100644 index 00000000000000..40a2e1c311c3ea --- /dev/null +++ b/homeassistant/components/html5/services.py @@ -0,0 +1,82 @@ +"""Service registration for HTML5 integration.""" + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + DOMAIN as NOTIFY_DOMAIN, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import ( + ATTR_ACTION, + ATTR_ACTIONS, + ATTR_BADGE, + ATTR_DIR, + ATTR_ICON, + ATTR_IMAGE, + ATTR_LANG, + ATTR_RENOTIFY, + ATTR_REQUIRE_INTERACTION, + ATTR_SILENT, + ATTR_TAG, + ATTR_TIMESTAMP, + ATTR_TTL, + ATTR_URGENCY, + ATTR_VIBRATE, + DOMAIN, +) + +SERVICE_SEND_MESSAGE = "send_message" + +SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema( + { + vol.Required(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, + vol.Optional(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_DIR): vol.In({"auto", "ltr", "rtl"}), + vol.Optional(ATTR_ICON): cv.string, + vol.Optional(ATTR_BADGE): cv.string, + vol.Optional(ATTR_IMAGE): cv.string, + vol.Optional(ATTR_TAG): cv.string, + vol.Exclusive(ATTR_VIBRATE, "silent_xor_vibrate"): vol.All( + cv.ensure_list, + [vol.All(vol.Coerce(int), vol.Range(min=0))], + ), + vol.Optional(ATTR_TIMESTAMP): cv.datetime, + vol.Optional(ATTR_LANG): cv.language, + vol.Exclusive(ATTR_SILENT, "silent_xor_vibrate"): cv.boolean, + vol.Optional(ATTR_RENOTIFY): cv.boolean, + vol.Optional(ATTR_REQUIRE_INTERACTION): cv.boolean, + vol.Optional(ATTR_URGENCY): vol.In({"normal", "high", "low"}), + vol.Optional(ATTR_TTL): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(ATTR_ACTIONS): vol.All( + cv.ensure_list, + [ + { + vol.Required(ATTR_ACTION): cv.string, + vol.Required(ATTR_TITLE): cv.string, + vol.Optional(ATTR_ICON): cv.string, + } + ], + ), + vol.Optional(ATTR_DATA): dict, + } +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for HTML5 integration.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SEND_MESSAGE, + entity_domain=NOTIFY_DOMAIN, + schema=SERVICE_SEND_MESSAGE_SCHEMA, + func="send_push_notification", + ) diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml index 929eb5a2dc1251..5f42fa7e30bb3d 100644 --- a/homeassistant/components/html5/services.yaml +++ b/homeassistant/components/html5/services.yaml @@ -8,3 +8,137 @@ dismiss: example: '{ "tag": "tagname" }' selector: object: +send_message: + target: + entity: + domain: notify + integration: html5 + fields: + title: + required: true + selector: + text: + example: Home Assistant + default: Home Assistant + message: + required: false + selector: + text: + multiline: true + example: Hello World + icon: + required: false + selector: + text: + type: url + example: /static/icons/favicon-192x192.png + badge: + required: false + selector: + text: + type: url + example: /static/images/notification-badge.png + image: + required: false + selector: + text: + type: url + example: /static/images/image.jpg + tag: + required: false + selector: + text: + example: message-group-1 + actions: + selector: + object: + label_field: "action" + description_field: "title" + multiple: true + translation_key: actions + fields: + action: + required: true + selector: + text: + title: + required: true + selector: + text: + icon: + selector: + text: + type: url + example: '[{"action": "test-action", "title": "🆗 Click here!", "icon": "/images/action-1-128x128.png"}]' + dir: + required: false + selector: + select: + options: + - auto + - ltr + - rtl + mode: dropdown + translation_key: dir + example: auto + renotify: + required: false + selector: + constant: + value: true + label: "" + example: true + silent: + required: false + selector: + constant: + value: true + label: "" + example: true + require_interaction: + required: false + selector: + constant: + value: true + label: "" + example: true + vibrate: + required: false + selector: + text: + multiple: true + type: number + suffix: ms + example: "[125,75,125,275,200,275,125,75,125,275,200,600,200,600]" + lang: + required: false + selector: + language: + example: es-419 + timestamp: + required: false + selector: + datetime: + example: "1970-01-01 00:00:00" + ttl: + required: false + selector: + duration: + enable_day: true + example: "{'days': 28}" + urgency: + required: false + selector: + select: + options: + - low + - normal + - high + mode: dropdown + translation_key: urgency + example: normal + data: + required: false + selector: + object: + example: "{'customKey': 'customValue'}" diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index c419451dc2c239..5c4dd18830a1e5 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -48,6 +48,44 @@ "message": "Sending notification to {target} failed due to a request error" } }, + "issues": { + "deprecated_notify_action": { + "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `notify.send_message` or `html5.send_message` actions instead.", + "title": "Detected use of deprecated action {action}" + } + }, + "selector": { + "actions": { + "fields": { + "action": { + "description": "The identifier of the action. This will be sent back to Home Assistant when the user clicks the button.", + "name": "Action identifier" + }, + "icon": { + "description": "URL of an image displayed as the icon for this button.", + "name": "Icon" + }, + "title": { + "description": "The label of the button displayed to the user.", + "name": "Title" + } + } + }, + "dir": { + "options": { + "auto": "[%key:common::state::auto%]", + "ltr": "Left-to-right", + "rtl": "Right-to-left" + } + }, + "urgency": { + "options": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]" + } + } + }, "services": { "dismiss": { "description": "Dismisses an HTML5 notification.", @@ -62,6 +100,80 @@ } }, "name": "Dismiss" + }, + "send_message": { + "description": "Sends a message via HTML5 Push Notifications", + "fields": { + "actions": { + "description": "Adds action buttons to the notification. When the user clicks a button, an event is sent back to Home Assistant. Amount of actions supported may vary between platforms.", + "name": "Action buttons" + }, + "badge": { + "description": "URL or relative path of a small image to replace the browser icon on mobile platforms. Maximum size is 96px by 96px", + "name": "Badge" + }, + "data": { + "description": "Additional custom key-value pairs to include in the payload of the push message. This can be used to include extra information that can be accessed in the notification events.", + "name": "Extra data" + }, + "dir": { + "description": "The direction of the notification's text. Adopts the browser's language setting behavior by default.", + "name": "Text direction" + }, + "icon": { + "description": "URL or relative path of an image to display as the main icon in the notification. Maximum size is 320px by 320px.", + "name": "Icon" + }, + "image": { + "description": "URL or relative path of a larger image to display in the main body of the notification. Experimental support, may not be displayed on all platforms.", + "name": "Image" + }, + "lang": { + "description": "The language of the notification's content.", + "name": "Language" + }, + "message": { + "description": "The message body of the notification.", + "name": "Message" + }, + "renotify": { + "description": "If enabled, the user will be alerted again (sound/vibration) when a notification with the same tag replaces a previous one.", + "name": "Renotify" + }, + "require_interaction": { + "description": "If enabled, the notification will remain active until the user clicks or dismisses it, rather than automatically closing after a few seconds. This provides the same behavior on desktop as on mobile platforms.", + "name": "Require interaction" + }, + "silent": { + "description": "If enabled, the notification will not play sounds or trigger vibration, regardless of the device's notification settings.", + "name": "Silent" + }, + "tag": { + "description": "The identifier of the notification. Sending a new notification with the same tag will replace the existing one. If not specified, a unique tag will be generated for each notification.", + "name": "Tag" + }, + "timestamp": { + "description": "The timestamp of the notification. By default, it uses the time when the notification is sent.", + "name": "Timestamp" + }, + "title": { + "description": "Title for your notification message.", + "name": "Title" + }, + "ttl": { + "description": "Specifies how long the push service should retain the message if the user's browser or device is offline. After this period, the notification expires. A value of 0 means the notification is discarded immediately if the target is not connected. Defaults to 1 day.", + "name": "Time to live" + }, + "urgency": { + "description": "Whether the push service should try to deliver the notification immediately or defer it in accordance with the user's power saving preferences.", + "name": "Urgency" + }, + "vibrate": { + "description": "A vibration pattern to run with the notification. An array of integers representing alternating periods of vibration and silence in milliseconds. For example, [200, 100, 200] would vibrate for 200ms, pause for 100ms, then vibrate for another 200ms.", + "name": "Vibration pattern" + } + }, + "name": "Send message" } } } diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index d7a83b2f66e692..ef978a7045e220 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -3,6 +3,7 @@ from collections.abc import Generator from http import HTTPStatus import json +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch from aiohttp import ClientError @@ -11,9 +12,28 @@ from pywebpush import WebPushException from syrupy.assertion import SnapshotAssertion -from homeassistant.components.html5 import notify as html5 +from homeassistant.components.html5 import DOMAIN, notify as html5 +from homeassistant.components.html5.const import ( + ATTR_ACTIONS, + ATTR_BADGE, + ATTR_DIR, + ATTR_ICON, + ATTR_IMAGE, + ATTR_LANG, + ATTR_RENOTIFY, + ATTR_REQUIRE_INTERACTION, + ATTR_SILENT, + ATTR_TAG, + ATTR_TIMESTAMP, + ATTR_TTL, + ATTR_URGENCY, + ATTR_VIBRATE, +) +from homeassistant.components.html5.notify import ATTR_ACTION, DEFAULT_TTL from homeassistant.components.notify import ( + ATTR_DATA, ATTR_MESSAGE, + ATTR_TARGET, ATTR_TITLE, DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, @@ -27,7 +47,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, snapshot_platform @@ -817,19 +837,16 @@ async def test_send_message( webpush_async.assert_awaited_once() assert webpush_async.await_args - assert webpush_async.await_args.args == ( - { - "endpoint": "https://googleapis.com", - "keys": {"auth": "auth", "p256dh": "p256dh"}, - }, - '{"badge": "/static/images/notification-badge.png", "body": "World", "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Hello", "timestamp": 1234567890000, "data": {"jwt": "JWT"}}', - "h6acSRds8_KR8hT9djD8WucTL06Gfe29XXyZ1KcUjN8", - { - "sub": "mailto:test@example.com", - "aud": "https://googleapis.com", - "exp": 1234611090, - }, - ) + _, payload, _, _ = webpush_async.await_args.args + assert json.loads(payload) == { + "title": "Hello", + "body": "World", + "badge": "/static/images/notification-badge.png", + "icon": "/static/icons/favicon-192x192.png", + "tag": "12345678-1234-5678-1234-567812345678", + "timestamp": 1234567890000, + "data": {"jwt": "JWT"}, + } @pytest.mark.parametrize( @@ -849,6 +866,7 @@ async def test_send_message( ), ], ) +@pytest.mark.parametrize("domain", [NOTIFY_DOMAIN, DOMAIN]) @pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") @pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") async def test_send_message_exceptions( @@ -858,6 +876,7 @@ async def test_send_message_exceptions( load_config: MagicMock, exception: Exception, translation_key: str, + domain: str, ) -> None: """Test sending a message with exceptions.""" load_config.return_value = {"my-desktop": SUBSCRIPTION_1} @@ -872,7 +891,7 @@ async def test_send_message_exceptions( with pytest.raises(HomeAssistantError) as e: await hass.services.async_call( - NOTIFY_DOMAIN, + domain, SERVICE_SEND_MESSAGE, { ATTR_ENTITY_ID: "notify.my_desktop", @@ -963,3 +982,174 @@ async def test_send_message_unavailable( state = hass.states.get("notify.my_desktop") assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("service_data", "expected_payload", "expected_ttl", "expected_headers"), + [ + ({ATTR_MESSAGE: "World"}, {"body": "World"}, DEFAULT_TTL, None), + ( + {ATTR_ICON: "/static/icons/favicon-192x192.png"}, + {"icon": "/static/icons/favicon-192x192.png"}, + DEFAULT_TTL, + None, + ), + ( + {ATTR_BADGE: "/static/images/notification-badge.png"}, + {"badge": "/static/images/notification-badge.png"}, + DEFAULT_TTL, + None, + ), + ( + {ATTR_IMAGE: "/static/images/image.jpg"}, + {"image": "/static/images/image.jpg"}, + DEFAULT_TTL, + None, + ), + ({ATTR_TAG: "message-group-1"}, {"tag": "message-group-1"}, DEFAULT_TTL, None), + ({ATTR_DIR: "rtl"}, {"dir": "rtl"}, DEFAULT_TTL, None), + ({ATTR_RENOTIFY: True}, {"renotify": True}, DEFAULT_TTL, None), + ({ATTR_SILENT: True}, {"silent": True}, DEFAULT_TTL, None), + ( + {ATTR_REQUIRE_INTERACTION: True}, + {"requireInteraction": True}, + DEFAULT_TTL, + None, + ), + ( + {ATTR_VIBRATE: [200, 100, 200]}, + {"vibrate": [200, 100, 200]}, + DEFAULT_TTL, + None, + ), + ({ATTR_LANG: "es-419"}, {"lang": "es-419"}, DEFAULT_TTL, None), + ({ATTR_TIMESTAMP: "1970-01-01 00:00:00"}, {"timestamp": 0}, DEFAULT_TTL, None), + ({ATTR_TTL: {"days": 28}}, {}, 2419200, None), + ({ATTR_TTL: {"seconds": 0}}, {}, 0, None), + ( + {ATTR_URGENCY: "high"}, + {}, + DEFAULT_TTL, + {"Urgency": "high"}, + ), + ( + { + ATTR_ACTIONS: [ + { + ATTR_ACTION: "callback-event", + ATTR_TITLE: "Callback Event", + ATTR_ICON: "/static/icons/favicon-192x192.png", + } + ] + }, + { + "actions": [ + { + "action": "callback-event", + "title": "Callback Event", + "icon": "/static/icons/favicon-192x192.png", + } + ] + }, + DEFAULT_TTL, + None, + ), + ( + {ATTR_DATA: {"customKey": "customValue"}}, + {"data": {"jwt": "JWT", "customKey": "customValue"}}, + DEFAULT_TTL, + None, + ), + ], +) +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_html5_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + webpush_async: AsyncMock, + load_config: MagicMock, + service_data: dict[str, Any], + expected_payload: dict[str, Any], + expected_ttl: int, + expected_headers: dict[str, Any] | None, +) -> None: + """Test sending a message via html5.send_message action.""" + load_config.return_value = {"my-desktop": SUBSCRIPTION_1} + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.my_desktop") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + {ATTR_ENTITY_ID: "notify.my_desktop", ATTR_TITLE: "Hello", **service_data}, + blocking=True, + ) + + state = hass.states.get("notify.my_desktop") + assert state + assert state.state == "2009-02-13T23:31:30+00:00" + + webpush_async.assert_awaited_once() + assert webpush_async.await_args + _, payload, _, _ = webpush_async.await_args.args + assert json.loads(payload) == { + "title": "Hello", + "tag": "12345678-1234-5678-1234-567812345678", + "timestamp": 1234567890000, + "data": {"jwt": "JWT"}, + **expected_payload, + } + + assert webpush_async.await_args.kwargs["ttl"] == expected_ttl + assert webpush_async.await_args.kwargs["headers"] == expected_headers + + +@pytest.mark.parametrize( + ("target", "issue_id"), + [ + (["my-desktop"], "deprecated_notify_action_notify.html5_my_desktop"), + (None, "deprecated_notify_action_notify.html5"), + (["my-desktop", "my-phone"], "deprecated_notify_action_notify.html5"), + ], +) +@pytest.mark.usefixtures("mock_wp", "mock_jwt", "mock_vapid", "mock_uuid") +async def test_deprecation_action_call( + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, + issue_registry: ir.IssueRegistry, + target: list[str] | None, + issue_id: str, +) -> None: + """Test deprecation action call.""" + load_config.return_value = { + "my-desktop": SUBSCRIPTION_1, + "my-phone": SUBSCRIPTION_2, + } + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + NOTIFY_DOMAIN, + DOMAIN, + {ATTR_MESSAGE: "Hello", ATTR_TARGET: target}, + blocking=True, + ) + + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=issue_id, + ) From fd54e45aebf8134f613a4e92de3000071a4b9003 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:51:05 +0200 Subject: [PATCH 0198/1707] Add dynamic device support for UniFi Access door platforms (#166793) --- .../components/unifi_access/binary_sensor.py | 23 +++++++++--- .../components/unifi_access/button.py | 21 ++++++++--- .../components/unifi_access/event.py | 23 +++++++++--- .../components/unifi_access/image.py | 26 ++++++++++--- .../unifi_access/quality_scale.yaml | 2 +- tests/components/unifi_access/test_init.py | 37 +++++++++++++++++++ 6 files changed, 111 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/unifi_access/binary_sensor.py b/homeassistant/components/unifi_access/binary_sensor.py index a59dc4d2b1c881..f8bf2b59065ad0 100644 --- a/homeassistant/components/unifi_access/binary_sensor.py +++ b/homeassistant/components/unifi_access/binary_sensor.py @@ -8,7 +8,7 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator @@ -24,10 +24,23 @@ async def async_setup_entry( ) -> None: """Set up UniFi Access binary sensor entities.""" coordinator = entry.runtime_data - async_add_entities( - UnifiAccessDoorPositionBinarySensor(coordinator, door) - for door in coordinator.data.doors.values() - ) + added_doors: set[str] = set() + + @callback + def _async_add_new_doors() -> None: + new_door_ids = sorted(set(coordinator.data.doors) - added_doors) + if not new_door_ids: + return + async_add_entities( + UnifiAccessDoorPositionBinarySensor( + coordinator, coordinator.data.doors[door_id] + ) + for door_id in new_door_ids + ) + added_doors.update(new_door_ids) + + _async_add_new_doors() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors)) class UnifiAccessDoorPositionBinarySensor(UnifiAccessEntity, BinarySensorEntity): diff --git a/homeassistant/components/unifi_access/button.py b/homeassistant/components/unifi_access/button.py index d1c795006cf682..4527dfb048aafb 100644 --- a/homeassistant/components/unifi_access/button.py +++ b/homeassistant/components/unifi_access/button.py @@ -5,7 +5,7 @@ from unifi_access_api import Door, UnifiAccessError from homeassistant.components.button import ButtonEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,10 +23,21 @@ async def async_setup_entry( ) -> None: """Set up UniFi Access button entities.""" coordinator = entry.runtime_data - async_add_entities( - UnifiAccessUnlockButton(coordinator, door) - for door in coordinator.data.doors.values() - ) + added_doors: set[str] = set() + + @callback + def _async_add_new_doors() -> None: + new_door_ids = sorted(set(coordinator.data.doors) - added_doors) + if not new_door_ids: + return + async_add_entities( + UnifiAccessUnlockButton(coordinator, coordinator.data.doors[door_id]) + for door_id in new_door_ids + ) + added_doors.update(new_door_ids) + + _async_add_new_doors() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors)) class UnifiAccessUnlockButton(UnifiAccessEntity, ButtonEntity): diff --git a/homeassistant/components/unifi_access/event.py b/homeassistant/components/unifi_access/event.py index b13bdce869e7d7..99a8b9b55ef960 100644 --- a/homeassistant/components/unifi_access/event.py +++ b/homeassistant/components/unifi_access/event.py @@ -55,11 +55,24 @@ async def async_setup_entry( ) -> None: """Set up UniFi Access event entities.""" coordinator = entry.runtime_data - async_add_entities( - UnifiAccessEventEntity(coordinator, door, description) - for door in coordinator.data.doors.values() - for description in EVENT_DESCRIPTIONS - ) + added_doors: set[str] = set() + + @callback + def _async_add_new_doors() -> None: + new_door_ids = sorted(set(coordinator.data.doors) - added_doors) + if not new_door_ids: + return + async_add_entities( + UnifiAccessEventEntity( + coordinator, coordinator.data.doors[door_id], description + ) + for door_id in new_door_ids + for description in EVENT_DESCRIPTIONS + ) + added_doors.update(new_door_ids) + + _async_add_new_doors() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors)) class UnifiAccessEventEntity(UnifiAccessEntity, EventEntity): diff --git a/homeassistant/components/unifi_access/image.py b/homeassistant/components/unifi_access/image.py index ccb45ede0c08d1..b2ca2b5d242811 100644 --- a/homeassistant/components/unifi_access/image.py +++ b/homeassistant/components/unifi_access/image.py @@ -8,7 +8,7 @@ from homeassistant.components.image import ImageEntity from homeassistant.const import CONF_VERIFY_SSL -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator @@ -24,10 +24,26 @@ async def async_setup_entry( ) -> None: """Set up image entities for UniFi Access doors.""" coordinator = entry.runtime_data - async_add_entities( - UnifiAccessDoorImageEntity(coordinator, hass, entry.data[CONF_VERIFY_SSL], door) - for door in coordinator.data.doors.values() - ) + added_doors: set[str] = set() + + @callback + def _async_add_new_doors() -> None: + new_door_ids = sorted(set(coordinator.data.doors) - added_doors) + if not new_door_ids: + return + async_add_entities( + UnifiAccessDoorImageEntity( + coordinator, + hass, + entry.data[CONF_VERIFY_SSL], + coordinator.data.doors[door_id], + ) + for door_id in new_door_ids + ) + added_doors.update(new_door_ids) + + _async_add_new_doors() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors)) class UnifiAccessDoorImageEntity(UnifiAccessEntity, ImageEntity): diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index 66dc50f69d2ce9..72d0bb8590da33 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -51,7 +51,7 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: diff --git a/tests/components/unifi_access/test_init.py b/tests/components/unifi_access/test_init.py index 733a72be5f98f2..6d1664c36de0f2 100644 --- a/tests/components/unifi_access/test_init.py +++ b/tests/components/unifi_access/test_init.py @@ -28,6 +28,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from .conftest import _make_door + from tests.common import MockConfigEntry FRONT_DOOR_BINARY_SENSOR = "binary_sensor.front_door" @@ -357,6 +359,41 @@ async def test_ws_location_update_thumbnail_only_no_state( assert hass.states.get(FRONT_DOOR_IMAGE).state != image_state_before +async def test_new_door_entities_created_on_refresh( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that new door entities are added dynamically via coordinator listener.""" + # Verify new door entities do not exist yet + assert not hass.states.get("binary_sensor.garage_door") + assert not hass.states.get("button.garage_door_unlock") + assert not hass.states.get("event.garage_door_doorbell") + assert not hass.states.get("event.garage_door_access") + assert not hass.states.get("image.garage_door_thumbnail") + + # Add a new door to the API response + mock_client.get_doors.return_value = [ + *mock_client.get_doors.return_value, + _make_door("door-003", "Garage Door"), + ] + + # Trigger natural refresh via WebSocket reconnect + on_disconnect = mock_client.start_websocket.call_args[1]["on_disconnect"] + on_connect = mock_client.start_websocket.call_args[1]["on_connect"] + on_disconnect() + await hass.async_block_till_done() + on_connect() + await hass.async_block_till_done() + + # Entities for the new door should now exist + assert hass.states.get("binary_sensor.garage_door") + assert hass.states.get("button.garage_door_unlock") + assert hass.states.get("event.garage_door_doorbell") + assert hass.states.get("event.garage_door_access") + assert hass.states.get("image.garage_door_thumbnail") + + async def test_stale_device_removed_on_refresh( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 13756863f1ad40e7889313fa4daa1410a7c94348 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 20:08:56 +0200 Subject: [PATCH 0199/1707] Rename component to integration in Fail2Ban (#166901) --- homeassistant/components/fail2ban/__init__.py | 2 +- tests/components/fail2ban/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fail2ban/__init__.py b/homeassistant/components/fail2ban/__init__.py index cb2716e581d42c..e6af0b95b2da30 100644 --- a/homeassistant/components/fail2ban/__init__.py +++ b/homeassistant/components/fail2ban/__init__.py @@ -1 +1 @@ -"""The fail2ban component.""" +"""The Fail2Ban integration.""" diff --git a/tests/components/fail2ban/__init__.py b/tests/components/fail2ban/__init__.py index ed1aef1e8337b6..932574f505430e 100644 --- a/tests/components/fail2ban/__init__.py +++ b/tests/components/fail2ban/__init__.py @@ -1 +1 @@ -"""Tests for the fail2ban component.""" +"""Tests for the Fail2Ban integration.""" From 5e443681c3a14af270090405d49e6e401bc6176c Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 30 Mar 2026 21:10:49 +0300 Subject: [PATCH 0200/1707] Add troubleshooting documentation for Anthropic integration (#166766) --- homeassistant/components/anthropic/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml index 39eb1fae8c0dcf..011073a9326139 100644 --- a/homeassistant/components/anthropic/quality_scale.yaml +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -66,7 +66,7 @@ rules: comment: | To write something about what models we support. docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: status: exempt From a2c65b91260b0b8fb141f34a5ca64ba293df66d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 30 Mar 2026 19:12:59 +0100 Subject: [PATCH 0201/1707] Remove checkout requirement from PR review skill (#166902) --- .claude/skills/github-pr-reviewer/SKILL.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.claude/skills/github-pr-reviewer/SKILL.md b/.claude/skills/github-pr-reviewer/SKILL.md index 3d3586eb0f45ce..1a7e09945cdca9 100644 --- a/.claude/skills/github-pr-reviewer/SKILL.md +++ b/.claude/skills/github-pr-reviewer/SKILL.md @@ -5,14 +5,6 @@ description: Review a GitHub pull request and provide feedback comments. Use whe # Review GitHub Pull Request -## Preparation: -- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'. -- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP. - - Do NOT attempt any workarounds. - - Do NOT proceed with the review. - - ALERT about the failure and WAIT for instructions. - - This is a hard requirement - no exceptions. - ## Follow these steps: 1. Use 'gh pr view' to get the PR details and description. 2. Use 'gh pr diff' to see all the changes in the PR. From 78b251e7cb1adb3cde0bd3e30cd7ef29a854184a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 30 Mar 2026 21:20:17 +0200 Subject: [PATCH 0202/1707] Add clean segment support to MQTT vacuum entities (#166794) --- .../components/mqtt/abbreviations.py | 2 + homeassistant/components/mqtt/vacuum.py | 71 +++- tests/components/mqtt/test_vacuum.py | 304 +++++++++++++++++- 3 files changed, 370 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 4cc391e0ca7920..6bd2bb4792311a 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -18,6 +18,8 @@ "bri_stat_t": "brightness_state_topic", "bri_tpl": "brightness_template", "bri_val_tpl": "brightness_value_template", + "cln_segmnts_cmd_t": "clean_segments_command_topic", + "cln_segmnts_cmd_tpl": "clean_segments_command_template", "clr_temp_cmd_tpl": "color_temp_command_template", "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 6896d51ef93c6d..fb1166250f10a5 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -10,12 +10,13 @@ from homeassistant.components import vacuum from homeassistant.components.vacuum import ( ENTITY_ID_FORMAT, + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,13 +28,14 @@ from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC from .entity import MqttEntity, async_setup_entity_entry_helper -from .models import ReceiveMessage +from .models import MqttCommandTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic PARALLEL_UPDATES = 0 FAN_SPEED = "fan_speed" +SEGMENTS = "segments" STATE = "state" STATE_IDLE = "idle" @@ -52,6 +54,8 @@ STATE_CLEANING: VacuumActivity.CLEANING, } +CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic" +CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template" CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES CONF_PAYLOAD_TURN_ON = "payload_turn_on" CONF_PAYLOAD_TURN_OFF = "payload_turn_off" @@ -137,8 +141,22 @@ def services_to_strings( MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" -PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( +def validate_clean_area_config(config: ConfigType) -> ConfigType: + """Validate clean area configuration.""" + if CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config: + return config + if not config.get(CONF_UNIQUE_ID): + raise vol.Invalid( + f"Option `{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` requires `{CONF_UNIQUE_ID}` to be configured" + ) + + return config + + +_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend( { + vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( cv.ensure_list, [cv.string] ), @@ -164,7 +182,10 @@ def services_to_strings( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA) +PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config) +DISCOVERY_SCHEMA = vol.All( + _BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config +) async def async_setup_entry( @@ -191,9 +212,11 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED + _segments: list[Segment] _command_topic: str | None _set_fan_speed_topic: str | None _send_command_topic: str | None + _clean_segments_command_topic: str | None = None _payloads: dict[str, str | None] def __init__( @@ -229,6 +252,14 @@ def _strings_to_services( self._attr_supported_features = _strings_to_services( supported_feature_strings, STRING_TO_SERVICE ) + self._clean_segments_command_topic = config.get( + CONF_CLEAN_SEGMENTS_COMMAND_TOPIC + ) + self._clean_segments_command_template = MqttCommandTemplate( + config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE), + entity=self, + ).async_render + self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] self._command_topic = config.get(CONF_COMMAND_TOPIC) self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) @@ -262,6 +293,24 @@ def _state_message_received(self, msg: ReceiveMessage) -> None: POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None ) del payload[STATE] + if ( + (segments_payload := payload.pop(SEGMENTS, None)) + and self._clean_segments_command_topic is not None + and isinstance(segments_payload, dict) + and ( + segments := [ + Segment(id=segment_id, name=str(segment_name)) + for segment_id, segment_name in segments_payload.items() + ] + ) + ): + self._segments = segments + self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA + if (last_seen := self.last_seen_segments) is not None and { + s.id: s for s in last_seen + } != {s.id: s for s in self._segments}: + self.async_create_segments_issue() + self._update_state_attributes(payload) @callback @@ -277,6 +326,20 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + assert self._clean_segments_command_topic is not None + await self.async_publish_with_config( + self._clean_segments_command_topic, + self._clean_segments_command_template( + json_dumps(segment_ids), {"value": segment_ids} + ), + ) + + async def async_get_segments(self) -> list[Segment]: + """Return the available segments.""" + return self._segments + async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: """Publish a command.""" if self._command_topic is None: diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index ea5d9f8f8e7b85..52ac9ad64c2d46 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -3,7 +3,7 @@ from copy import deepcopy import json from typing import Any -from unittest.mock import patch +from unittest.mock import call, patch import pytest @@ -27,9 +27,15 @@ SERVICE_STOP, VacuumActivity, ) -from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + CONF_NAME, + ENTITY_MATCH_ALL, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, issue_registry as ir from .common import ( help_custom_config, @@ -63,7 +69,11 @@ from tests.common import async_fire_mqtt_message from tests.components.vacuum import common -from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient +from tests.typing import ( + MqttMockHAClientGenerator, + MqttMockPahoClient, + WebSocketGenerator, +) COMMAND_TOPIC = "vacuum/command" SEND_COMMAND_TOPIC = "vacuum/send_command" @@ -82,6 +92,17 @@ } } +CONFIG_CLEAN_SEGMENTS = { + mqtt.DOMAIN: { + vacuum.DOMAIN: { + CONF_NAME: "test", + CONF_STATE_TOPIC: STATE_TOPIC, + "unique_id": "veryunique", + mqttvacuum.CONF_CLEAN_SEGMENTS_COMMAND_TOPIC: "vacuum/clean_segment", + } + } +} + DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} CONFIG_ALL_SERVICES = help_custom_config( @@ -294,6 +315,283 @@ async def test_command_without_command_topic( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS]) +async def test_clean_segments_initial_setup_without_repair_issue( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test initial setup does not fire repair flow after cleanable segments are received.""" + await mqtt_mock_entry() + # Receive a valid state + state = hass.states.get("vacuum.test") + assert state.state == STATE_UNKNOWN + + message = """{ + "battery_level": 54, + "state": "cleaning", + "segments":{ + "1":"Livingroom", + "2":"Kitchen", + "3":"Diningroom" + } + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + await hass.async_block_till_done() + state = hass.states.get("vacuum.test") + assert state.state == VacuumActivity.CLEANING + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + & vacuum.VacuumEntityFeature.CLEAN_AREA + ) + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS]) +async def test_clean_segments_command( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test cleaning segments and repair flow.""" + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + entity_registry.async_get_or_create( + vacuum.DOMAIN, + mqtt.DOMAIN, + "veryunique", + config_entry=config_entry, + suggested_object_id="test", + ) + entity_registry.async_update_entity_options( + "vacuum.test", + vacuum.DOMAIN, + { + "area_mapping": {"Nabu Casa": ["1", "2"]}, + "last_seen_segments": [ + {"id": "1", "name": "Livingroom"}, + {"id": "2", "name": "Kitchen"}, + ], + }, + ) + mqtt_mock = await mqtt_mock_entry() + await hass.async_block_till_done() + message = """{ + "battery_level": 54, + "state": "idle", + "segments":{ + "1":"Livingroom", + "2":"Kitchen" + } + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + await hass.async_block_till_done() + state = hass.states.get("vacuum.test") + assert state.state == VacuumActivity.IDLE + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + & vacuum.VacuumEntityFeature.CLEAN_AREA + ) + + issue_registry = ir.async_get(hass) + # We do not expect a repair flow as the segments did not change + assert len(issue_registry.issues) == 0 + + await common.async_clean_area(hass, ["Nabu Casa"], entity_id="vacuum.test") + assert ( + call("vacuum/clean_segment", '["1","2"]', 0, False) + in mqtt_mock.async_publish.mock_calls + ) + await hass.async_block_till_done() + message = """{ + "battery_level": 54, + "state": "cleaning", + "segments":{ + "1":"Livingroom", + "2":"Kitchen", + "3": "Diningroom" + } + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + await hass.async_block_till_done() + # We expect a repair issue now as the available segments have changed + assert len(issue_registry.issues) == 1 + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"]["segments"] == [ + {"id": "1", "name": "Livingroom", "group": None}, + {"id": "2", "name": "Kitchen", "group": None}, + {"id": "3", "name": "Diningroom", "group": None}, + ] + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + CONFIG_CLEAN_SEGMENTS, + ({"clean_segments_command_template": "{{ ';'.join(value) }}"},), + ) + ], +) +async def test_clean_segments_command_template( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test clean segments with command template.""" + mqtt_mock = await mqtt_mock_entry() + entity_registry.async_update_entity_options( + "vacuum.test", + vacuum.DOMAIN, + { + "area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]}, + "last_seen_segments": [ + {"id": "1", "name": "Livingroom"}, + {"id": "2", "name": "Kitchen"}, + ], + }, + ) + await hass.async_block_till_done() + message = """{ + "battery_level": 54, + "state": "idle", + "segments":{ + "1":"Livingroom", + "2":"Kitchen" + } + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + await hass.async_block_till_done() + state = hass.states.get("vacuum.test") + assert state.state == VacuumActivity.IDLE + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + & vacuum.VacuumEntityFeature.CLEAN_AREA + ) + + await common.async_clean_area( + hass, ["Livingroom", "Kitchen"], entity_id="vacuum.test" + ) + assert ( + call("vacuum/clean_segment", "1;2", 0, False) + in mqtt_mock.async_publish.mock_calls + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "vacuum/get_segments", "entity_id": "vacuum.test"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"]["segments"] == [ + {"id": "1", "name": "Livingroom", "group": None}, + {"id": "2", "name": "Kitchen", "group": None}, + ] + + +@pytest.mark.usefixtures("hass") +@pytest.mark.parametrize( + ("hass_config", "error_message"), + [ + ( + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + "clean_segments_command_topic": "test-topic", + }, + ), + ), + "Option `clean_segments_command_topic` requires `unique_id` to be configured", + ), + ], +) +async def test_clean_segments_config_validation( + mqtt_mock_entry: MqttMockHAClientGenerator, + error_message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test status clean segment config validation.""" + await mqtt_mock_entry() + assert error_message in caplog.text + + +async def test_removing_clean_segments_command_topic_resets_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the clean area feature is reset if the vacuum is reconfigured. + + The `clean_segments_command_topic` is required to support clean area support. + When this option is removed, the clean area feature should be reset. + """ + await mqtt_mock_entry() + + config_with_clean_segments_command_topic = CONFIG_CLEAN_SEGMENTS[mqtt.DOMAIN][ + vacuum.DOMAIN + ] + async_fire_mqtt_message( + hass, + "homeassistant/vacuum/bla/config", + json.dumps(config_with_clean_segments_command_topic), + ) + await hass.async_block_till_done() + message = """{ + "battery_level": 54, + "state": "idle", + "segments":{ + "1":"Livingroom", + "2":"Kitchen" + } + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + await hass.async_block_till_done() + state = hass.states.get("vacuum.test") + assert state.state == VacuumActivity.IDLE + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + & vacuum.VacuumEntityFeature.CLEAN_AREA + ) + + config_without_clean_segments_command_topic = ( + config_with_clean_segments_command_topic.copy() + ) + config_without_clean_segments_command_topic.pop( + mqttvacuum.CONF_CLEAN_SEGMENTS_COMMAND_TOPIC + ) + async_fire_mqtt_message( + hass, + "homeassistant/vacuum/bla/config", + json.dumps(config_without_clean_segments_command_topic), + ) + await hass.async_block_till_done() + message = """{ + "battery_level": 30, + "state": "cleaning", + "segments":{ + "1":"Livingroom", + "2":"Kitchen" + } + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + await hass.async_block_till_done() + state = hass.states.get("vacuum.test") + assert state.state == VacuumActivity.CLEANING + assert not ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + & vacuum.VacuumEntityFeature.CLEAN_AREA + ) + + @pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_status( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator From 58a376e68b2b1b9434b0fe13744a40ce1ab26114 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:23:22 -0400 Subject: [PATCH 0203/1707] Bump victron-ble-ha-parser (#166906) --- homeassistant/components/victron_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json index 85455f039e9f38..3a5ea6222a203e 100644 --- a/homeassistant/components/victron_ble/manifest.json +++ b/homeassistant/components/victron_ble/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["victron-ble-ha-parser==0.6.2"] + "requirements": ["victron-ble-ha-parser==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 58b53164f7a3ce..ee89a230198860 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3234,7 +3234,7 @@ venstarcolortouch==0.21 viaggiatreno_ha==0.2.4 # homeassistant.components.victron_ble -victron-ble-ha-parser==0.6.2 +victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41fae869efec84..96bf949f780a0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2737,7 +2737,7 @@ velbus-aio==2026.2.0 venstarcolortouch==0.21 # homeassistant.components.victron_ble -victron-ble-ha-parser==0.6.2 +victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 From 1c2f583587fb358f622547f5d7a62c3a7985cf03 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 21:33:06 +0200 Subject: [PATCH 0204/1707] Rename component to integration in FortiOS (#166887) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/fortios/__init__.py | 2 +- homeassistant/components/fortios/device_tracker.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fortios/__init__.py b/homeassistant/components/fortios/__init__.py index 873d6c00c65595..873e363643f5db 100644 --- a/homeassistant/components/fortios/__init__.py +++ b/homeassistant/components/fortios/__init__.py @@ -1 +1 @@ -"""Fortinet FortiOS components.""" +"""Fortinet FortiOS integration.""" diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 4360dd031c7581..3ce6d6e902fc5f 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -1,6 +1,6 @@ """Support to use FortiOS device like FortiGate as device tracker. -This component is part of the device_tracker platform. +This FortiOS integration provides a device_tracker platform. """ from __future__ import annotations From c12b7bfd1835f11307703023fb6b08f08e67fdb4 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 21:41:26 +0200 Subject: [PATCH 0205/1707] Rename component to integration in Bitcoin (#166882) --- homeassistant/components/bitcoin/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bitcoin/__init__.py b/homeassistant/components/bitcoin/__init__.py index cfdfb53c04434b..830541f830ea87 100644 --- a/homeassistant/components/bitcoin/__init__.py +++ b/homeassistant/components/bitcoin/__init__.py @@ -1 +1 @@ -"""The bitcoin component.""" +"""The Bitcoin integration.""" From c5b24e94709b8a52db51012176ca04fad2f7370a Mon Sep 17 00:00:00 2001 From: reneboer Date: Mon, 30 Mar 2026 22:34:51 +0200 Subject: [PATCH 0206/1707] Update datetime selector in Renault ac_start action (#166860) --- homeassistant/components/renault/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml index c9f4351a68cf45..98411f1733e696 100644 --- a/homeassistant/components/renault/services.yaml +++ b/homeassistant/components/renault/services.yaml @@ -17,7 +17,7 @@ ac_start: when: example: "2020-05-01T17:45:00" selector: - text: + datetime: ac_cancel: fields: From 073f498c75b373c19f9e9bb35d25e06e049e1547 Mon Sep 17 00:00:00 2001 From: Leon Grave Date: Mon, 30 Mar 2026 23:16:00 +0200 Subject: [PATCH 0207/1707] Add freshr diagnostics (#166912) --- .../components/freshr/diagnostics.py | 34 ++++++++++++ .../components/freshr/quality_scale.yaml | 2 +- .../freshr/snapshots/test_diagnostics.ambr | 53 +++++++++++++++++++ tests/components/freshr/test_diagnostics.py | 25 +++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/freshr/diagnostics.py create mode 100644 tests/components/freshr/snapshots/test_diagnostics.ambr create mode 100644 tests/components/freshr/test_diagnostics.py diff --git a/homeassistant/components/freshr/diagnostics.py b/homeassistant/components/freshr/diagnostics.py new file mode 100644 index 00000000000000..a3f37a9f5cb0c4 --- /dev/null +++ b/homeassistant/components/freshr/diagnostics.py @@ -0,0 +1,34 @@ +"""Diagnostics support for Fresh-r.""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .coordinator import FreshrConfigEntry + +TO_REDACT = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: FreshrConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + runtime_data = entry.runtime_data + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "devices": [ + dataclasses.asdict(device) for device in runtime_data.devices.data.values() + ], + "readings": { + device_id: dataclasses.asdict(coordinator.data) + if coordinator.data is not None + else None + for device_id, coordinator in runtime_data.readings.items() + }, + } diff --git a/homeassistant/components/freshr/quality_scale.yaml b/homeassistant/components/freshr/quality_scale.yaml index c8c60a6330cabf..ffc87c678a8d7d 100644 --- a/homeassistant/components/freshr/quality_scale.yaml +++ b/homeassistant/components/freshr/quality_scale.yaml @@ -41,7 +41,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Integration connects to a cloud service; no local network discovery is possible. diff --git a/tests/components/freshr/snapshots/test_diagnostics.ambr b/tests/components/freshr/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..fce057c54d7180 --- /dev/null +++ b/tests/components/freshr/snapshots/test_diagnostics.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'devices': list([ + dict({ + 'active_from': None, + 'extras': dict({ + }), + 'id': 'SN001', + 'type': 'unknown', + }), + ]), + 'entry': dict({ + 'created_at': '2026-01-01T00:00:00+00:00', + 'data': dict({ + 'password': '**REDACTED**', + 'username': 'test-user', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'freshr', + 'entry_id': '01JKRA6QKPBE00ZZ9BKWDB3CTB', + 'minor_version': 1, + 'modified_at': '2026-01-01T00:00:00+00:00', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'test-user', + 'version': 1, + }), + 'readings': dict({ + 'SN001': dict({ + 'co2': 850, + 'dp': 10.2, + 'extras': dict({ + }), + 'flow': 0.12, + 'hum': 45, + 't1': 21.5, + 't2': 5.3, + 't3': None, + 't4': None, + 'temp': None, + }), + }), + }) +# --- \ No newline at end of file diff --git a/tests/components/freshr/test_diagnostics.py b/tests/components/freshr/test_diagnostics.py new file mode 100644 index 00000000000000..98cca03af22be0 --- /dev/null +++ b/tests/components/freshr/test_diagnostics.py @@ -0,0 +1,25 @@ +"""Test the Fresh-r diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2026-01-01T00:00:00+00:00") +@pytest.mark.usefixtures("init_integration") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 3f9022cd53ca1f177bf8c2a5c57003ceea9076e1 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 23:23:27 +0200 Subject: [PATCH 0208/1707] Rename component to integration in Arris TG2492LG (#166883) --- homeassistant/components/arris_tg2492lg/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/arris_tg2492lg/__init__.py b/homeassistant/components/arris_tg2492lg/__init__.py index c08ddcba48fc50..b5247e5e7d2192 100644 --- a/homeassistant/components/arris_tg2492lg/__init__.py +++ b/homeassistant/components/arris_tg2492lg/__init__.py @@ -1 +1 @@ -"""The Arris TG2492LG component.""" +"""The Arris TG2492LG integration.""" From 5253dc11dc7865ac35fa345c993785103efae640 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 30 Mar 2026 23:42:00 +0200 Subject: [PATCH 0209/1707] Rename component to integration in Linksys Smart Wi-Fi (#166885) --- homeassistant/components/linksys_smart/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/linksys_smart/__init__.py b/homeassistant/components/linksys_smart/__init__.py index 489596c7ec6951..a4bfa1c511b075 100644 --- a/homeassistant/components/linksys_smart/__init__.py +++ b/homeassistant/components/linksys_smart/__init__.py @@ -1 +1 @@ -"""The linksys_smart component.""" +"""The Linksys Smart Wi-Fi integration.""" From 07998de35e41dd9918602b2992dccf788a294e4f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:05:37 +0200 Subject: [PATCH 0210/1707] Bump aiontfy to 0.8.4 (#166917) --- homeassistant/components/ntfy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index dda80fef2574ca..f033f1e836961c 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aiontfy"], "quality_scale": "platinum", - "requirements": ["aiontfy==0.8.3"] + "requirements": ["aiontfy==0.8.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee89a230198860..08e7466843c930 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -339,7 +339,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.3 +aiontfy==0.8.4 # homeassistant.components.nut aionut==4.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96bf949f780a0b..fa53a9cf01b38b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -324,7 +324,7 @@ aionanoleaf2==1.0.2 aionotion==2024.03.0 # homeassistant.components.ntfy -aiontfy==0.8.3 +aiontfy==0.8.4 # homeassistant.components.nut aionut==4.3.4 From e164e65217c9ebe14cb4288f182a495a4bcdc331 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 31 Mar 2026 01:35:41 -0400 Subject: [PATCH 0211/1707] Use aiohasupervisor for all Supervisor service calls (#166558) --- homeassistant/components/hassio/__init__.py | 265 +---------- .../components/hassio/addon_manager.py | 56 +-- homeassistant/components/hassio/services.py | 439 ++++++++++++++++++ .../components/hassio/websocket_api.py | 2 +- tests/components/hassio/test_init.py | 193 +++++--- 5 files changed, 580 insertions(+), 375 deletions(-) create mode 100644 homeassistant/components/hassio/services.py diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index a65e58a1b1251e..9e1ab66ab821bb 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -3,14 +3,12 @@ from __future__ import annotations import asyncio -from contextlib import suppress from dataclasses import replace from datetime import datetime import logging import os -import re import struct -from typing import Any, NamedTuple, cast +from typing import Any, cast from aiohasupervisor import SupervisorError from aiohasupervisor.models import ( @@ -41,35 +39,23 @@ ) from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_ID, - ATTR_NAME, EVENT_CORE_CONFIG_UPDATE, HASSIO_USER_NAME, SERVER_PORT, Platform, ) -from homeassistant.core import ( - Event, - HassJob, - HomeAssistant, - ServiceCall, - async_get_hass_or_none, - callback, -) -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.core import Event, HassJob, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, issue_registry as ir, - selector, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task -from homeassistant.util.dt import now # config_flow, diagnostics, system_health, and entity platforms are imported to # ensure other dependencies that wait for hassio are not waiting @@ -92,19 +78,7 @@ from .config import HassioConfig from .const import ( ADDONS_COORDINATOR, - ATTR_ADDON, - ATTR_ADDONS, - ATTR_APP, - ATTR_APPS, - ATTR_COMPRESSED, - ATTR_FOLDERS, - ATTR_HOMEASSISTANT, - ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, - ATTR_INPUT, - ATTR_LOCATION, - ATTR_PASSWORD, ATTR_REPOSITORIES, - ATTR_SLUG, DATA_ADDONS_LIST, DATA_COMPONENT, DATA_CONFIG_STORE, @@ -118,7 +92,6 @@ DATA_SUPERVISOR_INFO, DOMAIN, HASSIO_UPDATE_INTERVAL, - SupervisorEntityModel, ) from .coordinator import ( HassioDataUpdateCoordinator, @@ -136,15 +109,11 @@ get_supervisor_stats, ) from .discovery import async_setup_discovery_view -from .handler import ( - HassIO, - HassioAPIError, - async_update_diagnostics, - get_supervisor_client, -) +from .handler import HassIO, async_update_diagnostics, get_supervisor_client from .http import HassIOView from .ingress import async_setup_ingress_view from .issues import SupervisorIssues +from .services import async_setup_services from .websocket_api import async_load_websocket_api # Expose the future safe name now so integrations can use it @@ -190,23 +159,6 @@ extra=vol.ALLOW_EXTRA, ) -SERVICE_ADDON_START = "addon_start" -SERVICE_ADDON_STOP = "addon_stop" -SERVICE_ADDON_RESTART = "addon_restart" -SERVICE_ADDON_STDIN = "addon_stdin" -SERVICE_APP_START = "app_start" -SERVICE_APP_STOP = "app_stop" -SERVICE_APP_RESTART = "app_restart" -SERVICE_APP_STDIN = "app_stdin" -SERVICE_HOST_SHUTDOWN = "host_shutdown" -SERVICE_HOST_REBOOT = "host_reboot" -SERVICE_BACKUP_FULL = "backup_full" -SERVICE_BACKUP_PARTIAL = "backup_partial" -SERVICE_RESTORE_FULL = "restore_full" -SERVICE_RESTORE_PARTIAL = "restore_partial" -SERVICE_MOUNT_RELOAD = "mount_reload" - -VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) DEPRECATION_URL = ( "https://www.home-assistant.io/blog/2025/05/22/" @@ -214,148 +166,11 @@ ) -def valid_addon(value: Any) -> str: - """Validate value is a valid addon slug.""" - value = VALID_ADDON_SLUG(value) - hass = async_get_hass_or_none() - - if hass and (addons := get_addons_info(hass)) is not None and value not in addons: - raise vol.Invalid("Not a valid app slug") - return value - - -SCHEMA_NO_DATA = vol.Schema({}) - -SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon}) - -SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( - {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} -) - -SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon}) - -SCHEMA_APP_STDIN = SCHEMA_APP.extend( - {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} -) - -SCHEMA_BACKUP_FULL = vol.Schema( - { - vol.Optional( - ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S") - ): cv.string, - vol.Optional(ATTR_PASSWORD): cv.string, - vol.Optional(ATTR_COMPRESSED): cv.boolean, - vol.Optional(ATTR_LOCATION): vol.All( - cv.string, lambda v: None if v == "/backup" else v - ), - vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean, - } -) - -SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( - { - vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, - vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - # Legacy "addons", "apps" is preferred - vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - } -) - -SCHEMA_RESTORE_FULL = vol.Schema( - { - vol.Required(ATTR_SLUG): cv.slug, - vol.Optional(ATTR_PASSWORD): cv.string, - } -) - -SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( - { - vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, - vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - # Legacy "addons", "apps" is preferred - vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - } -) - -SCHEMA_MOUNT_RELOAD = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector( - selector.DeviceSelectorConfig( - filter=selector.DeviceFilterSelectorConfig( - integration=DOMAIN, - model=SupervisorEntityModel.MOUNT, - ) - ) - ) - } -) - - def _is_32_bit() -> bool: size = struct.calcsize("P") return size * 8 == 32 -class APIEndpointSettings(NamedTuple): - """Settings for API endpoint.""" - - command: str - schema: vol.Schema - timeout: int | None = 60 - pass_data: bool = False - - -MAP_SERVICE_API = { - # Legacy addon services - SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), - SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), - SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), - SERVICE_ADDON_STDIN: APIEndpointSettings( - "/addons/{addon}/stdin", SCHEMA_ADDON_STDIN - ), - # New app services - SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP), - SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP), - SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP), - SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN), - SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA), - SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA), - SERVICE_BACKUP_FULL: APIEndpointSettings( - "/backups/new/full", - SCHEMA_BACKUP_FULL, - None, - True, - ), - SERVICE_BACKUP_PARTIAL: APIEndpointSettings( - "/backups/new/partial", - SCHEMA_BACKUP_PARTIAL, - None, - True, - ), - SERVICE_RESTORE_FULL: APIEndpointSettings( - "/backups/{slug}/restore/full", - SCHEMA_RESTORE_FULL, - None, - True, - ), - SERVICE_RESTORE_PARTIAL: APIEndpointSettings( - "/backups/{slug}/restore/partial", - SCHEMA_RESTORE_PARTIAL, - None, - True, - ), -} - HARDWARE_INTEGRATIONS = { "green": "homeassistant_green", "odroid-c2": "hardkernel", @@ -397,7 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: host = os.environ["SUPERVISOR"] websession = async_get_clientsession(hass) - hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host) + hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host) supervisor_client = get_supervisor_client(hass) try: @@ -510,74 +325,8 @@ async def push_config(_: Event | None) -> None: hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass) issues_task = hass.async_create_task(issues.setup(), eager_start=True) - async def async_service_handler(service: ServiceCall) -> None: - """Handle service calls for Hass.io.""" - api_endpoint = MAP_SERVICE_API[service.service] - - data = service.data.copy() - addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None) - slug = data.pop(ATTR_SLUG, None) - - if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None): - data[ATTR_ADDONS] = addons - - payload = None - - # Pass data to Hass.io API - if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN): - payload = data[ATTR_INPUT] - elif api_endpoint.pass_data: - payload = data - - # Call API - # The exceptions are logged properly in hassio.send_command - with suppress(HassioAPIError): - await hassio.send_command( - api_endpoint.command.format(addon=addon, slug=slug), - payload=payload, - timeout=api_endpoint.timeout, - ) - - for service, settings in MAP_SERVICE_API.items(): - hass.services.async_register( - DOMAIN, service, async_service_handler, schema=settings.schema - ) - - dev_reg = dr.async_get(hass) - - async def async_mount_reload(service: ServiceCall) -> None: - """Handle service calls for Hass.io.""" - coordinator: HassioDataUpdateCoordinator | None = None - - if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="mount_reload_unknown_device_id", - ) - - if ( - device.name is None - or device.model != SupervisorEntityModel.MOUNT - or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None - or coordinator.entry_id not in device.config_entries - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="mount_reload_invalid_device", - ) - - try: - await supervisor_client.mounts.reload_mount(device.name) - except SupervisorError as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="mount_reload_error", - translation_placeholders={"name": device.name, "error": str(error)}, - ) from error - - hass.services.async_register( - DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD - ) + # Register services + async_setup_services(hass, supervisor_client) async def update_info_data(_: datetime | None = None) -> None: """Update last available supervisor information.""" diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index f176967923f481..9a4841b4bc9def 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .handler import HassioAPIError, get_supervisor_client +from .handler import get_supervisor_client type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]] type _ReturnFuncType[_T, **_P, _R] = Callable[ @@ -36,18 +36,15 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R]( error_message: str, - *, - expected_error_type: type[HassioAPIError | SupervisorError] | None = None, ) -> Callable[ [_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R] ]: - """Handle HassioAPIError and raise a specific AddonError.""" - error_type = expected_error_type or (HassioAPIError, SupervisorError) + """Handle SupervisorError and raise a specific AddonError.""" - def handle_hassio_api_error( + def handle_supervisor_error( func: _FuncType[_AddonManagerT, _P, _R], ) -> _ReturnFuncType[_AddonManagerT, _P, _R]: - """Handle a HassioAPIError.""" + """Handle a SupervisorError.""" @wraps(func) async def wrapper( @@ -56,7 +53,7 @@ async def wrapper( """Wrap an add-on manager method.""" try: return_value = await func(self, *args, **kwargs) - except error_type as err: + except SupervisorError as err: raise AddonError( f"{error_message.format(addon_name=self.addon_name)}: {err}" ) from err @@ -65,7 +62,7 @@ async def wrapper( return wrapper - return handle_hassio_api_error + return handle_supervisor_error @dataclass @@ -128,10 +125,7 @@ def task_in_progress(self) -> bool: ) ) - @api_error( - "Failed to get the {addon_name} app discovery info", - expected_error_type=SupervisorError, - ) + @api_error("Failed to get the {addon_name} app discovery info") async def async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" discovery_info = next( @@ -148,10 +142,7 @@ async def async_get_addon_discovery_info(self) -> dict: return discovery_info.config - @api_error( - "Failed to get the {addon_name} app info", - expected_error_type=SupervisorError, - ) + @api_error("Failed to get the {addon_name} app info") async def async_get_addon_info(self) -> AddonInfo: """Return and cache manager add-on info.""" addon_store_info = await self._supervisor_client.store.addon_info( @@ -199,19 +190,14 @@ def _async_convert_installed_addon_info( version=addon_info.version, ) - @api_error( - "Failed to set the {addon_name} app options", - expected_error_type=SupervisorError, - ) + @api_error("Failed to set the {addon_name} app options") async def async_set_addon_options(self, config: dict) -> None: """Set manager add-on options.""" await self._supervisor_client.addons.set_addon_options( self.addon_slug, AddonsOptions(config=config) ) - @api_error( - "Failed to install the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to install the {addon_name} app") async def async_install_addon(self) -> None: """Install the managed add-on.""" try: @@ -221,10 +207,7 @@ async def async_install_addon(self) -> None: f"{self.addon_name} app is not available: {err!s}" ) from None - @api_error( - "Failed to uninstall the {addon_name} app", - expected_error_type=SupervisorError, - ) + @api_error("Failed to uninstall the {addon_name} app") async def async_uninstall_addon(self) -> None: """Uninstall the managed add-on.""" await self._supervisor_client.addons.uninstall_addon(self.addon_slug) @@ -259,31 +242,22 @@ async def async_update_addon(self) -> None: self.addon_slug, StoreAddonUpdate(backup=False) ) - @api_error( - "Failed to start the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to start the {addon_name} app") async def async_start_addon(self) -> None: """Start the managed add-on.""" await self._supervisor_client.addons.start_addon(self.addon_slug) - @api_error( - "Failed to restart the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to restart the {addon_name} app") async def async_restart_addon(self) -> None: """Restart the managed add-on.""" await self._supervisor_client.addons.restart_addon(self.addon_slug) - @api_error( - "Failed to stop the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to stop the {addon_name} app") async def async_stop_addon(self) -> None: """Stop the managed add-on.""" await self._supervisor_client.addons.stop_addon(self.addon_slug) - @api_error( - "Failed to create a backup of the {addon_name} app", - expected_error_type=SupervisorError, - ) + @api_error("Failed to create a backup of the {addon_name} app") async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None: """Create a partial backup of the managed add-on.""" if addon_info: diff --git a/homeassistant/components/hassio/services.py b/homeassistant/components/hassio/services.py new file mode 100644 index 00000000000000..bd9076141d9c7d --- /dev/null +++ b/homeassistant/components/hassio/services.py @@ -0,0 +1,439 @@ +"""Set up Supervisor services.""" + +from collections.abc import Awaitable, Callable +import json +import re +from typing import Any + +from aiohasupervisor import SupervisorClient, SupervisorError +from aiohasupervisor.models import ( + FullBackupOptions, + FullRestoreOptions, + PartialBackupOptions, + PartialRestoreOptions, +) +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + async_get_hass_or_none, + callback, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + selector, +) +from homeassistant.util.dt import now + +from .const import ( + ADDONS_COORDINATOR, + ATTR_ADDON, + ATTR_ADDONS, + ATTR_APP, + ATTR_APPS, + ATTR_COMPRESSED, + ATTR_FOLDERS, + ATTR_HOMEASSISTANT, + ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, + ATTR_INPUT, + ATTR_LOCATION, + ATTR_PASSWORD, + ATTR_SLUG, + DOMAIN, + SupervisorEntityModel, +) +from .coordinator import HassioDataUpdateCoordinator, get_addons_info + +SERVICE_ADDON_START = "addon_start" +SERVICE_ADDON_STOP = "addon_stop" +SERVICE_ADDON_RESTART = "addon_restart" +SERVICE_ADDON_STDIN = "addon_stdin" +SERVICE_APP_START = "app_start" +SERVICE_APP_STOP = "app_stop" +SERVICE_APP_RESTART = "app_restart" +SERVICE_APP_STDIN = "app_stdin" +SERVICE_HOST_SHUTDOWN = "host_shutdown" +SERVICE_HOST_REBOOT = "host_reboot" +SERVICE_BACKUP_FULL = "backup_full" +SERVICE_BACKUP_PARTIAL = "backup_partial" +SERVICE_RESTORE_FULL = "restore_full" +SERVICE_RESTORE_PARTIAL = "restore_partial" +SERVICE_MOUNT_RELOAD = "mount_reload" + + +VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) + + +def valid_addon(value: Any) -> str: + """Validate value is a valid addon slug.""" + value = VALID_ADDON_SLUG(value) + hass = async_get_hass_or_none() + + if hass and (addons := get_addons_info(hass)) is not None and value not in addons: + raise vol.Invalid("Not a valid app slug") + return value + + +SCHEMA_NO_DATA = vol.Schema({}) + +SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon}) + +SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( + {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} +) + +SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon}) + +SCHEMA_APP_STDIN = SCHEMA_APP.extend( + {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} +) + +SCHEMA_BACKUP_FULL = vol.Schema( + { + vol.Optional( + ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S") + ): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, + vol.Optional(ATTR_COMPRESSED): cv.boolean, + vol.Optional(ATTR_LOCATION): vol.All( + cv.string, lambda v: None if v == "/backup" else v + ), + vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean, + } +) + +SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( + { + vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, + vol.Optional(ATTR_FOLDERS): vol.All( + cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set) + ), + vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + # Legacy "addons", "apps" is preferred + vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + } +) + +SCHEMA_RESTORE_FULL = vol.Schema( + { + vol.Required(ATTR_SLUG): cv.slug, + vol.Optional(ATTR_PASSWORD): cv.string, + } +) + +SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( + { + vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, + vol.Optional(ATTR_FOLDERS): vol.All( + cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set) + ), + vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + # Legacy "addons", "apps" is preferred + vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + } +) + +SCHEMA_MOUNT_RELOAD = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector( + selector.DeviceSelectorConfig( + filter=selector.DeviceFilterSelectorConfig( + integration=DOMAIN, + model=SupervisorEntityModel.MOUNT, + ) + ) + ) + } +) + + +@callback +def async_setup_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register the Supervisor services.""" + async_register_app_services(hass, supervisor_client) + async_register_host_services(hass, supervisor_client) + async_register_backup_restore_services(hass, supervisor_client) + async_register_network_storage_services(hass, supervisor_client) + + +@callback +def async_register_app_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register app services.""" + simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = { + SERVICE_APP_START: ("start", supervisor_client.addons.start_addon), + SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon), + SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon), + } + + async def async_simple_app_service_handler(service: ServiceCall) -> None: + """Handles app services which only take a slug and have no response.""" + action, api_method = simple_app_services[service.service] + app_slug = service.data[ATTR_APP] + + try: + await api_method(app_slug) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to {action} app {app_slug}: {err}" + ) from err + + for service in simple_app_services: + hass.services.async_register( + DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP + ) + + async def async_app_stdin_service_handler(service: ServiceCall) -> None: + """Handles app stdin service.""" + app_slug = service.data[ATTR_APP] + data: dict | str = service.data[ATTR_INPUT] + + # For backwards compatibility the payload here must be valid json + # This is sensible when a dictionary is provided, it must be serialized + # If user provides a string though, we wrap it in quotes before encoding + # This is purely for legacy reasons, Supervisor has no json requirement + # Supervisor just hands the raw request as binary to the container + data = json.dumps(data) + payload = data.encode(encoding="utf-8") + + try: + await supervisor_client.addons.write_addon_stdin(app_slug, payload) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to write stdin to app {app_slug}: {err}" + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_APP_STDIN, + async_app_stdin_service_handler, + schema=SCHEMA_APP_STDIN, + ) + + # LEGACY - Register equivalent addon services for compatibility + simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = { + SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon), + SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon), + SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon), + } + + async def async_simple_addon_service_handler(service: ServiceCall) -> None: + """Handles addon services which only take a slug and have no response.""" + action, api_method = simple_addon_services[service.service] + addon_slug = service.data[ATTR_ADDON] + + try: + await api_method(addon_slug) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to {action} app {addon_slug}: {err}" + ) from err + + for service in simple_addon_services: + hass.services.async_register( + DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON + ) + + async def async_addon_stdin_service_handler(service: ServiceCall) -> None: + """Handles addon stdin service.""" + addon_slug = service.data[ATTR_ADDON] + data: dict | str = service.data[ATTR_INPUT] + + # See explanation for why we make strings into json in async_app_stdin_service_handler + data = json.dumps(data) + payload = data.encode(encoding="utf-8") + + try: + await supervisor_client.addons.write_addon_stdin(addon_slug, payload) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to write stdin to app {addon_slug}: {err}" + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_ADDON_STDIN, + async_addon_stdin_service_handler, + schema=SCHEMA_ADDON_STDIN, + ) + + +@callback +def async_register_host_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register host services.""" + simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = { + SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot), + SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown), + } + + async def async_simple_host_service_handler(service: ServiceCall) -> None: + """Handler for host services that take no input and return no response.""" + action, api_method = simple_host_services[service.service] + try: + await api_method() + except SupervisorError as err: + raise HomeAssistantError(f"Failed to {action} the host: {err}") from err + + for service in simple_host_services: + hass.services.async_register( + DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA + ) + + +@callback +def async_register_backup_restore_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register backup and restore services.""" + + async def async_full_backup_service_handler( + service: ServiceCall, + ) -> ServiceResponse: + """Handler for create full backup service. Returns the new backup's ID.""" + options = FullBackupOptions(**service.data) + try: + backup = await supervisor_client.backups.full_backup(options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to create full backup {options.name}: {err}" + ) from err + + return {"backup": backup.slug} + + hass.services.async_register( + DOMAIN, + SERVICE_BACKUP_FULL, + async_full_backup_service_handler, + schema=SCHEMA_BACKUP_FULL, + supports_response=SupportsResponse.OPTIONAL, + ) + + async def async_partial_backup_service_handler( + service: ServiceCall, + ) -> ServiceResponse: + """Handler for create partial backup service. Returns the new backup's ID.""" + data = service.data.copy() + if ATTR_APPS in data: + data[ATTR_ADDONS] = data.pop(ATTR_APPS) + options = PartialBackupOptions(**data) + + try: + backup = await supervisor_client.backups.partial_backup(options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to create partial backup {options.name}: {err}" + ) from err + + return {"backup": backup.slug} + + hass.services.async_register( + DOMAIN, + SERVICE_BACKUP_PARTIAL, + async_partial_backup_service_handler, + schema=SCHEMA_BACKUP_PARTIAL, + supports_response=SupportsResponse.OPTIONAL, + ) + + async def async_full_restore_service_handler(service: ServiceCall) -> None: + """Handler for full restore service.""" + backup_slug = service.data[ATTR_SLUG] + options: FullRestoreOptions | None = None + if ATTR_PASSWORD in service.data: + options = FullRestoreOptions(password=service.data[ATTR_PASSWORD]) + + try: + await supervisor_client.backups.full_restore(backup_slug, options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to full restore from backup {backup_slug}: {err}" + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_RESTORE_FULL, + async_full_restore_service_handler, + schema=SCHEMA_RESTORE_FULL, + ) + + async def async_partial_restore_service_handler(service: ServiceCall) -> None: + """Handler for partial restore service.""" + data = service.data.copy() + backup_slug = data.pop(ATTR_SLUG) + if ATTR_APPS in data: + data[ATTR_ADDONS] = data.pop(ATTR_APPS) + options = PartialRestoreOptions(**data) + + try: + await supervisor_client.backups.partial_restore(backup_slug, options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to partial restore from backup {backup_slug}: {err}" + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_RESTORE_PARTIAL, + async_partial_restore_service_handler, + schema=SCHEMA_RESTORE_PARTIAL, + ) + + +@callback +def async_register_network_storage_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register network storage (or mount) services.""" + dev_reg = dr.async_get(hass) + + async def async_mount_reload(service: ServiceCall) -> None: + """Handle service calls for Hass.io.""" + coordinator: HassioDataUpdateCoordinator | None = None + + if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mount_reload_unknown_device_id", + ) + + if ( + device.name is None + or device.model != SupervisorEntityModel.MOUNT + or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None + or coordinator.entry_id not in device.config_entries + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mount_reload_invalid_device", + ) + + try: + await supervisor_client.mounts.reload_mount(device.name) + except SupervisorError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="mount_reload_error", + translation_placeholders={"name": device.name, "error": str(error)}, + ) from error + + hass.services.async_register( + DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD + ) diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 534106c4957a1a..21b8dbf8e124fd 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -18,7 +18,6 @@ async_dispatcher_send, ) -from . import HassioAPIError from .config import HassioUpdateParametersDict from .const import ( ATTR_DATA, @@ -40,6 +39,7 @@ WS_TYPE_SUBSCRIBE, ) from .coordinator import get_addons_list +from .handler import HassioAPIError from .update_helper import update_addon, update_core SCHEMA_WEBSOCKET_EVENT = vol.Schema( diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 071d616735d746..7723674d335158 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,7 +5,8 @@ import os from pathlib import PurePath from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, call, patch +from uuid import uuid4 from aiohasupervisor import SupervisorError from aiohasupervisor.models import ( @@ -13,6 +14,7 @@ AddonStage, AddonState, CIFSMountResponse, + FullBackupOptions, HomeAssistantOptions, InstalledAddon, InstalledAddonComplete, @@ -20,6 +22,9 @@ MountState, MountType, MountUsage, + NewBackup, + PartialBackupOptions, + PartialRestoreOptions, SupervisorOptions, ) from freezegun.api import FrozenDateTimeFactory @@ -54,7 +59,6 @@ from homeassistant.util.yaml import load_yaml_dict from tests.common import MockConfigEntry, async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @@ -461,7 +465,6 @@ async def test_service_register(hass: HomeAssistant) -> None: @pytest.mark.freeze_time("2021-11-13 11:48:00") async def test_service_calls( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, supervisor_client: AsyncMock, supervisor_is_connected: AsyncMock, app_or_addon: str, @@ -472,21 +475,7 @@ async def test_service_calls( assert await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() - aioclient_mock.post("http://127.0.0.1/addons/test/start", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/addons/test/restart", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/addons/test/update", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/addons/test/stdin", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/host/shutdown", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/host/reboot", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/backups/new/full", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/backups/new/partial", json={"result": "ok"}) - aioclient_mock.post( - "http://127.0.0.1/backups/test/restore/full", json={"result": "ok"} - ) - aioclient_mock.post( - "http://127.0.0.1/backups/test/restore/partial", json={"result": "ok"} - ) + supervisor_client.reset_mock() await hass.services.async_call( "hassio", f"{app_or_addon}_start", {app_or_addon: "test"} @@ -500,64 +489,90 @@ async def test_service_calls( await hass.services.async_call( "hassio", f"{app_or_addon}_stdin", {app_or_addon: "test", "input": "test"} ) + await hass.services.async_call( + "hassio", + f"{app_or_addon}_stdin", + {app_or_addon: "test", "input": {"hello": "world"}}, + ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 27 - assert aioclient_mock.mock_calls[-1][2] == "test" + supervisor_client.addons.start_addon.assert_called_once_with("test") + supervisor_client.addons.stop_addon.assert_called_once_with("test") + supervisor_client.addons.restart_addon.assert_called_once_with("test") + assert ( + call("test", b'"test"') in supervisor_client.addons.write_addon_stdin.mock_calls + ) + assert ( + call("test", b'{"hello": "world"}') + in supervisor_client.addons.write_addon_stdin.mock_calls + ) await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 29 + supervisor_client.host.shutdown.assert_called_once_with() + supervisor_client.host.reboot.assert_called_once_with() - await hass.services.async_call("hassio", "backup_full", {}) - await hass.services.async_call( + supervisor_client.backups.full_backup.return_value = NewBackup( + job_id=uuid4(), slug="full" + ) + supervisor_client.backups.partial_backup.return_value = NewBackup( + job_id=uuid4(), slug="partial" + ) + + full_backup = await hass.services.async_call( + "hassio", "backup_full", {}, blocking=True, return_response=True + ) + supervisor_client.backups.full_backup.assert_called_once_with( + FullBackupOptions(name="2021-11-13 03:48:00") + ) + assert full_backup == {"backup": "full"} + + partial_backup = await hass.services.async_call( "hassio", "backup_partial", { "homeassistant": True, - "apps": ["test"], + f"{app_or_addon}s": ["test"], "folders": ["ssl"], "password": "123456", }, + blocking=True, + return_response=True, ) - await hass.async_block_till_done() - - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 31 - # API receives "addons" even when we pass "apps" - assert aioclient_mock.mock_calls[-1][2] == { - "name": "2021-11-13 03:48:00", - "homeassistant": True, - "addons": ["test"], - "folders": ["ssl"], - "password": "123456", - } + supervisor_client.backups.partial_backup.assert_called_once_with( + PartialBackupOptions( + name="2021-11-13 03:48:00", + homeassistant=True, + addons={"test"}, + folders={"ssl"}, + password="123456", + ) + ) + assert partial_backup == {"backup": "partial"} await hass.services.async_call("hassio", "restore_full", {"slug": "test"}) - await hass.async_block_till_done() - await hass.services.async_call( "hassio", "restore_partial", { "slug": "test", "homeassistant": False, - "apps": ["test"], + f"{app_or_addon}s": ["test"], "folders": ["ssl"], "password": "123456", }, ) await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 33 - # API receives "addons" even when we pass "apps" - assert aioclient_mock.mock_calls[-1][2] == { - "addons": ["test"], - "folders": ["ssl"], - "homeassistant": False, - "password": "123456", - } + supervisor_client.backups.full_restore.assert_called_once_with("test", None) + supervisor_client.backups.partial_restore.assert_called_once_with( + "test", + PartialRestoreOptions( + homeassistant=False, addons={"test"}, folders={"ssl"}, password="123456" + ), + ) await hass.services.async_call( "hassio", @@ -569,13 +584,13 @@ async def test_service_calls( }, ) await hass.async_block_till_done() - - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 34 - assert aioclient_mock.mock_calls[-1][2] == { - "name": "backup_name", - "location": "backup_share", - "homeassistant_exclude_database": True, - } + supervisor_client.backups.full_backup.assert_called_with( + FullBackupOptions( + name="backup_name", + location="backup_share", + homeassistant_exclude_database=True, + ) + ) await hass.services.async_call( "hassio", @@ -585,12 +600,9 @@ async def test_service_calls( }, ) await hass.async_block_till_done() - - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 35 - assert aioclient_mock.mock_calls[-1][2] == { - "name": "2021-11-13 03:48:00", - "location": None, - } + supervisor_client.backups.full_backup.assert_called_with( + FullBackupOptions(name="2021-11-13 03:48:00", location=None) + ) # check backup with different timezone await hass.config.async_update(time_zone="Europe/London") @@ -604,12 +616,9 @@ async def test_service_calls( }, ) await hass.async_block_till_done() - - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 37 - assert aioclient_mock.mock_calls[-1][2] == { - "name": "2021-11-13 11:48:00", - "location": None, - } + supervisor_client.backups.full_backup.assert_called_with( + FullBackupOptions(name="2021-11-13 11:48:00", location=None) + ) @pytest.mark.parametrize( @@ -673,7 +682,6 @@ async def test_service_calls_apps_addons_exclusive( "app_or_addon", ["app", "addon"], ) -@pytest.mark.usefixtures("aioclient_mock") async def test_addon_service_call_with_complex_slug( hass: HomeAssistant, supervisor_is_connected: AsyncMock, @@ -714,26 +722,22 @@ async def test_addon_service_call_with_complex_slug( @pytest.mark.usefixtures("hassio_env") async def test_service_calls_core( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - supervisor_client: AsyncMock, + hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: """Call core service and check the API calls behind that.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "hassio", {}) - aioclient_mock.post("http://127.0.0.1/homeassistant/restart", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/homeassistant/stop", json={"result": "ok"}) - await hass.services.async_call("homeassistant", "stop") await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + supervisor_client.homeassistant.stop.assert_called_once_with() + assert len(supervisor_client.mock_calls) == 20 await hass.services.async_call("homeassistant", "check_config") await hass.async_block_till_done() - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 20 + assert len(supervisor_client.mock_calls) == 20 with patch( "homeassistant.config.async_check_ha_config_file", return_value=None @@ -742,7 +746,46 @@ async def test_service_calls_core( await hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 21 + supervisor_client.homeassistant.restart.assert_called_once_with() + assert len(supervisor_client.mock_calls) == 21 + + +@pytest.mark.parametrize( + "app_or_addon", + ["apps", "addons"], +) +@pytest.mark.usefixtures("hassio_env", "supervisor_client") +async def test_invalid_service_calls_app_duplicates( + hass: HomeAssistant, app_or_addon: str +) -> None: + """Test invalid backup/restore service calls due to duplicates in apps list.""" + assert await async_setup_component(hass, "hassio", {}) + + with pytest.raises(Invalid, match="contains duplicate items"): + await hass.services.async_call( + "hassio", "backup_partial", {app_or_addon: ["test", "test"]} + ) + + with pytest.raises(Invalid, match="contains duplicate items"): + await hass.services.async_call( + "hassio", "restore_partial", {app_or_addon: ["test", "test"]} + ) + + +@pytest.mark.usefixtures("hassio_env", "supervisor_client") +async def test_invalid_service_calls_folder_duplicates(hass: HomeAssistant) -> None: + """Test invalid backup/restore service calls due to duplicates in folder list.""" + assert await async_setup_component(hass, "hassio", {}) + + with pytest.raises(Invalid, match="contains duplicate items"): + await hass.services.async_call( + "hassio", "backup_partial", {"folders": ["ssl", "ssl"]} + ) + + with pytest.raises(Invalid, match="contains duplicate items"): + await hass.services.async_call( + "hassio", "restore_partial", {"folders": ["ssl", "ssl"]} + ) @pytest.mark.usefixtures("addon_installed") From 9e20a13936f30a66da6d8fc305d387300de0a31d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 31 Mar 2026 15:53:15 +1000 Subject: [PATCH 0212/1707] Fix Tesla Fleet startup scopes after OAuth refresh (#166922) --- .../components/tesla_fleet/__init__.py | 14 +++++++--- tests/components/tesla_fleet/test_init.py | 26 ++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index f1acf192a32747..5ea9ebc040f69a 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -16,7 +16,7 @@ from tesla_fleet_api.tesla import VehicleFleet from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -121,7 +121,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - ) raise ConfigEntryAuthFailed from e - access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + oauth_session = OAuth2Session(hass, entry, implementation) + try: + await oauth_session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed from err + except OAuth2TokenRequestError as err: + raise ConfigEntryNotReady from err + + access_token = oauth_session.token[CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) token = jwt.decode(access_token, options={"verify_signature": False}) @@ -129,8 +137,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - region_code = token["ou_code"].lower() region = region_code if is_valid_region(region_code) else None - oauth_session = OAuth2Session(hass, entry, implementation) - async def _get_access_token() -> str: await oauth_session.async_ensure_token_valid() token: str = oauth_session.token[CONF_ACCESS_TOKEN] diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 1a2afe768377fa..47d58e1734f4c1 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -19,7 +19,7 @@ VehicleOffline, ) -from homeassistant.components.tesla_fleet.const import DOMAIN +from homeassistant.components.tesla_fleet.const import DOMAIN, SCOPES from homeassistant.components.tesla_fleet.coordinator import ( ENERGY_HISTORY_INTERVAL, ENERGY_INTERVAL, @@ -136,6 +136,30 @@ async def test_oauth_refresh_error( assert normal_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_uses_scopes_from_refreshed_token( + hass: HomeAssistant, + noscope_config_entry: MockConfigEntry, +) -> None: + """Test setup uses scopes from the refreshed OAuth token.""" + refreshed_token = create_config_entry( + expires_at=3600, + scopes=SCOPES, + ).data[CONF_TOKEN] + + noscope_config_entry.data[CONF_TOKEN]["expires_at"] = 0 + + with patch( + "homeassistant.components.tesla_fleet.oauth.TeslaUserImplementation.async_refresh_token", + return_value=refreshed_token, + ) as mock_async_refresh_token: + await setup_platform(hass, noscope_config_entry) + + mock_async_refresh_token.assert_awaited_once() + assert noscope_config_entry.state is ConfigEntryState.LOADED + assert noscope_config_entry.runtime_data.scopes == SCOPES + assert noscope_config_entry.runtime_data.vehicles + + async def test_invalidate_access_token_updates_when_not_expired( hass: HomeAssistant, normal_config_entry: MockConfigEntry, From a71d48085a51f58a938b9e3e3be5d5bd51c920f1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:45:54 +0200 Subject: [PATCH 0213/1707] Bump j178/prek-action from 2.0.0 to 2.0.1 (#166924) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6aa251db25f106..6b3613284820f9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -280,7 +280,7 @@ jobs: echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" echo "::add-matcher::.github/workflows/matchers/codespell.json" - name: Run prek - uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0 + uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1 env: PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor RUFF_OUTPUT_FORMAT: github @@ -301,7 +301,7 @@ jobs: with: persist-credentials: false - name: Run zizmor - uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0 + uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1 with: extra-args: --all-files zizmor From de98bc7dcf913163abd0be18336da1541c2c0b2f Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:05:39 +0200 Subject: [PATCH 0214/1707] Unprefix entity name for entity ID generation (#166900) --- homeassistant/helpers/entity_registry.py | 6 ++ tests/helpers/test_entity_registry.py | 88 ++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 851ab2c8990f30..9665e884a5dccc 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -469,6 +469,7 @@ def _async_get_full_entity_name( original_name: str | None, original_name_unprefixed: str | None | UndefinedType = UNDEFINED, overridden_name: str | None = None, + unprefix_name: bool = False, use_legacy_naming: bool = False, ) -> str: """Get full name for an entity. @@ -500,6 +501,10 @@ def _async_get_full_entity_name( if original_name_unprefixed is not None else original_name ) + elif unprefix_name: + unprefixed_name = _async_strip_prefix_from_entity_name(name, device_name) + if unprefixed_name is not None: + name = unprefixed_name if not name: name = device_name @@ -1235,6 +1240,7 @@ def _async_generate_entity_id( name=name, original_name=object_id_base, overridden_name=suggested_object_id, + unprefix_name=True, ) return self.async_get_available_entity_id( domain, diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 94f330f63e2f11..4ffb819d0e2ad3 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -571,6 +571,94 @@ def test_get_available_entity_id_considers_existing_entities( ) +@pytest.mark.parametrize( + ( + "device_name", + "object_id_base", + "suggested_object_id", + "user_name", + "expected_entity_id", + ), + [ + ( + None, + "My Sensor", + None, + None, + "sensor.my_sensor", + ), + ( + "Living Room", + "Temperature", + None, + None, + "sensor.living_room_temperature", + ), + ( + "Living Room", + "Temperature", + "custom_id", + None, + "sensor.custom_id", + ), + ( + "Living Room", + "Temperature", + "custom_id", + "Humidity", + "sensor.living_room_humidity", + ), + ( + "Living Room", + "Temperature", + None, + "Living Room Sensor", + "sensor.living_room_sensor", + ), + ], +) +def test_regenerate_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_name: str | None, + object_id_base: str | None, + suggested_object_id: str | None, + user_name: str | None, + expected_entity_id: str, +) -> None: + """Test regenerating entity IDs.""" + config_entry = MockConfigEntry(domain="sensor") + config_entry.add_to_hass(hass) + + device_id: str | None = None + if device_name is not None: + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name=device_name, + ) + device_id = device_entry.id + + entry = entity_registry.async_get_or_create( + "sensor", + "test", + "1234", + config_entry=config_entry, + device_id=device_id, + has_entity_name=True, + object_id_base=object_id_base, + original_name=object_id_base, + suggested_object_id=suggested_object_id, + ) + + if user_name is not None: + entry = entity_registry.async_update_entity(entry.entity_id, name=user_name) + + new_entity_id = entity_registry.async_regenerate_entity_id(entry) + assert new_entity_id == expected_entity_id + + def test_is_registered(entity_registry: er.EntityRegistry) -> None: """Test that is_registered works.""" entry = entity_registry.async_get_or_create("light", "hue", "1234") From dc5547d7b69681dce8aa35dd360c1b600c7f9ee3 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:08:21 +0200 Subject: [PATCH 0215/1707] Unprefix entity name for template function (#166899) --- homeassistant/helpers/entity_registry.py | 21 ++++++++++++ homeassistant/helpers/template/__init__.py | 2 +- tests/helpers/template/test_init.py | 37 +++++++++++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 9665e884a5dccc..1bcf61a3cf94d0 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -458,6 +458,27 @@ def write_unavailable_state(self, hass: HomeAssistant) -> None: hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs) +@callback +def async_get_unprefixed_name(hass: HomeAssistant, entry: RegistryEntry) -> str: + """Get the entity name with device name prefix stripped, if applicable.""" + name = entry.name + if name is not None: + if ( + entry.device_id is not None + and (device := dr.async_get(hass).async_get(entry.device_id)) is not None + ): + device_name = device.name_by_user or device.name + unprefixed_name = _async_strip_prefix_from_entity_name(name, device_name) + if unprefixed_name is not None: + return unprefixed_name + return name + + if entry.original_name_unprefixed is not None: + return entry.original_name_unprefixed + + return entry.original_name or "" + + @callback def _async_get_full_entity_name( hass: HomeAssistant, diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index df033135460a21..22a476fb941e29 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -1409,7 +1409,7 @@ def entity_name(hass: HomeAssistant, entity_id: str) -> str | None: """Get the name of an entity from its entity ID.""" ent_reg = er.async_get(hass) if (entry := ent_reg.async_get(entity_id)) is not None: - return entry.name if entry.name is not None else entry.original_name + return er.async_get_unprefixed_name(hass, entry) # Fall back to state for entities without a unique_id (not in the registry) if (state := hass.states.get(entity_id)) is not None: diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 0b24953ea586e9..30846eef202ade 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -33,7 +33,13 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -from homeassistant.helpers import entity, entity_registry as er, template, translation +from homeassistant.helpers import ( + device_registry as dr, + entity, + entity_registry as er, + template, + translation, +) from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps from homeassistant.helpers.template.render_info import ( @@ -810,6 +816,7 @@ def test_if_state_exists(hass: HomeAssistant) -> None: def test_entity_name( hass: HomeAssistant, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test entity_name method.""" assert render(hass, "{{ entity_name('sensor.fake') }}") is None @@ -837,6 +844,34 @@ def test_entity_name( "No Unique ID Light" ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="My Device", + ) + entry2 = entity_registry.async_get_or_create( + "sensor", + "test", + "unique_2", + config_entry=config_entry, + device_id=device_entry.id, + has_entity_name=True, + original_name="Temperature", + ) + assert render(hass, f"{{{{ entity_name('{entry2.entity_id}') }}}}") == ( + "Temperature" + ) + + # Strips device name prefix + entity_registry.async_update_entity( + entry2.entity_id, name="My Device Custom Sensor" + ) + assert render(hass, f"{{{{ entity_name('{entry2.entity_id}') }}}}") == ( + "Custom Sensor" + ) + def test_is_hidden_entity( hass: HomeAssistant, From 7ce32f066876e0b2fcfac57cbdb042c6c44294f6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:27:49 +0200 Subject: [PATCH 0216/1707] Remove unused hass.data[DOMAIN] in nfandroidtv (#166931) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/nfandroidtv/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index bdda0d30356b04..9a2e7da2b0a18a 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1,7 +1,7 @@ """The NFAndroidTV integration.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType @@ -22,8 +22,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NFAndroidTV from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = entry.data[CONF_HOST] hass.async_create_task( discovery.async_load_platform( From 99e80666075939131720abe3062ef1ea5f3456f6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 31 Mar 2026 10:10:41 +0200 Subject: [PATCH 0217/1707] Use async download for translations (#166940) --- script/translations/const.py | 2 +- script/translations/download.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/script/translations/const.py b/script/translations/const.py index 18aa27b3e745b3..ce1cde14f552df 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -4,6 +4,6 @@ CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "v2.6.14" +CLI_2_DOCKER_IMAGE = "v3.1.4" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") diff --git a/script/translations/download.py b/script/translations/download.py index 4ed2d8f045f902..8caf7e3ec587ac 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -40,6 +40,7 @@ def run_download_docker() -> None: "file", "download", CORE_PROJECT_ID, + "--async", "--original-filenames=false", "--replace-breaks=false", "--filter-data", From 075e179972dfa79a475b3a1170e107cb72e31f1b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Mar 2026 10:59:23 +0200 Subject: [PATCH 0218/1707] Make field description optional for non config flows (#166892) --- script/hassfest/translations.py | 8 ++++---- tests/hassfest/test_translations.py | 10 +++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index cd24b42879d6f0..14993dd8df1c9c 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -404,7 +404,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: cv.schema_with_slug_keys( { vol.Required("name"): str, - vol.Required( + vol.Optional( "description" ): translation_value_validator, }, @@ -510,7 +510,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("fields"): cv.schema_with_slug_keys( { vol.Required("name"): str, - vol.Required("description"): translation_value_validator, + vol.Optional("description"): translation_value_validator, vol.Optional("example"): translation_value_validator, }, slug_validator=translation_key_validator, @@ -532,7 +532,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("fields"): cv.schema_with_slug_keys( { vol.Required("name"): str, - vol.Required("description"): translation_value_validator, + vol.Optional("description"): translation_value_validator, vol.Optional("example"): translation_value_validator, }, slug_validator=translation_key_validator, @@ -547,7 +547,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("fields"): cv.schema_with_slug_keys( { vol.Required("name"): str, - vol.Required("description"): translation_value_validator, + vol.Optional("description"): translation_value_validator, vol.Optional("example"): translation_value_validator, }, slug_validator=translation_key_validator, diff --git a/tests/hassfest/test_translations.py b/tests/hassfest/test_translations.py index fdd903ab8060ae..b0d79c1e7b8f88 100644 --- a/tests/hassfest/test_translations.py +++ b/tests/hassfest/test_translations.py @@ -166,6 +166,9 @@ def test_string_with_no_placeholders_in_single_quotes() -> None: "name": "Field one", "description": "Description of field one", }, + "field_two": { + "name": "Field two", + }, }, }, "field_old": { @@ -346,7 +349,6 @@ def test_string_with_no_placeholders_in_single_quotes() -> None: }, "target": { "name": "Target", - "description": "The target device", }, }, "sections": { @@ -371,6 +373,9 @@ def test_string_with_no_placeholders_in_single_quotes() -> None: "description": "The entity to check", "example": "light.living_room", }, + "some_option": { + "name": "Some option", + }, }, }, }, @@ -384,6 +389,9 @@ def test_string_with_no_placeholders_in_single_quotes() -> None: "description": "The entity to monitor", "example": "light.living_room", }, + "some_option": { + "name": "Some option", + }, }, }, }, From eda1eb2e35c1002b3cb46ce1c24c24f7e9677a6e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:05:18 +0200 Subject: [PATCH 0219/1707] Migrate notion to use runtime_data (#166936) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/notion/__init__.py | 17 +++++------------ .../components/notion/binary_sensor.py | 8 +++----- homeassistant/components/notion/coordinator.py | 10 ++++++---- homeassistant/components/notion/diagnostics.py | 9 ++++----- homeassistant/components/notion/sensor.py | 9 ++++----- tests/components/notion/conftest.py | 6 +++++- tests/components/notion/test_config_flow.py | 6 +++++- tests/components/notion/test_diagnostics.py | 2 +- 8 files changed, 33 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 79f5d951e7e80d..aef7d740860232 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -9,7 +9,6 @@ from aionotion.errors import InvalidCredentialsError, NotionError from aionotion.listener.models import ListenerKind -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -18,7 +17,6 @@ from .const import ( CONF_REFRESH_TOKEN, CONF_USER_UUID, - DOMAIN, LOGGER, SENSOR_BATTERY, SENSOR_DOOR, @@ -31,7 +29,7 @@ SENSOR_TEMPERATURE, SENSOR_WINDOW_HINGED, ) -from .coordinator import NotionDataUpdateCoordinator +from .coordinator import NotionConfigEntry, NotionDataUpdateCoordinator from .util import async_get_client_with_credentials, async_get_client_with_refresh_token PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -67,7 +65,7 @@ def is_uuid(value: str) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NotionConfigEntry) -> bool: """Set up Notion as a config entry.""" entry_updates: dict[str, Any] = {"data": {**entry.data}} @@ -119,8 +117,7 @@ def async_save_refresh_token(refresh_token: str) -> None: coordinator = NotionDataUpdateCoordinator(hass, entry=entry, client=client) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator @callback def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: @@ -157,10 +154,6 @@ def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NotionConfigEntry) -> bool: """Unload a Notion config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 5552305e867f48..24b60088e6a95d 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -12,13 +12,11 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, LOGGER, SENSOR_BATTERY, SENSOR_DOOR, @@ -30,7 +28,7 @@ SENSOR_SMOKE_CO, SENSOR_WINDOW_HINGED, ) -from .coordinator import NotionDataUpdateCoordinator +from .coordinator import NotionConfigEntry from .entity import NotionEntity, NotionEntityDescription @@ -108,11 +106,11 @@ class NotionBinarySensorDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NotionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Notion sensors based on a config entry.""" - coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/notion/coordinator.py b/homeassistant/components/notion/coordinator.py index d77bfa95f4796d..136644bcfb5142 100644 --- a/homeassistant/components/notion/coordinator.py +++ b/homeassistant/components/notion/coordinator.py @@ -28,10 +28,12 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) +type NotionConfigEntry = ConfigEntry[NotionDataUpdateCoordinator] + @callback def _async_register_new_bridge( - hass: HomeAssistant, entry: ConfigEntry, bridge: Bridge + hass: HomeAssistant, entry: NotionConfigEntry, bridge: Bridge ) -> None: """Register a new bridge.""" if name := bridge.name: @@ -55,7 +57,7 @@ class NotionData: """Define a manager class for Notion data.""" hass: HomeAssistant - entry: ConfigEntry + entry: NotionConfigEntry # Define a dict of bridges, indexed by bridge ID (an integer): bridges: dict[int, Bridge] = field(default_factory=dict) @@ -104,13 +106,13 @@ def asdict(self) -> dict[str, Any]: class NotionDataUpdateCoordinator(DataUpdateCoordinator[NotionData]): """Define a Notion data coordinator.""" - config_entry: ConfigEntry + config_entry: NotionConfigEntry def __init__( self, hass: HomeAssistant, *, - entry: ConfigEntry, + entry: NotionConfigEntry, client: Client, ) -> None: """Initialize.""" diff --git a/homeassistant/components/notion/diagnostics.py b/homeassistant/components/notion/diagnostics.py index 424e5f7d0acc32..7963f7db4ac96b 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -5,12 +5,11 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN -from .coordinator import NotionDataUpdateCoordinator +from .const import CONF_REFRESH_TOKEN, CONF_USER_UUID +from .coordinator import NotionConfigEntry CONF_DEVICE_KEY = "device_key" CONF_HARDWARE_ID = "hardware_id" @@ -34,10 +33,10 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: NotionConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return async_redact_data( { diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 24496c8391ae9e..bae095ad1a4fdd 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -10,13 +10,12 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE -from .coordinator import NotionDataUpdateCoordinator +from .const import SENSOR_MOLD, SENSOR_TEMPERATURE +from .coordinator import NotionConfigEntry from .entity import NotionEntity, NotionEntityDescription @@ -43,11 +42,11 @@ class NotionSensorDescription(SensorEntityDescription, NotionEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NotionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Notion sensors based on a config entry.""" - coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index 6a6e150c9606a3..24b8a46bf7c271 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -10,7 +10,11 @@ from aionotion.user.models import UserPreferences import pytest -from homeassistant.components.notion import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN +from homeassistant.components.notion.const import ( + CONF_REFRESH_TOKEN, + CONF_USER_UUID, + DOMAIN, +) from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index 15c211c19cb35a..371de9bf995a70 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -5,7 +5,11 @@ from aionotion.errors import InvalidCredentialsError, NotionError import pytest -from homeassistant.components.notion import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN +from homeassistant.components.notion.const import ( + CONF_REFRESH_TOKEN, + CONF_USER_UUID, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index c1d1bd1bb2e9e7..ed5191722715de 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Notion diagnostics.""" from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.notion import DOMAIN +from homeassistant.components.notion.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import ANY From 94994769400ba66eb720a4c5356790c641429e35 Mon Sep 17 00:00:00 2001 From: pedroterzero <72469659+pedroterzero@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:10:39 +0200 Subject: [PATCH 0220/1707] Add water_full fault sensor for D825A dehumidifier (#166847) --- .../components/tuya/binary_sensor.py | 8 +++ .../tuya/snapshots/test_binary_sensor.ambr | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 91ae00da39f338..a2ffe563613b8d 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -74,6 +74,14 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): TAMPER_BINARY_SENSOR, ), DeviceCategory.CS: ( + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_water_full", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="water_full", + translation_key="tankfull", + ), TuyaBinarySensorEntityDescription( key="tankfull", dpcode=DPCode.FAULT, diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 5519325b177dfa..2dfccbf14ee0f4 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -253,6 +253,57 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.d825a_i_tank_full-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.d825a_i_tank_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tank full', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank full', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tankfull', + 'unique_id': 'tuya.giqs1xhsekjelfibscfault_water_full', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.d825a_i_tank_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'D825A I Tank full', + }), + 'context': , + 'entity_id': 'binary_sensor.d825a_i_tank_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_defrost-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 9bfac71bd70dcd511b7f2b04a36d37c70f75780f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:10:50 +0200 Subject: [PATCH 0221/1707] Migrate netatmo to use runtime_data (#166925) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/netatmo/__init__.py | 50 ++++++++----------- .../components/netatmo/binary_sensor.py | 5 +- homeassistant/components/netatmo/button.py | 5 +- homeassistant/components/netatmo/camera.py | 5 +- homeassistant/components/netatmo/climate.py | 5 +- .../components/netatmo/config_flow.py | 12 ++--- homeassistant/components/netatmo/const.py | 2 - homeassistant/components/netatmo/cover.py | 5 +- .../components/netatmo/data_handler.py | 14 ++++-- .../components/netatmo/diagnostics.py | 10 ++-- homeassistant/components/netatmo/fan.py | 5 +- homeassistant/components/netatmo/light.py | 5 +- homeassistant/components/netatmo/select.py | 5 +- homeassistant/components/netatmo/sensor.py | 15 ++++-- homeassistant/components/netatmo/switch.py | 5 +- 15 files changed, 66 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index a8e6e52d7d3c9f..62b99eb9b3e797 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -15,7 +15,6 @@ async_register as webhook_register, async_unregister as webhook_unregister, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( @@ -38,12 +37,10 @@ from . import api from .const import ( - AUTH, CONF_CLOUDHOOK_URL, DATA_CAMERAS, DATA_DEVICE_IDS, DATA_EVENTS, - DATA_HANDLER, DATA_HOMES, DATA_PERSONS, DATA_SCHEDULES, @@ -52,7 +49,7 @@ WEBHOOK_DEACTIVATION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import NetatmoDataHandler +from .data_handler import NetatmoConfigEntry, NetatmoDataHandler from .webhook import async_handle_webhook _LOGGER = logging.getLogger(__name__) @@ -76,7 +73,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> bool: """Set up Netatmo from a config entry.""" try: implementation = await async_get_config_entry_implementation(hass, entry) @@ -106,14 +103,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) raise ConfigEntryAuthFailed("Token scope not valid, trigger renewal") - hass.data[DOMAIN][entry.entry_id] = { - AUTH: api.AsyncConfigEntryNetatmoAuth( - aiohttp_client.async_get_clientsession(hass), session - ) - } + auth = api.AsyncConfigEntryNetatmoAuth( + aiohttp_client.async_get_clientsession(hass), session + ) - data_handler = NetatmoDataHandler(hass, entry) - hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] = data_handler + data_handler = NetatmoDataHandler(hass, entry, auth) + entry.runtime_data = data_handler await data_handler.async_setup() async def unregister_webhook( @@ -129,7 +124,7 @@ async def unregister_webhook( ) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) try: - await hass.data[DOMAIN][entry.entry_id][AUTH].async_dropwebhook() + await entry.runtime_data.auth.async_dropwebhook() except pyatmo.ApiError: _LOGGER.debug( "No webhook to be dropped for %s", entry.data[CONF_WEBHOOK_ID] @@ -165,7 +160,7 @@ async def register_webhook( ) try: - await hass.data[DOMAIN][entry.entry_id][AUTH].async_addwebhook(webhook_url) + await entry.runtime_data.auth.async_addwebhook(webhook_url) _LOGGER.debug("Register Netatmo webhook: %s", webhook_url) except pyatmo.ApiError as err: _LOGGER.error("Error during webhook registration - %s", err) @@ -199,7 +194,9 @@ async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: return True -async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: +async def async_cloudhook_generate_url( + hass: HomeAssistant, entry: NetatmoConfigEntry +) -> str: """Generate the full URL for a webhook_id.""" if CONF_CLOUDHOOK_URL not in entry.data: webhook_url = await cloud.async_create_cloudhook( @@ -211,32 +208,27 @@ async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) return str(entry.data[CONF_CLOUDHOOK_URL]) -async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_config_entry_updated( + hass: HomeAssistant, entry: NetatmoConfigEntry +) -> None: """Handle signals of config entry being updated.""" async_dispatcher_send(hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}") -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> bool: """Unload a config entry.""" - data = hass.data[DOMAIN] - if CONF_WEBHOOK_ID in entry.data: webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) try: - await data[entry.entry_id][AUTH].async_dropwebhook() + await entry.runtime_data.auth.async_dropwebhook() except pyatmo.ApiError: _LOGGER.debug("No webhook to be dropped") _LOGGER.debug("Unregister Netatmo webhook") - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok and entry.entry_id in data: - data.pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> None: """Cleanup when entry is removed.""" if CONF_WEBHOOK_ID in entry.data and cloud.async_active_subscription(hass): try: @@ -249,10 +241,10 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: NetatmoConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a config entry from a device.""" - data = hass.data[DOMAIN][config_entry.entry_id][DATA_HANDLER] + data = config_entry.runtime_data modules = [m for h in data.account.homes.values() for m in h.modules] rooms = [r for h in data.account.homes.values() for r in h.rooms] diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index c550c31c4a6c90..21fbff3fc72487 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -13,7 +13,6 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -38,7 +37,7 @@ NETATMO_CREATE_OPENING_BINARY_SENSOR, NETATMO_CREATE_WEATHER_BINARY_SENSOR, ) -from .data_handler import SIGNAL_NAME, NetatmoDevice +from .data_handler import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity, NetatmoWeatherModuleEntity _LOGGER = logging.getLogger(__name__) @@ -180,7 +179,7 @@ class NetatmoBinarySensorEntityDescription(BinarySensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Netatmo weather binary sensors based on a config entry.""" diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index e77b5188067b65..288a7664eb1d4f 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -7,13 +7,12 @@ from pyatmo import modules as NaModules from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) @@ -21,7 +20,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo button platform.""" diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index e0d84784ee8279..b181ebb4af2200 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform @@ -41,7 +40,7 @@ SERVICE_SET_PERSONS_HOME, WEBHOOK_PUSH_TYPE, ) -from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) @@ -51,7 +50,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo camera platform.""" diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index a74ed630a4b718..3d3eb8d449f7b4 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -20,7 +20,6 @@ HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, @@ -54,7 +53,7 @@ SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoRoom +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom from .entity import NetatmoRoomEntity _LOGGER = logging.getLogger(__name__) @@ -120,7 +119,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo energy platform.""" diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index b33d4898832985..812f8fbb3c0554 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -9,12 +9,7 @@ import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_SHOW_ON_MAP, CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv @@ -31,6 +26,7 @@ CONF_WEATHER_AREAS, DOMAIN, ) +from .data_handler import NetatmoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -45,7 +41,7 @@ class NetatmoFlowHandler( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: NetatmoConfigEntry, ) -> OptionsFlow: """Get the options flow for this handler.""" return NetatmoOptionsFlowHandler(config_entry) @@ -99,7 +95,7 @@ async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: class NetatmoOptionsFlowHandler(OptionsFlow): """Handle Netatmo options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: NetatmoConfigEntry) -> None: """Initialize Netatmo options flow.""" self.options = dict(config_entry.options) self.options.setdefault(CONF_WEATHER_AREAS, {}) diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 9a95cd36fed3e8..35ef790684a7ae 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -27,11 +27,9 @@ CONF_URL_CONTROL = "https://home.netatmo.com/control" CONF_URL_PUBLIC_WEATHER = "https://weathermap.netatmo.com/" -AUTH = "netatmo_auth" CONF_PUBLIC = "public_sensor_config" CAMERA_DATA = "netatmo_camera" HOME_DATA = "netatmo_home_data" -DATA_HANDLER = "netatmo_data_handler" SIGNAL_NAME = "signal_name" API_SCOPES_EXCLUDED_FROM_CLOUD = [ diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index a599aacd719ea8..eafc573829d4d8 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -13,13 +13,12 @@ CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) @@ -27,7 +26,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo cover platform.""" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 31845e1c0c7c42..767582249e1587 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -27,7 +27,6 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( - AUTH, CAMERA_CONNECTION_WEBHOOKS, DATA_PERSONS, DATA_SCHEDULES, @@ -89,6 +88,8 @@ } SCAN_INTERVAL = 60 +type NetatmoConfigEntry = ConfigEntry[NetatmoDataHandler] + @dataclass class NetatmoDevice: @@ -138,11 +139,16 @@ class NetatmoDataHandler: account: pyatmo.AsyncAccount _interval_factor: int - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: NetatmoConfigEntry, + auth: pyatmo.AbstractAsyncAuth, + ) -> None: """Initialize self.""" self.hass = hass self.config_entry = config_entry - self._auth = hass.data[DOMAIN][config_entry.entry_id][AUTH] + self.auth = auth self.publisher: dict[str, NetatmoPublisher] = {} self._queue: deque = deque() self._webhook: bool = False @@ -171,7 +177,7 @@ async def async_setup(self) -> None: ) ) - self.account = pyatmo.AsyncAccount(self._auth) + self.account = pyatmo.AsyncAccount(self.auth) await self.subscribe(ACCOUNT, ACCOUNT, None) diff --git a/homeassistant/components/netatmo/diagnostics.py b/homeassistant/components/netatmo/diagnostics.py index 8cb07d1f9d821b..50f58ab1891229 100644 --- a/homeassistant/components/netatmo/diagnostics.py +++ b/homeassistant/components/netatmo/diagnostics.py @@ -5,11 +5,9 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DATA_HANDLER, DOMAIN -from .data_handler import ACCOUNT, NetatmoDataHandler +from .data_handler import ACCOUNT, NetatmoConfigEntry TO_REDACT = { "access_token", @@ -32,12 +30,10 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: NetatmoConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data_handler: NetatmoDataHandler = hass.data[DOMAIN][config_entry.entry_id][ - DATA_HANDLER - ] + data_handler = config_entry.runtime_data return { "info": async_redact_data( diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index b0dc74c2b5827c..aefb47a995b451 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -8,13 +8,12 @@ from pyatmo import modules as NaModules from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) @@ -27,7 +26,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo fan platform.""" diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 4d4c4ba9509454..cd7a688db41cb9 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -8,7 +8,6 @@ from pyatmo import modules as NaModules from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -22,7 +21,7 @@ NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_LIGHT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) @@ -30,7 +29,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo camera light platform.""" diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index cb6675e412979d..ec7d801a4dd2df 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -5,7 +5,6 @@ import logging from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -19,7 +18,7 @@ MANUFACTURER, NETATMO_CREATE_SELECT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoHome +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -27,7 +26,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo energy platform schedule selector.""" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 56b8233912f56a..058d948da6296b 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -16,7 +16,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -45,7 +44,6 @@ CONF_URL_ENERGY, CONF_URL_PUBLIC_WEATHER, CONF_WEATHER_AREAS, - DATA_HANDLER, DOMAIN, NETATMO_CREATE_BATTERY, NETATMO_CREATE_ROOM_SENSOR, @@ -53,7 +51,14 @@ NETATMO_CREATE_WEATHER_SENSOR, SIGNAL_NAME, ) -from .data_handler import HOME, PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom +from .data_handler import ( + HOME, + PUBLIC, + NetatmoConfigEntry, + NetatmoDataHandler, + NetatmoDevice, + NetatmoRoom, +) from .entity import ( NetatmoBaseEntity, NetatmoModuleEntity, @@ -390,7 +395,7 @@ class NetatmoPublicWeatherSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo sensor platform.""" @@ -456,7 +461,7 @@ def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None: ) device_registry = dr.async_get(hass) - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] + data_handler = entry.runtime_data async def add_public_entities(update: bool = True) -> None: """Retrieve Netatmo public weather entities.""" diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 9ee37c11528a5d..4a37acac425fb4 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -8,13 +8,12 @@ from pyatmo import modules as NaModules from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) @@ -22,7 +21,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo switch platform.""" From 751f06eb58933a31fd1f347033a697737b21894e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:11:12 +0200 Subject: [PATCH 0222/1707] Migrate nmap_tracker to use runtime_data (#166932) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/nmap_tracker/__init__.py | 12 ++++++++---- .../components/nmap_tracker/config_flow.py | 8 +++++--- .../components/nmap_tracker/device_tracker.py | 14 +++++++++----- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index fda6ec08b45863..591db22f6a0ddf 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -39,6 +39,8 @@ TRACKER_SCAN_INTERVAL, ) +type NmapTrackerConfigEntry = ConfigEntry[NmapDeviceScanner] + # Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true" MAX_SCAN_ATTEMPTS: Final = 16 @@ -85,23 +87,25 @@ def __init__(self) -> None: _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NmapTrackerConfigEntry) -> bool: """Set up Nmap Tracker from a config entry.""" domain_data = hass.data.setdefault(DOMAIN, {}) devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices()) - scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices) + scanner = NmapDeviceScanner(hass, entry, devices) await scanner.async_setup() + entry.runtime_data = scanner await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: NmapTrackerConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: _async_untrack_devices(hass, entry) - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 7bde59b768ee96..b2c009271e84cb 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -16,7 +16,6 @@ ) from homeassistant.components.network import MDNS_TARGET_IP from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -26,6 +25,7 @@ from homeassistant.helpers.selector import TextSelector, TextSelectorConfig from homeassistant.helpers.typing import VolDictType +from . import NmapTrackerConfigEntry from .const import ( CONF_HOME_INTERVAL, CONF_HOSTS_EXCLUDE, @@ -184,7 +184,7 @@ async def _async_build_schema_with_user_input( class OptionsFlowHandler(OptionsFlowWithReload): """Handle an option flow for nmap tracker.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: NmapTrackerConfigEntry) -> None: """Initialize options flow.""" self.options = dict(config_entry.options) @@ -259,6 +259,8 @@ def _async_is_unique_host_list(self, user_input: dict[str, Any]) -> bool: @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow( + config_entry: NmapTrackerConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index afac3f0643555a..26762577007d77 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -6,24 +6,28 @@ from typing import Any from homeassistant.components.device_tracker import ScannerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NmapDevice, NmapDeviceScanner, short_hostname, signal_device_update -from .const import DOMAIN +from . import ( + NmapDevice, + NmapDeviceScanner, + NmapTrackerConfigEntry, + short_hostname, + signal_device_update, +) _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NmapTrackerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Nmap Tracker component.""" - nmap_tracker = hass.data[DOMAIN][entry.entry_id] + nmap_tracker = entry.runtime_data @callback def device_new(mac_address): From 0edc2cbbab04cc9b0785c31ed31ef07518bb240c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Mar 2026 11:16:18 +0200 Subject: [PATCH 0223/1707] Improve time action naming consistency (#166532) --- homeassistant/components/time/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/time/strings.json b/homeassistant/components/time/strings.json index e22b3b325b86db..463e6c8d1d168b 100644 --- a/homeassistant/components/time/strings.json +++ b/homeassistant/components/time/strings.json @@ -6,14 +6,14 @@ }, "services": { "set_value": { - "description": "Sets the time.", + "description": "Sets the value of a time entity.", "fields": { "time": { "description": "The time to set.", "name": "Time" } }, - "name": "Set Time" + "name": "Set time" } }, "title": "Time" From f3b64dcbe07846e9bc15a18bfca9baf0f0a2c696 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:16:19 +0200 Subject: [PATCH 0224/1707] Migrate nobo_hub to use runtime_data (#166934) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/nobo_hub/__init__.py | 16 +++++++--------- homeassistant/components/nobo_hub/climate.py | 6 +++--- homeassistant/components/nobo_hub/config_flow.py | 4 ++-- homeassistant/components/nobo_hub/select.py | 6 +++--- homeassistant/components/nobo_hub/sensor.py | 6 +++--- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 7c886c534cbb44..8a0b171d5e7458 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -9,12 +9,14 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN +from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] +type NoboHubConfigEntry = ConfigEntry[nobo] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> bool: """Set up Nobø Ecohub from a config entry.""" serial = entry.data[CONF_SERIAL] @@ -29,8 +31,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hub.connect() - hass.data.setdefault(DOMAIN, {}) - async def _async_close(event): """Close the Nobø Ecohub socket connection when HA stops.""" await hub.stop() @@ -38,7 +38,7 @@ async def _async_close(event): entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) ) - hass.data[DOMAIN][entry.entry_id] = hub + entry.runtime_data = hub await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -47,12 +47,10 @@ async def _async_close(event): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> bool: """Unload a config entry.""" - hub: nobo = hass.data[DOMAIN][entry.entry_id] if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await hub.stop() - hass.data[DOMAIN].pop(entry.entry_id) + await entry.runtime_data.stop() return unload_ok diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 018f3e2b06ade5..e0f21e4d549cee 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -17,13 +17,13 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util +from . import NoboHubConfigEntry from .const import ( ATTR_SERIAL, ATTR_TEMP_COMFORT_C, @@ -45,13 +45,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NoboHubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nobø Ecohub platform from UI configuration.""" # Setup connection with hub - hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data override_type = ( nobo.API.OVERRIDE_TYPE_NOW diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 05ece456f15258..7809b66d00e58a 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -18,6 +17,7 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from . import NoboHubConfigEntry from .const import ( CONF_AUTO_DISCOVERED, CONF_OVERRIDE_TYPE, @@ -172,7 +172,7 @@ def _hubs(self): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: NoboHubConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 566ff88abaca07..98d8ffc62950f5 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -5,13 +5,13 @@ from pynobo import nobo from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import NoboHubConfigEntry from .const import ( ATTR_HARDWARE_VERSION, ATTR_SERIAL, @@ -25,13 +25,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NoboHubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" # Setup connection with hub - hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data override_type = ( nobo.API.OVERRIDE_TYPE_NOW diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 6a394f23f4c1dd..a56a02f875e103 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -9,25 +9,25 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODEL, ATTR_NAME, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from . import NoboHubConfigEntry from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NoboHubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" # Setup connection with hub - hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data async_add_entities( NoboTemperatureSensor(component["serial"], hub) From 904a2d1b4d905da152238699a8fea067fb1f39e0 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 31 Mar 2026 11:37:49 +0200 Subject: [PATCH 0225/1707] Remove invalid Matter `HeatingCoolingUnit` device type (#166828) --- homeassistant/components/matter/switch.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 7c125763703b49..8e19d3d13b684b 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -206,7 +206,6 @@ def _update_from_device(self) -> None: device_types.Cooktop, device_types.Dishwasher, device_types.ExtractorHood, - device_types.HeatingCoolingUnit, device_types.LaundryDryer, device_types.LaundryWasher, device_types.Oven, @@ -241,7 +240,6 @@ def _update_from_device(self) -> None: device_types.Dishwasher, device_types.ExtractorHood, device_types.Fan, - device_types.HeatingCoolingUnit, device_types.LaundryDryer, device_types.LaundryWasher, device_types.Oven, From 6c453c8b49c488d9e597d6243e1ca64f8a244e56 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:49:38 +0200 Subject: [PATCH 0226/1707] Register trigger platform upon use (#166911) --- homeassistant/helpers/trigger.py | 29 +++++++++++++------- tests/helpers/test_trigger.py | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 99dd07ac75f738..cde2e3adc67566 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -202,22 +202,28 @@ async def _register_trigger_platform( from homeassistant.components import automation # noqa: PLC0415 new_triggers: set[str] = set() + triggers = hass.data[TRIGGERS] if hasattr(platform, "async_get_triggers"): - for trigger_key in await platform.async_get_triggers(hass): + all_triggers = await platform.async_get_triggers(hass) + for trigger_key in all_triggers: trigger_key = get_absolute_description_key(integration_domain, trigger_key) - hass.data[TRIGGERS][trigger_key] = integration_domain - new_triggers.add(trigger_key) + if trigger_key not in triggers: + triggers[trigger_key] = integration_domain + new_triggers.add(trigger_key) if not new_triggers: - _LOGGER.debug( - "Integration %s returned no triggers in async_get_triggers", - integration_domain, - ) + if not all_triggers: + _LOGGER.debug( + "Integration %s returned no triggers in async_get_triggers", + integration_domain, + ) return elif hasattr(platform, "async_validate_trigger_config") or hasattr( platform, "TRIGGER_SCHEMA" ): - hass.data[TRIGGERS][integration_domain] = integration_domain + if integration_domain in triggers: + return + triggers[integration_domain] = integration_domain new_triggers.add(integration_domain) else: _LOGGER.debug( @@ -1184,12 +1190,17 @@ async def _async_get_trigger_platform( except IntegrationNotFound: raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") from None try: - return platform, await integration.async_get_platform("trigger") + platform_module = await integration.async_get_platform("trigger") except ImportError: raise vol.Invalid( f"Integration '{platform}' does not provide trigger support" ) from None + # Ensure triggers are registered so descriptions can be loaded + await _register_trigger_platform(hass, platform, platform_module) + + return platform, platform_module + async def async_validate_trigger_config( hass: HomeAssistant, trigger_config: list[ConfigType] diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 8ba53241771acc..e9122a20de331e 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -42,6 +42,7 @@ ) from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, + TRIGGERS, EntityNumericalStateChangedTriggerWithUnitBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, EntityTriggerBase, @@ -669,6 +670,51 @@ class MockTriggerPlatform: assert result == config_old_style +async def test_get_trigger_platform_registers_triggers( + hass: HomeAssistant, +) -> None: + """Test _async_get_trigger_platform registers triggers and notifies subscribers.""" + + class MockTrigger(Trigger): + """Mock trigger.""" + + async def async_attach_runner( + self, run_action: TriggerActionRunner + ) -> CALLBACK_TYPE: + return lambda: None + + async def async_get_triggers( + hass: HomeAssistant, + ) -> dict[str, type[Trigger]]: + return {"trig_a": MockTrigger, "trig_b": MockTrigger} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + subscriber_events: list[set[str]] = [] + + async def subscriber(new_triggers: set[str]) -> None: + subscriber_events.append(new_triggers) + + trigger.async_subscribe_platform_events(hass, subscriber) + + assert "test.trig_a" not in hass.data[TRIGGERS] + assert "test.trig_b" not in hass.data[TRIGGERS] + + # First call registers all triggers from the platform and notifies subscribers + await _async_get_trigger_platform(hass, "test.trig_a") + + assert hass.data[TRIGGERS]["test.trig_a"] == "test" + assert hass.data[TRIGGERS]["test.trig_b"] == "test" + assert len(subscriber_events) == 1 + assert subscriber_events[0] == {"test.trig_a", "test.trig_b"} + + # Subsequent calls are idempotent — no re-registration or re-notification + await _async_get_trigger_platform(hass, "test.trig_a") + await _async_get_trigger_platform(hass, "test.trig_b") + assert len(subscriber_events) == 1 + + @pytest.mark.parametrize( "sun_trigger_descriptions", [ From 24e0627b41892cea439fe17e67d83719c2b5608e Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:53:36 +0200 Subject: [PATCH 0227/1707] Register condition platform upon use (#166939) --- homeassistant/helpers/condition.py | 25 +++++++++----- tests/helpers/test_condition.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5cf8df5d36c76d..810b8f40b73270 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -230,19 +230,23 @@ async def _register_condition_platform( from homeassistant.components import automation # noqa: PLC0415 new_conditions: set[str] = set() + conditions = hass.data[CONDITIONS] if hasattr(platform, "async_get_conditions"): - for condition_key in await platform.async_get_conditions(hass): + all_conditions = await platform.async_get_conditions(hass) + for condition_key in all_conditions: condition_key = get_absolute_description_key( integration_domain, condition_key ) - hass.data[CONDITIONS][condition_key] = integration_domain - new_conditions.add(condition_key) + if condition_key not in conditions: + conditions[condition_key] = integration_domain + new_conditions.add(condition_key) if not new_conditions: - _LOGGER.debug( - "Integration %s returned no conditions in async_get_conditions", - integration_domain, - ) + if not all_conditions: + _LOGGER.debug( + "Integration %s returned no conditions in async_get_conditions", + integration_domain, + ) return else: _LOGGER.debug( @@ -821,12 +825,17 @@ async def _async_get_condition_platform( f'Invalid condition "{condition_key}" specified' ) from None try: - return platform, await integration.async_get_platform("condition") + platform_module = await integration.async_get_platform("condition") except ImportError: raise HomeAssistantError( f"Integration '{platform}' does not provide condition support" ) from None + # Ensure conditions are registered so descriptions can be loaded + await _register_condition_platform(hass, platform, platform_module) + + return platform, platform_module + async def _async_get_checker(condition: Condition) -> ConditionCheckerType: new_checker = await condition.async_get_checker() diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index e21a3d048d005d..ab5fe80825c18d 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -48,9 +48,11 @@ ATTR_BEHAVIOR, BEHAVIOR_ALL, BEHAVIOR_ANY, + CONDITIONS, Condition, ConditionChecker, EntityNumericalConditionWithUnitBase, + _async_get_condition_platform, async_validate_condition_config, make_entity_numerical_condition, make_entity_numerical_condition_with_unit, @@ -2276,6 +2278,57 @@ async def test_platform_backwards_compatibility_for_new_style_configs( assert result == config_old_style +async def test_get_condition_platform_registers_conditions( + hass: HomeAssistant, +) -> None: + """Test _async_get_condition_platform registers conditions and notifies subscribers.""" + + class MockCondition(Condition): + """Mock condition.""" + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + return config + + async def async_get_checker(self) -> ConditionChecker: + return lambda **kwargs: True + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[Condition]]: + return {"cond_a": MockCondition, "cond_b": MockCondition} + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + subscriber_events: list[set[str]] = [] + + async def subscriber(new_conditions: set[str]) -> None: + subscriber_events.append(new_conditions) + + condition.async_subscribe_platform_events(hass, subscriber) + + assert "test.cond_a" not in hass.data[CONDITIONS] + assert "test.cond_b" not in hass.data[CONDITIONS] + + # First call registers all conditions from the platform and notifies subscribers + await _async_get_condition_platform(hass, "test.cond_a") + + assert hass.data[CONDITIONS]["test.cond_a"] == "test" + assert hass.data[CONDITIONS]["test.cond_b"] == "test" + assert len(subscriber_events) == 1 + assert subscriber_events[0] == {"test.cond_a", "test.cond_b"} + + # Subsequent calls are idempotent — no re-registration or re-notification + await _async_get_condition_platform(hass, "test.cond_a") + await _async_get_condition_platform(hass, "test.cond_b") + assert len(subscriber_events) == 1 + + @pytest.mark.parametrize("enabled_value", [True, "{{ 1 == 1 }}"]) async def test_enabled_condition( hass: HomeAssistant, enabled_value: bool | str From 51785f10c1f8480aba0db2f87764167026803a9f Mon Sep 17 00:00:00 2001 From: Alex Barcelo Date: Tue, 31 Mar 2026 12:19:07 +0200 Subject: [PATCH 0228/1707] Adjust Thread network diagnostics prefixes to include double colon (#166520) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/thread/diagnostics.py | 8 ++++++-- tests/components/thread/snapshots/test_diagnostics.ambr | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index c66aec3bac98e2..2d9deb9184a111 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -17,6 +17,7 @@ from __future__ import annotations +from ipaddress import IPv6Address from typing import TYPE_CHECKING, Any, TypedDict from python_otbr_api.tlv_parser import MeshcopTLVType @@ -147,8 +148,11 @@ async def async_get_config_entry_diagnostics( }, ) if mlp_item := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX): - mlp = str(mlp_item) - network["prefixes"].add(f"{mlp[0:4]}:{mlp[4:8]}:{mlp[8:12]}:{mlp[12:16]}") + # We know that it is indeed a /64 mesh-local IPv6 NETWORK because Thread spec; + # However, the "prefixes" field contains no /XX (prefix length) in their entries ATM, + # so we use an IPv6Address in order to get a "prefixes" entry with no prefix length. + prefix_address = IPv6Address(mlp_item.data.ljust(16, b"\x00")) + network["prefixes"].add(str(prefix_address)) # Find all routes currently act that might be thread related, so we can match them to # border routers as we process the zeroconf data. diff --git a/tests/components/thread/snapshots/test_diagnostics.ambr b/tests/components/thread/snapshots/test_diagnostics.ambr index 8f3e9225614dca..a345a70f5097aa 100644 --- a/tests/components/thread/snapshots/test_diagnostics.ambr +++ b/tests/components/thread/snapshots/test_diagnostics.ambr @@ -5,7 +5,7 @@ '1111111122222222': dict({ 'name': 'OpenThreadDemo', 'prefixes': list([ - 'fdad:70bf:e5aa:15dd', + 'fdad:70bf:e5aa:15dd::', ]), 'routers': dict({ }), From b350712f9e6989f460618dc1986b532941a0372a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Mar 2026 12:22:13 +0200 Subject: [PATCH 0229/1707] Add last_non_buffering_state media_player state attribute (#166941) --- .../components/media_player/__init__.py | 10 ++- .../components/media_player/const.py | 1 + .../snapshots/test_media_player.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_media_player.ambr | 23 +++++++ .../snapshots/test_media_player.ambr | 1 + .../control4/snapshots/test_media_player.ambr | 1 + tests/components/group/test_config_flow.py | 2 +- .../heos/snapshots/test_diagnostics.ambr | 1 + .../heos/snapshots/test_media_player.ambr | 1 + .../snapshots/test_init.ambr | 1 + .../snapshots/test_media_player.ambr | 1 + tests/components/media_player/test_init.py | 65 ++++++++++++++++++- .../snapshots/test_media_player.ambr | 3 + .../onkyo/snapshots/test_media_player.ambr | 3 + .../snapshots/test_media_player.ambr | 9 +++ .../snapshots/test_media_player.ambr | 7 ++ .../snapcast/snapshots/test_media_player.ambr | 2 + .../sonos/snapshots/test_media_player.ambr | 1 + .../spotify/snapshots/test_media_player.ambr | 2 + .../snapshots/test_media_player.ambr | 1 + .../snapshots/test_media_player.ambr | 3 + .../snapshots/test_media_player.ambr | 5 ++ .../tessie/snapshots/test_media_player.ambr | 2 + .../webostv/snapshots/test_media_player.ambr | 1 + .../xbox/snapshots/test_media_player.ambr | 6 ++ 26 files changed, 152 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index ea9dd0adcfdd2b..613c07a1f4a170 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -75,6 +75,7 @@ ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_LAST_NON_BUFFERING_STATE, ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ANNOUNCE, @@ -587,6 +588,8 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_volume_level: float | None = None _attr_volume_step: float + __last_non_buffering_state: MediaPlayerState | None = None + # Implement these for your media player @cached_property def device_class(self) -> MediaPlayerDeviceClass | None: @@ -1124,7 +1127,12 @@ def capability_attributes(self) -> dict[str, Any]: @property def state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - state_attr: dict[str, Any] = {} + if (state := self.state) != MediaPlayerState.BUFFERING: + self.__last_non_buffering_state = state + + state_attr: dict[str, Any] = { + ATTR_LAST_NON_BUFFERING_STATE: self.__last_non_buffering_state + } if self.support_grouping: state_attr[ATTR_GROUP_MEMBERS] = self.group_members diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 4415b9ab7d1763..a5d9a07637d8f6 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -13,6 +13,7 @@ ATTR_GROUP_MEMBERS = "group_members" ATTR_INPUT_SOURCE = "source" ATTR_INPUT_SOURCE_LIST = "source_list" +ATTR_LAST_NON_BUFFERING_STATE = "last_non_buffering_state" ATTR_MEDIA_ANNOUNCE = "announce" ATTR_MEDIA_ALBUM_ARTIST = "media_album_artist" ATTR_MEDIA_ALBUM_NAME = "media_album_name" diff --git a/tests/components/arcam_fmj/snapshots/test_media_player.ambr b/tests/components/arcam_fmj/snapshots/test_media_player.ambr index 5b1b15cb884c6b..dc3b8a9003b43d 100644 --- a/tests/components/arcam_fmj/snapshots/test_media_player.ambr +++ b/tests/components/arcam_fmj/snapshots/test_media_player.ambr @@ -41,6 +41,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Arcam FMJ (127.0.0.1)', + 'last_non_buffering_state': , 'supported_features': , 'volume_level': 0.0, }), @@ -94,6 +95,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Arcam FMJ (127.0.0.1) Zone 2', + 'last_non_buffering_state': , 'supported_features': , 'volume_level': 0.0, }), diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index 0d45adf710b2a7..62470669867c00 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -63,6 +63,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': 'playing', 'media_content_type': 'music', 'repeat': 'off', 'shuffle': False, @@ -185,6 +186,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'media_player.beosound_a5_44444444', ]), + 'last_non_buffering_state': 'playing', 'media_content_type': 'music', 'repeat': 'off', 'shuffle': False, diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index c62490f3bf94bf..55da97266d42c9 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -23,6 +23,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -71,6 +72,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -120,6 +122,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -169,6 +172,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -218,6 +222,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -267,6 +272,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -315,6 +321,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -363,6 +370,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -411,6 +419,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -459,6 +468,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -507,6 +517,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -555,6 +566,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -603,6 +615,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -652,6 +665,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -701,6 +715,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -750,6 +765,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -799,6 +815,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'media_position': 0, 'repeat': , @@ -849,6 +866,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -898,6 +916,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -947,6 +966,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -996,6 +1016,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -1042,6 +1063,7 @@ 'media_player.beoconnect_core_22222222', 'media_player.beosound_balance_11111111', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, @@ -1090,6 +1112,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, diff --git a/tests/components/bluesound/snapshots/test_media_player.ambr b/tests/components/bluesound/snapshots/test_media_player.ambr index 73ae06945a8ed6..c6063081cc0bd8 100644 --- a/tests/components/bluesound/snapshots/test_media_player.ambr +++ b/tests/components/bluesound/snapshots/test_media_player.ambr @@ -5,6 +5,7 @@ 'friendly_name': 'player-name1111', 'group_members': None, 'is_volume_muted': False, + 'last_non_buffering_state': , 'master': False, 'media_album_name': 'album', 'media_artist': 'artist', diff --git a/tests/components/control4/snapshots/test_media_player.ambr b/tests/components/control4/snapshots/test_media_player.ambr index cc62a06ddb8254..2b9bd3cf9b5771 100644 --- a/tests/components/control4/snapshots/test_media_player.ambr +++ b/tests/components/control4/snapshots/test_media_player.ambr @@ -46,6 +46,7 @@ 'device_class': 'tv', 'friendly_name': 'Living Room', 'is_volume_muted': False, + 'last_non_buffering_state': , 'source_list': list([ 'TV', ]), diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index c570492e6b5c93..30c3d23c5a6117 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -489,7 +489,7 @@ async def test_options_flow_hides_members( ] LOCK_ATTRS = [{"supported_features": 1}, {}] NOTIFY_ATTRS = [{"supported_features": 0}, {}] -MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}] +MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {"last_non_buffering_state": "on"}] SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}] VALVE_ATTRS = [{"supported_features": 0}, {"is_closed": False}] diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 58685f5cf8f491..c2e0c4c52f71d0 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -314,6 +314,7 @@ 'media_player.test_player_2', ]), 'is_volume_muted': False, + 'last_non_buffering_state': 'idle', 'media_album_id': '1', 'media_album_name': 'Album', 'media_artist': 'Artist', diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 68ab24c6479cfc..d9c6e1957300b4 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -208,6 +208,7 @@ 'media_player.test_player_2', ]), 'is_volume_muted': False, + 'last_non_buffering_state': , 'media_album_id': '1', 'media_album_name': 'Album', 'media_artist': 'Artist', diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 404f1d2af19fab..1a6973035edebc 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -18319,6 +18319,7 @@ 'attributes': dict({ 'device_class': 'tv', 'friendly_name': 'LG webOS TV AF80', + 'last_non_buffering_state': , 'source': 'HDMI 4', 'source_list': list([ 'AirPlay', diff --git a/tests/components/lg_infrared/snapshots/test_media_player.ambr b/tests/components/lg_infrared/snapshots/test_media_player.ambr index a48def334c84d2..24e3ae8949e8d7 100644 --- a/tests/components/lg_infrared/snapshots/test_media_player.ambr +++ b/tests/components/lg_infrared/snapshots/test_media_player.ambr @@ -43,6 +43,7 @@ 'assumed_state': True, 'device_class': 'tv', 'friendly_name': 'LG TV', + 'last_non_buffering_state': , 'supported_features': , }), 'context': , diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 0affc727123eae..9d1d0965e3357e 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -12,10 +12,12 @@ ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_FILTER_CLASSES, ATTR_MEDIA_SEARCH_QUERY, + DOMAIN, BrowseMedia, MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, + MediaPlayerState, SearchMedia, SearchMediaQuery, ) @@ -24,11 +26,11 @@ SERVICE_SEARCH_MEDIA, ) from homeassistant.components.websocket_api import TYPE_RESULT -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockEntityPlatform +from tests.common import MockEntityPlatform, setup_test_component_platform from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -635,3 +637,62 @@ async def test_play_media_via_selector(hass: HomeAssistant) -> None: }, blocking=True, ) + + +async def test_media_player_state(hass: HomeAssistant) -> None: + """Test that media player state includes last_non_buffering_state.""" + entity1 = MediaPlayerEntity() + entity1._attr_name = "test1" + + setup_test_component_platform(hass, DOMAIN, [entity1]) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("media_player.test1") + assert state.state == "unknown" + assert state.attributes == { + "friendly_name": "test1", + "last_non_buffering_state": None, + "supported_features": 0, + } + + entity1._attr_state = MediaPlayerState.PLAYING + entity1.async_write_ha_state() + state = hass.states.get("media_player.test1") + assert state.state == "playing" + assert state.attributes == { + "friendly_name": "test1", + "last_non_buffering_state": "playing", + "supported_features": 0, + } + + # last_non_buffering_state not updated when state is buffering + entity1._attr_state = MediaPlayerState.BUFFERING + entity1.async_write_ha_state() + state = hass.states.get("media_player.test1") + assert state.state == "buffering" + assert state.attributes == { + "friendly_name": "test1", + "last_non_buffering_state": "playing", + "supported_features": 0, + } + + entity1._attr_state = MediaPlayerState.PAUSED + entity1.async_write_ha_state() + state = hass.states.get("media_player.test1") + assert state.state == "paused" + assert state.attributes == { + "friendly_name": "test1", + "last_non_buffering_state": "paused", + "supported_features": 0, + } + + # last_non_buffering_state not present when unavailable + entity1._attr_available = False + entity1.async_write_ha_state() + state = hass.states.get("media_player.test1") + assert state.state == "unavailable" + assert state.attributes == { + "friendly_name": "test1", + "supported_features": 0, + } diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index 99a19304321326..dc9e7603570bf6 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -49,6 +49,7 @@ ]), 'icon': 'mdi:speaker', 'is_volume_muted': False, + 'last_non_buffering_state': , 'mass_player_type': 'player', 'media_album_name': 'Test Album', 'media_artist': 'Test Artist', @@ -121,6 +122,7 @@ ]), 'icon': 'mdi:speaker-multiple', 'is_volume_muted': False, + 'last_non_buffering_state': , 'mass_player_type': 'group', 'media_album_name': 'Use Your Illusion I', 'media_artist': "Guns N' Roses", @@ -194,6 +196,7 @@ 'group_members': list([ ]), 'icon': 'mdi:speaker', + 'last_non_buffering_state': , 'mass_player_type': 'player', 'source_list': list([ 'Music Assistant Queue', diff --git a/tests/components/onkyo/snapshots/test_media_player.ambr b/tests/components/onkyo/snapshots/test_media_player.ambr index 4f61e1d7981f64..8aea58e3c9d497 100644 --- a/tests/components/onkyo/snapshots/test_media_player.ambr +++ b/tests/components/onkyo/snapshots/test_media_player.ambr @@ -53,6 +53,7 @@ }), 'friendly_name': 'TX-NR7100', 'is_volume_muted': False, + 'last_non_buffering_state': , 'preset': 1, 'sound_mode': 'DIRECT', 'sound_mode_list': list([ @@ -127,6 +128,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'TX-NR7100 Zone 2', + 'last_non_buffering_state': , 'sound_mode': 'Stereo', 'sound_mode_list': list([ 'Stereo', @@ -193,6 +195,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'TX-NR7100 Zone 3', + 'last_non_buffering_state': , 'source_list': list([ 'TV', 'FM Radio', diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr index 1bab6276bb047c..677de4149365bc 100644 --- a/tests/components/playstation_network/snapshots/test_media_player.ambr +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -42,6 +42,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'receiver', 'friendly_name': 'PlayStation Vita', + 'last_non_buffering_state': , 'supported_features': , }), 'context': , @@ -97,6 +98,7 @@ 'entity_picture': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG', 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_vita?token=123456789&cache=c7c916a6e18aec3d', 'friendly_name': 'PlayStation Vita', + 'last_non_buffering_state': , 'media_content_id': 'PCSB00074_00', 'media_content_type': , 'media_title': "Assassin's Creed® III Liberation", @@ -154,6 +156,7 @@ 'device_class': 'receiver', 'entity_picture_local': None, 'friendly_name': 'PlayStation Vita', + 'last_non_buffering_state': , 'media_content_type': , 'supported_features': , }), @@ -209,6 +212,7 @@ 'device_class': 'receiver', 'entity_picture_local': None, 'friendly_name': 'PlayStation 4', + 'last_non_buffering_state': , 'media_content_type': , 'supported_features': , }), @@ -263,6 +267,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'receiver', 'friendly_name': 'PlayStation 4', + 'last_non_buffering_state': , 'supported_features': , }), 'context': , @@ -318,6 +323,7 @@ 'entity_picture': 'http://gs2-sec.ww.prod.dl.playstation.net/gs2-sec/appkgo/prod/CUSA23081_00/5/i_f5d2adec7665af80b8550fb33fe808df10d292cdd47629a991debfdf72bdee34/i/icon0.png', 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_4?token=123456789&cache=924f463745523102', 'friendly_name': 'PlayStation 4', + 'last_non_buffering_state': , 'media_content_id': 'CUSA23081_00', 'media_content_type': , 'media_title': 'Untitled Goose Game', @@ -375,6 +381,7 @@ 'device_class': 'receiver', 'entity_picture_local': None, 'friendly_name': 'PlayStation 5', + 'last_non_buffering_state': , 'media_content_type': , 'supported_features': , }), @@ -429,6 +436,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'receiver', 'friendly_name': 'PlayStation 5', + 'last_non_buffering_state': , 'supported_features': , }), 'context': , @@ -484,6 +492,7 @@ 'entity_picture': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_5?token=123456789&cache=50dfb7140be0060b', 'friendly_name': 'PlayStation 5', + 'last_non_buffering_state': , 'media_content_id': 'PPSA07784_00', 'media_content_type': , 'media_title': 'STAR WARS Jedi: Survivor™', diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr index fe70141ee16a53..4489834af83b18 100644 --- a/tests/components/smartthings/snapshots/test_media_player.ambr +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -42,6 +42,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Robot Vacuum', 'is_volume_muted': False, + 'last_non_buffering_state': , 'repeat': , 'supported_features': , 'volume_level': 0.2, @@ -105,6 +106,7 @@ 'device_class': 'speaker', 'friendly_name': 'Soundbar', 'is_volume_muted': False, + 'last_non_buffering_state': , 'media_artist': 'Rick Astley', 'media_title': 'Never Gonna Give You Up', 'source': 'wifi', @@ -170,6 +172,7 @@ 'device_class': 'speaker', 'friendly_name': 'Galaxy Home Mini', 'is_volume_muted': False, + 'last_non_buffering_state': , 'repeat': , 'shuffle': False, 'supported_features': , @@ -227,6 +230,7 @@ 'device_class': 'speaker', 'friendly_name': 'Elliots Rum', 'is_volume_muted': False, + 'last_non_buffering_state': , 'media_artist': 'David Guetta', 'media_title': 'Forever Young', 'supported_features': , @@ -284,6 +288,7 @@ 'device_class': 'speaker', 'friendly_name': 'Soundbar Living', 'is_volume_muted': False, + 'last_non_buffering_state': , 'media_artist': '', 'media_title': '', 'source': 'HDMI1', @@ -341,6 +346,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Soundbar 1', + 'last_non_buffering_state': , 'supported_features': , }), 'context': , @@ -401,6 +407,7 @@ 'device_class': 'tv', 'friendly_name': '[TV] Samsung 8 Series (49)', 'is_volume_muted': True, + 'last_non_buffering_state': , 'source': 'HDMI1', 'source_list': list([ 'digitalTv', diff --git a/tests/components/snapcast/snapshots/test_media_player.ambr b/tests/components/snapcast/snapshots/test_media_player.ambr index 2abdfd2bb8097f..79049718f17222 100644 --- a/tests/components/snapcast/snapshots/test_media_player.ambr +++ b/tests/components/snapcast/snapshots/test_media_player.ambr @@ -51,6 +51,7 @@ 'media_player.test_client_1_snapcast_client', ]), 'is_volume_muted': False, + 'last_non_buffering_state': , 'latency': 6, 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', 'media_album_name': 'Test Album', @@ -127,6 +128,7 @@ 'media_player.test_client_2_snapcast_client', ]), 'is_volume_muted': False, + 'last_non_buffering_state': , 'latency': 6, 'media_content_type': , 'source': 'test_stream_2', diff --git a/tests/components/sonos/snapshots/test_media_player.ambr b/tests/components/sonos/snapshots/test_media_player.ambr index 9fb98183fe1900..0a23c119f11b5f 100644 --- a/tests/components/sonos/snapshots/test_media_player.ambr +++ b/tests/components/sonos/snapshots/test_media_player.ambr @@ -46,6 +46,7 @@ 'media_player.zone_a', ]), 'is_volume_muted': False, + 'last_non_buffering_state': , 'media_content_type': , 'repeat': , 'shuffle': False, diff --git a/tests/components/spotify/snapshots/test_media_player.ambr b/tests/components/spotify/snapshots/test_media_player.ambr index 9b1179e984fab4..408055be23c9de 100644 --- a/tests/components/spotify/snapshots/test_media_player.ambr +++ b/tests/components/spotify/snapshots/test_media_player.ambr @@ -45,6 +45,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture': '/api/media_player_proxy/media_player.spotify_spotify_1?token=mock-token&cache=7bb89748322acb6c', 'friendly_name': 'Spotify spotify_1', + 'last_non_buffering_state': , 'media_album_name': 'Permanent Waves', 'media_artist': 'Rush', 'media_content_id': 'spotify:track:4e9hUiLsN4mx61ARosFi7p', @@ -118,6 +119,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture': '/api/media_player_proxy/media_player.spotify_spotify_1?token=mock-token&cache=cf1e6e1e830f08d3', 'friendly_name': 'Spotify spotify_1', + 'last_non_buffering_state': , 'media_artist': 'Safety Third', 'media_content_id': 'spotify:episode:3o0RYoo5iOMKSmEbunsbvW', 'media_content_type': , diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index f00de202f69e26..822baf1e3d2344 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -44,6 +44,7 @@ 'group_members': list([ ]), 'is_volume_muted': True, + 'last_non_buffering_state': , 'media_duration': 1, 'media_position': 1, 'query_result': dict({ diff --git a/tests/components/tesla_fleet/snapshots/test_media_player.ambr b/tests/components/tesla_fleet/snapshots/test_media_player.ambr index dbd0bcbb40f1f7..acae9c2da857ea 100644 --- a/tests/components/tesla_fleet/snapshots/test_media_player.ambr +++ b/tests/components/tesla_fleet/snapshots/test_media_player.ambr @@ -42,6 +42,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'media_album_name': 'Elon Musk', 'media_artist': 'Walter Isaacson', 'media_duration': 651.0, @@ -65,6 +66,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'media_album_name': '', 'media_artist': '', 'media_playlist': '', @@ -124,6 +126,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'media_album_name': 'Elon Musk', 'media_artist': 'Walter Isaacson', 'media_duration': 651.0, diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr index 52b59ddd6e7b48..8500bc50f898a7 100644 --- a/tests/components/teslemetry/snapshots/test_media_player.ambr +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -42,6 +42,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'media_album_name': 'Elon Musk', 'media_artist': 'Walter Isaacson', 'media_duration': 651.0, @@ -65,6 +66,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'media_album_name': '', 'media_artist': '', 'media_duration': 0.0, @@ -125,6 +127,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'media_album_name': 'Elon Musk', 'media_artist': 'Walter Isaacson', 'media_duration': 651.0, @@ -148,6 +151,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'supported_features': , }), 'context': , @@ -163,6 +167,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'media_album_name': 'Test Album', 'media_artist': 'Test Artist', 'media_duration': 60, diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index eedba98b73916f..c46eba5f55e415 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -42,6 +42,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'supported_features': , 'volume_level': 0.2258032258064516, }), @@ -58,6 +59,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', 'friendly_name': 'Test Media player', + 'last_non_buffering_state': , 'media_album_name': 'Album', 'media_artist': 'Artist', 'media_duration': 60.0, diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 7c0bdfb0d13c51..44423d59790811 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -15,6 +15,7 @@ 'device_class': 'tv', 'friendly_name': 'LG webOS TV MODEL', 'is_volume_muted': False, + 'last_non_buffering_state': , 'media_content_type': , 'media_title': 'Channel 1', 'sound_output': 'speaker', diff --git a/tests/components/xbox/snapshots/test_media_player.ambr b/tests/components/xbox/snapshots/test_media_player.ambr index 2d5b7f6301ae00..59d92bc566072b 100644 --- a/tests/components/xbox/snapshots/test_media_player.ambr +++ b/tests/components/xbox/snapshots/test_media_player.ambr @@ -168,6 +168,7 @@ 'entity_picture': 'https://store-images.s-microsoft.com/image/apps.9815.9007199266246365.7dc5d343-fe4a-40c3-93dd-c78e77f97331.45eebdef-f725-4799-bbf8-9ad8391a8279', 'entity_picture_local': '/api/media_player_proxy/media_player.xone?token=mock_token&cache=1cae983bd1c4c429', 'friendly_name': 'XONE', + 'last_non_buffering_state': , 'media_content_id': '9WZDNCRFJ3TJ', 'media_content_type': , 'media_title': 'Netflix', @@ -225,6 +226,7 @@ 'entity_picture': 'https://store-images.s-microsoft.com/image/apps.9815.9007199266246365.7dc5d343-fe4a-40c3-93dd-c78e77f97331.45eebdef-f725-4799-bbf8-9ad8391a8279', 'entity_picture_local': '/api/media_player_proxy/media_player.xonex?token=mock_token&cache=1cae983bd1c4c429', 'friendly_name': 'XONEX', + 'last_non_buffering_state': , 'media_content_id': '9WZDNCRFJ3TJ', 'media_content_type': , 'media_title': 'Netflix', @@ -281,6 +283,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture_local': None, 'friendly_name': 'XONE', + 'last_non_buffering_state': , 'media_content_type': , 'supported_features': , }), @@ -335,6 +338,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture_local': None, 'friendly_name': 'XONEX', + 'last_non_buffering_state': , 'media_content_type': , 'supported_features': , }), @@ -390,6 +394,7 @@ 'entity_picture': 'https://images-eds-ssl.xboxlive.com/image?url=8Oaj9Ryq1G1_p3lLnXlsaZgGzAie6Mnu24_PawYuDYIoH77pJ.X5Z.MqQPibUVTcbx57bBxf63xu2Ef8acP3S7Uz80NbHc5nza..4R00GT1V5G760cdfX7Hl0uIHdHCbkzTikdvNE0TedhKgQfQy.2gjOGbd8kXZXzy4VzeJiNPLhLq2QUQbo8q3sVoSPaw73J4BxM7gaNX8V8qLcWtO5sn6vgbTso51OaEIn4zeAiw-', 'entity_picture_local': '/api/media_player_proxy/media_player.xone?token=mock_token&cache=cf419ddd9fb966d6', 'friendly_name': 'XONE', + 'last_non_buffering_state': , 'media_content_id': '9VWGNH0VBZJX', 'media_content_type': , 'media_title': 'TV', @@ -447,6 +452,7 @@ 'entity_picture': 'https://images-eds-ssl.xboxlive.com/image?url=8Oaj9Ryq1G1_p3lLnXlsaZgGzAie6Mnu24_PawYuDYIoH77pJ.X5Z.MqQPibUVTcbx57bBxf63xu2Ef8acP3S7Uz80NbHc5nza..4R00GT1V5G760cdfX7Hl0uIHdHCbkzTikdvNE0TedhKgQfQy.2gjOGbd8kXZXzy4VzeJiNPLhLq2QUQbo8q3sVoSPaw73J4BxM7gaNX8V8qLcWtO5sn6vgbTso51OaEIn4zeAiw-', 'entity_picture_local': '/api/media_player_proxy/media_player.xonex?token=mock_token&cache=cf419ddd9fb966d6', 'friendly_name': 'XONEX', + 'last_non_buffering_state': , 'media_content_id': '9VWGNH0VBZJX', 'media_content_type': , 'media_title': 'TV', From e9a61963f2730d581dcf10379de788e09c0b65a4 Mon Sep 17 00:00:00 2001 From: Andreas Jakl Date: Tue, 31 Mar 2026 12:55:04 +0200 Subject: [PATCH 0230/1707] Prevent invalid phase count state in nrgkick (#166575) --- homeassistant/components/nrgkick/number.py | 54 +++++++--- tests/components/nrgkick/test_number.py | 112 ++++++++++++++++++++- 2 files changed, 149 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/nrgkick/number.py b/homeassistant/components/nrgkick/number.py index 3261650b824a9f..aff9ccfc494b87 100644 --- a/homeassistant/components/nrgkick/number.py +++ b/homeassistant/components/nrgkick/number.py @@ -87,19 +87,18 @@ class NRGkickNumberEntityDescription(NumberEntityDescription): int(value) ), ), - NRGkickNumberEntityDescription( - key="phase_count", - translation_key="phase_count", - native_min_value=1, - native_max_value=3, - native_step=1, - mode=NumberMode.SLIDER, - value_fn=lambda data: data.control.get(CONTROL_KEY_PHASE_COUNT), - set_value_fn=lambda coordinator, value: coordinator.api.set_phase_count( - int(value) - ), - max_value_fn=_get_phase_count_max, - ), +) + +PHASE_COUNT_DESCRIPTION = NRGkickNumberEntityDescription( + key="phase_count", + translation_key="phase_count", + native_min_value=1, + native_max_value=3, + native_step=1, + mode=NumberMode.SLIDER, + value_fn=lambda data: data.control.get(CONTROL_KEY_PHASE_COUNT), + set_value_fn=lambda coordinator, value: coordinator.api.set_phase_count(int(value)), + max_value_fn=_get_phase_count_max, ) @@ -111,9 +110,11 @@ async def async_setup_entry( """Set up NRGkick number entities based on a config entry.""" coordinator = entry.runtime_data - async_add_entities( + entities: list[NRGkickNumber] = [ NRGkickNumber(coordinator, description) for description in NUMBERS - ) + ] + entities.append(NRGkickPhaseCountNumber(coordinator, PHASE_COUNT_DESCRIPTION)) + async_add_entities(entities) class NRGkickNumber(NRGkickEntity, NumberEntity): @@ -153,3 +154,26 @@ async def async_set_native_value(self, value: float) -> None: await self._async_call_api( self.entity_description.set_value_fn(self.coordinator, value) ) + + +class NRGkickPhaseCountNumber(NRGkickNumber): + """Phase count number entity with optimistic state. + + The device briefly reports 0 phases while switching. This subclass + caches the last valid value to avoid exposing the transient state. + """ + + _last_phase_count: float | None = None + + @property + def native_value(self) -> float | None: + """Return the current value, filtering transient zeros.""" + value = super().native_value + if value is not None and value != 0: + self._last_phase_count = value + return self._last_phase_count + + async def async_set_native_value(self, value: float) -> None: + """Set phase count with optimistic update.""" + self._last_phase_count = int(value) + await super().async_set_native_value(value) diff --git a/tests/components/nrgkick/test_number.py b/tests/components/nrgkick/test_number.py index 601f1716d8ab5f..b348b774b9c81f 100644 --- a/tests/components/nrgkick/test_number.py +++ b/tests/components/nrgkick/test_number.py @@ -2,8 +2,10 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from nrgkick_api import NRGkickCommandRejectedError from nrgkick_api.const import ( CONTROL_KEY_CURRENT_SET, @@ -13,6 +15,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.nrgkick.const import DEFAULT_SCAN_INTERVAL from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -25,7 +28,9 @@ from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) pytestmark = pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -114,7 +119,7 @@ async def test_set_phase_count( assert (state := hass.states.get(entity_id)) assert state.state == "3" - # Set to 1 phase + # Set phase count to 1 control_data = mock_nrgkick_api.get_control.return_value.copy() control_data[CONTROL_KEY_PHASE_COUNT] = 1 mock_nrgkick_api.get_control.return_value = control_data @@ -130,6 +135,109 @@ async def test_set_phase_count( mock_nrgkick_api.set_phase_count.assert_awaited_once_with(1) +async def test_phase_count_filters_transient_zero_on_poll( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nrgkick_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a transient phase count of 0 from a poll is filtered. + + During a phase-count switch the device briefly reports 0 phases. + A coordinator refresh must not expose the transient value. + """ + await setup_integration(hass, mock_config_entry, platforms=[Platform.NUMBER]) + + entity_id = "number.nrgkick_test_phase_count" + + assert (state := hass.states.get(entity_id)) + assert state.state == "3" + + # One refresh happened during setup. + assert mock_nrgkick_api.get_control.call_count == 1 + + # Device briefly reports 0 during a phase switch. + control_data = mock_nrgkick_api.get_control.return_value.copy() + control_data[CONTROL_KEY_PHASE_COUNT] = 0 + mock_nrgkick_api.get_control.return_value = control_data + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify the coordinator actually polled the device. + assert mock_nrgkick_api.get_control.call_count == 2 + + # The transient 0 must not surface; state stays at the previous value. + assert (state := hass.states.get(entity_id)) + assert state.state == "3" + + # Once the device settles it reports the real phase count. + control_data = mock_nrgkick_api.get_control.return_value.copy() + control_data[CONTROL_KEY_PHASE_COUNT] = 1 + mock_nrgkick_api.get_control.return_value = control_data + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify the coordinator polled again. + assert mock_nrgkick_api.get_control.call_count == 3 + + assert (state := hass.states.get(entity_id)) + assert state.state == "1" + + +async def test_phase_count_filters_transient_zero_on_service_call( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nrgkick_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a service call keeps the cached value when refreshing returns 0. + + When the user sets a new phase count, the immediate refresh triggered + by the service call may still see 0. The entity should keep the + requested value instead. + """ + await setup_integration(hass, mock_config_entry, platforms=[Platform.NUMBER]) + + entity_id = "number.nrgkick_test_phase_count" + + assert (state := hass.states.get(entity_id)) + assert state.state == "3" + + # The refresh triggered by the service call will see 0. + control_data = mock_nrgkick_api.get_control.return_value.copy() + control_data[CONTROL_KEY_PHASE_COUNT] = 0 + mock_nrgkick_api.get_control.return_value = control_data + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 1}, + blocking=True, + ) + mock_nrgkick_api.set_phase_count.assert_awaited_once_with(1) + + # State must not show 0; the entity keeps the cached value. + assert (state := hass.states.get(entity_id)) + assert state.state == "1" + + # Once the device settles it reports the real phase count again. + control_data = mock_nrgkick_api.get_control.return_value.copy() + control_data[CONTROL_KEY_PHASE_COUNT] = 1 + mock_nrgkick_api.get_control.return_value = control_data + prior_call_count = mock_nrgkick_api.get_control.call_count + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify that a periodic refresh actually occurred. + assert mock_nrgkick_api.get_control.call_count > prior_call_count + + assert (state := hass.states.get(entity_id)) + assert state.state == "1" + + async def test_number_command_rejected_by_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From af6b8d4f66f649d6a8a6411215afd8e417738335 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Mar 2026 13:01:20 +0200 Subject: [PATCH 0231/1707] Improve date action naming consistency (#166529) --- homeassistant/components/date/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/date/strings.json b/homeassistant/components/date/strings.json index fb4976f5399c80..a406772a8ab263 100644 --- a/homeassistant/components/date/strings.json +++ b/homeassistant/components/date/strings.json @@ -6,7 +6,7 @@ }, "services": { "set_value": { - "description": "Sets the date.", + "description": "Sets the value of a date.", "fields": { "date": { "description": "The date to set.", From 971579f0213c83238e5caeee569ede93ebdb4d46 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Mar 2026 13:01:32 +0200 Subject: [PATCH 0232/1707] Improve datetime action naming consistency (#166530) --- homeassistant/components/datetime/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/datetime/strings.json b/homeassistant/components/datetime/strings.json index 8316bbaedb5194..3fb944185f48cb 100644 --- a/homeassistant/components/datetime/strings.json +++ b/homeassistant/components/datetime/strings.json @@ -6,7 +6,7 @@ }, "services": { "set_value": { - "description": "Sets the date/time for a datetime entity.", + "description": "Sets the value of a date/time.", "fields": { "datetime": { "description": "The date/time to set. The time zone of the Home Assistant instance is assumed.", From 80802c99972fab0c018eef647554d12ad35b6bbe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Mar 2026 13:29:05 +0200 Subject: [PATCH 0233/1707] Update hassfest conditions, services and triggers plugins to not require field descriptions (#166954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- script/hassfest/conditions.py | 18 ++---------------- script/hassfest/services.py | 15 ++------------- script/hassfest/triggers.py | 18 ++---------------- tests/hassfest/test_conditions.py | 8 ++++++-- tests/hassfest/test_triggers.py | 6 ++++-- 5 files changed, 16 insertions(+), 49 deletions(-) diff --git a/script/hassfest/conditions.py b/script/hassfest/conditions.py index 22449cfd636679..6fef91309fd785 100644 --- a/script/hassfest/conditions.py +++ b/script/hassfest/conditions.py @@ -231,8 +231,8 @@ def validate_conditions(config: Config, integration: Integration) -> None: # no f"Condition {condition_name} has no description {error_msg_suffix}", ) - # The same check is done for the description in each of the fields of the - # condition schema. + # The same check is done for each of the fields of the condition schema, + # except that we don't enforce that fields have a description. for field_name, field_schema in condition_schema.get("fields", {}).items(): if "fields" in field_schema: # This is a section @@ -249,20 +249,6 @@ def validate_conditions(config: Config, integration: Integration) -> None: # no ), ) - if "description" not in field_schema and integration.core: - try: - strings["conditions"][condition_name]["fields"][field_name][ - "description" - ] - except KeyError: - integration.add_error( - "conditions", - ( - f"Condition {condition_name} has a field {field_name} with no " - f"description {error_msg_suffix}" - ), - ) - if "selector" in field_schema: with contextlib.suppress(KeyError): translation_key = field_schema["selector"]["select"][ diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 723a9ec927803a..5e2d3cae587349 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -303,8 +303,8 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa integration, service_name, strings, service_schema ) - # The same check is done for the description in each of the fields of the - # service schema. + # The same check is done for each field in the service schema, + # except that we don't require fields to have a description. for field_name, field_schema in service_schema.get("fields", {}).items(): if "fields" in field_schema: # This is a section @@ -318,17 +318,6 @@ def validate_services(config: Config, integration: Integration) -> None: # noqa f"Service {service_name} has a field {field_name} with no name {error_msg_suffix}", ) - if "description" not in field_schema and integration.core: - try: - strings["services"][service_name]["fields"][field_name][ - "description" - ] - except KeyError: - integration.add_error( - "services", - f"Service {service_name} has a field {field_name} with no description {error_msg_suffix}", - ) - if "selector" in field_schema: with contextlib.suppress(KeyError): translation_key = field_schema["selector"]["select"][ diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index 86e4a475475494..87bbb4d8f5738d 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -245,8 +245,8 @@ def validate_triggers(config: Config, integration: Integration) -> None: # noqa f"Trigger {trigger_name} has no description {error_msg_suffix}", ) - # The same check is done for the description in each of the fields of the - # trigger schema. + # The same check is done for each of the fields of the trigger schema, + # except that we don't enforce that fields have a description. for field_name, field_schema in trigger_schema.get("fields", {}).items(): if "fields" in field_schema: # This is a section @@ -263,20 +263,6 @@ def validate_triggers(config: Config, integration: Integration) -> None: # noqa ), ) - if "description" not in field_schema and integration.core: - try: - strings["triggers"][trigger_name]["fields"][field_name][ - "description" - ] - except KeyError: - integration.add_error( - "triggers", - ( - f"Trigger {trigger_name} has a field {field_name} with no " - f"description {error_msg_suffix}" - ), - ) - if "selector" in field_schema: with contextlib.suppress(KeyError): translation_key = field_schema["selector"]["select"][ diff --git a/tests/hassfest/test_conditions.py b/tests/hassfest/test_conditions.py index 12c3682e92d78e..8cfdf4a0270c51 100644 --- a/tests/hassfest/test_conditions.py +++ b/tests/hassfest/test_conditions.py @@ -35,6 +35,9 @@ after_offset: selector: time: null + after_offset_no_description: + selector: + time: null """, CONDITION_ICONS_FILENAME: {"conditions": {"_": {"condition": "mdi:flash"}}}, CONDITION_STRINGS_FILENAME: { @@ -48,6 +51,9 @@ "name": "Offset", "description": "The offset.", }, + "after_offset_no_description": { + "name": "Offset", + }, }, } } @@ -105,10 +111,8 @@ "has no name", "has no description", "field after with no name", - "field after with no description", "field after with a selector with a translation key", "field after_offset with no name", - "field after_offset with no description", ], }, } diff --git a/tests/hassfest/test_triggers.py b/tests/hassfest/test_triggers.py index 0bd28fd4e80f01..e3f43740ed1230 100644 --- a/tests/hassfest/test_triggers.py +++ b/tests/hassfest/test_triggers.py @@ -32,6 +32,9 @@ offset: selector: time: null + offset_no_description: + selector: + time: null """, TRIGGER_ICONS_FILENAME: {"triggers": {"_": {"trigger": "mdi:flash"}}}, TRIGGER_STRINGS_FILENAME: { @@ -42,6 +45,7 @@ "fields": { "event": {"name": "Event", "description": "The event."}, "offset": {"name": "Offset", "description": "The offset."}, + "offset_no_description": {"name": "Offset"}, }, } } @@ -99,10 +103,8 @@ "has no name", "has no description", "field event with no name", - "field event with no description", "field event with a selector with a translation key", "field offset with no name", - "field offset with no description", ], }, } From c82cfaf63305ad1ac82a56ec8c42b783b3568922 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:51:42 +0200 Subject: [PATCH 0234/1707] Cancel brands rotate_token on shutdown (#166957) --- homeassistant/components/brands/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/brands/__init__.py b/homeassistant/components/brands/__init__.py index 0cfe254904f323..e6cf7b9112f028 100644 --- a/homeassistant/components/brands/__init__.py +++ b/homeassistant/components/brands/__init__.py @@ -52,7 +52,9 @@ def _rotate_token(_now: Any) -> None: """Rotate the access token.""" access_tokens.append(hex(_RND.getrandbits(256))[2:]) - async_track_time_interval(hass, _rotate_token, TOKEN_CHANGE_INTERVAL) + async_track_time_interval( + hass, _rotate_token, TOKEN_CHANGE_INTERVAL, cancel_on_shutdown=True + ) hass.http.register_view(BrandsIntegrationView(hass)) hass.http.register_view(BrandsHardwareView(hass)) From cb8597d62f30162315b2628b1b7beda64ea321a4 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 31 Mar 2026 13:54:40 +0200 Subject: [PATCH 0235/1707] Improve SNMP tests and avoid dns lookups (#166604) --- tests/components/snmp/conftest.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/components/snmp/conftest.py diff --git a/tests/components/snmp/conftest.py b/tests/components/snmp/conftest.py new file mode 100644 index 00000000000000..1ed2f456c8ac7a --- /dev/null +++ b/tests/components/snmp/conftest.py @@ -0,0 +1,13 @@ +"""Conftest for SNMP tests.""" + +import socket +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def patch_gethostbyname(): + """Patch gethostbyname to avoid DNS lookups in SNMP tests.""" + with patch.object(socket, "gethostbyname"): + yield From 7b9b457f15a9193eac84c73aef7cb20f587802de Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:55:19 +0200 Subject: [PATCH 0236/1707] Migrate nuki to use runtime_data (#166943) --- homeassistant/components/nuki/__init__.py | 41 +++++-------------- .../components/nuki/binary_sensor.py | 8 ++-- homeassistant/components/nuki/coordinator.py | 17 +++++++- homeassistant/components/nuki/lock.py | 9 ++-- homeassistant/components/nuki/sensor.py | 8 ++-- 5 files changed, 35 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 6e89fd074b9c7f..ae7f9fb4140904 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from http import HTTPStatus import logging @@ -14,7 +13,6 @@ from homeassistant import exceptions from homeassistant.components import webhook -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, @@ -28,7 +26,7 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from .const import CONF_ENCRYPT_TOKEN, DEFAULT_TIMEOUT, DOMAIN -from .coordinator import NukiCoordinator +from .coordinator import NukiConfigEntry, NukiCoordinator, NukiEntryData from .helpers import NukiWebhookException, parse_id _LOGGER = logging.getLogger(__name__) @@ -36,22 +34,12 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] -@dataclass(slots=True) -class NukiEntryData: - """Class to hold Nuki data.""" - - coordinator: NukiCoordinator - bridge: NukiBridge - locks: list[NukiLock] - openers: list[NukiOpener] - - def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOpener]]: return bridge.locks, bridge.openers async def _create_webhook( - hass: HomeAssistant, entry: ConfigEntry, bridge: NukiBridge + hass: HomeAssistant, entry: NukiConfigEntry, bridge: NukiBridge ) -> None: # Create HomeAssistant webhook async def handle_webhook( @@ -63,16 +51,14 @@ async def handle_webhook( except ValueError: return web.Response(status=HTTPStatus.BAD_REQUEST) - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] - locks = entry_data.locks - openers = entry_data.openers + locks = entry.runtime_data.locks + openers = entry.runtime_data.openers devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]] if len(devices) == 1: devices[0].update_from_callback(data) - coordinator = entry_data.coordinator - coordinator.async_set_updated_data(None) + entry.runtime_data.coordinator.async_set_updated_data(None) return web.Response(status=HTTPStatus.OK) @@ -157,11 +143,9 @@ def _remove_webhook(bridge: NukiBridge, entry_id: str) -> None: bridge.callback_remove(item["id"]) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NukiConfigEntry) -> bool: """Set up the Nuki entry.""" - hass.data.setdefault(DOMAIN, {}) - # Migration of entry unique_id if isinstance(entry.unique_id, int): new_id = parse_id(entry.unique_id) @@ -225,7 +209,7 @@ async def _stop_nuki(_: Event): ) coordinator = NukiCoordinator(hass, entry, bridge, locks, openers) - hass.data[DOMAIN][entry.entry_id] = NukiEntryData( + entry.runtime_data = NukiEntryData( coordinator=coordinator, bridge=bridge, locks=locks, @@ -240,16 +224,15 @@ async def _stop_nuki(_: Event): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NukiConfigEntry) -> bool: """Unload the Nuki entry.""" webhook.async_unregister(hass, entry.entry_id) - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] try: async with asyncio.timeout(10): await hass.async_add_executor_job( _remove_webhook, - entry_data.bridge, + entry.runtime_data.bridge, entry.entry_id, ) except InvalidCredentialsException as err: @@ -261,8 +244,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unable to remove callback. Error communicating with Bridge: {err}" ) from err - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 7ba908c13e48ff..247ebfe0d71069 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -9,23 +9,21 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NukiEntryData -from .const import DOMAIN +from .coordinator import NukiConfigEntry from .entity import NukiEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NukiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki binary sensors.""" - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + entry_data = entry.runtime_data entities: list[NukiEntity] = [] diff --git a/homeassistant/components/nuki/coordinator.py b/homeassistant/components/nuki/coordinator.py index cccff99e3974ca..36bed1b5d4622d 100644 --- a/homeassistant/components/nuki/coordinator.py +++ b/homeassistant/components/nuki/coordinator.py @@ -4,6 +4,7 @@ import asyncio from collections import defaultdict +from dataclasses import dataclass from datetime import timedelta import logging @@ -25,16 +26,28 @@ UPDATE_INTERVAL = timedelta(seconds=30) +type NukiConfigEntry = ConfigEntry[NukiEntryData] + + +@dataclass(slots=True) +class NukiEntryData: + """Class to hold Nuki data.""" + + coordinator: NukiCoordinator + bridge: NukiBridge + locks: list[NukiLock] + openers: list[NukiOpener] + class NukiCoordinator(DataUpdateCoordinator[None]): """Data Update Coordinator for the Nuki integration.""" - config_entry: ConfigEntry + config_entry: NukiConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NukiConfigEntry, bridge: NukiBridge, locks: list[NukiLock], openers: list[NukiOpener], diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 95c01eac730257..8ff36ba6f919c2 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -12,24 +12,23 @@ import voluptuous as vol from homeassistant.components.lock import LockEntity, LockEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NukiEntryData -from .const import ATTR_ENABLE, ATTR_UNLATCH, DOMAIN, ERROR_STATES +from .const import ATTR_ENABLE, ATTR_UNLATCH, ERROR_STATES +from .coordinator import NukiConfigEntry from .entity import NukiEntity from .helpers import CannotConnect async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NukiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock platform.""" - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + entry_data = entry.runtime_data coordinator = entry_data.coordinator entities: list[NukiDeviceEntity] = [ diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 46bb165543da7e..0f2a49a8b5ec4c 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -9,23 +9,21 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NukiEntryData -from .const import DOMAIN +from .coordinator import NukiConfigEntry from .entity import NukiEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NukiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock sensor.""" - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + entry_data = entry.runtime_data async_add_entities( NukiBatterySensor(entry_data.coordinator, lock) for lock in entry_data.locks From 3596771af16d12268828d04e155dfb8e1d7cf73a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:10:57 +0200 Subject: [PATCH 0237/1707] Migrate nzbget to use runtime_data (#166947) --- homeassistant/components/nzbget/__init__.py | 28 ++++++------------- homeassistant/components/nzbget/const.py | 4 --- .../components/nzbget/coordinator.py | 7 +++-- homeassistant/components/nzbget/sensor.py | 10 ++----- homeassistant/components/nzbget/services.py | 3 +- homeassistant/components/nzbget/switch.py | 12 +++----- 6 files changed, 21 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index 5060e6ad0246a4..d24aaeb86209fd 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -1,13 +1,12 @@ """The NZBGet integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, DOMAIN -from .coordinator import NZBGetDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import NZBGetConfigEntry, NZBGetDataUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -22,37 +21,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NZBGetConfigEntry) -> bool: """Set up NZBGet from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - coordinator = NZBGetDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - undo_listener = entry.add_update_listener(_async_update_listener) + entry.runtime_data = coordinator - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NZBGetConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: NZBGetConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/nzbget/const.py b/homeassistant/components/nzbget/const.py index 6742567bbf2d17..cc704e9ae86501 100644 --- a/homeassistant/components/nzbget/const.py +++ b/homeassistant/components/nzbget/const.py @@ -5,10 +5,6 @@ # Attributes ATTR_SPEED = "speed" -# Data -DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" - # Defaults DEFAULT_NAME = "NZBGet" DEFAULT_PORT = 6789 diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index 9e6b06da7609eb..1fdad398d576b1 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -23,15 +23,18 @@ _LOGGER = logging.getLogger(__name__) +type NZBGetConfigEntry = ConfigEntry[NZBGetDataUpdateCoordinator] + + class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching NZBGet data.""" - config_entry: ConfigEntry + config_entry: NZBGetConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NZBGetConfigEntry, ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 2328bf453f0367..65d01aebf52649 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -10,15 +10,13 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import NZBGetDataUpdateCoordinator +from .coordinator import NZBGetConfigEntry, NZBGetDataUpdateCoordinator from .entity import NZBGetEntity _LOGGER = logging.getLogger(__name__) @@ -92,13 +90,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NZBGetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NZBGet sensor based on a config entry.""" - coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data entities = [ NZBGetSensor(coordinator, entry.entry_id, entry.data[CONF_NAME], description) for description in SENSOR_TYPES diff --git a/homeassistant/components/nzbget/services.py b/homeassistant/components/nzbget/services.py index ebcdd362b0c17d..0b5464c4f010a6 100644 --- a/homeassistant/components/nzbget/services.py +++ b/homeassistant/components/nzbget/services.py @@ -8,7 +8,6 @@ from .const import ( ATTR_SPEED, - DATA_COORDINATOR, DEFAULT_SPEED_LIMIT, DOMAIN, SERVICE_PAUSE, @@ -30,7 +29,7 @@ def _get_coordinator(call: ServiceCall) -> NZBGetDataUpdateCoordinator: translation_domain=DOMAIN, translation_key="invalid_config_entry", ) - return call.hass.data[DOMAIN][entries[0].entry_id][DATA_COORDINATOR] + return entries[0].runtime_data def pause(call: ServiceCall) -> None: diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index a4b2dde4c47938..05373345494cd7 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -5,25 +5,21 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import NZBGetDataUpdateCoordinator +from .coordinator import NZBGetConfigEntry, NZBGetDataUpdateCoordinator from .entity import NZBGetEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NZBGetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up NZBGet sensor based on a config entry.""" - coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + """Set up NZBGet switch based on a config entry.""" + coordinator = entry.runtime_data switches = [ NZBGetDownloadSwitch( From 8a9c0f4fde47041f2ee37911f1c654d550c44ed3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:49:54 +0200 Subject: [PATCH 0238/1707] Fix lingering tasks in nest tests (#166959) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/components/nest/conftest.py | 10 ++++++++++ tests/components/nest/test_config_flow.py | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 2f417aba9131e8..f4484ef0b015c3 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -203,6 +203,16 @@ def mock_subscriber() -> YieldFixture[AsyncMock]: yield mock_subscriber +@pytest.fixture +def mock_subscriber_refresh() -> YieldFixture[None]: + """Fixture for mocking subscriber refresh.""" + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber._async_run_refresh", + new=AsyncMock(), + ): + yield + + @pytest.fixture async def device_id() -> str: """Fixture to set default device id used when creating devices.""" diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 9ff7713e9ed2f3..7b7629cb70b5ed 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -497,6 +497,7 @@ def mock_pubsub_api_responses_fixture( "user-managed-topic-existing-subscription", ], ) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_full_flow( hass: HomeAssistant, oauth: OAuthFixture, @@ -641,6 +642,7 @@ async def test_full_flow( "user-managed-topic-existing-subscription", ], ) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_config_flow_restart( hass: HomeAssistant, oauth: OAuthFixture, @@ -701,6 +703,7 @@ async def test_config_flow_restart( } +@pytest.mark.usefixtures("mock_subscriber_refresh") @pytest.mark.parametrize(("sdm_managed_topic"), [True]) async def test_config_flow_wrong_project_id( hass: HomeAssistant, @@ -763,6 +766,7 @@ async def test_config_flow_wrong_project_id( ("create_subscription_status"), [HTTPStatus.NOT_FOUND, HTTPStatus.INTERNAL_SERVER_ERROR, HTTPStatus.UNAUTHORIZED], ) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_config_flow_pubsub_create_subscription_failure( hass: HomeAssistant, oauth: OAuthFixture, @@ -872,6 +876,7 @@ async def test_config_flow_pubsub_create_subscription_failure( ), ], ) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_multiple_config_entries( hass: HomeAssistant, oauth: OAuthFixture, @@ -1026,6 +1031,7 @@ async def test_pubsub_subscriber_config_entry_reauth( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_config_entry_title_from_home( hass: HomeAssistant, oauth: OAuthFixture, @@ -1078,6 +1084,7 @@ async def test_config_entry_title_from_home( (False, {"selected_topic": "create_new_topic"}), ], ) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_config_entry_title_multiple_homes( hass: HomeAssistant, oauth: OAuthFixture, @@ -1159,6 +1166,7 @@ async def test_title_failure_fallback( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_structure_missing_trait( hass: HomeAssistant, oauth: OAuthFixture, auth: FakeAuth ) -> None: @@ -1312,6 +1320,7 @@ async def test_dhcp_discovery_already_setup( "user-managed-select-existing-subscription", ], ) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_dhcp_discovery_with_creds( hass: HomeAssistant, oauth: OAuthFixture, @@ -1407,6 +1416,7 @@ async def test_token_error( ) ], ) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_existing_topic_and_subscription( hass: HomeAssistant, oauth: OAuthFixture, @@ -1448,6 +1458,7 @@ async def test_existing_topic_and_subscription( } +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_no_eligible_topics( hass: HomeAssistant, oauth: OAuthFixture, @@ -1520,6 +1531,7 @@ async def test_list_topics_failure( assert result.get("reason") == "pubsub_api_error" +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_create_topic_failed( hass: HomeAssistant, oauth: OAuthFixture, @@ -1607,6 +1619,7 @@ async def test_create_topic_failed( @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) +@pytest.mark.usefixtures("mock_subscriber_refresh") async def test_list_subscriptions_failure( hass: HomeAssistant, oauth: OAuthFixture, From d1bfd94d33a5f594361c6ffb45e5349a7e743b35 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:51:55 +0200 Subject: [PATCH 0239/1707] Shutdown debouncer in tests (#166958) --- tests/helpers/test_debounce.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 55c03aa630aa92..35a48e0963e171 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -70,6 +70,8 @@ async def test_immediate_works(hass: HomeAssistant) -> None: debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function + debouncer.async_shutdown() + async def test_immediate_works_with_schedule_call(hass: HomeAssistant) -> None: """Test immediate works with scheduled calls.""" @@ -128,6 +130,8 @@ async def test_immediate_works_with_schedule_call(hass: HomeAssistant) -> None: debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function + debouncer.async_shutdown() + async def test_immediate_works_with_callback_function(hass: HomeAssistant) -> None: """Test immediate works with callback function.""" @@ -147,7 +151,7 @@ async def test_immediate_works_with_callback_function(hass: HomeAssistant) -> No assert debouncer._execute_at_end_of_timer is False assert debouncer._job.target == debouncer.function - debouncer.async_cancel() + debouncer.async_shutdown() async def test_immediate_works_with_executor_function(hass: HomeAssistant) -> None: @@ -168,7 +172,7 @@ async def test_immediate_works_with_executor_function(hass: HomeAssistant) -> No assert debouncer._execute_at_end_of_timer is False assert debouncer._job.target == debouncer.function - debouncer.async_cancel() + debouncer.async_shutdown() async def test_immediate_works_with_passed_callback_function_raises( @@ -234,6 +238,8 @@ def _append_and_raise() -> None: debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function + debouncer.async_shutdown() + async def test_immediate_works_with_passed_coroutine_raises( hass: HomeAssistant, @@ -297,6 +303,8 @@ async def _append_and_raise() -> None: debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function + debouncer.async_shutdown() + async def test_not_immediate_works(hass: HomeAssistant) -> None: """Test immediate works.""" @@ -348,6 +356,8 @@ async def test_not_immediate_works(hass: HomeAssistant) -> None: debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function + debouncer.async_shutdown() + async def test_not_immediate_works_schedule_call(hass: HomeAssistant) -> None: """Test immediate works with schedule call.""" @@ -403,6 +413,8 @@ async def test_not_immediate_works_schedule_call(hass: HomeAssistant) -> None: debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function + debouncer.async_shutdown() + async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> None: """Test immediate works and we can change out the function.""" @@ -465,6 +477,8 @@ async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> Non debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function + debouncer.async_shutdown() + async def test_shutdown(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test shutdown.""" From 0aef0cc121219d341295933604da2115f0a28e37 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Tue, 31 Mar 2026 09:19:35 -0400 Subject: [PATCH 0240/1707] Add integration_type to opnsense (#166965) --- homeassistant/components/opnsense/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json index 0a9aecbde2560c..7d2712b38d5af7 100644 --- a/homeassistant/components/opnsense/manifest.json +++ b/homeassistant/components/opnsense/manifest.json @@ -3,6 +3,7 @@ "name": "OPNsense", "codeowners": ["@mtreinish"], "documentation": "https://www.home-assistant.io/integrations/opnsense", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pbr", "pyopnsense"], "quality_scale": "legacy", From f95601a2e70e0afb0c3d75afb5b505f4a6878f23 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 31 Mar 2026 15:26:21 +0200 Subject: [PATCH 0241/1707] Fix "Shutdown" grammar in Roborock strings (#166948) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/roborock/strings.json | 2 +- .../components/roborock/snapshots/test_button.ambr | 14 +++++++------- tests/components/roborock/test_button.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 66bff33c5101dc..e3ba066f9ba9a2 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -103,7 +103,7 @@ "name": "Reset side brush consumable" }, "shutdown": { - "name": "Shutdown" + "name": "Shut down" }, "start": { "name": "Start" diff --git a/tests/components/roborock/snapshots/test_button.ambr b/tests/components/roborock/snapshots/test_button.ambr index 9b0fb4addfd477..272e822965a0d7 100644 --- a/tests/components/roborock/snapshots/test_button.ambr +++ b/tests/components/roborock/snapshots/test_button.ambr @@ -699,7 +699,7 @@ 'state': 'unknown', }) # --- -# name: test_buttons[button.zeo_one_shutdown-entry] +# name: test_buttons[button.zeo_one_shut_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -713,7 +713,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': None, - 'entity_id': 'button.zeo_one_shutdown', + 'entity_id': 'button.zeo_one_shut_down', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -721,12 +721,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Shutdown', + 'object_id_base': 'Shut down', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Shutdown', + 'original_name': 'Shut down', 'platform': 'roborock', 'previous_unique_id': None, 'suggested_object_id': None, @@ -736,13 +736,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[button.zeo_one_shutdown-state] +# name: test_buttons[button.zeo_one_shut_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Zeo One Shutdown', + 'friendly_name': 'Zeo One Shut down', }), 'context': , - 'entity_id': 'button.zeo_one_shutdown', + 'entity_id': 'button.zeo_one_shut_down', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 515d61c499c1fa..fcbbff13fb02fc 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -199,7 +199,7 @@ async def test_press_routine_button_failure( [ ("button.zeo_one_start", "START"), ("button.zeo_one_pause", "PAUSE"), - ("button.zeo_one_shutdown", "SHUTDOWN"), + ("button.zeo_one_shut_down", "SHUTDOWN"), ], ) @pytest.mark.freeze_time("2023-10-30 08:50:00") From f15d9e59565eb80745e2def6fcd0a8c70482a8bb Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 31 Mar 2026 15:32:07 +0200 Subject: [PATCH 0242/1707] Fix Shutdown grammar in Synology DSM strings (#166946) --- homeassistant/components/synology_dsm/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f31e7934420724..1ccd549be79e71 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -78,7 +78,7 @@ }, "button": { "shutdown": { - "name": "Shutdown" + "name": "Shut down" } }, "sensor": { @@ -248,14 +248,14 @@ "name": "Reboot" }, "shutdown": { - "description": "Shutdowns the NAS. This action is deprecated and will be removed in future release. Please use the corresponding button entity.", + "description": "Shuts down the NAS. This action is deprecated and will be removed in a future release. Please use the corresponding button entity.", "fields": { "serial": { - "description": "Serial of the NAS to shutdown; required when multiple NAS are configured.", + "description": "Serial of the NAS to shut down; required when multiple NAS are configured.", "name": "[%key:component::synology_dsm::services::reboot::fields::serial::name%]" } }, - "name": "Shutdown" + "name": "Shut down" } } } From ac6ddf32c8c9c651e06b93f5b988c3377ee96a8f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:35:17 +0200 Subject: [PATCH 0243/1707] Fix StopIteration error in ista EcoTrend coordinator (#166929) --- homeassistant/components/ista_ecotrend/__init__.py | 1 - homeassistant/components/ista_ecotrend/config_flow.py | 2 -- homeassistant/components/ista_ecotrend/coordinator.py | 8 +++----- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ista_ecotrend/__init__.py b/homeassistant/components/ista_ecotrend/__init__.py index e39850d6c51671..747e33835b1b98 100644 --- a/homeassistant/components/ista_ecotrend/__init__.py +++ b/homeassistant/components/ista_ecotrend/__init__.py @@ -23,7 +23,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool ista = PyEcotrendIsta( entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], - _LOGGER, ) coordinator = IstaCoordinator(hass, entry, ista) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index 3eb7c4720b2150..e24441c9f4ed95 100644 --- a/homeassistant/components/ista_ecotrend/config_flow.py +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -51,7 +51,6 @@ async def async_step_user( ista = PyEcotrendIsta( user_input[CONF_EMAIL], user_input[CONF_PASSWORD], - _LOGGER, ) try: await self.hass.async_add_executor_job(ista.login) @@ -102,7 +101,6 @@ async def async_step_reauth_confirm( ista = PyEcotrendIsta( user_input[CONF_EMAIL], user_input[CONF_PASSWORD], - _LOGGER, ) def get_consumption_units() -> set[str]: diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py index 13167b9d06c115..75591b09728fb4 100644 --- a/homeassistant/components/ista_ecotrend/coordinator.py +++ b/homeassistant/components/ista_ecotrend/coordinator.py @@ -94,10 +94,8 @@ def get_details(self) -> dict[str, Any]: result = self.ista.get_consumption_unit_details() return { - consumption_unit: next( - details - for details in result["consumptionUnits"] - if details["id"] == consumption_unit - ) + consumption_unit: details for consumption_unit in self.ista.get_uuids() + for details in result["consumptionUnits"] + if details["id"] == consumption_unit } From c09d91765fe6299835a04e030e678ae66c009230 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 31 Mar 2026 14:36:23 +0100 Subject: [PATCH 0244/1707] Bump aiomealie to 1.2.3 (#166942) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../mealie/fixtures/get_mealplan_today.json | 6 +- .../mealie/fixtures/get_mealplans.json | 4 + .../mealie/fixtures/get_recipe.json | 2 + .../mealie/fixtures/get_recipes.json | 8 +- .../mealie/snapshots/test_diagnostics.ambr | 28 ++ .../mealie/snapshots/test_services.ambr | 248 +++++++++++++++++- 9 files changed, 292 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 6f9e61fd0fd893..01b3e2212680c0 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["aiomealie==1.2.2"] + "requirements": ["aiomealie==1.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 08e7466843c930..ab38176c376ddc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -324,7 +324,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.2.2 +aiomealie==1.2.3 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa53a9cf01b38b..c1d3d4e8daaae0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -309,7 +309,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.2.2 +aiomealie==1.2.3 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/tests/components/mealie/fixtures/get_mealplan_today.json b/tests/components/mealie/fixtures/get_mealplan_today.json index 634c6fad449cfe..01ee46fce849ad 100644 --- a/tests/components/mealie/fixtures/get_mealplan_today.json +++ b/tests/components/mealie/fixtures/get_mealplan_today.json @@ -15,6 +15,8 @@ "name": "Cauliflower Salad", "slug": "cauliflower-salad", "image": "qLdv", + "recipeServings": 6.0, + "recipeYieldQuantity": 6.0, "recipeYield": "6 servings", "totalTime": "2 Hours 35 Minutes", "prepTime": "25 Minutes", @@ -56,7 +58,9 @@ "name": "15 Minute Cheesy Sausage & Veg Pasta", "slug": "15-minute-cheesy-sausage-veg-pasta", "image": "BeNc", - "recipeYield": "", + "recipeServings": null, + "recipeYieldQuantity": null, + "recipeYield": null, "totalTime": null, "prepTime": null, "cookTime": null, diff --git a/tests/components/mealie/fixtures/get_mealplans.json b/tests/components/mealie/fixtures/get_mealplans.json index c7918ed8e80f55..6aa40e471bd1cd 100644 --- a/tests/components/mealie/fixtures/get_mealplans.json +++ b/tests/components/mealie/fixtures/get_mealplans.json @@ -20,6 +20,8 @@ "name": "Zoete aardappel curry traybake", "slug": "zoete-aardappel-curry-traybake", "image": "AiIo", + "recipeServings": null, + "recipeYieldQuantity": null, "recipeYield": "2 servings", "totalTime": "40 Minutes", "prepTime": null, @@ -141,6 +143,8 @@ "name": "Boeuf bourguignon : la vraie recette (2)", "slug": "boeuf-bourguignon-la-vraie-recette-2", "image": "nj5M", + "recipeServings": 4, + "recipeYieldQuantity": 4.0, "recipeYield": "4 servings", "totalTime": "5 Hours", "prepTime": "1 Hour", diff --git a/tests/components/mealie/fixtures/get_recipe.json b/tests/components/mealie/fixtures/get_recipe.json index 38dab8facafa1c..1ddb92e8507c93 100644 --- a/tests/components/mealie/fixtures/get_recipe.json +++ b/tests/components/mealie/fixtures/get_recipe.json @@ -5,6 +5,8 @@ "name": "Original Sacher-Torte (2)", "slug": "original-sacher-torte-2", "image": "SuPW", + "recipeServings": 4.0, + "recipeYieldQuantity": 4.0, "recipeYield": "4 servings", "totalTime": "2 hours 30 minutes", "prepTime": "1 hour 30 minutes", diff --git a/tests/components/mealie/fixtures/get_recipes.json b/tests/components/mealie/fixtures/get_recipes.json index 9988f7e46c8941..6e7154171060da 100644 --- a/tests/components/mealie/fixtures/get_recipes.json +++ b/tests/components/mealie/fixtures/get_recipes.json @@ -12,7 +12,9 @@ "name": "tu6y", "slug": "tu6y", "image": null, - "recipeYield": null, + "recipeServings": 4.0, + "recipeYieldQuantity": 4.0, + "recipeYield": "4 servings", "totalTime": null, "prepTime": null, "cookTime": null, @@ -87,7 +89,9 @@ "name": "Sweet potatoes", "slug": "sweet-potatoes", "image": "kdhm", - "recipeYield": "", + "recipeServings": null, + "recipeYieldQuantity": null, + "recipeYield": null, "totalTime": null, "prepTime": null, "cookTime": null, diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index ce1606859c0455..8d2877686a7572 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -34,7 +34,9 @@ 'prep_time': '15 Minutes', 'rating': 5.0, 'recipe_id': '5b055066-d57d-4fd0-8dfd-a2c2f07b36f1', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'roast-chicken', 'tags': list([ ]), @@ -74,7 +76,9 @@ 'prep_time': '40 Minutes', 'rating': None, 'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', 'tags': list([ ]), @@ -114,7 +118,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), @@ -152,7 +158,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', 'tags': list([ ]), @@ -190,7 +198,9 @@ 'prep_time': '15 Minutes', 'rating': 3.0, 'recipe_id': '92635fd0-f2dc-4e78-a6e4-ecd556ad361f', + 'recipe_servings': None, 'recipe_yield': '12 servings', + 'recipe_yield_quantity': None, 'slug': 'pampered-chef-double-chocolate-mocha-trifle', 'tags': list([ dict({ @@ -238,7 +248,9 @@ 'prep_time': '8 Minutes', 'rating': 5.0, 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', + 'recipe_servings': None, 'recipe_yield': '24 servings', + 'recipe_yield_quantity': None, 'slug': 'cheeseburger-sliders-easy-30-min-recipe', 'tags': list([ dict({ @@ -286,7 +298,9 @@ 'prep_time': '5 Minutes', 'rating': None, 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'all-american-beef-stew-recipe', 'tags': list([ dict({ @@ -329,7 +343,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '25b814f2-d9bf-4df0-b40d-d2f2457b4317', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'miso-udon-noodles-with-spinach-and-tofu', 'tags': list([ ]), @@ -383,7 +399,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'einfacher-nudelauflauf-mit-brokkoli', 'tags': list([ dict({ @@ -428,7 +446,9 @@ 'prep_time': '3 Minutes', 'rating': None, 'recipe_id': 'e360a0cc-18b0-4a84-a91b-8aa59e2451c9', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'receta-de-pollo-al-curry-en-10-minutos-con-video-incluido', 'tags': list([ ]), @@ -466,7 +486,9 @@ 'prep_time': '1 Hour', 'rating': None, 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', + 'recipe_servings': 4.0, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': 4.0, 'slug': 'boeuf-bourguignon-la-vraie-recette-2', 'tags': list([ dict({ @@ -564,7 +586,9 @@ 'prep_time': '5 Minutes', 'rating': None, 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'all-american-beef-stew-recipe', 'tags': list([ dict({ @@ -609,7 +633,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'einfacher-nudelauflauf-mit-brokkoli', 'tags': list([ dict({ @@ -654,7 +680,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb', + 'recipe_servings': None, 'recipe_yield': '12 servings', + 'recipe_yield_quantity': None, 'slug': 'mousse-de-saumon', 'tags': list([ ]), diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index c86c6e71beeb4c..ff25b1e6072ef1 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -18,7 +18,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'e82f5449-c33b-437c-b712-337587199264', - 'recipe_yield': None, + 'recipe_servings': 4.0, + 'recipe_yield': '4 servings', + 'recipe_yield_quantity': 4.0, 'slug': 'tu6y', 'tags': list([ ]), @@ -40,7 +42,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', 'tags': list([ ]), @@ -62,7 +66,9 @@ 'prep_time': None, 'rating': 5.0, 'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'patates-douces-au-four-1', 'tags': list([ ]), @@ -84,7 +90,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '98845807-9365-41fd-acd1-35630b468c27', - 'recipe_yield': '', + 'recipe_servings': None, + 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'sweet-potatoes', 'tags': list([ ]), @@ -106,7 +114,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', 'tags': list([ ]), @@ -128,7 +138,9 @@ 'prep_time': '1 Hour', 'rating': None, 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'boeuf-bourguignon-la-vraie-recette-2', 'tags': list([ dict({ @@ -210,7 +222,9 @@ 'prep_time': '1 Hour', 'rating': None, 'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'boeuf-bourguignon-la-vraie-recette-1', 'tags': list([ dict({ @@ -292,7 +306,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91', + 'recipe_servings': None, 'recipe_yield': '14 servings', + 'recipe_yield_quantity': None, 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', 'tags': list([ ]), @@ -314,7 +330,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', 'tags': list([ ]), @@ -336,7 +354,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'test123', 'tags': list([ ]), @@ -358,7 +378,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'bureeto', 'tags': list([ ]), @@ -380,7 +402,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'subway-double-cookies', 'tags': list([ ]), @@ -402,7 +426,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'qwerty12345', 'tags': list([ ]), @@ -424,7 +450,9 @@ 'prep_time': '8 Minutes', 'rating': 5.0, 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', + 'recipe_servings': None, 'recipe_yield': '24 servings', + 'recipe_yield_quantity': None, 'slug': 'cheeseburger-sliders-easy-30-min-recipe', 'tags': list([ dict({ @@ -456,7 +484,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291', + 'recipe_servings': None, 'recipe_yield': '4', + 'recipe_yield_quantity': None, 'slug': 'meatloaf', 'tags': list([ ]), @@ -478,7 +508,9 @@ 'prep_time': '1 Hour', 'rating': 3.0, 'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'richtig-rheinischer-sauerbraten', 'tags': list([ ]), @@ -500,7 +532,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'orientalischer-gemuse-hahnchen-eintopf', 'tags': list([ dict({ @@ -562,7 +596,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d', + 'recipe_servings': None, 'recipe_yield': '4', + 'recipe_yield_quantity': None, 'slug': 'test-20240121', 'tags': list([ ]), @@ -584,7 +620,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'loempia-bowl', 'tags': list([ ]), @@ -606,7 +644,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': '5-ingredient-chocolate-mousse', 'tags': list([ ]), @@ -628,7 +668,9 @@ 'prep_time': '5 Minutes', 'rating': None, 'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer', 'tags': list([ dict({ @@ -685,7 +727,9 @@ 'prep_time': '1h', 'rating': None, 'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842', + 'recipe_servings': None, 'recipe_yield': '1', + 'recipe_yield_quantity': None, 'slug': 'dinkel-sauerteigbrot', 'tags': list([ dict({ @@ -712,7 +756,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'test-234234', 'tags': list([ ]), @@ -734,7 +780,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'test-243', 'tags': list([ ]), @@ -756,7 +804,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'einfacher-nudelauflauf-mit-brokkoli', 'tags': list([ dict({ @@ -794,7 +844,9 @@ 'prep_time': '1 Hour', 'rating': None, 'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554', + 'recipe_servings': None, 'recipe_yield': '8 servings', + 'recipe_yield_quantity': None, 'slug': 'tarta-cytrynowa-z-beza', 'tags': list([ ]), @@ -816,7 +868,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'martins-test-recipe', 'tags': list([ ]), @@ -838,7 +892,9 @@ 'prep_time': '25 Minutes', 'rating': None, 'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69', + 'recipe_servings': None, 'recipe_yield': '12', + 'recipe_yield_quantity': None, 'slug': 'muffinki-czekoladowe', 'tags': list([ dict({ @@ -880,7 +936,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'my-test-recipe', 'tags': list([ ]), @@ -902,7 +960,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'my-test-receipe', 'tags': list([ ]), @@ -924,7 +984,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'patates-douces-au-four', 'tags': list([ ]), @@ -946,7 +1008,9 @@ 'prep_time': '2 Hours 15 Minutes', 'rating': None, 'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'easy-homemade-pizza-dough', 'tags': list([ ]), @@ -968,7 +1032,9 @@ 'prep_time': '5 Minutes', 'rating': None, 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'all-american-beef-stew-recipe', 'tags': list([ dict({ @@ -995,7 +1061,9 @@ 'prep_time': '20 Minutes', 'rating': 5.0, 'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce', 'tags': list([ dict({ @@ -1062,7 +1130,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'schnelle-kasespatzle', 'tags': list([ ]), @@ -1084,7 +1154,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'taco', 'tags': list([ ]), @@ -1106,7 +1178,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'vodkapasta', 'tags': list([ ]), @@ -1128,7 +1202,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'vodkapasta2', 'tags': list([ ]), @@ -1150,7 +1226,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670', + 'recipe_servings': None, 'recipe_yield': '1', + 'recipe_yield_quantity': None, 'slug': 'rub', 'tags': list([ ]), @@ -1172,7 +1250,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'banana-bread-chocolate-chip-cookies', 'tags': list([ dict({ @@ -1229,7 +1309,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', 'tags': list([ ]), @@ -1251,7 +1333,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'prova', 'tags': list([ ]), @@ -1273,7 +1357,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'pate-au-beurre-1', 'tags': list([ ]), @@ -1295,7 +1381,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'pate-au-beurre', 'tags': list([ ]), @@ -1317,7 +1405,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'sous-vide-cheesecake-recipe', 'tags': list([ ]), @@ -1339,7 +1429,9 @@ 'prep_time': '30 Minutes', 'rating': None, 'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b', + 'recipe_servings': None, 'recipe_yield': '10 servings', + 'recipe_yield_quantity': None, 'slug': 'the-bomb-mini-cheesecakes', 'tags': list([ ]), @@ -1361,7 +1453,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'tagliatelle-al-salmone', 'tags': list([ dict({ @@ -1438,7 +1532,9 @@ 'prep_time': '25 Minutes', 'rating': None, 'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627', + 'recipe_servings': None, 'recipe_yield': '1 serving', + 'recipe_yield_quantity': None, 'slug': 'death-by-chocolate', 'tags': list([ ]), @@ -1460,7 +1556,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'palak-dal-rezept-aus-indien', 'tags': list([ dict({ @@ -1502,7 +1600,9 @@ 'prep_time': '30 Minutes', 'rating': None, 'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'tortelline-a-la-romana', 'tags': list([ dict({ @@ -1547,7 +1647,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'e82f5449-c33b-437c-b712-337587199264', - 'recipe_yield': None, + 'recipe_servings': 4.0, + 'recipe_yield': '4 servings', + 'recipe_yield_quantity': 4.0, 'slug': 'tu6y', 'tags': list([ ]), @@ -1569,7 +1671,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', 'tags': list([ ]), @@ -1591,7 +1695,9 @@ 'prep_time': None, 'rating': 5.0, 'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'patates-douces-au-four-1', 'tags': list([ ]), @@ -1613,7 +1719,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '98845807-9365-41fd-acd1-35630b468c27', - 'recipe_yield': '', + 'recipe_servings': None, + 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'sweet-potatoes', 'tags': list([ ]), @@ -1635,7 +1743,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', 'tags': list([ ]), @@ -1657,7 +1767,9 @@ 'prep_time': '1 Hour', 'rating': None, 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'boeuf-bourguignon-la-vraie-recette-2', 'tags': list([ dict({ @@ -1739,7 +1851,9 @@ 'prep_time': '1 Hour', 'rating': None, 'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'boeuf-bourguignon-la-vraie-recette-1', 'tags': list([ dict({ @@ -1821,7 +1935,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91', + 'recipe_servings': None, 'recipe_yield': '14 servings', + 'recipe_yield_quantity': None, 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', 'tags': list([ ]), @@ -1843,7 +1959,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', 'tags': list([ ]), @@ -1865,7 +1983,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'test123', 'tags': list([ ]), @@ -1887,7 +2007,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'bureeto', 'tags': list([ ]), @@ -1909,7 +2031,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'subway-double-cookies', 'tags': list([ ]), @@ -1931,7 +2055,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'qwerty12345', 'tags': list([ ]), @@ -1953,7 +2079,9 @@ 'prep_time': '8 Minutes', 'rating': 5.0, 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', + 'recipe_servings': None, 'recipe_yield': '24 servings', + 'recipe_yield_quantity': None, 'slug': 'cheeseburger-sliders-easy-30-min-recipe', 'tags': list([ dict({ @@ -1985,7 +2113,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291', + 'recipe_servings': None, 'recipe_yield': '4', + 'recipe_yield_quantity': None, 'slug': 'meatloaf', 'tags': list([ ]), @@ -2007,7 +2137,9 @@ 'prep_time': '1 Hour', 'rating': 3.0, 'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'richtig-rheinischer-sauerbraten', 'tags': list([ ]), @@ -2029,7 +2161,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'orientalischer-gemuse-hahnchen-eintopf', 'tags': list([ dict({ @@ -2091,7 +2225,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d', + 'recipe_servings': None, 'recipe_yield': '4', + 'recipe_yield_quantity': None, 'slug': 'test-20240121', 'tags': list([ ]), @@ -2113,7 +2249,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'loempia-bowl', 'tags': list([ ]), @@ -2135,7 +2273,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': '5-ingredient-chocolate-mousse', 'tags': list([ ]), @@ -2157,7 +2297,9 @@ 'prep_time': '5 Minutes', 'rating': None, 'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer', 'tags': list([ dict({ @@ -2214,7 +2356,9 @@ 'prep_time': '1h', 'rating': None, 'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842', + 'recipe_servings': None, 'recipe_yield': '1', + 'recipe_yield_quantity': None, 'slug': 'dinkel-sauerteigbrot', 'tags': list([ dict({ @@ -2241,7 +2385,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'test-234234', 'tags': list([ ]), @@ -2263,7 +2409,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'test-243', 'tags': list([ ]), @@ -2285,7 +2433,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'einfacher-nudelauflauf-mit-brokkoli', 'tags': list([ dict({ @@ -2323,7 +2473,9 @@ 'prep_time': '1 Hour', 'rating': None, 'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554', + 'recipe_servings': None, 'recipe_yield': '8 servings', + 'recipe_yield_quantity': None, 'slug': 'tarta-cytrynowa-z-beza', 'tags': list([ ]), @@ -2345,7 +2497,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'martins-test-recipe', 'tags': list([ ]), @@ -2367,7 +2521,9 @@ 'prep_time': '25 Minutes', 'rating': None, 'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69', + 'recipe_servings': None, 'recipe_yield': '12', + 'recipe_yield_quantity': None, 'slug': 'muffinki-czekoladowe', 'tags': list([ dict({ @@ -2409,7 +2565,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'my-test-recipe', 'tags': list([ ]), @@ -2431,7 +2589,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'my-test-receipe', 'tags': list([ ]), @@ -2453,7 +2613,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'patates-douces-au-four', 'tags': list([ ]), @@ -2475,7 +2637,9 @@ 'prep_time': '2 Hours 15 Minutes', 'rating': None, 'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'easy-homemade-pizza-dough', 'tags': list([ ]), @@ -2497,7 +2661,9 @@ 'prep_time': '5 Minutes', 'rating': None, 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'all-american-beef-stew-recipe', 'tags': list([ dict({ @@ -2524,7 +2690,9 @@ 'prep_time': '20 Minutes', 'rating': 5.0, 'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce', 'tags': list([ dict({ @@ -2591,7 +2759,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'schnelle-kasespatzle', 'tags': list([ ]), @@ -2613,7 +2783,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'taco', 'tags': list([ ]), @@ -2635,7 +2807,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'vodkapasta', 'tags': list([ ]), @@ -2657,7 +2831,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'vodkapasta2', 'tags': list([ ]), @@ -2679,7 +2855,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670', + 'recipe_servings': None, 'recipe_yield': '1', + 'recipe_yield_quantity': None, 'slug': 'rub', 'tags': list([ ]), @@ -2701,7 +2879,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'banana-bread-chocolate-chip-cookies', 'tags': list([ dict({ @@ -2758,7 +2938,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', 'tags': list([ ]), @@ -2780,7 +2962,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a', + 'recipe_servings': None, 'recipe_yield': '', + 'recipe_yield_quantity': None, 'slug': 'prova', 'tags': list([ ]), @@ -2802,7 +2986,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'pate-au-beurre-1', 'tags': list([ ]), @@ -2824,7 +3010,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c', + 'recipe_servings': None, 'recipe_yield': None, + 'recipe_yield_quantity': None, 'slug': 'pate-au-beurre', 'tags': list([ ]), @@ -2846,7 +3034,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'sous-vide-cheesecake-recipe', 'tags': list([ ]), @@ -2868,7 +3058,9 @@ 'prep_time': '30 Minutes', 'rating': None, 'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b', + 'recipe_servings': None, 'recipe_yield': '10 servings', + 'recipe_yield_quantity': None, 'slug': 'the-bomb-mini-cheesecakes', 'tags': list([ ]), @@ -2890,7 +3082,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'tagliatelle-al-salmone', 'tags': list([ dict({ @@ -2967,7 +3161,9 @@ 'prep_time': '25 Minutes', 'rating': None, 'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627', + 'recipe_servings': None, 'recipe_yield': '1 serving', + 'recipe_yield_quantity': None, 'slug': 'death-by-chocolate', 'tags': list([ ]), @@ -2989,7 +3185,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'palak-dal-rezept-aus-indien', 'tags': list([ dict({ @@ -3031,7 +3229,9 @@ 'prep_time': '30 Minutes', 'rating': None, 'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'tortelline-a-la-romana', 'tags': list([ dict({ @@ -3447,7 +3647,9 @@ 'prep_time': '1 hour 30 minutes', 'rating': None, 'recipe_id': 'fada9582-709b-46aa-b384-d5952123ad93', + 'recipe_servings': 4.0, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': 4.0, 'slug': 'original-sacher-torte-2', 'tags': list([ dict({ @@ -3516,7 +3718,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), @@ -3548,7 +3752,9 @@ 'prep_time': '15 Minutes', 'rating': 5.0, 'recipe_id': '5b055066-d57d-4fd0-8dfd-a2c2f07b36f1', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'roast-chicken', 'tags': list([ ]), @@ -3580,7 +3786,9 @@ 'prep_time': '3 Minutes', 'rating': None, 'recipe_id': 'e360a0cc-18b0-4a84-a91b-8aa59e2451c9', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'receta-de-pollo-al-curry-en-10-minutos-con-video-incluido', 'tags': list([ ]), @@ -3612,7 +3820,9 @@ 'prep_time': '1 Hour', 'rating': None, 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', + 'recipe_servings': 4.0, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': 4.0, 'slug': 'boeuf-bourguignon-la-vraie-recette-2', 'tags': list([ dict({ @@ -3704,7 +3914,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', 'tags': list([ ]), @@ -3736,7 +3948,9 @@ 'prep_time': '40 Minutes', 'rating': None, 'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce', 'tags': list([ ]), @@ -3768,7 +3982,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'einfacher-nudelauflauf-mit-brokkoli', 'tags': list([ dict({ @@ -3805,7 +4021,9 @@ 'prep_time': '15 Minutes', 'rating': 3.0, 'recipe_id': '92635fd0-f2dc-4e78-a6e4-ecd556ad361f', + 'recipe_servings': None, 'recipe_yield': '12 servings', + 'recipe_yield_quantity': None, 'slug': 'pampered-chef-double-chocolate-mocha-trifle', 'tags': list([ dict({ @@ -3847,7 +4065,9 @@ 'prep_time': '8 Minutes', 'rating': 5.0, 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', + 'recipe_servings': None, 'recipe_yield': '24 servings', + 'recipe_yield_quantity': None, 'slug': 'cheeseburger-sliders-easy-30-min-recipe', 'tags': list([ dict({ @@ -3889,7 +4109,9 @@ 'prep_time': '5 Minutes', 'rating': None, 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'all-american-beef-stew-recipe', 'tags': list([ dict({ @@ -3926,7 +4148,9 @@ 'prep_time': '5 Minutes', 'rating': None, 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_servings': None, 'recipe_yield': '6 servings', + 'recipe_yield_quantity': None, 'slug': 'all-american-beef-stew-recipe', 'tags': list([ dict({ @@ -3963,7 +4187,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_servings': None, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': None, 'slug': 'einfacher-nudelauflauf-mit-brokkoli', 'tags': list([ dict({ @@ -4000,7 +4226,9 @@ 'prep_time': '10 Minutes', 'rating': None, 'recipe_id': '25b814f2-d9bf-4df0-b40d-d2f2457b4317', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'miso-udon-noodles-with-spinach-and-tofu', 'tags': list([ ]), @@ -4032,7 +4260,9 @@ 'prep_time': '15 Minutes', 'rating': None, 'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb', + 'recipe_servings': None, 'recipe_yield': '12 servings', + 'recipe_yield_quantity': None, 'slug': 'mousse-de-saumon', 'tags': list([ ]), @@ -4293,7 +4523,9 @@ 'prep_time': '1 hour 30 minutes', 'rating': None, 'recipe_id': 'fada9582-709b-46aa-b384-d5952123ad93', + 'recipe_servings': 4.0, 'recipe_yield': '4 servings', + 'recipe_yield_quantity': 4.0, 'slug': 'original-sacher-torte-2', 'tags': list([ dict({ @@ -4361,7 +4593,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), @@ -4397,7 +4631,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), @@ -4433,7 +4669,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), @@ -4469,7 +4707,9 @@ 'prep_time': None, 'rating': None, 'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93', + 'recipe_servings': None, 'recipe_yield': '2 servings', + 'recipe_yield_quantity': None, 'slug': 'zoete-aardappel-curry-traybake', 'tags': list([ ]), From 2ff84b633c87dcfc6366dd3935bbd80aa5f414dc Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Tue, 31 Mar 2026 15:38:39 +0200 Subject: [PATCH 0245/1707] Add myself to blebox codeowners (#166966) --- CODEOWNERS | 4 ++-- homeassistant/components/blebox/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 78374a8d180b8d..a7fac84580c936 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -222,8 +222,8 @@ build.json @home-assistant/supervisor /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core /homeassistant/components/bizkaibus/ @UgaitzEtxebarria -/homeassistant/components/blebox/ @bbx-a @swistakm -/tests/components/blebox/ @bbx-a @swistakm +/homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx +/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx /homeassistant/components/blink/ @fronzbot /tests/components/blink/ @fronzbot /homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index 19a8a06c835937..97d3702ab26131 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -1,7 +1,7 @@ { "domain": "blebox", "name": "BleBox devices", - "codeowners": ["@bbx-a", "@swistakm"], + "codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blebox", "integration_type": "device", From 35287c381bbb211bf6daae5098fbf50509b70fc4 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 31 Mar 2026 14:42:14 +0100 Subject: [PATCH 0246/1707] Bump aiomealie to 1.2.3 (#166942) From 9ada10e0cfe2a8037901f07ffb509af04ffda023 Mon Sep 17 00:00:00 2001 From: Branden Cash <203336+ammmze@users.noreply.github.com> Date: Tue, 31 Mar 2026 06:48:48 -0700 Subject: [PATCH 0247/1707] Bump srpenergy to 1.3.8 (#166926) --- homeassistant/components/srp_energy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/srp_energy/manifest.json b/homeassistant/components/srp_energy/manifest.json index 27deb87b0ca1da..ccbe73a97fd664 100644 --- a/homeassistant/components/srp_energy/manifest.json +++ b/homeassistant/components/srp_energy/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["srpenergy"], - "requirements": ["srpenergy==1.3.6"] + "requirements": ["srpenergy==1.3.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab38176c376ddc..02b97a5e455e0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3011,7 +3011,7 @@ spotifyaio==2.0.2 sqlparse==0.5.5 # homeassistant.components.srp_energy -srpenergy==1.3.6 +srpenergy==1.3.8 # homeassistant.components.starline starline==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1d3d4e8daaae0..8b158e357810c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2553,7 +2553,7 @@ spotifyaio==2.0.2 sqlparse==0.5.5 # homeassistant.components.srp_energy -srpenergy==1.3.6 +srpenergy==1.3.8 # homeassistant.components.starline starline==0.1.5 From daaa68ce226f34ad7b4e2a2d092fc687fb06a311 Mon Sep 17 00:00:00 2001 From: prpr19xx <58330423+prpr19xx@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:01:21 +0100 Subject: [PATCH 0248/1707] London Underground integration: Add Tram and IFS Cloud Cable Car status (#166712) --- homeassistant/components/london_underground/const.py | 2 ++ homeassistant/components/london_underground/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py index 9c96ff1ece04bb..8d6425bc7a8bde 100644 --- a/homeassistant/components/london_underground/const.py +++ b/homeassistant/components/london_underground/const.py @@ -29,6 +29,8 @@ "Suffragette", "Weaver", "Windrush", + "Tram", + "IFS Cloud Cable Car", ] # Default lines to monitor if none selected diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index 15cf41ef98cd8e..d05376b863a35e 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["london_tube_status"], - "requirements": ["london-tube-status==0.5"], + "requirements": ["london-tube-status==0.7"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 02b97a5e455e0f..26302484ad8927 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1455,7 +1455,7 @@ locationsharinglib==5.0.1 lojack-api==0.7.2 # homeassistant.components.london_underground -london-tube-status==0.5 +london-tube-status==0.7 # homeassistant.components.loqed loqedAPI==2.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b158e357810c1..b6e3ec9f1513cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1277,7 +1277,7 @@ livisi==0.0.25 lojack-api==0.7.2 # homeassistant.components.london_underground -london-tube-status==0.5 +london-tube-status==0.7 # homeassistant.components.loqed loqedAPI==2.1.11 From a3f3b0bed49f8d830f1e31cb1df3a70699391b43 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:21:26 +0200 Subject: [PATCH 0249/1707] Fix lingering tasks in update_coordinator test (#166968) --- tests/helpers/test_update_coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 77a3c90ee0e60a..d4a35c7aa55523 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -534,7 +534,7 @@ async def _update_method() -> int: # Add subscriber update_callback = Mock() - crd.async_add_listener(update_callback) + remove_callbacks = crd.async_add_listener(update_callback) assert crd.update_interval @@ -578,6 +578,10 @@ async def _update_method() -> int: # Unblock queued update block.set() + # Remove callbacks to avoid lingering timers + remove_callbacks() + await crd.async_shutdown() + async def test_refresh_recover( crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture From b8652e70e5e891e873ae83bb0c7a6bf62d765b99 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Mar 2026 17:39:42 +0200 Subject: [PATCH 0250/1707] Remove calendar and todo from unconditionally loaded integrations (#166951) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- homeassistant/bootstrap.py | 4 +++- tests/snapshots/test_bootstrap.ambr | 4 ---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index ce411280772471..226aec8f130437 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -238,7 +238,9 @@ "timer", # # Base platforms: - *BASE_PLATFORMS, + # Note: Calendar and todo are not included to prevent them from registering + # their frontend panels when there are no calendar or todo integrations. + *(BASE_PLATFORMS - {"calendar", "todo"}), # # Integrations providing triggers and conditions for base platforms: "air_quality", diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index 67d20710aa4290..8e21e77d4f82b1 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -19,7 +19,6 @@ 'blueprint', 'brands', 'button', - 'calendar', 'camera', 'climate', 'config', @@ -94,7 +93,6 @@ 'text', 'time', 'timer', - 'todo', 'trace', 'tts', 'update', @@ -129,7 +127,6 @@ 'blueprint', 'brands', 'button', - 'calendar', 'camera', 'climate', 'config', @@ -203,7 +200,6 @@ 'text', 'time', 'timer', - 'todo', 'trace', 'tts', 'update', From 73a86b8606fcf5a1a645fd2ffd3df3bc8a693157 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Mar 2026 17:46:07 +0200 Subject: [PATCH 0251/1707] Remove redundant field descriptions from triggers and conditions (#166955) --- .../components/air_quality/strings.json | 90 +------------------ .../alarm_control_panel/strings.json | 20 +---- .../components/assist_satellite/strings.json | 14 +-- homeassistant/components/battery/strings.json | 26 +----- .../components/calendar/strings.json | 4 +- homeassistant/components/climate/strings.json | 35 +------- homeassistant/components/counter/strings.json | 12 +-- homeassistant/components/cover/strings.json | 26 +----- .../components/device_tracker/strings.json | 10 +-- homeassistant/components/door/strings.json | 10 +-- homeassistant/components/fan/strings.json | 10 +-- .../components/garage_door/strings.json | 10 +-- homeassistant/components/gate/strings.json | 10 +-- .../components/humidifier/strings.json | 21 +---- .../components/humidity/strings.json | 18 +--- .../components/illuminance/strings.json | 22 +---- .../components/lawn_mower/strings.json | 16 +--- homeassistant/components/light/strings.json | 22 +---- homeassistant/components/lock/strings.json | 14 +-- .../components/media_player/strings.json | 12 +-- .../components/moisture/strings.json | 22 +---- homeassistant/components/motion/strings.json | 10 +-- .../components/occupancy/strings.json | 10 +-- homeassistant/components/person/strings.json | 10 +-- homeassistant/components/power/strings.json | 18 +--- homeassistant/components/remote/strings.json | 5 +- .../components/schedule/strings.json | 10 +-- homeassistant/components/select/strings.json | 3 +- homeassistant/components/siren/strings.json | 10 +-- homeassistant/components/switch/strings.json | 10 +-- .../components/temperature/strings.json | 18 +--- homeassistant/components/text/strings.json | 4 +- homeassistant/components/timer/strings.json | 6 +- homeassistant/components/update/strings.json | 4 +- homeassistant/components/vacuum/strings.json | 16 +--- homeassistant/components/valve/strings.json | 12 +-- .../components/water_heater/strings.json | 24 +---- homeassistant/components/window/strings.json | 10 +-- 38 files changed, 94 insertions(+), 510 deletions(-) diff --git a/homeassistant/components/air_quality/strings.json b/homeassistant/components/air_quality/strings.json index f3369398b34bd3..996bf26005702c 100644 --- a/homeassistant/components/air_quality/strings.json +++ b/homeassistant/components/air_quality/strings.json @@ -1,25 +1,18 @@ { "common": { - "condition_behavior_description": "How the value should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_co2_value": { "description": "Tests the carbon dioxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -29,7 +22,6 @@ "description": "Tests if one or more carbon monoxide sensors are cleared.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -39,7 +31,6 @@ "description": "Tests if one or more carbon monoxide sensors are detecting carbon monoxide.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -49,11 +40,9 @@ "description": "Tests the carbon monoxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -63,7 +52,6 @@ "description": "Tests if one or more gas sensors are cleared.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -73,7 +61,6 @@ "description": "Tests if one or more gas sensors are detecting gas.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -83,11 +70,9 @@ "description": "Tests the nitrous oxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -97,11 +82,9 @@ "description": "Tests the nitrogen dioxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -111,11 +94,9 @@ "description": "Tests the nitrogen monoxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -125,11 +106,9 @@ "description": "Tests the ozone level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -139,11 +118,9 @@ "description": "Tests the PM10 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -153,11 +130,9 @@ "description": "Tests the PM1 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -167,11 +142,9 @@ "description": "Tests the PM2.5 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -181,11 +154,9 @@ "description": "Tests the PM4 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -195,7 +166,6 @@ "description": "Tests if one or more smoke sensors are cleared.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -205,7 +175,6 @@ "description": "Tests if one or more smoke sensors are detecting smoke.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" } }, @@ -215,11 +184,9 @@ "description": "Tests the sulphur dioxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -229,11 +196,9 @@ "description": "Tests the volatile organic compounds ratio of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -243,11 +208,9 @@ "description": "Tests the volatile organic compounds level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -275,7 +238,6 @@ "description": "Triggers after one or more carbon dioxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -285,11 +247,9 @@ "description": "Triggers after one or more carbon dioxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -299,7 +259,6 @@ "description": "Triggers after one or more carbon monoxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -309,7 +268,6 @@ "description": "Triggers after one or more carbon monoxide sensors stop detecting carbon monoxide.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -319,11 +277,9 @@ "description": "Triggers after one or more carbon monoxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -333,7 +289,6 @@ "description": "Triggers after one or more carbon monoxide sensors start detecting carbon monoxide.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -343,7 +298,6 @@ "description": "Triggers after one or more gas sensors stop detecting gas.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -353,7 +307,6 @@ "description": "Triggers after one or more gas sensors start detecting gas.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -363,7 +316,6 @@ "description": "Triggers after one or more nitrous oxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -373,11 +325,9 @@ "description": "Triggers after one or more nitrous oxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -387,7 +337,6 @@ "description": "Triggers after one or more nitrogen dioxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -397,11 +346,9 @@ "description": "Triggers after one or more nitrogen dioxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -411,7 +358,6 @@ "description": "Triggers after one or more nitrogen monoxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -421,11 +367,9 @@ "description": "Triggers after one or more nitrogen monoxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -435,7 +379,6 @@ "description": "Triggers after one or more ozone levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -445,11 +388,9 @@ "description": "Triggers after one or more ozone levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -459,7 +400,6 @@ "description": "Triggers after one or more PM10 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -469,11 +409,9 @@ "description": "Triggers after one or more PM10 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -483,7 +421,6 @@ "description": "Triggers after one or more PM1 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -493,11 +430,9 @@ "description": "Triggers after one or more PM1 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -507,7 +442,6 @@ "description": "Triggers after one or more PM2.5 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -517,11 +451,9 @@ "description": "Triggers after one or more PM2.5 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -531,7 +463,6 @@ "description": "Triggers after one or more PM4 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -541,11 +472,9 @@ "description": "Triggers after one or more PM4 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -555,7 +484,6 @@ "description": "Triggers after one or more smoke sensors stop detecting smoke.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -565,7 +493,6 @@ "description": "Triggers after one or more smoke sensors start detecting smoke.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" } }, @@ -575,7 +502,6 @@ "description": "Triggers after one or more sulphur dioxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -585,11 +511,9 @@ "description": "Triggers after one or more sulphur dioxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -599,7 +523,6 @@ "description": "Triggers after one or more volatile organic compound levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -609,11 +532,9 @@ "description": "Triggers after one or more volatile organic compounds levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -623,7 +544,6 @@ "description": "Triggers after one or more volatile organic compound ratios change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -633,11 +553,9 @@ "description": "Triggers after one or more volatile organic compounds ratios cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 63c907d25c6e34..c49e3d9df337bf 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted alarms.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted alarms to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_armed": { "description": "Tests if one or more alarms are armed.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more alarms are armed in away mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more alarms are armed in home mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more alarms are armed in night mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more alarms are armed in vacation mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -60,7 +53,6 @@ "description": "Tests if one or more alarms are disarmed.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -70,7 +62,6 @@ "description": "Tests if one or more alarms are triggered.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" } }, @@ -242,7 +233,6 @@ "description": "Triggers after one or more alarms become armed, regardless of the mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -252,7 +242,6 @@ "description": "Triggers after one or more alarms become armed in away mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -262,7 +251,6 @@ "description": "Triggers after one or more alarms become armed in home mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -272,7 +260,6 @@ "description": "Triggers after one or more alarms become armed in night mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -282,7 +269,6 @@ "description": "Triggers after one or more alarms become armed in vacation mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -292,7 +278,6 @@ "description": "Triggers after one or more alarms become disarmed.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, @@ -302,7 +287,6 @@ "description": "Triggers after one or more alarms become triggered.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index f544ddbd8d0967..70f167f323e0af 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted Assist satellites.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_idle": { "description": "Tests if one or more Assist satellites are idle.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more Assist satellites are listening.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more Assist satellites are processing.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more Assist satellites are responding.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" } }, @@ -165,7 +159,6 @@ "description": "Triggers after one or more voice assistant satellites become idle after having processed a command.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" } }, @@ -175,7 +168,6 @@ "description": "Triggers after one or more voice assistant satellites start listening for a command from someone.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" } }, @@ -185,7 +177,6 @@ "description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" } }, @@ -195,7 +186,6 @@ "description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/battery/strings.json b/homeassistant/components/battery/strings.json index dc6c518f665f12..61f1698bea4e10 100644 --- a/homeassistant/components/battery/strings.json +++ b/homeassistant/components/battery/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted batteries.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted batteries to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_charging": { "description": "Tests if one or more batteries are charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" } }, @@ -25,11 +19,9 @@ "description": "Tests the battery level of one or more batteries.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::battery::common::condition_threshold_description%]", "name": "[%key:component::battery::common::condition_threshold_name%]" } }, @@ -39,7 +31,6 @@ "description": "Tests if one or more batteries are low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" } }, @@ -49,7 +40,6 @@ "description": "Tests if one or more batteries are not charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" } }, @@ -59,7 +49,6 @@ "description": "Tests if one or more batteries are not low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" } }, @@ -87,7 +76,6 @@ "description": "Triggers after the battery level of one or more batteries changes.", "fields": { "threshold": { - "description": "[%key:component::battery::common::trigger_threshold_changed_description%]", "name": "[%key:component::battery::common::trigger_threshold_name%]" } }, @@ -97,11 +85,9 @@ "description": "Triggers after the battery level of one or more batteries crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::battery::common::trigger_threshold_crossed_description%]", "name": "[%key:component::battery::common::trigger_threshold_name%]" } }, @@ -111,7 +97,6 @@ "description": "Triggers after one or more batteries become low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" } }, @@ -121,7 +106,6 @@ "description": "Triggers after one or more batteries are no longer low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" } }, @@ -131,7 +115,6 @@ "description": "Triggers after one or more batteries start charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" } }, @@ -141,7 +124,6 @@ "description": "Triggers after one or more batteries stop charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 8cac1016e80687..1175002adc8441 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -1,14 +1,12 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted calendars.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if" }, "conditions": { "is_event_active": { "description": "Tests if one or more calendars have an active event.", "fields": { "behavior": { - "description": "[%key:component::calendar::common::condition_behavior_description%]", "name": "[%key:component::calendar::common::condition_behavior_name%]" } }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 7fc608ff419719..2c2947c15ee28c 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted climate-control devices.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted climates to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_cooling": { "description": "Tests if one or more climate-control devices are cooling.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -25,7 +19,6 @@ "description": "Tests if one or more climate-control devices are drying.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -35,7 +28,6 @@ "description": "Tests if one or more climate-control devices are heating.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -45,7 +37,6 @@ "description": "Tests if one or more climate-control devices are set to a specific HVAC mode.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, "hvac_mode": { @@ -59,7 +50,6 @@ "description": "Tests if one or more climate-control devices are off.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -69,7 +59,6 @@ "description": "Tests if one or more climate-control devices are on.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" } }, @@ -79,11 +68,9 @@ "description": "Tests the humidity setpoint of one or more climate-control devices.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::climate::common::condition_threshold_description%]", "name": "[%key:component::climate::common::condition_threshold_name%]" } }, @@ -93,11 +80,9 @@ "description": "Tests the temperature setpoint of one or more climate-control devices.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::climate::common::condition_threshold_description%]", "name": "[%key:component::climate::common::condition_threshold_name%]" } }, @@ -398,7 +383,6 @@ "description": "Triggers after the mode of one or more climate-control devices changes.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" }, "hvac_mode": { @@ -412,7 +396,6 @@ "description": "Triggers after one or more climate-control devices start cooling.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, @@ -422,7 +405,6 @@ "description": "Triggers after one or more climate-control devices start drying.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, @@ -432,7 +414,6 @@ "description": "Triggers after one or more climate-control devices start heating.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, @@ -442,7 +423,6 @@ "description": "Triggers after the humidity setpoint of one or more climate-control devices changes.", "fields": { "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_changed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, @@ -452,11 +432,9 @@ "description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_crossed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, @@ -466,7 +444,6 @@ "description": "Triggers after the temperature setpoint of one or more climate-control devices changes.", "fields": { "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_changed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, @@ -476,11 +453,9 @@ "description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_crossed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, @@ -490,7 +465,6 @@ "description": "Triggers after one or more climate-control devices turn off.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, @@ -500,7 +474,6 @@ "description": "Triggers after one or more climate-control devices turn on, regardless of the mode.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 5bede3a676b4e1..4e728b0bc44e9d 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -1,19 +1,16 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted counters to trigger on.", - "trigger_behavior_name": "Behavior" + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_value": { "description": "Tests the value of one or more counters.", "fields": { "behavior": { - "description": "How the state should match on the targeted counters.", - "name": "Behavior" + "name": "Condition passes if" }, "threshold": { - "description": "What to test for and threshold values.", - "name": "Threshold" + "name": "Threshold type" } }, "name": "Counter value" @@ -98,7 +95,6 @@ "description": "Triggers after one or more counters reach their maximum value.", "fields": { "behavior": { - "description": "[%key:component::counter::common::trigger_behavior_description%]", "name": "[%key:component::counter::common::trigger_behavior_name%]" } }, @@ -108,7 +104,6 @@ "description": "Triggers after one or more counters reach their minimum value.", "fields": { "behavior": { - "description": "[%key:component::counter::common::trigger_behavior_description%]", "name": "[%key:component::counter::common::trigger_behavior_name%]" } }, @@ -118,7 +113,6 @@ "description": "Triggers after one or more counters are reset.", "fields": { "behavior": { - "description": "[%key:component::counter::common::trigger_behavior_description%]", "name": "[%key:component::counter::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 30a25185aab8c2..3be0ed28d79f54 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted covers.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted covers to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "awning_is_closed": { "description": "Tests if one or more awnings are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more awnings are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more blinds are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more blinds are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more curtains are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -60,7 +53,6 @@ "description": "Tests if one or more curtains are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -70,7 +62,6 @@ "description": "Tests if one or more shades are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -80,7 +71,6 @@ "description": "Tests if one or more shades are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -90,7 +80,6 @@ "description": "Tests if one or more shutters are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -100,7 +89,6 @@ "description": "Tests if one or more shutters are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" } }, @@ -265,7 +253,6 @@ "description": "Triggers after one or more awnings close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -275,7 +262,6 @@ "description": "Triggers after one or more awnings open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -285,7 +271,6 @@ "description": "Triggers after one or more blinds close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -295,7 +280,6 @@ "description": "Triggers after one or more blinds open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -305,7 +289,6 @@ "description": "Triggers after one or more curtains close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -315,7 +298,6 @@ "description": "Triggers after one or more curtains open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -325,7 +307,6 @@ "description": "Triggers after one or more shades close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -335,7 +316,6 @@ "description": "Triggers after one or more shades open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -345,7 +325,6 @@ "description": "Triggers after one or more shutters close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, @@ -355,7 +334,6 @@ "description": "Triggers after one or more shutters open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index f4f7031fa79238..ff71fb30c65c83 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted device trackers.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_home": { "description": "Tests if one or more device trackers are home.", "fields": { "behavior": { - "description": "[%key:component::device_tracker::common::condition_behavior_description%]", "name": "[%key:component::device_tracker::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more device trackers are not home.", "fields": { "behavior": { - "description": "[%key:component::device_tracker::common::condition_behavior_description%]", "name": "[%key:component::device_tracker::common::condition_behavior_name%]" } }, @@ -129,7 +125,6 @@ "description": "Triggers when one or more device trackers enter home.", "fields": { "behavior": { - "description": "[%key:component::device_tracker::common::trigger_behavior_description%]", "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" } }, @@ -139,7 +134,6 @@ "description": "Triggers when one or more device trackers leave home.", "fields": { "behavior": { - "description": "[%key:component::device_tracker::common::trigger_behavior_description%]", "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/door/strings.json b/homeassistant/components/door/strings.json index 8cad12e029901e..c6e5961ceff7ac 100644 --- a/homeassistant/components/door/strings.json +++ b/homeassistant/components/door/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted doors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted doors to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more doors are closed.", "fields": { "behavior": { - "description": "[%key:component::door::common::condition_behavior_description%]", "name": "[%key:component::door::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more doors are open.", "fields": { "behavior": { - "description": "[%key:component::door::common::condition_behavior_description%]", "name": "[%key:component::door::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more doors close.", "fields": { "behavior": { - "description": "[%key:component::door::common::trigger_behavior_description%]", "name": "[%key:component::door::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more doors open.", "fields": { "behavior": { - "description": "[%key:component::door::common::trigger_behavior_description%]", "name": "[%key:component::door::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index efeb6efa3fce02..51a05b6bf4ccb7 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted fans.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted fans to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_off": { "description": "Tests if one or more fans are off.", "fields": { "behavior": { - "description": "[%key:component::fan::common::condition_behavior_description%]", "name": "[%key:component::fan::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more fans are on.", "fields": { "behavior": { - "description": "[%key:component::fan::common::condition_behavior_description%]", "name": "[%key:component::fan::common::condition_behavior_name%]" } }, @@ -199,7 +195,6 @@ "description": "Triggers after one or more fans turn off.", "fields": { "behavior": { - "description": "[%key:component::fan::common::trigger_behavior_description%]", "name": "[%key:component::fan::common::trigger_behavior_name%]" } }, @@ -209,7 +204,6 @@ "description": "Triggers after one or more fans turn on.", "fields": { "behavior": { - "description": "[%key:component::fan::common::trigger_behavior_description%]", "name": "[%key:component::fan::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/garage_door/strings.json b/homeassistant/components/garage_door/strings.json index f0e50ad82a12e7..574a117f517911 100644 --- a/homeassistant/components/garage_door/strings.json +++ b/homeassistant/components/garage_door/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted garage doors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more garage doors are closed.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::condition_behavior_description%]", "name": "[%key:component::garage_door::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more garage doors are open.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::condition_behavior_description%]", "name": "[%key:component::garage_door::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more garage doors close.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::trigger_behavior_description%]", "name": "[%key:component::garage_door::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more garage doors open.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::trigger_behavior_description%]", "name": "[%key:component::garage_door::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/gate/strings.json b/homeassistant/components/gate/strings.json index 134e9bb108f794..ed1f04b0fc6d07 100644 --- a/homeassistant/components/gate/strings.json +++ b/homeassistant/components/gate/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted gates.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted gates to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more gates are closed.", "fields": { "behavior": { - "description": "[%key:component::gate::common::condition_behavior_description%]", "name": "[%key:component::gate::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more gates are open.", "fields": { "behavior": { - "description": "[%key:component::gate::common::condition_behavior_description%]", "name": "[%key:component::gate::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more gates close.", "fields": { "behavior": { - "description": "[%key:component::gate::common::trigger_behavior_description%]", "name": "[%key:component::gate::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more gates open.", "fields": { "behavior": { - "description": "[%key:component::gate::common::trigger_behavior_description%]", "name": "[%key:component::gate::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 82ae8b57436419..ff7f28a2e5fc31 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -1,18 +1,14 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted humidifiers.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_drying": { "description": "Tests if one or more humidifiers are drying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" } }, @@ -22,7 +18,6 @@ "description": "Tests if one or more humidifiers are humidifying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" } }, @@ -32,7 +27,6 @@ "description": "Tests if one or more humidifiers are set to a specific mode.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, "mode": { @@ -46,7 +40,6 @@ "description": "Tests if one or more humidifiers are off.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" } }, @@ -56,7 +49,6 @@ "description": "Tests if one or more humidifiers are on.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" } }, @@ -66,11 +58,9 @@ "description": "Tests the target humidity of one or more humidifiers.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::humidifier::common::condition_threshold_description%]", "name": "[%key:component::humidifier::common::condition_threshold_name%]" } }, @@ -219,7 +209,6 @@ "description": "Triggers after the operation mode of one or more humidifiers changes.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" }, "mode": { @@ -233,7 +222,6 @@ "description": "Triggers after one or more humidifiers start drying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" } }, @@ -243,7 +231,6 @@ "description": "Triggers after one or more humidifiers start humidifying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" } }, @@ -253,7 +240,6 @@ "description": "Triggers after one or more humidifiers turn off.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" } }, @@ -263,7 +249,6 @@ "description": "Triggers after one or more humidifiers turn on.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/humidity/strings.json b/homeassistant/components/humidity/strings.json index 06836f05dce70c..20df0ca139a53f 100644 --- a/homeassistant/components/humidity/strings.json +++ b/homeassistant/components/humidity/strings.json @@ -1,25 +1,18 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_value": { "description": "Tests if a relative humidity value is above a threshold, below a threshold, or in a range of values.", "fields": { "behavior": { - "description": "[%key:component::humidity::common::condition_behavior_description%]", "name": "[%key:component::humidity::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::humidity::common::condition_threshold_description%]", "name": "[%key:component::humidity::common::condition_threshold_name%]" } }, @@ -47,7 +40,6 @@ "description": "Triggers after one or more relative humidity values change.", "fields": { "threshold": { - "description": "[%key:component::humidity::common::trigger_threshold_changed_description%]", "name": "[%key:component::humidity::common::trigger_threshold_name%]" } }, @@ -57,11 +49,9 @@ "description": "Triggers after one or more relative humidity values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::humidity::common::trigger_behavior_description%]", "name": "[%key:component::humidity::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::humidity::common::trigger_threshold_crossed_description%]", "name": "[%key:component::humidity::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/illuminance/strings.json b/homeassistant/components/illuminance/strings.json index 5ed11170df0b26..e1c478fff9f2de 100644 --- a/homeassistant/components/illuminance/strings.json +++ b/homeassistant/components/illuminance/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_detected": { "description": "Tests if light is currently detected.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" } }, @@ -25,7 +19,6 @@ "description": "Tests if light is currently not detected.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" } }, @@ -35,11 +28,9 @@ "description": "Tests the illuminance value.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::illuminance::common::condition_threshold_description%]", "name": "[%key:component::illuminance::common::condition_threshold_name%]" } }, @@ -67,7 +58,6 @@ "description": "Triggers after one or more illuminance values change.", "fields": { "threshold": { - "description": "[%key:component::illuminance::common::trigger_threshold_changed_description%]", "name": "[%key:component::illuminance::common::trigger_threshold_name%]" } }, @@ -77,7 +67,6 @@ "description": "Triggers after one or more light sensors stop detecting light.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::trigger_behavior_description%]", "name": "[%key:component::illuminance::common::trigger_behavior_name%]" } }, @@ -87,11 +76,9 @@ "description": "Triggers after one or more illuminance values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::trigger_behavior_description%]", "name": "[%key:component::illuminance::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::illuminance::common::trigger_threshold_crossed_description%]", "name": "[%key:component::illuminance::common::trigger_threshold_name%]" } }, @@ -101,7 +88,6 @@ "description": "Triggers after one or more light sensors start detecting light.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::trigger_behavior_description%]", "name": "[%key:component::illuminance::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index e882b260aeb399..973d046979aff0 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted lawn mowers.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted lawn mowers to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_docked": { "description": "Tests if one or more lawn mowers are docked.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more lawn mowers are encountering an error.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more lawn mowers are mowing.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more lawn mowers are paused.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more lawn mowers are returning to the dock.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" } }, @@ -104,7 +97,6 @@ "description": "Triggers after one or more lawn mowers have returned to dock.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, @@ -114,7 +106,6 @@ "description": "Triggers after one or more lawn mowers encounter an error.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, @@ -124,7 +115,6 @@ "description": "Triggers after one or more lawn mowers pause mowing.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, @@ -134,7 +124,6 @@ "description": "Triggers after one or more lawn mowers start mowing.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, @@ -144,7 +133,6 @@ "description": "Triggers after one or more lawn mowers start returning to dock.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index dd0ae383c924ff..69356bb4ad824b 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -1,9 +1,7 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted lights.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", "field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.", "field_brightness_name": "Brightness value", "field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.", @@ -37,22 +35,17 @@ "field_xy_color_description": "Color in XY-format. A list of two decimal numbers between 0 and 1.", "field_xy_color_name": "XY-color", "section_advanced_fields_name": "Advanced options", - "trigger_behavior_description": "The behavior of the targeted lights to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_brightness": { "description": "Tests the brightness of one or more lights.", "fields": { "behavior": { - "description": "[%key:component::light::common::condition_behavior_description%]", "name": "[%key:component::light::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::light::common::condition_threshold_description%]", "name": "[%key:component::light::common::condition_threshold_name%]" } }, @@ -62,7 +55,6 @@ "description": "Tests if one or more lights are off.", "fields": { "behavior": { - "description": "[%key:component::light::common::condition_behavior_description%]", "name": "[%key:component::light::common::condition_behavior_name%]" } }, @@ -72,7 +64,6 @@ "description": "Tests if one or more lights are on.", "fields": { "behavior": { - "description": "[%key:component::light::common::condition_behavior_description%]", "name": "[%key:component::light::common::condition_behavior_name%]" } }, @@ -513,7 +504,6 @@ "description": "Triggers after the brightness of one or more lights changes.", "fields": { "threshold": { - "description": "[%key:component::light::common::trigger_threshold_changed_description%]", "name": "[%key:component::light::common::trigger_threshold_name%]" } }, @@ -523,11 +513,9 @@ "description": "Triggers after the brightness of one or more lights crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::light::common::trigger_behavior_description%]", "name": "[%key:component::light::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::light::common::trigger_threshold_crossed_description%]", "name": "[%key:component::light::common::trigger_threshold_name%]" } }, @@ -537,7 +525,6 @@ "description": "Triggers after one or more lights turn off.", "fields": { "behavior": { - "description": "[%key:component::light::common::trigger_behavior_description%]", "name": "[%key:component::light::common::trigger_behavior_name%]" } }, @@ -547,7 +534,6 @@ "description": "Triggers after one or more lights turn on.", "fields": { "behavior": { - "description": "[%key:component::light::common::trigger_behavior_description%]", "name": "[%key:component::light::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index fea8afdfb04ace..b53a2f92cf3f64 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted locks.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted locks to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_jammed": { "description": "Tests if one or more locks are jammed.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more locks are locked.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more locks are open.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more locks are unlocked.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" } }, @@ -151,7 +145,6 @@ "description": "Triggers after one or more locks jam.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" } }, @@ -161,7 +154,6 @@ "description": "Triggers after one or more locks lock.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" } }, @@ -171,7 +163,6 @@ "description": "Triggers after one or more locks open.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" } }, @@ -181,7 +172,6 @@ "description": "Triggers after one or more locks unlock.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 8ff5d13b225b8e..d9ef8c051035cb 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted media players.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted media players to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_not_playing": { "description": "Tests if one or more media players are not playing.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more media players are off.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more media players are on.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more media players are paused.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more media players are playing.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" } }, @@ -444,7 +437,6 @@ "description": "Triggers after one or more media players stop playing media.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::trigger_behavior_description%]", "name": "[%key:component::media_player::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/moisture/strings.json b/homeassistant/components/moisture/strings.json index c2f9705bcca0bd..d125ccf9a5bdac 100644 --- a/homeassistant/components/moisture/strings.json +++ b/homeassistant/components/moisture/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_detected": { "description": "Tests if one or more moisture sensors are detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" } }, @@ -25,7 +19,6 @@ "description": "Tests if one or more moisture sensors are not detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" } }, @@ -35,11 +28,9 @@ "description": "Tests the moisture level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::moisture::common::condition_threshold_description%]", "name": "[%key:component::moisture::common::condition_threshold_name%]" } }, @@ -67,7 +58,6 @@ "description": "Triggers after one or more moisture content values change.", "fields": { "threshold": { - "description": "[%key:component::moisture::common::trigger_threshold_changed_description%]", "name": "[%key:component::moisture::common::trigger_threshold_name%]" } }, @@ -77,7 +67,6 @@ "description": "Triggers after one or more moisture sensors stop detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::trigger_behavior_description%]", "name": "[%key:component::moisture::common::trigger_behavior_name%]" } }, @@ -87,11 +76,9 @@ "description": "Triggers after one or more moisture content values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::trigger_behavior_description%]", "name": "[%key:component::moisture::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::moisture::common::trigger_threshold_crossed_description%]", "name": "[%key:component::moisture::common::trigger_threshold_name%]" } }, @@ -101,7 +88,6 @@ "description": "Triggers after one or more moisture sensors start detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::trigger_behavior_description%]", "name": "[%key:component::moisture::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/motion/strings.json b/homeassistant/components/motion/strings.json index cf810f0065cdbb..44f8703d83d5e8 100644 --- a/homeassistant/components/motion/strings.json +++ b/homeassistant/components/motion/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted motion sensors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_detected": { "description": "Tests if one or more motion sensors are detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::condition_behavior_description%]", "name": "[%key:component::motion::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more motion sensors are not detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::condition_behavior_description%]", "name": "[%key:component::motion::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more motion sensors stop detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::trigger_behavior_description%]", "name": "[%key:component::motion::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more motion sensors start detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::trigger_behavior_description%]", "name": "[%key:component::motion::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/occupancy/strings.json b/homeassistant/components/occupancy/strings.json index b93743b2bb8f80..062dfa8e3369e2 100644 --- a/homeassistant/components/occupancy/strings.json +++ b/homeassistant/components/occupancy/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted occupancy sensors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted occupancy sensors to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_detected": { "description": "Tests if one or more occupancy sensors are detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::condition_behavior_description%]", "name": "[%key:component::occupancy::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more occupancy sensors are not detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::condition_behavior_description%]", "name": "[%key:component::occupancy::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more occupancy sensors stop detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::trigger_behavior_description%]", "name": "[%key:component::occupancy::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more occupancy sensors start detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::trigger_behavior_description%]", "name": "[%key:component::occupancy::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index 24b430197f85c4..af211e373a7e61 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted persons.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted persons to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_home": { "description": "Tests if one or more persons are home.", "fields": { "behavior": { - "description": "[%key:component::person::common::condition_behavior_description%]", "name": "[%key:component::person::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more persons are not home.", "fields": { "behavior": { - "description": "[%key:component::person::common::condition_behavior_description%]", "name": "[%key:component::person::common::condition_behavior_name%]" } }, @@ -80,7 +76,6 @@ "description": "Triggers when one or more persons enter home.", "fields": { "behavior": { - "description": "[%key:component::person::common::trigger_behavior_description%]", "name": "[%key:component::person::common::trigger_behavior_name%]" } }, @@ -90,7 +85,6 @@ "description": "Triggers when one or more persons leave home.", "fields": { "behavior": { - "description": "[%key:component::person::common::trigger_behavior_description%]", "name": "[%key:component::person::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/power/strings.json b/homeassistant/components/power/strings.json index f4369b0e225772..9be4af702e59af 100644 --- a/homeassistant/components/power/strings.json +++ b/homeassistant/components/power/strings.json @@ -1,25 +1,18 @@ { "common": { - "condition_behavior_description": "How the power value should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_value": { "description": "Tests the power value of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::power::common::condition_behavior_description%]", "name": "[%key:component::power::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::power::common::condition_threshold_description%]", "name": "[%key:component::power::common::condition_threshold_name%]" } }, @@ -47,7 +40,6 @@ "description": "Triggers after one or more power values change.", "fields": { "threshold": { - "description": "[%key:component::power::common::trigger_threshold_changed_description%]", "name": "[%key:component::power::common::trigger_threshold_name%]" } }, @@ -57,11 +49,9 @@ "description": "Triggers after one or more power values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::power::common::trigger_behavior_description%]", "name": "[%key:component::power::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::power::common::trigger_threshold_crossed_description%]", "name": "[%key:component::power::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index e2f6af02673799..8cad5e289acfda 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -1,7 +1,6 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted remotes to trigger on.", - "trigger_behavior_name": "Behavior" + "trigger_behavior_name": "Trigger when" }, "device_automation": { "action_type": { @@ -132,7 +131,6 @@ "description": "Triggers when one or more remotes turn off.", "fields": { "behavior": { - "description": "[%key:component::remote::common::trigger_behavior_description%]", "name": "[%key:component::remote::common::trigger_behavior_name%]" } }, @@ -142,7 +140,6 @@ "description": "Triggers when one or more remotes turn on.", "fields": { "behavior": { - "description": "[%key:component::remote::common::trigger_behavior_description%]", "name": "[%key:component::remote::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index bb51bd39dc092a..b8d3581a696d16 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted schedules.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted schedules to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_off": { "description": "Tests if one or more schedule blocks are currently not active.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::condition_behavior_description%]", "name": "[%key:component::schedule::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more schedule blocks are currently active.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::condition_behavior_description%]", "name": "[%key:component::schedule::common::condition_behavior_name%]" } }, @@ -79,7 +75,6 @@ "description": "Triggers when a schedule block ends.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::trigger_behavior_description%]", "name": "[%key:component::schedule::common::trigger_behavior_name%]" } }, @@ -89,7 +84,6 @@ "description": "Triggers when a schedule block starts.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::trigger_behavior_description%]", "name": "[%key:component::schedule::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index cac07327f53e1f..77f6d51a7fb7cc 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -4,8 +4,7 @@ "description": "Tests if one or more dropdowns have a specific option selected.", "fields": { "behavior": { - "description": "Whether the condition should pass when any or all targeted entities match.", - "name": "Behavior" + "name": "Condition passes if" }, "option": { "description": "The options to check for.", diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index b33c2592255eac..e20c34217364f5 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted sirens.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted sirens to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_off": { "description": "Tests if one or more sirens are off.", "fields": { "behavior": { - "description": "[%key:component::siren::common::condition_behavior_description%]", "name": "[%key:component::siren::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more sirens are on.", "fields": { "behavior": { - "description": "[%key:component::siren::common::condition_behavior_description%]", "name": "[%key:component::siren::common::condition_behavior_name%]" } }, @@ -90,7 +86,6 @@ "description": "Triggers after one or more sirens turn off.", "fields": { "behavior": { - "description": "[%key:component::siren::common::trigger_behavior_description%]", "name": "[%key:component::siren::common::trigger_behavior_name%]" } }, @@ -100,7 +95,6 @@ "description": "Triggers after one or more sirens turn on.", "fields": { "behavior": { - "description": "[%key:component::siren::common::trigger_behavior_description%]", "name": "[%key:component::siren::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 93b406e4eb1b5c..40f629b9e64106 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted switches.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted switches to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_off": { "description": "Tests if one or more switches are off.", "fields": { "behavior": { - "description": "[%key:component::switch::common::condition_behavior_description%]", "name": "[%key:component::switch::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more switches are on.", "fields": { "behavior": { - "description": "[%key:component::switch::common::condition_behavior_description%]", "name": "[%key:component::switch::common::condition_behavior_name%]" } }, @@ -104,7 +100,6 @@ "description": "Triggers after one or more switches turn off.", "fields": { "behavior": { - "description": "[%key:component::switch::common::trigger_behavior_description%]", "name": "[%key:component::switch::common::trigger_behavior_name%]" } }, @@ -114,7 +109,6 @@ "description": "Triggers after one or more switches turn on.", "fields": { "behavior": { - "description": "[%key:component::switch::common::trigger_behavior_description%]", "name": "[%key:component::switch::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/temperature/strings.json b/homeassistant/components/temperature/strings.json index e1c74365759cc8..c970474b78e022 100644 --- a/homeassistant/components/temperature/strings.json +++ b/homeassistant/components/temperature/strings.json @@ -1,25 +1,18 @@ { "common": { - "condition_behavior_description": "How the temperature should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_value": { "description": "Tests the temperature of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::temperature::common::condition_behavior_description%]", "name": "[%key:component::temperature::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::temperature::common::condition_threshold_description%]", "name": "[%key:component::temperature::common::condition_threshold_name%]" } }, @@ -47,7 +40,6 @@ "description": "Triggers after one or more temperatures change.", "fields": { "threshold": { - "description": "[%key:component::temperature::common::trigger_threshold_changed_description%]", "name": "[%key:component::temperature::common::trigger_threshold_name%]" } }, @@ -57,11 +49,9 @@ "description": "Triggers after one or more temperatures cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::temperature::common::trigger_behavior_description%]", "name": "[%key:component::temperature::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::temperature::common::trigger_threshold_crossed_description%]", "name": "[%key:component::temperature::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index e7fea2f230ed53..0eae84e3013032 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -1,14 +1,12 @@ { "common": { - "condition_behavior_description": "The behavior of the targeted texts to check.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if" }, "conditions": { "is_equal_to": { "description": "Tests if one or more texts are equal to a specified value.", "fields": { "behavior": { - "description": "[%key:component::text::common::condition_behavior_description%]", "name": "[%key:component::text::common::condition_behavior_name%]" }, "value": { diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 0b54a62f68b03f..4a3e6c2a3b3489 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,14 +1,12 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted timers.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if" }, "conditions": { "is_active": { "description": "Tests if one or more timers are active.", "fields": { "behavior": { - "description": "[%key:component::timer::common::condition_behavior_description%]", "name": "[%key:component::timer::common::condition_behavior_name%]" } }, @@ -18,7 +16,6 @@ "description": "Tests if one or more timers are idle.", "fields": { "behavior": { - "description": "[%key:component::timer::common::condition_behavior_description%]", "name": "[%key:component::timer::common::condition_behavior_name%]" } }, @@ -28,7 +25,6 @@ "description": "Tests if one or more timers are paused.", "fields": { "behavior": { - "description": "[%key:component::timer::common::condition_behavior_description%]", "name": "[%key:component::timer::common::condition_behavior_name%]" } }, diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index c0851796efea1d..7634d59a3c3777 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -1,7 +1,6 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted updates to become available.", - "trigger_behavior_name": "Behavior" + "trigger_behavior_name": "Trigger when" }, "device_automation": { "extra_fields": { @@ -98,7 +97,6 @@ "description": "Triggers after one or more updates become available.", "fields": { "behavior": { - "description": "[%key:component::update::common::trigger_behavior_description%]", "name": "[%key:component::update::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index ebd0febdbed0b8..364a4bfef0ee79 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted vacuum cleaners.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted vacuum cleaners to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_cleaning": { "description": "Tests if one or more vacuum cleaners are cleaning.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more vacuum cleaners are docked.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -30,7 +26,6 @@ "description": "Tests if one or more vacuum cleaners are encountering an error.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -40,7 +35,6 @@ "description": "Tests if one or more vacuum cleaners are paused.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -50,7 +44,6 @@ "description": "Tests if one or more vacuum cleaners are returning to the dock.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" } }, @@ -197,7 +190,6 @@ "description": "Triggers after one or more vacuums have returned to dock.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, @@ -207,7 +199,6 @@ "description": "Triggers after one or more vacuums encounter an error.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, @@ -217,7 +208,6 @@ "description": "Triggers after one or more vacuums pause cleaning.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, @@ -227,7 +217,6 @@ "description": "Triggers after one or more vacuums start cleaning.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, @@ -237,7 +226,6 @@ "description": "Triggers after one or more vacuums start returning to dock.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json index cd01e3142cf407..3775f38fb1851e 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,15 +1,14 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted valves to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more valves are closed.", "fields": { "behavior": { - "description": "Whether the condition should pass when any or all targeted entities match.", - "name": "Behavior" + "name": "[%key:component::valve::common::condition_behavior_name%]" } }, "name": "Valve is closed" @@ -18,8 +17,7 @@ "description": "Tests if one or more valves are open.", "fields": { "behavior": { - "description": "Whether the condition should pass when any or all targeted entities match.", - "name": "Behavior" + "name": "[%key:component::valve::common::condition_behavior_name%]" } }, "name": "Valve is open" @@ -97,7 +95,6 @@ "description": "Triggers after one or more valves close.", "fields": { "behavior": { - "description": "[%key:component::valve::common::trigger_behavior_description%]", "name": "[%key:component::valve::common::trigger_behavior_name%]" } }, @@ -107,7 +104,6 @@ "description": "Triggers after one or more valves open.", "fields": { "behavior": { - "description": "[%key:component::valve::common::trigger_behavior_description%]", "name": "[%key:component::valve::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 46362df0654c13..1e7da70662aab0 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -1,21 +1,15 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted water heaters.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted water heaters to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "condition_behavior_name": "Condition passes if", + "condition_threshold_name": "Threshold type", + "trigger_behavior_name": "Trigger when", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_off": { "description": "Tests if one or more water heaters are off.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" } }, @@ -25,7 +19,6 @@ "description": "Tests if one or more water heaters are on.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" } }, @@ -35,7 +28,6 @@ "description": "Tests if one or more water heaters are set to a specific operation mode.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, "operation_mode": { @@ -49,11 +41,9 @@ "description": "Tests the temperature setpoint of one or more water heaters.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, "threshold": { - "description": "[%key:component::water_heater::common::condition_threshold_description%]", "name": "[%key:component::water_heater::common::condition_threshold_name%]" } }, @@ -192,7 +182,6 @@ "description": "Triggers after the operation mode of one or more water heaters changes to a specific mode.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" }, "operation_mode": { @@ -206,7 +195,6 @@ "description": "Triggers after the temperature setpoint of one or more water heaters changes.", "fields": { "threshold": { - "description": "[%key:component::water_heater::common::trigger_threshold_changed_description%]", "name": "[%key:component::water_heater::common::trigger_threshold_name%]" } }, @@ -216,11 +204,9 @@ "description": "Triggers after the temperature setpoint of one or more water heaters crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" }, "threshold": { - "description": "[%key:component::water_heater::common::trigger_threshold_crossed_description%]", "name": "[%key:component::water_heater::common::trigger_threshold_name%]" } }, @@ -230,7 +216,6 @@ "description": "Triggers after one or more water heaters turn off.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" } }, @@ -240,7 +225,6 @@ "description": "Triggers after one or more water heaters turn on, regardless of the operation mode.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" } }, diff --git a/homeassistant/components/window/strings.json b/homeassistant/components/window/strings.json index b0b4d3f4aefa5d..5f8de98998f72a 100644 --- a/homeassistant/components/window/strings.json +++ b/homeassistant/components/window/strings.json @@ -1,16 +1,13 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted windows.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted windows to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "trigger_behavior_name": "Trigger when" }, "conditions": { "is_closed": { "description": "Tests if one or more windows are closed.", "fields": { "behavior": { - "description": "[%key:component::window::common::condition_behavior_description%]", "name": "[%key:component::window::common::condition_behavior_name%]" } }, @@ -20,7 +17,6 @@ "description": "Tests if one or more windows are open.", "fields": { "behavior": { - "description": "[%key:component::window::common::condition_behavior_description%]", "name": "[%key:component::window::common::condition_behavior_name%]" } }, @@ -48,7 +44,6 @@ "description": "Triggers after one or more windows close.", "fields": { "behavior": { - "description": "[%key:component::window::common::trigger_behavior_description%]", "name": "[%key:component::window::common::trigger_behavior_name%]" } }, @@ -58,7 +53,6 @@ "description": "Triggers after one or more windows open.", "fields": { "behavior": { - "description": "[%key:component::window::common::trigger_behavior_description%]", "name": "[%key:component::window::common::trigger_behavior_name%]" } }, From 0807525e1b813520607a2474796b03153687debd Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Mar 2026 18:26:51 +0200 Subject: [PATCH 0252/1707] Update frontend to 20260325.4 (#166970) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4c8256a82e6091..a87ec738fe02dc 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.2"] + "requirements": ["home-assistant-frontend==20260325.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 430e8d81f1cb52..e6417021b0ded6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.2 +home-assistant-frontend==20260325.4 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 26302484ad8927..0bbeb5e2827cf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.2 +home-assistant-frontend==20260325.4 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6e3ec9f1513cd..4b9bcea7f9a653 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.2 +home-assistant-frontend==20260325.4 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From 84f36b0d4d0815e9491beb43d9221cee1fca6f4d Mon Sep 17 00:00:00 2001 From: Claw Explorer Date: Tue, 31 Mar 2026 12:33:48 -0400 Subject: [PATCH 0253/1707] Migrate tilt_ble to use runtime_data (#166663) --- homeassistant/components/tilt_ble/__init__.py | 28 ++++++++----------- homeassistant/components/tilt_ble/sensor.py | 10 ++----- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tilt_ble/__init__.py b/homeassistant/components/tilt_ble/__init__.py index 9ac2cb91aff7ae..09bda4212f3195 100644 --- a/homeassistant/components/tilt_ble/__init__.py +++ b/homeassistant/components/tilt_ble/__init__.py @@ -14,27 +14,26 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type TiltBLEConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] + -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TiltBLEConfigEntry) -> bool: """Set up Tilt BLE device from a config entry.""" address = entry.unique_id assert address is not None data = TiltBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=data.update, - ) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( coordinator.async_start() @@ -42,9 +41,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TiltBLEConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tilt_ble/sensor.py b/homeassistant/components/tilt_ble/sensor.py index 411484cf2fe8e3..d5f9bd342321fc 100644 --- a/homeassistant/components/tilt_ble/sensor.py +++ b/homeassistant/components/tilt_ble/sensor.py @@ -4,12 +4,10 @@ from tilt_ble import DeviceClass, DeviceKey, SensorUpdate, Units -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -23,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import TiltBLEConfigEntry SENSOR_DESCRIPTIONS = { (DeviceClass.TEMPERATURE, Units.TEMP_FAHRENHEIT): SensorEntityDescription( @@ -85,13 +83,11 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: TiltBLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tilt Hydrometer BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( From 5425e82fb40b4895fc562644501b33145df2c238 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:55:47 +0200 Subject: [PATCH 0254/1707] Migrate nuheat to use runtime_data (#166937) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/nuheat/__init__.py | 20 ++++++------------- homeassistant/components/nuheat/climate.py | 10 +++++----- .../components/nuheat/coordinator.py | 7 +++++-- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py index 21c7ca79a1fad9..ca72d4906aee7b 100644 --- a/homeassistant/components/nuheat/__init__.py +++ b/homeassistant/components/nuheat/__init__.py @@ -6,13 +6,12 @@ import nuheat import requests -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS -from .coordinator import NuHeatCoordinator +from .const import CONF_SERIAL_NUMBER, PLATFORMS +from .coordinator import NuHeatConfigEntry, NuHeatCoordinator _LOGGER = logging.getLogger(__name__) @@ -23,7 +22,7 @@ def _get_thermostat(api: nuheat.NuHeat, serial_number: str) -> nuheat.NuHeatTher return api.get_thermostat(serial_number) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NuHeatConfigEntry) -> bool: """Set up NuHeat from a config entry.""" conf = entry.data @@ -52,20 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Failed to login to nuheat: %s", ex) return False - coordinator = NuHeatCoordinator(hass, entry, thermostat) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = (thermostat, coordinator) + entry.runtime_data = NuHeatCoordinator(hass, entry, thermostat) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NuHeatConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index e666e4be0cd03f..4625614e773b62 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -18,7 +18,6 @@ HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import event as event_helper @@ -27,7 +26,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, NUHEAT_API_STATE_SHIFT_DELAY -from .coordinator import NuHeatCoordinator +from .coordinator import NuHeatConfigEntry, NuHeatCoordinator _LOGGER = logging.getLogger(__name__) @@ -55,14 +54,15 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NuHeatConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NuHeat thermostat(s).""" - thermostat, coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data temperature_unit = hass.config.units.temperature_unit - entity = NuHeatThermostat(coordinator, thermostat, temperature_unit) + + entity = NuHeatThermostat(coordinator, coordinator.thermostat, temperature_unit) # No longer need a service as set_hvac_mode to auto does this # since climate 1.0 has been implemented diff --git a/homeassistant/components/nuheat/coordinator.py b/homeassistant/components/nuheat/coordinator.py index 6555f7376ed116..e1c61bbf1cc816 100644 --- a/homeassistant/components/nuheat/coordinator.py +++ b/homeassistant/components/nuheat/coordinator.py @@ -16,15 +16,18 @@ SCAN_INTERVAL = timedelta(minutes=5) +type NuHeatConfigEntry = ConfigEntry[NuHeatCoordinator] + + class NuHeatCoordinator(DataUpdateCoordinator[None]): """Coordinator for NuHeat thermostat data.""" - config_entry: ConfigEntry + config_entry: NuHeatConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: NuHeatConfigEntry, thermostat: nuheat.NuHeatThermostat, ) -> None: """Initialize the coordinator.""" From cda1974e406ed05a33e72855be2e2a70c7600303 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:02:58 +0200 Subject: [PATCH 0255/1707] Add `html5.dismiss_message` action to HTML5 integration (#166909) --- homeassistant/components/html5/icons.json | 5 +- homeassistant/components/html5/issue.py | 19 ++++ homeassistant/components/html5/notify.py | 10 ++- homeassistant/components/html5/services.py | 13 +++ homeassistant/components/html5/services.yaml | 9 +- homeassistant/components/html5/strings.json | 14 +++ tests/components/html5/test_notify.py | 95 ++++++++++++++++++-- 7 files changed, 153 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/html5/icons.json b/homeassistant/components/html5/icons.json index e4b738f22b3704..a01f7cf3b72cc8 100644 --- a/homeassistant/components/html5/icons.json +++ b/homeassistant/components/html5/icons.json @@ -10,8 +10,11 @@ "dismiss": { "service": "mdi:bell-off" }, + "dismiss_message": { + "service": "mdi:comment-remove" + }, "send_message": { - "service": "mdi:message-arrow-right" + "service": "mdi:comment-arrow-right" } } } diff --git a/homeassistant/components/html5/issue.py b/homeassistant/components/html5/issue.py index a12c5e9217d8a5..6f3070ed6042fc 100644 --- a/homeassistant/components/html5/issue.py +++ b/homeassistant/components/html5/issue.py @@ -29,3 +29,22 @@ def deprecated_notify_action_call( translation_key="deprecated_notify_action", translation_placeholders={"action": action}, ) + + +@callback +def deprecated_dismiss_action_call(hass: HomeAssistant) -> None: + """Deprecated action call.""" + + async_create_issue( + hass, + DOMAIN, + "deprecated_dismiss_action", + breaks_in_ha_version="2026.11.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_dismiss_action", + translation_placeholders={ + "action": "html5.dismiss", + "new_action": "html5.dismiss_message", + }, + ) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 5d7989a129ec0e..c3ea03d01b91ec 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -60,7 +60,7 @@ SERVICE_DISMISS, ) from .entity import HTML5Entity, Registration -from .issue import deprecated_notify_action_call +from .issue import deprecated_dismiss_action_call, deprecated_notify_action_call _LOGGER = logging.getLogger(__name__) @@ -460,6 +460,9 @@ async def async_dismiss(self, **kwargs: Any) -> None: This method must be run in the event loop. """ + + deprecated_dismiss_action_call(self.hass) + data: dict[str, Any] | None = kwargs.get(ATTR_DATA) tag: str = data.get(ATTR_TAG, "") if data else "" payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} @@ -624,6 +627,11 @@ async def send_push_notification(self, **kwargs: Any) -> None: await self._webpush(**kwargs) self._async_record_notification() + async def dismiss_notification(self, tag: str = "") -> None: + """Dismiss a message via html5.dismiss_message action.""" + await self._webpush(dismiss=True, tag=tag) + self._async_record_notification() + async def _webpush( self, message: str | None = None, diff --git a/homeassistant/components/html5/services.py b/homeassistant/components/html5/services.py index 40a2e1c311c3ea..06b6e5f92a0386 100644 --- a/homeassistant/components/html5/services.py +++ b/homeassistant/components/html5/services.py @@ -32,6 +32,7 @@ ) SERVICE_SEND_MESSAGE = "send_message" +SERVICE_DISMISS_MESSAGE = "dismiss_message" SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema( { @@ -67,6 +68,10 @@ } ) +SERVICE_DISMISS_MESSAGE_SCHEMA = cv.make_entity_service_schema( + {vol.Optional(ATTR_TAG): cv.string} +) + @callback def async_setup_services(hass: HomeAssistant) -> None: @@ -80,3 +85,11 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_SEND_MESSAGE_SCHEMA, func="send_push_notification", ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_DISMISS_MESSAGE, + entity_domain=NOTIFY_DOMAIN, + schema=SERVICE_DISMISS_MESSAGE_SCHEMA, + func="dismiss_notification", + ) diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml index 5f42fa7e30bb3d..9ed88b2ba71b21 100644 --- a/homeassistant/components/html5/services.yaml +++ b/homeassistant/components/html5/services.yaml @@ -44,7 +44,7 @@ send_message: text: type: url example: /static/images/image.jpg - tag: + tag: &tag required: false selector: text: @@ -142,3 +142,10 @@ send_message: selector: object: example: "{'customKey': 'customValue'}" +dismiss_message: + target: + entity: + domain: notify + integration: html5 + fields: + tag: *tag diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 5c4dd18830a1e5..98e3b3bf0cabe0 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -49,6 +49,10 @@ } }, "issues": { + "deprecated_dismiss_action": { + "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action}` action instead.", + "title": "[%key:component::html5::issues::deprecated_notify_action::title%]" + }, "deprecated_notify_action": { "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `notify.send_message` or `html5.send_message` actions instead.", "title": "Detected use of deprecated action {action}" @@ -101,6 +105,16 @@ }, "name": "Dismiss" }, + "dismiss_message": { + "description": "Dismisses one or more HTML5 notifications.", + "fields": { + "tag": { + "description": "The tag of the notifications to dismiss. If not specified, all notifications will be dismissed.", + "name": "Tag" + } + }, + "name": "Dismiss message" + }, "send_message": { "description": "Sends a message via HTML5 Push Notifications", "fields": { diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index ef978a7045e220..5a2c6d55c5ad5a 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -28,8 +28,10 @@ ATTR_TTL, ATTR_URGENCY, ATTR_VIBRATE, + SERVICE_DISMISS, ) -from homeassistant.components.html5.notify import ATTR_ACTION, DEFAULT_TTL +from homeassistant.components.html5.notify import ATTR_ACTION, ATTR_DISMISS, DEFAULT_TTL +from homeassistant.components.html5.services import SERVICE_DISMISS_MESSAGE from homeassistant.components.notify import ( ATTR_DATA, ATTR_MESSAGE, @@ -1114,11 +1116,32 @@ async def test_html5_send_message( @pytest.mark.parametrize( - ("target", "issue_id"), + ("domain", "service", "service_data", "issue_id"), [ - (["my-desktop"], "deprecated_notify_action_notify.html5_my_desktop"), - (None, "deprecated_notify_action_notify.html5"), - (["my-desktop", "my-phone"], "deprecated_notify_action_notify.html5"), + ( + NOTIFY_DOMAIN, + "html5_my_desktop", + {ATTR_MESSAGE: "Hello", ATTR_TARGET: ["my-desktop"]}, + "deprecated_notify_action_notify.html5_my_desktop", + ), + ( + NOTIFY_DOMAIN, + DOMAIN, + {ATTR_MESSAGE: "Hello"}, + "deprecated_notify_action_notify.html5", + ), + ( + NOTIFY_DOMAIN, + DOMAIN, + {ATTR_MESSAGE: "Hello", ATTR_TARGET: ["my-desktop", "my-phone"]}, + "deprecated_notify_action_notify.html5", + ), + ( + DOMAIN, + SERVICE_DISMISS, + {}, + "deprecated_dismiss_action", + ), ], ) @pytest.mark.usefixtures("mock_wp", "mock_jwt", "mock_vapid", "mock_uuid") @@ -1127,7 +1150,9 @@ async def test_deprecation_action_call( config_entry: MockConfigEntry, load_config: MagicMock, issue_registry: ir.IssueRegistry, - target: list[str] | None, + domain: str, + service: str, + service_data: dict[str, Any] | None, issue_id: str, ) -> None: """Test deprecation action call.""" @@ -1143,9 +1168,9 @@ async def test_deprecation_action_call( assert config_entry.state is ConfigEntryState.LOADED await hass.services.async_call( - NOTIFY_DOMAIN, - DOMAIN, - {ATTR_MESSAGE: "Hello", ATTR_TARGET: target}, + domain, + service, + service_data, blocking=True, ) @@ -1153,3 +1178,55 @@ async def test_deprecation_action_call( domain=DOMAIN, issue_id=issue_id, ) + + +@pytest.mark.parametrize( + ("service_data", "expected_payload"), + [ + ( + {ATTR_TAG: "message-group-1"}, + {ATTR_DISMISS: True, ATTR_TAG: "message-group-1"}, + ), + ( + {}, + {ATTR_DISMISS: True, ATTR_TAG: ""}, + ), + ], +) +@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid") +@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z") +async def test_html5_dismiss_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + webpush_async: AsyncMock, + load_config: MagicMock, + service_data: dict[str, Any], + expected_payload: dict[str, Any], +) -> None: + """Test dismissing a message via html5.dismiss_message action.""" + load_config.return_value = {"my-desktop": SUBSCRIPTION_1} + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + DOMAIN, + SERVICE_DISMISS_MESSAGE, + { + ATTR_ENTITY_ID: "notify.my_desktop", + **service_data, + }, + blocking=True, + ) + + webpush_async.assert_awaited_once() + assert webpush_async.await_args + _, payload, _, _ = webpush_async.await_args.args + assert json.loads(payload) == { + "timestamp": 1234567890000, + "data": {"jwt": "JWT"}, + **expected_payload, + } From fc32f0dbd3376f780383bb412b6ac2504174031b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Mar 2026 19:59:28 +0200 Subject: [PATCH 0256/1707] Make sure we can fetch player stats in Chess.com (#166980) --- homeassistant/components/chess_com/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/chess_com/config_flow.py b/homeassistant/components/chess_com/config_flow.py index 687d331b1ddb65..fea9ffd94df9ba 100644 --- a/homeassistant/components/chess_com/config_flow.py +++ b/homeassistant/components/chess_com/config_flow.py @@ -30,6 +30,7 @@ async def async_step_user( client = ChessComClient(session=session) try: user = await client.get_player(user_input[CONF_USERNAME]) + await client.get_player_stats(user_input[CONF_USERNAME]) except NotFoundError: errors["base"] = "player_not_found" except Exception: From d6cd1dffa477a969941331c2a7bb20cd8e54c139 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 31 Mar 2026 20:00:37 +0200 Subject: [PATCH 0257/1707] Fix grammar of `input_shutdown_failure` error in `victron_ble` (#166972) --- homeassistant/components/victron_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/victron_ble/strings.json b/homeassistant/components/victron_ble/strings.json index c599e61a83ae54..901594b473efa3 100644 --- a/homeassistant/components/victron_ble/strings.json +++ b/homeassistant/components/victron_ble/strings.json @@ -145,7 +145,7 @@ "input_current": "Input overcurrent", "input_power": "Input overpower", "input_shutdown_current": "Input shutdown (current flow during off mode)", - "input_shutdown_failure": "PV input failed to shutdown", + "input_shutdown_failure": "PV input shutdown failed", "input_shutdown_voltage": "Input shutdown (battery overvoltage)", "input_voltage": "Input overvoltage", "internal_dc_voltage": "Internal DC voltage error", From 02bcae00cf2b1ed9d97f4d92b44769c5a81d8751 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 31 Mar 2026 21:27:12 +0300 Subject: [PATCH 0258/1707] Document supported features for Anthropic integration (#166818) --- homeassistant/components/anthropic/quality_scale.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml index 011073a9326139..8d62ea26f479c9 100644 --- a/homeassistant/components/anthropic/quality_scale.yaml +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -61,10 +61,7 @@ rules: No data updates. docs-examples: done docs-known-limitations: done - docs-supported-devices: - status: todo - comment: | - To write something about what models we support. + docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done From ef6718c2429b3a4ac2458b6394dc728ac46a5c25 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:31:45 +0200 Subject: [PATCH 0259/1707] Add skeleton with repair issue to bmw integration (#166983) Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- .../bmw_connected_drive/__init__.py | 41 ++++++++++ .../bmw_connected_drive/config_flow.py | 9 +++ .../bmw_connected_drive/manifest.json | 10 +++ .../bmw_connected_drive/strings.json | 8 ++ script/hassfest/quality_scale.py | 2 + .../bmw_connected_drive/__init__.py | 1 + .../bmw_connected_drive/test_init.py | 79 +++++++++++++++++++ 7 files changed, 150 insertions(+) create mode 100644 homeassistant/components/bmw_connected_drive/__init__.py create mode 100644 homeassistant/components/bmw_connected_drive/config_flow.py create mode 100644 homeassistant/components/bmw_connected_drive/manifest.json create mode 100644 homeassistant/components/bmw_connected_drive/strings.json create mode 100644 tests/components/bmw_connected_drive/__init__.py create mode 100644 tests/components/bmw_connected_drive/test_init.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py new file mode 100644 index 00000000000000..16133b5a1c19eb --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -0,0 +1,41 @@ +"""The BMW Connected Drive integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +DOMAIN = "bmw_connected_drive" + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up BMW Connected Drive from a config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/bmw_connected_drive", + "custom_component_url": "https://github.com/kvanbiesen/bmw-cardata-ha", + }, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py new file mode 100644 index 00000000000000..7295864c29c8a4 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -0,0 +1,9 @@ +"""The BMW Connected Drive integration config flow.""" + +from homeassistant.config_entries import ConfigFlow + +from . import DOMAIN + + +class BMWConnectedDriveConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BMW Connected Drive.""" diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json new file mode 100644 index 00000000000000..b1c3cc9769bbaa --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bmw_connected_drive", + "name": "BMW Connected Drive", + "codeowners": [], + "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", + "integration_type": "system", + "iot_class": "cloud_polling", + "quality_scale": "legacy", + "requirements": [] +} diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json new file mode 100644 index 00000000000000..7ff1b1eb99c13b --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "integration_removed": { + "description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).", + "title": "The BMW Connected Drive integration has been removed" + } + } +} diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3e5b852d5b45a7..bd3288e75107d3 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -212,6 +212,7 @@ class Rule: "bluetooth", "bluetooth_adapters", "bluetooth_le_tracker", + "bmw_connected_drive", "bond", "bosch_shc", "braviatv", @@ -1183,6 +1184,7 @@ class Rule: "bluetooth", "bluetooth_adapters", "bluetooth_le_tracker", + "bmw_connected_drive", "bond", "bosch_shc", "braviatv", diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py new file mode 100644 index 00000000000000..265e4b0c4f5876 --- /dev/null +++ b/tests/components/bmw_connected_drive/__init__.py @@ -0,0 +1 @@ +"""Tests for the BMW Connected Drive integration.""" diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py new file mode 100644 index 00000000000000..c05c98d07f7dbe --- /dev/null +++ b/tests/components/bmw_connected_drive/test_init.py @@ -0,0 +1,79 @@ +"""Tests for the BMW Connected Drive integration.""" + +from homeassistant.components.bmw_connected_drive import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_bmw_connected_drive_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the BMW Connected Drive configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From 3ba985f771e43bdade405de2dabc8a774a260b9f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Mar 2026 20:40:04 +0200 Subject: [PATCH 0260/1707] Pull out Dropbox integration (#166986) --- .strict-typing | 1 - CODEOWNERS | 2 - homeassistant/components/dropbox/__init__.py | 64 -- .../dropbox/application_credentials.py | 38 -- homeassistant/components/dropbox/auth.py | 44 -- homeassistant/components/dropbox/backup.py | 230 ------- .../components/dropbox/config_flow.py | 60 -- homeassistant/components/dropbox/const.py | 19 - .../components/dropbox/manifest.json | 13 - .../components/dropbox/quality_scale.yaml | 112 ---- homeassistant/components/dropbox/strings.json | 35 -- .../generated/application_credentials.py | 1 - homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - mypy.ini | 10 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/dropbox/__init__.py | 1 - tests/components/dropbox/conftest.py | 114 ---- tests/components/dropbox/test_backup.py | 577 ------------------ tests/components/dropbox/test_config_flow.py | 210 ------- tests/components/dropbox/test_init.py | 100 --- 22 files changed, 1644 deletions(-) delete mode 100644 homeassistant/components/dropbox/__init__.py delete mode 100644 homeassistant/components/dropbox/application_credentials.py delete mode 100644 homeassistant/components/dropbox/auth.py delete mode 100644 homeassistant/components/dropbox/backup.py delete mode 100644 homeassistant/components/dropbox/config_flow.py delete mode 100644 homeassistant/components/dropbox/const.py delete mode 100644 homeassistant/components/dropbox/manifest.json delete mode 100644 homeassistant/components/dropbox/quality_scale.yaml delete mode 100644 homeassistant/components/dropbox/strings.json delete mode 100644 tests/components/dropbox/__init__.py delete mode 100644 tests/components/dropbox/conftest.py delete mode 100644 tests/components/dropbox/test_backup.py delete mode 100644 tests/components/dropbox/test_config_flow.py delete mode 100644 tests/components/dropbox/test_init.py diff --git a/.strict-typing b/.strict-typing index 5e1549256616c9..14a2a7ed98c739 100644 --- a/.strict-typing +++ b/.strict-typing @@ -174,7 +174,6 @@ homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.downloader.* -homeassistant.components.dropbox.* homeassistant.components.droplet.* homeassistant.components.dsmr.* homeassistant.components.duckdns.* diff --git a/CODEOWNERS b/CODEOWNERS index a7fac84580c936..32705e6c684e13 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -401,8 +401,6 @@ build.json @home-assistant/supervisor /tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer -/homeassistant/components/dropbox/ @bdr99 -/tests/components/dropbox/ @bdr99 /homeassistant/components/droplet/ @sarahseidman /tests/components/droplet/ @sarahseidman /homeassistant/components/dsmr/ @Robbie1221 diff --git a/homeassistant/components/dropbox/__init__.py b/homeassistant/components/dropbox/__init__.py deleted file mode 100644 index 4be8074a5cd188..00000000000000 --- a/homeassistant/components/dropbox/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -"""The Dropbox integration.""" - -from __future__ import annotations - -from python_dropbox_api import ( - DropboxAPIClient, - DropboxAuthException, - DropboxUnknownException, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.config_entry_oauth2_flow import ( - ImplementationUnavailableError, - OAuth2Session, - async_get_config_entry_implementation, -) - -from .auth import DropboxConfigEntryAuth -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN - -type DropboxConfigEntry = ConfigEntry[DropboxAPIClient] - - -async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: - """Set up Dropbox from a config entry.""" - try: - oauth2_implementation = await async_get_config_entry_implementation(hass, entry) - except ImplementationUnavailableError as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="oauth2_implementation_unavailable", - ) from err - oauth2_session = OAuth2Session(hass, entry, oauth2_implementation) - - auth = DropboxConfigEntryAuth( - aiohttp_client.async_get_clientsession(hass), oauth2_session - ) - - client = DropboxAPIClient(auth) - - try: - await client.get_account_info() - except DropboxAuthException as err: - raise ConfigEntryAuthFailed from err - except (DropboxUnknownException, TimeoutError) as err: - raise ConfigEntryNotReady from err - - entry.runtime_data = client - - def async_notify_backup_listeners() -> None: - for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): - listener() - - entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: - """Unload a config entry.""" - return True diff --git a/homeassistant/components/dropbox/application_credentials.py b/homeassistant/components/dropbox/application_credentials.py deleted file mode 100644 index 3babe856a28aca..00000000000000 --- a/homeassistant/components/dropbox/application_credentials.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Application credentials platform for the Dropbox integration.""" - -from homeassistant.components.application_credentials import ClientCredential -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import ( - AbstractOAuth2Implementation, - LocalOAuth2ImplementationWithPkce, -) - -from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN - - -async def async_get_auth_implementation( - hass: HomeAssistant, auth_domain: str, credential: ClientCredential -) -> AbstractOAuth2Implementation: - """Return custom auth implementation.""" - return DropboxOAuth2Implementation( - hass, - auth_domain, - credential.client_id, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, - credential.client_secret, - ) - - -class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce): - """Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters.""" - - @property - def extra_authorize_data(self) -> dict: - """Extra data that needs to be appended to the authorize url.""" - data: dict = { - "token_access_type": "offline", - "scope": " ".join(OAUTH2_SCOPES), - } - data.update(super().extra_authorize_data) - return data diff --git a/homeassistant/components/dropbox/auth.py b/homeassistant/components/dropbox/auth.py deleted file mode 100644 index da6d72f6748f23..00000000000000 --- a/homeassistant/components/dropbox/auth.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Authentication for Dropbox.""" - -from typing import cast - -from aiohttp import ClientSession -from python_dropbox_api import Auth - -from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session - - -class DropboxConfigEntryAuth(Auth): - """Provide Dropbox authentication tied to an OAuth2 based config entry.""" - - def __init__( - self, - websession: ClientSession, - oauth_session: OAuth2Session, - ) -> None: - """Initialize DropboxConfigEntryAuth.""" - super().__init__(websession) - self._oauth_session = oauth_session - - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - await self._oauth_session.async_ensure_token_valid() - - return cast(str, self._oauth_session.token["access_token"]) - - -class DropboxConfigFlowAuth(Auth): - """Provide authentication tied to a fixed token for the config flow.""" - - def __init__( - self, - websession: ClientSession, - token: str, - ) -> None: - """Initialize DropboxConfigFlowAuth.""" - super().__init__(websession) - self._token = token - - async def async_get_access_token(self) -> str: - """Return the fixed access token.""" - return self._token diff --git a/homeassistant/components/dropbox/backup.py b/homeassistant/components/dropbox/backup.py deleted file mode 100644 index bc7af3d5cbc859..00000000000000 --- a/homeassistant/components/dropbox/backup.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Backup platform for the Dropbox integration.""" - -from collections.abc import AsyncIterator, Callable, Coroutine -from functools import wraps -import json -import logging -from typing import Any, Concatenate - -from python_dropbox_api import ( - DropboxAPIClient, - DropboxAuthException, - DropboxFileOrFolderNotFoundException, - DropboxUnknownException, -) - -from homeassistant.components.backup import ( - AgentBackup, - BackupAgent, - BackupAgentError, - BackupNotFound, - suggested_filename, -) -from homeassistant.core import HomeAssistant, callback - -from . import DropboxConfigEntry -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]: - """Return the suggested filenames for the backup and metadata.""" - base_name = suggested_filename(backup).rsplit(".", 1)[0] - return f"{base_name}.tar", f"{base_name}.metadata.json" - - -async def _async_string_iterator(content: str) -> AsyncIterator[bytes]: - """Yield a string as a single bytes chunk.""" - yield content.encode() - - -def handle_backup_errors[_R, **P]( - func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]], -) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]: - """Handle backup errors.""" - - @wraps(func) - async def wrapper( - self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs - ) -> _R: - try: - return await func(self, *args, **kwargs) - except DropboxFileOrFolderNotFoundException as err: - raise BackupNotFound( - f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" - ) from err - except DropboxAuthException as err: - self._entry.async_start_reauth(self._hass) - raise BackupAgentError("Authentication error") from err - except DropboxUnknownException as err: - _LOGGER.error( - "Error during %s: %s", - func.__name__, - err, - ) - _LOGGER.debug("Full error: %s", err, exc_info=True) - raise BackupAgentError( - f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" - ) from err - - return wrapper - - -async def async_get_backup_agents( - hass: HomeAssistant, - **kwargs: Any, -) -> list[BackupAgent]: - """Return a list of backup agents.""" - entries = hass.config_entries.async_loaded_entries(DOMAIN) - return [DropboxBackupAgent(hass, entry) for entry in entries] - - -@callback -def async_register_backup_agents_listener( - hass: HomeAssistant, - *, - listener: Callable[[], None], - **kwargs: Any, -) -> Callable[[], None]: - """Register a listener to be called when agents are added or removed. - - :return: A function to unregister the listener. - """ - hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) - - @callback - def remove_listener() -> None: - """Remove the listener.""" - hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) - if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: - del hass.data[DATA_BACKUP_AGENT_LISTENERS] - - return remove_listener - - -class DropboxBackupAgent(BackupAgent): - """Backup agent for the Dropbox integration.""" - - domain = DOMAIN - - def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None: - """Initialize the backup agent.""" - super().__init__() - self._hass = hass - self._entry = entry - self.name = entry.title - assert entry.unique_id - self.unique_id = entry.unique_id - self._api: DropboxAPIClient = entry.runtime_data - - async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]: - """Get backups and their corresponding file names.""" - files = await self._api.list_folder("") - - tar_files = {f.name for f in files if f.name.endswith(".tar")} - metadata_files = [f for f in files if f.name.endswith(".metadata.json")] - - backups: list[tuple[AgentBackup, str]] = [] - for metadata_file in metadata_files: - tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar" - if tar_name not in tar_files: - _LOGGER.warning( - "Found metadata file '%s' without matching backup file", - metadata_file.name, - ) - continue - - metadata_stream = self._api.download_file(f"/{metadata_file.name}") - raw = b"".join([chunk async for chunk in metadata_stream]) - try: - data = json.loads(raw) - backup = AgentBackup.from_dict(data) - except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err: - _LOGGER.warning( - "Skipping invalid metadata file '%s': %s", - metadata_file.name, - err, - ) - continue - backups.append((backup, tar_name)) - - return backups - - @handle_backup_errors - async def async_upload_backup( - self, - *, - open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - backup: AgentBackup, - **kwargs: Any, - ) -> None: - """Upload a backup.""" - backup_filename, metadata_filename = _suggested_filenames(backup) - backup_path = f"/{backup_filename}" - metadata_path = f"/{metadata_filename}" - - file_stream = await open_stream() - await self._api.upload_file(backup_path, file_stream) - - metadata_stream = _async_string_iterator(json.dumps(backup.as_dict())) - - try: - await self._api.upload_file(metadata_path, metadata_stream) - except ( - DropboxAuthException, - DropboxUnknownException, - ): - await self._api.delete_file(backup_path) - raise - - @handle_backup_errors - async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: - """List backups.""" - return [backup for backup, _ in await self._async_get_backups()] - - @handle_backup_errors - async def async_download_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AsyncIterator[bytes]: - """Download a backup file.""" - backups = await self._async_get_backups() - for backup, filename in backups: - if backup.backup_id == backup_id: - return self._api.download_file(f"/{filename}") - - raise BackupNotFound(f"Backup {backup_id} not found") - - @handle_backup_errors - async def async_get_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AgentBackup: - """Return a backup.""" - backups = await self._async_get_backups() - - for backup, _ in backups: - if backup.backup_id == backup_id: - return backup - - raise BackupNotFound(f"Backup {backup_id} not found") - - @handle_backup_errors - async def async_delete_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> None: - """Delete a backup file.""" - backups = await self._async_get_backups() - for backup, tar_filename in backups: - if backup.backup_id == backup_id: - metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json" - await self._api.delete_file(f"/{tar_filename}") - await self._api.delete_file(f"/{metadata_filename}") - return - - raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/dropbox/config_flow.py b/homeassistant/components/dropbox/config_flow.py deleted file mode 100644 index 045f858bd59b89..00000000000000 --- a/homeassistant/components/dropbox/config_flow.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Config flow for Dropbox.""" - -from collections.abc import Mapping -import logging -from typing import Any - -from python_dropbox_api import DropboxAPIClient - -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler - -from .auth import DropboxConfigFlowAuth -from .const import DOMAIN - - -class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): - """Config flow to handle Dropbox OAuth2 authentication.""" - - DOMAIN = DOMAIN - - @property - def logger(self) -> logging.Logger: - """Return logger.""" - return logging.getLogger(__name__) - - async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: - """Create an entry for the flow, or update existing entry.""" - access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN] - - auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token) - - client = DropboxAPIClient(auth) - account_info = await client.get_account_info() - - await self.async_set_unique_id(account_info.account_id) - if self.source == SOURCE_REAUTH: - self._abort_if_unique_id_mismatch(reason="wrong_account") - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=data - ) - - self._abort_if_unique_id_configured() - - return self.async_create_entry(title=account_info.email, data=data) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Dialog that informs the user that reauth is required.""" - if user_input is None: - return self.async_show_form(step_id="reauth_confirm") - return await self.async_step_user() diff --git a/homeassistant/components/dropbox/const.py b/homeassistant/components/dropbox/const.py deleted file mode 100644 index 042f5b5c7bfddf..00000000000000 --- a/homeassistant/components/dropbox/const.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Constants for the Dropbox integration.""" - -from collections.abc import Callable - -from homeassistant.util.hass_dict import HassKey - -DOMAIN = "dropbox" - -OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize" -OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token" -OAUTH2_SCOPES = [ - "account_info.read", - "files.content.read", - "files.content.write", -] - -DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( - f"{DOMAIN}.backup_agent_listeners" -) diff --git a/homeassistant/components/dropbox/manifest.json b/homeassistant/components/dropbox/manifest.json deleted file mode 100644 index 01254682b79285..00000000000000 --- a/homeassistant/components/dropbox/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "dropbox", - "name": "Dropbox", - "after_dependencies": ["backup"], - "codeowners": ["@bdr99"], - "config_flow": true, - "dependencies": ["application_credentials"], - "documentation": "https://www.home-assistant.io/integrations/dropbox", - "integration_type": "service", - "iot_class": "cloud_polling", - "quality_scale": "bronze", - "requirements": ["python-dropbox-api==0.1.3"] -} diff --git a/homeassistant/components/dropbox/quality_scale.yaml b/homeassistant/components/dropbox/quality_scale.yaml deleted file mode 100644 index 3f46b70b7a5e1f..00000000000000 --- a/homeassistant/components/dropbox/quality_scale.yaml +++ /dev/null @@ -1,112 +0,0 @@ -rules: - # Bronze - action-setup: - status: exempt - comment: Integration does not register any actions. - appropriate-polling: - status: exempt - comment: Integration does not poll. - brands: done - common-modules: - status: exempt - comment: Integration does not have any entities or coordinators. - config-flow-test-coverage: done - config-flow: done - dependency-transparency: done - docs-actions: - status: exempt - comment: Integration does not register any actions. - docs-high-level-description: done - docs-installation-instructions: done - docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: Integration does not have any entities. - entity-unique-id: - status: exempt - comment: Integration does not have any entities. - has-entity-name: - status: exempt - comment: Integration does not have any entities. - runtime-data: done - test-before-configure: done - test-before-setup: done - unique-config-entry: done - - # Silver - action-exceptions: - status: exempt - comment: Integration does not register any actions. - config-entry-unloading: done - docs-configuration-parameters: - status: exempt - comment: Integration does not have any configuration parameters. - docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: Integration does not have any entities. - integration-owner: done - log-when-unavailable: todo - parallel-updates: - status: exempt - comment: Integration does not make any entity updates. - reauthentication-flow: done - test-coverage: done - - # Gold - devices: - status: exempt - comment: Integration does not have any entities. - diagnostics: - status: exempt - comment: Integration does not have any data to diagnose. - discovery-update-info: - status: exempt - comment: Integration is a service. - discovery: - status: exempt - comment: Integration is a service. - docs-data-update: - status: exempt - comment: Integration does not update any data. - docs-examples: - status: exempt - comment: Integration only provides backup functionality. - docs-known-limitations: todo - docs-supported-devices: - status: exempt - comment: Integration does not support any devices. - docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: done - dynamic-devices: - status: exempt - comment: Integration does not use any devices. - entity-category: - status: exempt - comment: Integration does not have any entities. - entity-device-class: - status: exempt - comment: Integration does not have any entities. - entity-disabled-by-default: - status: exempt - comment: Integration does not have any entities. - entity-translations: - status: exempt - comment: Integration does not have any entities. - exception-translations: todo - icon-translations: - status: exempt - comment: Integration does not have any entities. - reconfiguration-flow: todo - repair-issues: - status: exempt - comment: Integration does not have any repairs. - stale-devices: - status: exempt - comment: Integration does not have any devices. - - # Platinum - async-dependency: done - inject-websession: done - strict-typing: done diff --git a/homeassistant/components/dropbox/strings.json b/homeassistant/components/dropbox/strings.json deleted file mode 100644 index 4904f997e314e7..00000000000000 --- a/homeassistant/components/dropbox/strings.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", - "wrong_account": "Wrong account: Please authenticate with the correct account." - }, - "create_entry": { - "default": "[%key:common::config_flow::create_entry::authenticated%]" - }, - "step": { - "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - }, - "reauth_confirm": { - "description": "The Dropbox integration needs to re-authenticate your account.", - "title": "[%key:common::config_flow::title::reauth%]" - } - } - }, - "exceptions": { - "oauth2_implementation_unavailable": { - "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" - } - } -} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index a520338e91629c..51435aac0bb4dd 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -6,7 +6,6 @@ APPLICATION_CREDENTIALS = [ "aladdin_connect", "august", - "dropbox", "ekeybionyx", "electric_kiwi", "fitbit", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bb6901c64603d7..9d223490e6b360 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -160,7 +160,6 @@ "downloader", "dremel_3d_printer", "drop_connect", - "dropbox", "droplet", "dsmr", "dsmr_reader", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e43bb5959e679e..d5036074b7e50d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1479,12 +1479,6 @@ "config_flow": true, "iot_class": "local_push" }, - "dropbox": { - "name": "Dropbox", - "integration_type": "service", - "config_flow": true, - "iot_class": "cloud_polling" - }, "droplet": { "name": "Droplet", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 5b59dbdc476361..987b3c7f4ac803 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1496,16 +1496,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.dropbox.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.droplet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0bbeb5e2827cf3..ab5875cbbe5c9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2568,9 +2568,6 @@ python-clementine-remote==1.0.1 # homeassistant.components.digital_ocean python-digitalocean==1.13.2 -# homeassistant.components.dropbox -python-dropbox-api==0.1.3 - # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b9bcea7f9a653..b127fd90f0e26d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2188,9 +2188,6 @@ python-awair==0.2.5 # homeassistant.components.bsblan python-bsblan==5.1.3 -# homeassistant.components.dropbox -python-dropbox-api==0.1.3 - # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/tests/components/dropbox/__init__.py b/tests/components/dropbox/__init__.py deleted file mode 100644 index 505d840280e053..00000000000000 --- a/tests/components/dropbox/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Dropbox integration.""" diff --git a/tests/components/dropbox/conftest.py b/tests/components/dropbox/conftest.py deleted file mode 100644 index a5c324c2be5503..00000000000000 --- a/tests/components/dropbox/conftest.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Shared fixtures for Dropbox integration tests.""" - -from __future__ import annotations - -from collections.abc import Generator -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) -from homeassistant.components.dropbox.const import DOMAIN, OAUTH2_SCOPES -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" -ACCOUNT_ID = "dbid:1234567890abcdef" -ACCOUNT_EMAIL = "user@example.com" -CONFIG_ENTRY_TITLE = "Dropbox test account" -TEST_AGENT_ID = f"{DOMAIN}.{ACCOUNT_ID}" - - -@pytest.fixture(autouse=True) -async def setup_credentials(hass: HomeAssistant) -> None: - """Set up application credentials for Dropbox.""" - - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - -@pytest.fixture -def account_info() -> SimpleNamespace: - """Return mocked Dropbox account information.""" - - return SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL) - - -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return a default Dropbox config entry.""" - - return MockConfigEntry( - domain=DOMAIN, - unique_id=ACCOUNT_ID, - title=CONFIG_ENTRY_TITLE, - data={ - "auth_implementation": DOMAIN, - "token": { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "expires_at": 9_999_999_999, - "scope": " ".join(OAUTH2_SCOPES), - }, - }, - ) - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - - with patch( - "homeassistant.components.dropbox.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def mock_dropbox_client(account_info: SimpleNamespace) -> Generator[MagicMock]: - """Patch DropboxAPIClient to exercise auth while mocking API calls.""" - - client = MagicMock() - client.list_folder = AsyncMock(return_value=[]) - client.download_file = MagicMock() - client.upload_file = AsyncMock() - client.delete_file = AsyncMock() - - captured_auth = None - - def capture_auth(auth): - nonlocal captured_auth - captured_auth = auth - return client - - async def get_account_info_with_auth(): - await captured_auth.async_get_access_token() - return client.get_account_info.return_value - - client.get_account_info = AsyncMock( - side_effect=get_account_info_with_auth, - return_value=account_info, - ) - - with ( - patch( - "homeassistant.components.dropbox.config_flow.DropboxAPIClient", - side_effect=capture_auth, - ), - patch( - "homeassistant.components.dropbox.DropboxAPIClient", - side_effect=capture_auth, - ), - ): - yield client diff --git a/tests/components/dropbox/test_backup.py b/tests/components/dropbox/test_backup.py deleted file mode 100644 index 804a37ef3ee0f2..00000000000000 --- a/tests/components/dropbox/test_backup.py +++ /dev/null @@ -1,577 +0,0 @@ -"""Test the Dropbox backup platform.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from io import StringIO -import json -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from python_dropbox_api import DropboxAuthException - -from homeassistant.components.backup import ( - DOMAIN as BACKUP_DOMAIN, - AddonInfo, - AgentBackup, - suggested_filename, -) -from homeassistant.components.dropbox.backup import ( - DropboxFileOrFolderNotFoundException, - DropboxUnknownException, - async_register_backup_agents_listener, -) -from homeassistant.components.dropbox.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import mock_stream -from tests.typing import ClientSessionGenerator, WebSocketGenerator - -TEST_AGENT_BACKUP = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="dropbox-backup", - database_included=True, - date="2025-01-01T00:00:00.000Z", - extra_metadata={"with_automatic_settings": False}, - folders=[], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Dropbox backup", - protected=False, - size=2048, -) - -TEST_AGENT_BACKUP_RESULT = { - "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agents": {TEST_AGENT_ID: {"protected": False, "size": 2048}}, - "backup_id": TEST_AGENT_BACKUP.backup_id, - "database_included": True, - "date": TEST_AGENT_BACKUP.date, - "extra_metadata": {"with_automatic_settings": False}, - "failed_addons": [], - "failed_agent_ids": [], - "failed_folders": [], - "folders": [], - "homeassistant_included": True, - "homeassistant_version": TEST_AGENT_BACKUP.homeassistant_version, - "name": TEST_AGENT_BACKUP.name, - "with_automatic_settings": None, -} - - -def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]: - """Return the suggested filenames for the backup and metadata.""" - base_name = suggested_filename(backup).rsplit(".", 1)[0] - return f"{base_name}.tar", f"{base_name}.metadata.json" - - -async def _mock_metadata_stream(backup: AgentBackup) -> AsyncIterator[bytes]: - """Create a mock metadata download stream.""" - yield json.dumps(backup.as_dict()).encode() - - -def _setup_list_folder_with_backup( - mock_dropbox_client: Mock, - backup: AgentBackup, -) -> None: - """Set up mock to return a backup in list_folder and download_file.""" - tar_name, metadata_name = _suggested_filenames(backup) - mock_dropbox_client.list_folder = AsyncMock( - return_value=[ - SimpleNamespace(name=tar_name), - SimpleNamespace(name=metadata_name), - ] - ) - mock_dropbox_client.download_file = Mock(return_value=_mock_metadata_stream(backup)) - - -@pytest.fixture(autouse=True) -async def setup_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_dropbox_client, -) -> None: - """Set up the Dropbox and Backup integrations for testing.""" - - mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - mock_dropbox_client.reset_mock() - - -async def test_agents_info( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_config_entry: MockConfigEntry, -) -> None: - """Test listing available backup agents.""" - - client = await hass_ws_client(hass) - - await client.send_json_auto_id({"type": "backup/agents/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == { - "agents": [ - {"agent_id": "backup.local", "name": "local"}, - {"agent_id": TEST_AGENT_ID, "name": CONFIG_ENTRY_TITLE}, - ] - } - - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - await client.send_json_auto_id({"type": "backup/agents/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == { - "agents": [{"agent_id": "backup.local", "name": "local"}] - } - - -async def test_agents_list_backups( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test listing backups via the Dropbox agent.""" - - _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] - mock_dropbox_client.list_folder.assert_awaited() - - -async def test_agents_list_backups_metadata_without_tar( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that orphaned metadata files are skipped with a warning.""" - - mock_dropbox_client.list_folder = AsyncMock( - return_value=[SimpleNamespace(name="orphan.metadata.json")] - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - assert response["result"]["backups"] == [] - assert "without matching backup file" in caplog.text - - -async def test_agents_list_backups_invalid_metadata( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that invalid metadata files are skipped with a warning.""" - - async def _invalid_stream() -> AsyncIterator[bytes]: - yield b"not valid json" - - mock_dropbox_client.list_folder = AsyncMock( - return_value=[ - SimpleNamespace(name="backup.tar"), - SimpleNamespace(name="backup.metadata.json"), - ] - ) - mock_dropbox_client.download_file = Mock(return_value=_invalid_stream()) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - assert response["result"]["backups"] == [] - assert "Skipping invalid metadata file" in caplog.text - - -async def test_agents_list_backups_fail( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test handling list backups failures.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxUnknownException("boom") - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["backups"] == [] - assert response["result"]["agent_errors"] == { - TEST_AGENT_ID: "Failed to list backups" - } - - -async def test_agents_list_backups_reauth( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test reauthentication is triggered on auth error.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxAuthException("auth failed") - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/info"}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["backups"] == [] - assert response["result"]["agent_errors"] == {TEST_AGENT_ID: "Authentication error"} - - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - - flow = flows[0] - assert flow["step_id"] == "reauth_confirm" - assert flow["handler"] == DOMAIN - assert flow["context"]["source"] == SOURCE_REAUTH - assert flow["context"]["entry_id"] == mock_config_entry.entry_id - - -@pytest.mark.parametrize( - "backup_id", - [TEST_AGENT_BACKUP.backup_id, "other-backup"], - ids=["found", "not_found"], -) -async def test_agents_get_backup( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, - backup_id: str, -) -> None: - """Test retrieving a backup's metadata.""" - - _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == {} - if backup_id == TEST_AGENT_BACKUP.backup_id: - assert response["result"]["backup"] == TEST_AGENT_BACKUP_RESULT - else: - assert response["result"]["backup"] is None - - -async def test_agents_download( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test downloading a backup file.""" - - tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP) - - mock_dropbox_client.list_folder = AsyncMock( - return_value=[ - SimpleNamespace(name=tar_name), - SimpleNamespace(name=metadata_name), - ] - ) - - def download_side_effect(path: str) -> AsyncIterator[bytes]: - if path == f"/{tar_name}": - return mock_stream(b"backup data") - return _mock_metadata_stream(TEST_AGENT_BACKUP) - - mock_dropbox_client.download_file = Mock(side_effect=download_side_effect) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 200 - assert await resp.content.read() == b"backup data" - - -async def test_agents_download_fail( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test handling download failures.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxUnknownException("boom") - ) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 500 - body = await resp.content.read() - assert b"Failed to get backup" in body - - -async def test_agents_download_not_found( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test download when backup disappears between get and download.""" - - tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP) - files = [ - SimpleNamespace(name=tar_name), - SimpleNamespace(name=metadata_name), - ] - - # First list_folder call (async_get_backup) returns the backup; - # second call (async_download_backup) returns empty, simulating deletion. - mock_dropbox_client.list_folder = AsyncMock(side_effect=[files, []]) - mock_dropbox_client.download_file = Mock( - return_value=_mock_metadata_stream(TEST_AGENT_BACKUP) - ) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 404 - assert await resp.content.read() == b"" - - -async def test_agents_download_file_not_found( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test download when Dropbox file is not found returns 404.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxFileOrFolderNotFoundException("not found") - ) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 404 - - -async def test_agents_download_metadata_not_found( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test download when metadata lookup fails.""" - - mock_dropbox_client.list_folder = AsyncMock(return_value=[]) - - client = await hass_client() - resp = await client.get( - f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" - ) - - assert resp.status == 404 - assert await resp.content.read() == b"" - - -async def test_agents_upload( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_dropbox_client: Mock, -) -> None: - """Test uploading a backup to Dropbox.""" - - mock_dropbox_client.upload_file = AsyncMock(return_value=None) - - client = await hass_client() - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch("pathlib.Path.open") as mocked_open, - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - resp = await client.post( - f"/api/backup/upload?agent_id={TEST_AGENT_ID}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert f"Uploading backup {TEST_AGENT_BACKUP.backup_id} to agents" in caplog.text - assert mock_dropbox_client.upload_file.await_count == 2 - - -async def test_agents_upload_fail( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_dropbox_client: Mock, -) -> None: - """Test that backup tar is cleaned up when metadata upload fails.""" - - call_count = 0 - - async def upload_side_effect(path: str, stream: AsyncIterator[bytes]) -> None: - nonlocal call_count - call_count += 1 - async for _ in stream: - pass - if call_count == 2: - raise DropboxUnknownException("metadata upload failed") - - mock_dropbox_client.upload_file = AsyncMock(side_effect=upload_side_effect) - mock_dropbox_client.delete_file = AsyncMock() - - client = await hass_client() - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_AGENT_BACKUP, - ), - patch("pathlib.Path.open") as mocked_open, - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - resp = await client.post( - f"/api/backup/upload?agent_id={TEST_AGENT_ID}", - data={"file": StringIO("test")}, - ) - await hass.async_block_till_done() - - assert resp.status == 201 - assert "Failed to upload backup" in caplog.text - mock_dropbox_client.delete_file.assert_awaited_once() - - -async def test_agents_delete( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test deleting a backup.""" - - _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) - mock_dropbox_client.delete_file = AsyncMock(return_value=None) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": TEST_AGENT_BACKUP.backup_id, - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"agent_errors": {}} - assert mock_dropbox_client.delete_file.await_count == 2 - - -async def test_agents_delete_fail( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test error handling when delete fails.""" - - mock_dropbox_client.list_folder = AsyncMock( - side_effect=DropboxUnknownException("boom") - ) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": TEST_AGENT_BACKUP.backup_id, - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == { - "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} - } - - -async def test_agents_delete_not_found( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_dropbox_client: Mock, -) -> None: - """Test deleting a backup that does not exist.""" - - mock_dropbox_client.list_folder = AsyncMock(return_value=[]) - - client = await hass_ws_client(hass) - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": TEST_AGENT_BACKUP.backup_id, - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"agent_errors": {}} - - -async def test_remove_backup_agents_listener( - hass: HomeAssistant, -) -> None: - """Test removing a backup agent listener.""" - listener = Mock() - remove = async_register_backup_agents_listener(hass, listener=listener) - - assert DATA_BACKUP_AGENT_LISTENERS in hass.data - assert listener in hass.data[DATA_BACKUP_AGENT_LISTENERS] - - # Remove all other listeners to test the cleanup path - hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] - - remove() - - assert DATA_BACKUP_AGENT_LISTENERS not in hass.data diff --git a/tests/components/dropbox/test_config_flow.py b/tests/components/dropbox/test_config_flow.py deleted file mode 100644 index 9be36ecf0f4eb3..00000000000000 --- a/tests/components/dropbox/test_config_flow.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Test the Dropbox config flow.""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from yarl import URL - -from homeassistant import config_entries -from homeassistant.components.dropbox.const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_SCOPES, - OAUTH2_TOKEN, -) -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_entry_oauth2_flow - -from .conftest import ACCOUNT_EMAIL, ACCOUNT_ID, CLIENT_ID - -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - - -@pytest.mark.usefixtures("current_request_with_host") -async def test_full_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_dropbox_client, - mock_setup_entry: AsyncMock, -) -> None: - """Test creating a new config entry through the OAuth flow.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - result_url = URL(result["url"]) - assert f"{result_url.origin()}{result_url.path}" == OAUTH2_AUTHORIZE - assert result_url.query["response_type"] == "code" - assert result_url.query["client_id"] == CLIENT_ID - assert ( - result_url.query["redirect_uri"] == "https://example.com/auth/external/callback" - ) - assert result_url.query["state"] == state - assert result_url.query["scope"] == " ".join(OAUTH2_SCOPES) - assert result_url.query["token_access_type"] == "offline" - assert result_url.query["code_challenge"] - assert result_url.query["code_challenge_method"] == "S256" - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "token_type": "Bearer", - "expires_in": 60, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == ACCOUNT_EMAIL - assert result["data"]["token"]["access_token"] == "mock-access-token" - assert result["result"].unique_id == ACCOUNT_ID - assert len(mock_setup_entry.mock_calls) == 1 - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -@pytest.mark.usefixtures("current_request_with_host") -async def test_already_configured( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_config_entry, - mock_dropbox_client, -) -> None: - """Test aborting when the account is already configured.""" - - mock_config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "token_type": "Bearer", - "expires_in": 60, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -@pytest.mark.usefixtures("current_request_with_host") -@pytest.mark.parametrize( - ( - "new_account_info", - "expected_reason", - "expected_setup_calls", - "expected_access_token", - ), - [ - ( - SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL), - "reauth_successful", - 1, - "updated-access-token", - ), - ( - SimpleNamespace(account_id="dbid:different", email="other@example.com"), - "wrong_account", - 0, - "mock-access-token", - ), - ], - ids=["success", "wrong_account"], -) -async def test_reauth_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_config_entry, - mock_dropbox_client, - mock_setup_entry: AsyncMock, - new_account_info: SimpleNamespace, - expected_reason: str, - expected_setup_calls: int, - expected_access_token: str, -) -> None: - """Test reauthentication flow outcomes.""" - - mock_config_entry.add_to_hass(hass) - - mock_dropbox_client.get_account_info.return_value = new_account_info - - result = await mock_config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == 200 - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "updated-access-token", - "token_type": "Bearer", - "expires_in": 120, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == expected_reason - assert mock_setup_entry.await_count == expected_setup_calls - - assert mock_config_entry.data["token"]["access_token"] == expected_access_token diff --git a/tests/components/dropbox/test_init.py b/tests/components/dropbox/test_init.py deleted file mode 100644 index 8d468f18727b4d..00000000000000 --- a/tests/components/dropbox/test_init.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Test the Dropbox integration setup.""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, patch - -import pytest -from python_dropbox_api import DropboxAuthException, DropboxUnknownException - -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import ( - ImplementationUnavailableError, -) - -from tests.common import MockConfigEntry - - -@pytest.mark.usefixtures("mock_dropbox_client") -async def test_setup_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test successful setup of a config entry.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - - -async def test_setup_entry_auth_failed( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_dropbox_client: AsyncMock, -) -> None: - """Test setup failure when authentication fails.""" - mock_dropbox_client.get_account_info.side_effect = DropboxAuthException( - "Invalid token" - ) - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR - - -@pytest.mark.parametrize( - "side_effect", - [DropboxUnknownException("Unknown error"), TimeoutError("Connection timed out")], - ids=["unknown_exception", "timeout_error"], -) -async def test_setup_entry_not_ready( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_dropbox_client: AsyncMock, - side_effect: Exception, -) -> None: - """Test setup retry when the service is temporarily unavailable.""" - mock_dropbox_client.get_account_info.side_effect = side_effect - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_entry_implementation_unavailable( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test setup retry when OAuth implementation is unavailable.""" - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.dropbox.async_get_config_entry_implementation", - side_effect=ImplementationUnavailableError, - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -@pytest.mark.usefixtures("mock_dropbox_client") -async def test_unload_entry( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test unloading a config entry.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From 962d5386c728cb6193a6f0de8fbbb1dc9e26fc2b Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 31 Mar 2026 22:35:09 +0300 Subject: [PATCH 0261/1707] Add diagnostics to Anthropic integration (#166739) --- .../components/anthropic/diagnostics.py | 64 ++++++++++++++ .../components/anthropic/quality_scale.yaml | 2 +- .../anthropic/snapshots/test_diagnostics.ambr | 87 +++++++++++++++++++ .../components/anthropic/test_diagnostics.py | 44 ++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/anthropic/diagnostics.py create mode 100644 tests/components/anthropic/snapshots/test_diagnostics.ambr create mode 100644 tests/components/anthropic/test_diagnostics.py diff --git a/homeassistant/components/anthropic/diagnostics.py b/homeassistant/components/anthropic/diagnostics.py new file mode 100644 index 00000000000000..d5985f10b1deac --- /dev/null +++ b/homeassistant/components/anthropic/diagnostics.py @@ -0,0 +1,64 @@ +"""Diagnostics support for Anthropic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from anthropic import __title__, __version__ + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import entity_registry as er + +from .const import ( + CONF_PROMPT, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, +) + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from . import AnthropicConfigEntry + + +TO_REDACT = { + CONF_API_KEY, + CONF_PROMPT, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_TIMEZONE, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AnthropicConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "client": f"{__title__}=={__version__}", + "title": entry.title, + "entry_id": entry.entry_id, + "entry_version": f"{entry.version}.{entry.minor_version}", + "state": entry.state.value, + "data": async_redact_data(entry.data, TO_REDACT), + "options": async_redact_data(entry.options, TO_REDACT), + "subentries": { + subentry.subentry_id: { + "title": subentry.title, + "subentry_type": subentry.subentry_type, + "data": async_redact_data(subentry.data, TO_REDACT), + } + for subentry in entry.subentries.values() + }, + "entities": { + entity_entry.entity_id: entity_entry.extended_dict + for entity_entry in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + }, + } diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml index 8d62ea26f479c9..1cf9008fc418d0 100644 --- a/homeassistant/components/anthropic/quality_scale.yaml +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -46,7 +46,7 @@ rules: test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: | diff --git a/tests/components/anthropic/snapshots/test_diagnostics.ambr b/tests/components/anthropic/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..5ca08252b337d8 --- /dev/null +++ b/tests/components/anthropic/snapshots/test_diagnostics.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + }), + 'entities': dict({ + 'ai_task.claude_ai_task': dict({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'device_class': None, + 'disabled_by': None, + 'entity_category': None, + 'entity_id': 'ai_task.claude_ai_task', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'anthropic', + 'translation_key': 'ai_task_data', + }), + 'conversation.claude_conversation': dict({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'device_class': None, + 'disabled_by': None, + 'entity_category': None, + 'entity_id': 'conversation.claude_conversation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'anthropic', + 'translation_key': 'conversation', + }), + }), + 'entry_version': '2.3', + 'options': dict({ + }), + 'state': 'loaded', + 'subentries': list([ + dict({ + 'data': dict({ + }), + 'subentry_type': 'conversation', + 'title': 'Claude conversation', + }), + dict({ + 'data': dict({ + }), + 'subentry_type': 'ai_task_data', + 'title': 'Claude AI Task', + }), + ]), + 'title': 'Claude', + }) +# --- diff --git a/tests/components/anthropic/test_diagnostics.py b/tests/components/anthropic/test_diagnostics.py new file mode 100644 index 00000000000000..44b6bdc73c04ba --- /dev/null +++ b/tests/components/anthropic/test_diagnostics.py @@ -0,0 +1,44 @@ +"""Test Anthropic diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_init_component: None, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + # Remove non-deterministic values from diagnostics. + assert diagnostics.pop("client").startswith("anthropic==") + diagnostics.pop("entry_id") + subentries = diagnostics.pop("subentries") + diagnostics["subentries"] = [ + subentry for subentry_id, subentry in subentries.items() + ] + for entity_id, entity in diagnostics["entities"].copy().items(): + for key in ( + "config_entry_id", + "config_subentry_id", + "created_at", + "device_id", + "id", + "modified_at", + "unique_id", + ): + if key in entity: + entity.pop(key) + diagnostics["entities"][entity_id] = entity + + assert diagnostics == snapshot From 7d145cd3b8cc7d7d6aa8d71c1d81437eeae1d260 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 1 Apr 2026 05:52:09 +1000 Subject: [PATCH 0262/1707] Add command compatibility scaffold for Tessie migration (#166458) --- homeassistant/components/tessie/entity.py | 39 +++++++++------------- homeassistant/components/tessie/helpers.py | 27 +++++++++++++-- homeassistant/components/tessie/models.py | 3 +- tests/components/tessie/test_init.py | 10 ++++++ 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index 98a424eefc1845..e42ed57316c958 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -2,21 +2,20 @@ from abc import abstractmethod from collections.abc import Awaitable, Callable +from inspect import isawaitable from typing import Any -from aiohttp import ClientError - -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, TRANSLATED_ERRORS +from .const import DOMAIN from .coordinator import ( TessieEnergyHistoryCoordinator, TessieEnergySiteInfoCoordinator, TessieEnergySiteLiveCoordinator, TessieStateUpdateCoordinator, ) +from .helpers import handle_command, handle_legacy_command from .models import TessieEnergyData, TessieVehicleData @@ -93,30 +92,24 @@ def set(self, *args: Any) -> None: self.async_write_ha_state() async def run( - self, func: Callable[..., Awaitable[dict[str, Any]]], **kargs: Any + self, + command: Callable[..., Awaitable[dict[str, Any]]] | Awaitable[dict[str, Any]], + **kargs: Any, ) -> None: - """Run a tessie_api function and handle exceptions.""" - try: - response = await func( + """Run a legacy tessie_api command function or awaitable Vehicle command.""" + if isawaitable(command): + await handle_command(command) + return + + await handle_legacy_command( + command( session=self._session, vin=self.vin, api_key=self._api_key, **kargs, - ) - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from e - if response["result"] is False: - name: str = getattr(self, "name", self.entity_id) - reason: str = response.get("reason", "unknown") - translation_key = TRANSLATED_ERRORS.get(reason, "command_failed") - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=translation_key, - translation_placeholders={"name": name, "message": reason}, - ) + ), + name=getattr(self, "name", self.entity_id), + ) def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" diff --git a/homeassistant/components/tessie/helpers.py b/homeassistant/components/tessie/helpers.py index 41e619ac10d2c7..321ad0d9aa0d68 100644 --- a/homeassistant/components/tessie/helpers.py +++ b/homeassistant/components/tessie/helpers.py @@ -1,17 +1,19 @@ """Tessie helper functions.""" +from collections.abc import Awaitable from typing import Any +from aiohttp import ClientError from tesla_fleet_api.exceptions import TeslaFleetError from homeassistant.exceptions import HomeAssistantError from . import _LOGGER -from .const import DOMAIN +from .const import DOMAIN, TRANSLATED_ERRORS -async def handle_command(command) -> dict[str, Any]: - """Handle a command.""" +async def handle_command(command: Awaitable[dict[str, Any]]) -> dict[str, Any]: + """Handle an awaitable Vehicle/EnergySite command.""" try: result = await command except TeslaFleetError as e: @@ -22,3 +24,22 @@ async def handle_command(command) -> dict[str, Any]: ) from e _LOGGER.debug("Command result: %s", result) return result + + +async def handle_legacy_command(command: Awaitable[dict[str, Any]], name: str) -> None: + """Handle a legacy tessie_api command result.""" + try: + response = await command + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e + if response["result"] is False: + reason: str = response.get("reason", "unknown") + translation_key = TRANSLATED_ERRORS.get(reason, "command_failed") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"name": name, "message": reason}, + ) diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index e4e4bb34e81a46..c9b1105281ef2f 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from tesla_fleet_api.tessie import EnergySite +from tesla_fleet_api.tessie import EnergySite, Vehicle from homeassistant.helpers.device_registry import DeviceInfo @@ -43,3 +43,4 @@ class TessieVehicleData: data_coordinator: TessieStateUpdateCoordinator device: DeviceInfo vin: str + api: Vehicle | None = None diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index 921ef93b1ae241..e0ffd8fd57ed74 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -68,3 +68,13 @@ async def test_scopes_error(hass: HomeAssistant) -> None: ): entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_vehicle_api_handle_is_optional(hass: HomeAssistant) -> None: + """Test runtime vehicle API handle defaults to None during scaffold stage.""" + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + vehicles = entry.runtime_data.vehicles + assert vehicles + assert all(vehicle.api is None for vehicle in vehicles) From e91b49e7cd32cc9f09b314d276ff6bacca0a24b0 Mon Sep 17 00:00:00 2001 From: Jackson_57 <49173011+jackson-57@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:21:39 -0700 Subject: [PATCH 0263/1707] Bump led-ble to 1.1.8 (#166999) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index e64ef235a9f0c3..0fb5a3b33179f0 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.28.4", "led-ble==1.1.7"] + "requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab5875cbbe5c9d..92942f598abf9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1410,7 +1410,7 @@ ld2410-ble==0.1.1 leaone-ble==0.3.0 # homeassistant.components.led_ble -led-ble==1.1.7 +led-ble==1.1.8 # homeassistant.components.lektrico lektricowifi==0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b127fd90f0e26d..36168047d8da99 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1247,7 +1247,7 @@ ld2410-ble==0.1.1 leaone-ble==0.3.0 # homeassistant.components.led_ble -led-ble==1.1.7 +led-ble==1.1.8 # homeassistant.components.lektrico lektricowifi==0.1 From e4328fe34d4587862d090e8eea69063ec2831332 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:26:03 +0200 Subject: [PATCH 0264/1707] Bump solarlog_cli to 0.7.1 (#166990) --- homeassistant/components/solarlog/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index b9b47dbbaa2cd6..9b7d7eb183f852 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["solarlog_cli"], "quality_scale": "platinum", - "requirements": ["solarlog_cli==0.7.0"] + "requirements": ["solarlog_cli==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 92942f598abf9a..c99203b1b60966 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2981,7 +2981,7 @@ solaredge-local==0.2.3 solaredge-web==0.0.1 # homeassistant.components.solarlog -solarlog_cli==0.7.0 +solarlog_cli==0.7.1 # homeassistant.components.solarman solarman-opendata==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36168047d8da99..f139f6eb7e378f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2523,7 +2523,7 @@ soco==0.30.14 solaredge-web==0.0.1 # homeassistant.components.solarlog -solarlog_cli==0.7.0 +solarlog_cli==0.7.1 # homeassistant.components.solarman solarman-opendata==0.0.3 From 19761a25da7fc5ff2645906b256de77d7e991d46 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:45:32 +0200 Subject: [PATCH 0265/1707] Improve strings in HTML5 integration (#166985) --- homeassistant/components/html5/issue.py | 6 +++++- homeassistant/components/html5/strings.json | 8 +++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/html5/issue.py b/homeassistant/components/html5/issue.py index 6f3070ed6042fc..66c11f5c74219c 100644 --- a/homeassistant/components/html5/issue.py +++ b/homeassistant/components/html5/issue.py @@ -27,7 +27,11 @@ def deprecated_notify_action_call( is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_notify_action", - translation_placeholders={"action": action}, + translation_placeholders={ + "action": action, + "new_action_1": "notify.send_message", + "new_action_2": "html5.send_message", + }, ) diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 98e3b3bf0cabe0..3dea89e6ead279 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -32,7 +32,9 @@ "received": "Received" } }, - "tag": { "name": "Tag" } + "tag": { + "name": "[%key:component::html5::services::send_message::fields::tag::name%]" + } } } } @@ -54,7 +56,7 @@ "title": "[%key:component::html5::issues::deprecated_notify_action::title%]" }, "deprecated_notify_action": { - "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `notify.send_message` or `html5.send_message` actions instead.", + "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action_1}` or `{new_action_2}` actions instead.", "title": "Detected use of deprecated action {action}" } }, @@ -110,7 +112,7 @@ "fields": { "tag": { "description": "The tag of the notifications to dismiss. If not specified, all notifications will be dismissed.", - "name": "Tag" + "name": "[%key:component::html5::services::send_message::fields::tag::name%]" } }, "name": "Dismiss message" From 69a2284a0094f83cadce0958bc23a3bcf35f0e23 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:48:29 +0200 Subject: [PATCH 0266/1707] Migrate nightscout to use runtime_data (#166927) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/nightscout/__init__.py | 15 ++++++--------- homeassistant/components/nightscout/sensor.py | 8 ++++---- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/nightscout/__init__.py b/homeassistant/components/nightscout/__init__.py index 798fcf1ec9dbbe..9e01a2712abd13 100644 --- a/homeassistant/components/nightscout/__init__.py +++ b/homeassistant/components/nightscout/__init__.py @@ -16,8 +16,10 @@ PLATFORMS = [Platform.SENSOR] _API_TIMEOUT = SLOW_UPDATE_WARNING - 1 +type NightscoutConfigEntry = ConfigEntry[NightscoutAPI] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: NightscoutConfigEntry) -> bool: """Set up Nightscout from a config entry.""" server_url = entry.data[CONF_URL] api_key = entry.data.get(CONF_API_KEY) @@ -28,8 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (ClientError, TimeoutError, OSError) as error: raise ConfigEntryNotReady from error - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = api + entry.runtime_data = api device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -46,10 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NightscoutConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index de1dadf1143af6..126a568a1d1352 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -10,12 +10,12 @@ from py_nightscout import Api as NightscoutAPI from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DATE, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, DOMAIN +from . import NightscoutConfigEntry +from .const import ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION SCAN_INTERVAL = timedelta(minutes=1) @@ -26,11 +26,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NightscoutConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Glucose Sensor.""" - api = hass.data[DOMAIN][entry.entry_id] + api = entry.runtime_data async_add_entities([NightscoutSensor(api, "Blood Sugar", entry.unique_id)], True) From 586d2ceff66480faea4ee1e9025172239b1aa346 Mon Sep 17 00:00:00 2001 From: potelux Date: Tue, 31 Mar 2026 16:07:26 -0500 Subject: [PATCH 0267/1707] Add reload service to shell_command (#166557) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- .../components/shell_command/__init__.py | 101 +++++++++++++++--- .../components/shell_command/icons.json | 7 ++ .../components/shell_command/services.yaml | 2 +- .../components/shell_command/strings.json | 12 +++ tests/components/shell_command/test_init.py | 83 +++++++++++++- 5 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/shell_command/icons.json diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 842dc74ea5a68d..8dbd867a389304 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -3,12 +3,16 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Coroutine from contextlib import suppress import logging import shlex +from typing import Any import voluptuous as vol +import homeassistant.config as conf_util +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,7 +20,12 @@ SupportsResponse, ) from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + service as service_helper, + template, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType @@ -31,16 +40,14 @@ ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the shell_command component.""" - conf = config.get(DOMAIN, {}) - - cache: dict[str, tuple[str, str | None, template.Template | None]] = {} +def _make_handler( + cmd: str, + hass: HomeAssistant, + cache: dict[str, tuple[str, str | None, template.Template | None]], +) -> Callable[[ServiceCall], Coroutine[Any, Any, ServiceResponse]]: + """Return a service handler that executes the given shell command.""" async def async_service_handler(service: ServiceCall) -> ServiceResponse: - """Execute a shell command service.""" - cmd = conf[service.service] - if cmd in cache: prog, args, args_compiled = cache[cmd] elif " " not in cmd: @@ -66,7 +73,6 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: if rendered_args == args: # No template used. default behavior - create_process = asyncio.create_subprocess_shell( cmd, stdin=None, @@ -78,7 +84,6 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: # Template used. Break into list and use create_subprocess_exec # (which uses shell=False) for security shlexed_cmd = [prog, *shlex.split(rendered_args)] - create_process = asyncio.create_subprocess_exec( *shlexed_cmd, stdin=None, @@ -153,11 +158,81 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: return service_response return None - for name in conf: + return async_service_handler + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the shell_command component.""" + conf = config.get(DOMAIN, {}) + + cache: dict[str, tuple[str, str | None, template.Template | None]] = {} + + for name, command in conf.items(): + if name == SERVICE_RELOAD: + ir.async_create_issue( + hass, + DOMAIN, + f"reserved_{SERVICE_RELOAD}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="reserved_reload_name", + translation_placeholders={"name": name}, + ) + _LOGGER.warning("Skipping shell_command entry '%s': name is reserved", name) + continue hass.services.async_register( DOMAIN, name, - async_service_handler, + _make_handler(command, hass, cache), supports_response=SupportsResponse.OPTIONAL, ) + + async def reload_service_handler(service_call: ServiceCall) -> None: + """Reload shell_command from YAML configuration.""" + try: + raw_config = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error("Error loading configuration.yaml: %s", err) + return + + try: + new_conf = CONFIG_SCHEMA(raw_config).get(DOMAIN, {}) + except vol.Invalid as err: + _LOGGER.error("Invalid shell_command configuration: %s", err) + return + + for svc in list(hass.services.async_services_for_domain(DOMAIN)): + if svc != SERVICE_RELOAD: + hass.services.async_remove(DOMAIN, svc) + cache.clear() + ir.async_delete_issue(hass, DOMAIN, f"reserved_{SERVICE_RELOAD}") + for name, command in new_conf.items(): + if name == SERVICE_RELOAD: + ir.async_create_issue( + hass, + DOMAIN, + f"reserved_{SERVICE_RELOAD}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="reserved_reload_name", + translation_placeholders={"name": name}, + ) + _LOGGER.warning( + "Skipping shell_command entry '%s': name is reserved", name + ) + continue + hass.services.async_register( + DOMAIN, + name, + _make_handler(command, hass, cache), + supports_response=SupportsResponse.OPTIONAL, + ) + + service_helper.async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD, + reload_service_handler, + ) + return True diff --git a/homeassistant/components/shell_command/icons.json b/homeassistant/components/shell_command/icons.json new file mode 100644 index 00000000000000..a9829425570a0c --- /dev/null +++ b/homeassistant/components/shell_command/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "reload": { + "service": "mdi:reload" + } + } +} diff --git a/homeassistant/components/shell_command/services.yaml b/homeassistant/components/shell_command/services.yaml index df056f94e85fa0..c983a105c93977 100644 --- a/homeassistant/components/shell_command/services.yaml +++ b/homeassistant/components/shell_command/services.yaml @@ -1 +1 @@ -# Empty file, shell_command services are dynamically created +reload: diff --git a/homeassistant/components/shell_command/strings.json b/homeassistant/components/shell_command/strings.json index f2f2dc1b819dd9..a395ef9bd52631 100644 --- a/homeassistant/components/shell_command/strings.json +++ b/homeassistant/components/shell_command/strings.json @@ -6,5 +6,17 @@ "timeout": { "message": "Timed out running command: `{command}`, after: {timeout} seconds" } + }, + "issues": { + "reserved_reload_name": { + "description": "The shell command name {name} is a reserved for the reload action and cannot be used for user-defined commands. Please rename or remove this entry from your configuration.", + "title": "Reserved shell command action name" + } + }, + "services": { + "reload": { + "description": "Reloads shell command configuration.", + "name": "[%key:common::action::reload%]" + } } } diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py index 526ac1643eca14..cfc36d297e1e1d 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -10,10 +10,14 @@ import pytest from homeassistant.components import shell_command -from homeassistant.core import HomeAssistant +from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from tests.common import MockUser + def mock_process_creator(error: bool = False): """Mock a coroutine that creates a process when yielded.""" @@ -276,3 +280,80 @@ async def block(): mock_process.kill.assert_called_once() assert "Timed out" in caplog.text assert "mock_sleep 10000" in caplog.text + + +async def test_reload_service(hass: HomeAssistant, hass_admin_user: MockUser) -> None: + """Test that the reload service re-registers commands from YAML.""" + assert await async_setup_component( + hass, + shell_command.DOMAIN, + {shell_command.DOMAIN: {"initial_cmd": "echo initial"}}, + ) + await hass.async_block_till_done() + + assert hass.services.has_service(shell_command.DOMAIN, "initial_cmd") + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={shell_command.DOMAIN: {"reloaded_cmd": "echo reloaded"}}, + ): + await hass.services.async_call( + shell_command.DOMAIN, + SERVICE_RELOAD, + blocking=True, + context=Context(user_id=hass_admin_user.id), + ) + + assert not hass.services.has_service(shell_command.DOMAIN, "initial_cmd") + assert hass.services.has_service(shell_command.DOMAIN, "reloaded_cmd") + + +async def test_repair_issue_on_reserved_reload_name( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, hass_admin_user: MockUser +) -> None: + """Test repair issue is created if 'reload' is used as a shell_command name.""" + config = {shell_command.DOMAIN: {"reload": "echo should not work"}} + await async_setup_component(hass, shell_command.DOMAIN, config) + await hass.async_block_till_done() + issue = issue_registry.async_get_issue(shell_command.DOMAIN, "reserved_reload") + assert issue is not None + assert issue.translation_key == "reserved_reload_name" + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders["name"] == "reload" + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={shell_command.DOMAIN: {"reloaded_cmd": "echo reloaded"}}, + ): + await hass.services.async_call( + shell_command.DOMAIN, + SERVICE_RELOAD, + blocking=True, + context=Context(user_id=hass_admin_user.id), + ) + issue = issue_registry.async_get_issue(shell_command.DOMAIN, "reserved_reload") + assert issue is None + + +async def test_repair_issue_on_reload_service_reload( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, hass_admin_user: MockUser +) -> None: + """Test repair issue is created if 'reload' is used in YAML and reload service is called.""" + config = {shell_command.DOMAIN: {"test": "echo ok"}} + await async_setup_component(hass, shell_command.DOMAIN, config) + await hass.async_block_till_done() + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={shell_command.DOMAIN: {"reload": "echo reloaded"}}, + ): + await hass.services.async_call( + shell_command.DOMAIN, + SERVICE_RELOAD, + blocking=True, + context=Context(user_id=hass_admin_user.id), + ) + issue = issue_registry.async_get_issue(shell_command.DOMAIN, "reserved_reload") + assert issue is not None From 8842b4840e3ef7823f530f2b61853fec0cafc5f4 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:09:00 +0200 Subject: [PATCH 0268/1707] Rename component to integration in Glances (#167012) --- homeassistant/components/glances/__init__.py | 2 +- homeassistant/components/glances/const.py | 2 +- homeassistant/components/glances/sensor.py | 2 +- tests/components/glances/conftest.py | 2 +- tests/components/glances/test_config_flow.py | 2 +- tests/components/glances/test_sensor.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index d7b645d9e115fa..44460ed1928b2a 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -1,4 +1,4 @@ -"""The Glances component.""" +"""The Glances integration.""" import logging from typing import Any diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index f0477a30463844..6831ccb9e3b64a 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,4 +1,4 @@ -"""Constants for Glances component.""" +"""Constants for Glances integration.""" from datetime import timedelta import sys diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 67f57ee0fbfc77..c618c674a8b73b 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -1,4 +1,4 @@ -"""Support gathering system information of hosts which are running glances.""" +"""Support gathering system information of hosts which are running Glances.""" from __future__ import annotations diff --git a/tests/components/glances/conftest.py b/tests/components/glances/conftest.py index 339136f44e8066..be0da5832de743 100644 --- a/tests/components/glances/conftest.py +++ b/tests/components/glances/conftest.py @@ -1,4 +1,4 @@ -"""Conftest for speedtestdotnet.""" +"""Conftest for Glances.""" from unittest.mock import AsyncMock, patch diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index b8d376d652f5cb..b16da5122e0db2 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -22,7 +22,7 @@ @pytest.fixture(autouse=True) def glances_setup_fixture(): - """Mock glances entry setup.""" + """Mock Glances entry setup.""" with patch("homeassistant.components.glances.async_setup_entry", return_value=True): yield diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 71bb689f3ff4e7..292009b30b118c 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -1,4 +1,4 @@ -"""Tests for glances sensors.""" +"""Tests for Glances sensors.""" from datetime import timedelta from unittest.mock import AsyncMock From f29c051c739a2f54ff6e056601ad560bcc06b674 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:11:02 +0200 Subject: [PATCH 0269/1707] Rename component to integration in BlinkStick (#167009) --- homeassistant/components/blinksticklight/__init__.py | 2 +- homeassistant/components/blinksticklight/light.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blinksticklight/__init__.py b/homeassistant/components/blinksticklight/__init__.py index dd45fbcd690cbc..06375f0ab6973b 100644 --- a/homeassistant/components/blinksticklight/__init__.py +++ b/homeassistant/components/blinksticklight/__init__.py @@ -1 +1 @@ -"""The blinksticklight component.""" +"""The BlinkStick integration.""" diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py index 01e5c90aadf748..31bbe3e08bb12b 100644 --- a/homeassistant/components/blinksticklight/light.py +++ b/homeassistant/components/blinksticklight/light.py @@ -1,4 +1,4 @@ -"""Support for Blinkstick lights.""" +"""Support for BlinkStick lights.""" # mypy: ignore-errors from __future__ import annotations @@ -40,7 +40,7 @@ def setup_platform( add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up Blinkstick device specified by serial number.""" + """Set up BlinkStick device specified by serial number.""" name = config[CONF_NAME] serial = config[CONF_SERIAL] From a266976c33f6fc71d3797d93776d2c7f199a4b9a Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:12:48 +0200 Subject: [PATCH 0270/1707] Rename component to integration in Edimax (#167011) --- homeassistant/components/edimax/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/edimax/__init__.py b/homeassistant/components/edimax/__init__.py index 33614bf4f9597a..8084a10bc6be47 100644 --- a/homeassistant/components/edimax/__init__.py +++ b/homeassistant/components/edimax/__init__.py @@ -1 +1 @@ -"""The edimax component.""" +"""The Edimax integration.""" From bba3c0e6bb3eb2bfe727012ef1210484fad5a6c6 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:13:44 +0200 Subject: [PATCH 0271/1707] Rename component to integration in Denon AVR (#167008) --- homeassistant/components/denonavr/__init__.py | 2 +- tests/components/denonavr/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index cd68308e124f5c..09b084ec07cab4 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -1,4 +1,4 @@ -"""The denonavr component.""" +"""The Denon AVR Network Receivers integration.""" import logging diff --git a/tests/components/denonavr/__init__.py b/tests/components/denonavr/__init__.py index 5ad16068f2a255..586f362d91f573 100644 --- a/tests/components/denonavr/__init__.py +++ b/tests/components/denonavr/__init__.py @@ -1 +1 @@ -"""Tests for the denonavr integration.""" +"""Tests for the Denon AVR Network Receivers integration.""" From 058e8ba455b2033843abb3c51a8eddf7a1426a36 Mon Sep 17 00:00:00 2001 From: potelux Date: Tue, 31 Mar 2026 16:16:54 -0500 Subject: [PATCH 0272/1707] Add reload service to shell_command (#166557) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Joostlek From 7b5408d20c2220303d49b7d2c2edcbf3fccf949b Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:19:14 +0200 Subject: [PATCH 0273/1707] Rename component to integration in Denon Network Receivers (#167006) --- homeassistant/components/denon/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/denon/__init__.py b/homeassistant/components/denon/__init__.py index ab8cd1b896e84f..524d9becfc743c 100644 --- a/homeassistant/components/denon/__init__.py +++ b/homeassistant/components/denon/__init__.py @@ -1 +1 @@ -"""The denon component.""" +"""The Denon Network Receivers integration.""" From 3a63f9fbb1580cd5a152f8629456591f50f3f2bf Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:20:52 +0200 Subject: [PATCH 0274/1707] Rename component to integration in Tomato (#167002) --- homeassistant/components/tomato/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tomato/__init__.py b/homeassistant/components/tomato/__init__.py index e8a67f7e3bc1ab..4441481d034310 100644 --- a/homeassistant/components/tomato/__init__.py +++ b/homeassistant/components/tomato/__init__.py @@ -1 +1 @@ -"""The tomato component.""" +"""The Tomato integration.""" From 3c86f1eee86602e71221e0beb6904591e06725c8 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:22:05 +0200 Subject: [PATCH 0275/1707] Rename component to integration in Fido (#166997) --- homeassistant/components/fido/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fido/__init__.py b/homeassistant/components/fido/__init__.py index d950d39ef70755..227a03cba32ebc 100644 --- a/homeassistant/components/fido/__init__.py +++ b/homeassistant/components/fido/__init__.py @@ -1 +1 @@ -"""The fido component.""" +"""The Fido integration.""" From 4fa1d6b0a1a79f4b53cb2163f7205c845e5879ab Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:22:25 +0200 Subject: [PATCH 0276/1707] Rename component to integration in Actiontec (#167004) --- homeassistant/components/actiontec/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/actiontec/__init__.py b/homeassistant/components/actiontec/__init__.py index fa59cc870633af..adcb750a794450 100644 --- a/homeassistant/components/actiontec/__init__.py +++ b/homeassistant/components/actiontec/__init__.py @@ -1 +1 @@ -"""The actiontec component.""" +"""The Actiontec integration.""" From 08726af215db7b5c385e5cf94cf5ac31078141b6 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:22:51 +0200 Subject: [PATCH 0277/1707] Rename component to integration in EBox (#166996) --- homeassistant/components/ebox/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ebox/__init__.py b/homeassistant/components/ebox/__init__.py index 3f807666a4baca..1a482327a840af 100644 --- a/homeassistant/components/ebox/__init__.py +++ b/homeassistant/components/ebox/__init__.py @@ -1 +1 @@ -"""The ebox component.""" +"""The EBox integration.""" From 7563ea621797136babbdecf3dc57a78c1747e5f0 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:24:17 +0200 Subject: [PATCH 0278/1707] Rename component to integration in Bbox (#166998) --- homeassistant/components/bbox/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bbox/__init__.py b/homeassistant/components/bbox/__init__.py index 8c3bbf0d57f28b..ccbd46cb9a5e28 100644 --- a/homeassistant/components/bbox/__init__.py +++ b/homeassistant/components/bbox/__init__.py @@ -1 +1 @@ -"""The bbox component.""" +"""The Bbox integration.""" From fa7af346784ed7c1637d1b3b341fecdb321f35ad Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:24:19 +0200 Subject: [PATCH 0279/1707] Rename component to integration in EBox (#166996) From 2b0cff2c934daf7da1473791f20e599da489f663 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:24:21 +0200 Subject: [PATCH 0280/1707] Rename component to integration in DNS IP (#166993) --- homeassistant/components/dnsip/__init__.py | 4 ++-- tests/components/dnsip/__init__.py | 2 +- tests/components/dnsip/test_config_flow.py | 2 +- tests/components/dnsip/test_init.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 3487ce83c7bf4b..52d27e02c269c4 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -1,4 +1,4 @@ -"""The dnsip component.""" +"""The DNS IP integration.""" from __future__ import annotations @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload dnsip config entry.""" + """Unload DNS IP config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/tests/components/dnsip/__init__.py b/tests/components/dnsip/__init__.py index 254aad8f1da148..f26b935df21385 100644 --- a/tests/components/dnsip/__init__.py +++ b/tests/components/dnsip/__init__.py @@ -1,4 +1,4 @@ -"""Tests for the dnsip integration.""" +"""Tests for the DNS IP integration.""" from __future__ import annotations diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py index d9420afaa8c935..1ad035bf16f4ec 100644 --- a/tests/components/dnsip/test_config_flow.py +++ b/tests/components/dnsip/test_config_flow.py @@ -1,4 +1,4 @@ -"""Test the dnsip config flow.""" +"""Test the DNS IP config flow.""" from __future__ import annotations diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index ac5da227bde9c3..8d408b82156a82 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -1,4 +1,4 @@ -"""Test for DNS IP component Init.""" +"""Test for DNS IP integration Init.""" from __future__ import annotations From b2047c1acad33073e34dd36283b3865e5baf9cfd Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:26:11 +0200 Subject: [PATCH 0281/1707] Rename component to integration in SNMP (#166994) --- homeassistant/components/snmp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/snmp/__init__.py b/homeassistant/components/snmp/__init__.py index 4a049ee1553558..1da23965dce3f5 100644 --- a/homeassistant/components/snmp/__init__.py +++ b/homeassistant/components/snmp/__init__.py @@ -1,4 +1,4 @@ -"""The snmp component.""" +"""The SNMP integration.""" from .util import async_get_snmp_engine From 3b396814ae9aa8e4fdcd637305442cb0174bc379 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:27:18 +0200 Subject: [PATCH 0282/1707] Update mypy to 1.20.0 (#167000) --- .../components/alarmdecoder/config_flow.py | 22 ++++++++++++------- .../components/config/entity_registry.py | 2 +- .../components/devolo_home_control/sensor.py | 4 ++++ homeassistant/components/eafm/__init__.py | 2 +- homeassistant/components/elevenlabs/tts.py | 2 +- homeassistant/components/elkm1/__init__.py | 2 +- homeassistant/components/homekit/__init__.py | 2 +- homeassistant/components/lidarr/sensor.py | 3 ++- homeassistant/components/onkyo/coordinator.py | 2 +- homeassistant/components/sonarr/helpers.py | 4 ++-- homeassistant/components/sonarr/sensor.py | 9 ++++---- homeassistant/core_config.py | 2 +- mypy.ini | 1 - requirements_test.txt | 4 ++-- script/hassfest/mypy_config.py | 1 - 15 files changed, 36 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index 093ed220973e45..7680dc47703e8e 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -3,10 +3,10 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from adext import AdExt -from alarmdecoder.devices import SerialDevice, SocketDevice +from alarmdecoder.devices import Device, SerialDevice, SocketDevice from alarmdecoder.util import NoDeviceError import voluptuous as vol @@ -102,16 +102,21 @@ async def async_step_protocol( self._async_current_entries(), user_input, self.protocol ): return self.async_abort(reason="already_configured") - connection = {} + connection: dict[str, Any] = {} baud = None + device: Device if self.protocol == PROTOCOL_SOCKET: - host = connection[CONF_HOST] = user_input[CONF_HOST] - port = connection[CONF_PORT] = user_input[CONF_PORT] - title = f"{host}:{port}" + host = connection[CONF_HOST] = cast(str, user_input[CONF_HOST]) + port = connection[CONF_PORT] = cast(int, user_input[CONF_PORT]) + title: str = f"{host}:{port}" device = SocketDevice(interface=(host, port)) if self.protocol == PROTOCOL_SERIAL: - path = connection[CONF_DEVICE_PATH] = user_input[CONF_DEVICE_PATH] - baud = connection[CONF_DEVICE_BAUD] = user_input[CONF_DEVICE_BAUD] + path = connection[CONF_DEVICE_PATH] = cast( + str, user_input[CONF_DEVICE_PATH] + ) + baud = connection[CONF_DEVICE_BAUD] = cast( + int, user_input[CONF_DEVICE_BAUD] + ) title = path device = SerialDevice(interface=path) @@ -132,6 +137,7 @@ def test_connection(): _LOGGER.exception("Unexpected exception during AlarmDecoder setup") errors["base"] = "unknown" + schema: vol.Schema if self.protocol == PROTOCOL_SOCKET: schema = vol.Schema( { diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index ce9f315ff78037..86c5a8dd3edbe9 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -210,7 +210,7 @@ def websocket_update_entity( ) return - changes = {} + changes: dict[str, Any] = {} for key in ( "area_id", diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index e601728d851956..9f711ad9c2978d 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from devolo_home_control_api.devices.zwave import Zwave from devolo_home_control_api.homecontrol import HomeControl @@ -188,6 +190,8 @@ def unique_id(self) -> str: def sync_callback(self, message: tuple) -> None: """Update the consumption sensor state.""" if message[0] == self._attr_unique_id: + if TYPE_CHECKING: + assert self._attr_unique_id is not None self._value = getattr( self._device_instance.consumption_property[self._attr_unique_id], self._sensor_type, diff --git a/homeassistant/components/eafm/__init__.py b/homeassistant/components/eafm/__init__.py index ff1d622139af28..019e5adc137349 100644 --- a/homeassistant/components/eafm/__init__.py +++ b/homeassistant/components/eafm/__init__.py @@ -25,7 +25,7 @@ def _fix_device_registry_identifiers( if old_identifier not in device_entry.identifiers: # type: ignore[comparison-overlap] continue new_identifiers = device_entry.identifiers.copy() - new_identifiers.discard(old_identifier) # type: ignore[arg-type] + new_identifiers.discard(old_identifier) new_identifiers.add((DOMAIN, entry.data["station"])) device_registry.async_update_device( device_entry.id, new_identifiers=new_identifiers diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index b1c26093cf9d2e..b3743fd32706ed 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -273,7 +273,7 @@ async def _add_sentences() -> None: continue # Build kwargs common to both modes - kwargs = base_stream_params | { + kwargs: dict[str, Any] = base_stream_params | { "text": text, } diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 14bd8c55aebe27..54d6ebcc357202 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -293,7 +293,7 @@ def _keypad_changed(keypad: Element, changeset: dict[str, Any]) -> None: elk_temp_unit = elk.panel.temperature_units if elk_temp_unit == "C": - temperature_unit = UnitOfTemperature.CELSIUS + temperature_unit = UnitOfTemperature.CELSIUS # type: ignore[unreachable] else: temperature_unit = UnitOfTemperature.FAHRENHEIT config["temperature_unit"] = temperature_unit diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index ce08feaaebb1b8..1af5b86b5a18bc 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -979,7 +979,7 @@ def _async_purge_old_bridges( for entry in dev_reg.devices.get_devices_for_config_entry_id(self._entry_id) if ( identifier not in entry.identifiers # type: ignore[comparison-overlap] - or connection not in entry.connections + or connection not in entry.connections # type: ignore[unreachable] ) ] diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 81b2c570eaba1f..313804677b579a 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -97,7 +97,8 @@ class LidarrSensorEntityDescription( state_class=SensorStateClass.TOTAL, entity_registry_enabled_default=False, attributes_fn=lambda data: { - album.title: album.artist.artistName for album in data.records + album.title: album.artist.artistName # type: ignore[misc] + for album in data.records }, ), "albums": LidarrSensorEntityDescription[int]( diff --git a/homeassistant/components/onkyo/coordinator.py b/homeassistant/components/onkyo/coordinator.py index d418b09ad04b83..5c1713e992d412 100644 --- a/homeassistant/components/onkyo/coordinator.py +++ b/homeassistant/components/onkyo/coordinator.py @@ -122,7 +122,7 @@ async def async_send_command( """Send muting command for a channel.""" self._desired[channel] = param message_data: ChannelMutingDesired = self.data | self._desired - message = command.ChannelMuting(**message_data) # type: ignore[misc] + message = command.ChannelMuting(**message_data) await self.manager.write(message) async def _update_callback(self, message: Status) -> None: diff --git a/homeassistant/components/sonarr/helpers.py b/homeassistant/components/sonarr/helpers.py index 522009785b1783..e0943139ef4459 100644 --- a/homeassistant/components/sonarr/helpers.py +++ b/homeassistant/components/sonarr/helpers.py @@ -276,7 +276,7 @@ def format_upcoming( for episode in calendar: # Create a unique key combining series title and episode identifier - series_title = episode.series.title if hasattr(episode, "series") else "Unknown" + series_title = episode.series.title if hasattr(episode, "series") else "Unknown" # type: ignore[misc] identifier = f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}" key = f"{series_title} {identifier}" episodes[key] = format_upcoming_item(episode, base_url) @@ -324,7 +324,7 @@ def format_wanted( for item in wanted.records: # Create a unique key combining series title and episode identifier series_title = ( - item.series.title if hasattr(item, "series") and item.series else "Unknown" + item.series.title if hasattr(item, "series") and item.series else "Unknown" # type: ignore[misc] ) identifier = f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}" key = f"{series_title} {identifier}" diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 3aeb4348e6d866..74e172580ef604 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -65,9 +65,9 @@ def get_queue_attr(queue: SonarrQueue) -> dict[str, str]: remaining = 1 if item.size == 0 else item.sizeleft / item.size remaining_pct = 100 * (1 - remaining) identifier = ( - f"S{item.episode.seasonNumber:02d}E{item.episode.episodeNumber:02d}" + f"S{item.episode.seasonNumber:02d}E{item.episode.episodeNumber:02d}" # type: ignore[misc] ) - attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%" + attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%" # type: ignore[misc] return attrs @@ -77,7 +77,7 @@ def get_wanted_attr(wanted: SonarrWantedMissing) -> dict[str, str]: for item in wanted.records: identifier = f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}" - name = f"{item.series.title} {identifier}" + name = f"{item.series.title} {identifier}" # type: ignore[misc] attrs[name] = dt_util.as_local( item.airDateUtc.replace(tzinfo=dt_util.UTC) ).isoformat() @@ -126,7 +126,8 @@ def get_wanted_attr(wanted: SonarrWantedMissing) -> dict[str, str]: translation_key="upcoming", value_fn=len, attributes_fn=lambda data: { - e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" for e in data + e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" # type: ignore[misc] + for e in data }, ), "wanted": SonarrSensorEntityDescription[SonarrWantedMissing]( diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index f7169c38b91601..678094a3a1d65a 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -526,7 +526,7 @@ def remove(self, value: str) -> None: self._top_level_components.remove(value) return super().remove(value) - def discard(self, value: str) -> None: + def discard(self, value: object) -> None: """Remove a component from the store.""" raise NotImplementedError("_ComponentSet does not support discard, use remove") diff --git a/mypy.ini b/mypy.ini index 987b3c7f4ac803..1994a1ace0a36b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -14,7 +14,6 @@ strict_bytes = true no_implicit_optional = true warn_incomplete_stub = true warn_redundant_casts = true -warn_unused_configs = true warn_unused_ignores = true enable_error_code = deprecated, ignore-without-code, redundant-self, truthy-iterable disable_error_code = annotation-unchecked, import-not-found, import-untyped diff --git a/requirements_test.txt b/requirements_test.txt index dd7c0aa4adfdb0..e82c77721c009b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,10 +11,10 @@ astroid==4.0.4 coverage==7.10.6 freezegun==1.5.2 # librt is an internal mypy dependency -librt==0.7.3 +librt==0.8.1 license-expression==30.4.3 mock-open==1.4.0 -mypy==1.19.1 +mypy==1.20.0 prek==0.2.28 pydantic==2.12.2 pylint==4.0.5 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 1eb1c5ee4768e7..230997d2774cad 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -50,7 +50,6 @@ "no_implicit_optional": "true", "warn_incomplete_stub": "true", "warn_redundant_casts": "true", - "warn_unused_configs": "true", "warn_unused_ignores": "true", "enable_error_code": ", ".join( # noqa: FLY002 [ From 3d07ec86966626cf36a0e71d02c27c0f4a9b1bd5 Mon Sep 17 00:00:00 2001 From: Leon Grave Date: Tue, 31 Mar 2026 23:27:33 +0200 Subject: [PATCH 0283/1707] Add freshr reconfiguration flow (#166907) --- .../components/freshr/config_flow.py | 74 +++++++++++++------ .../components/freshr/quality_scale.yaml | 2 +- homeassistant/components/freshr/strings.json | 12 ++- tests/components/freshr/test_config_flow.py | 62 ++++++++++++++++ 4 files changed, 126 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/freshr/config_flow.py b/homeassistant/components/freshr/config_flow.py index e3d366ff03dfda..90c5dd21420e0c 100644 --- a/homeassistant/components/freshr/config_flow.py +++ b/homeassistant/components/freshr/config_flow.py @@ -30,22 +30,31 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + async def _validate_input(self, username: str, password: str) -> str | None: + """Validate credentials, returning an error key or None on success.""" + client = FreshrClient(session=async_get_clientsession(self.hass)) + try: + await client.login(username, password) + except LoginError: + return "invalid_auth" + except ClientError: + return "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + return "unknown" + return None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - client = FreshrClient(session=async_get_clientsession(self.hass)) - try: - await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - except LoginError: - errors["base"] = "invalid_auth" - except ClientError: - errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + error = await self._validate_input( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if error: + errors["base"] = error else: await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) self._abort_if_unique_id_configured() @@ -58,6 +67,34 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + error = await self._validate_input( + reconfigure_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if error: + errors["base"] = error + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={ + CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME] + }, + errors=errors, + ) + async def async_step_reauth( self, _user_input: Mapping[str, Any] ) -> ConfigFlowResult: @@ -72,18 +109,11 @@ async def async_step_reauth_confirm( reauth_entry = self._get_reauth_entry() if user_input is not None: - client = FreshrClient(session=async_get_clientsession(self.hass)) - try: - await client.login( - reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] - ) - except LoginError: - errors["base"] = "invalid_auth" - except ClientError: - errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + error = await self._validate_input( + reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if error: + errors["base"] = error else: return self.async_update_reload_and_abort( reauth_entry, diff --git a/homeassistant/components/freshr/quality_scale.yaml b/homeassistant/components/freshr/quality_scale.yaml index ffc87c678a8d7d..1d3ae24ae3ecce 100644 --- a/homeassistant/components/freshr/quality_scale.yaml +++ b/homeassistant/components/freshr/quality_scale.yaml @@ -62,7 +62,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow. diff --git a/homeassistant/components/freshr/strings.json b/homeassistant/components/freshr/strings.json index ee833d999c9cc9..f7627054914f44 100644 --- a/homeassistant/components/freshr/strings.json +++ b/homeassistant/components/freshr/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,6 +20,15 @@ }, "description": "Re-enter the password for your Fresh-r account `{username}`." }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::freshr::config::step::user::data_description::password%]" + }, + "description": "Update the password for your Fresh-r account `{username}`." + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/tests/components/freshr/test_config_flow.py b/tests/components/freshr/test_config_flow.py index 16841d4f353de8..0a12f560ac373b 100644 --- a/tests/components/freshr/test_config_flow.py +++ b/tests/components/freshr/test_config_flow.py @@ -159,6 +159,68 @@ async def test_reauth_error( assert mock_config_entry.data[CONF_PASSWORD] == "new-pass" +@pytest.mark.usefixtures("mock_freshr_client") +async def test_reconfigure_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reconfiguration updates the password and reloads.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["description_placeholders"] == {CONF_USERNAME: "test-user"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "new-pass"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-pass" + assert mock_config_entry.data[CONF_USERNAME] == "test-user" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (LoginError("bad credentials"), "invalid_auth"), + (RuntimeError("unexpected"), "unknown"), + (ClientError("network"), "cannot_connect"), + ], +) +async def test_reconfigure_error( + hass: HomeAssistant, + mock_freshr_client: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_error: str, +) -> None: + """Test reconfiguration handles errors and recovers correctly.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_freshr_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "wrong-pass"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_freshr_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "new-pass"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-pass" + assert mock_config_entry.data[CONF_USERNAME] == "test-user" + + @pytest.mark.usefixtures("mock_freshr_client") async def test_form_already_configured_case_insensitive( hass: HomeAssistant, From c05c2b7f7038f9a0e4725d6a357fdb1f3a03656f Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Tue, 31 Mar 2026 23:30:29 +0200 Subject: [PATCH 0284/1707] Rename component to integration in Start.ca (#166989) --- homeassistant/components/startca/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/startca/__init__.py b/homeassistant/components/startca/__init__.py index aca4a424a36cf4..fe2ab1fd151563 100644 --- a/homeassistant/components/startca/__init__.py +++ b/homeassistant/components/startca/__init__.py @@ -1 +1 @@ -"""The startca component.""" +"""The Start.ca integration.""" From bb345dfd091a539ddccb7ea590f9ec199614a53e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 12:25:09 -1000 Subject: [PATCH 0285/1707] Bump aiohttp to 3.13.5 (#167015) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e6417021b0ded6..6b3cd196fee13e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==4.0.0 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.4 +aiohttp==3.13.5 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index d76bc3610ef3ff..f45c493cf5b590 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # module level in `bootstrap.py` and its requirements thus need to be in # requirements.txt to ensure they are always installed "aiogithubapi==26.0.0", - "aiohttp==3.13.4", + "aiohttp==3.13.5", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 50c1319f8be083..3fd6558cc6caa1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==4.0.0 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.4 +aiohttp==3.13.5 aiohttp_cors==0.8.1 aiozoneinfo==0.2.3 annotatedyaml==1.0.2 From b63ea3595938b444b311c3febdec85bf1813083c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:32:00 +0200 Subject: [PATCH 0286/1707] Update requests to 2.33.1 (#167014) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6b3cd196fee13e..2b13550e5c4da7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 -requests==2.32.5 +requests==2.33.1 securetar==2026.2.0 SQLAlchemy==2.0.41 standard-aifc==3.13.0 diff --git a/pyproject.toml b/pyproject.toml index f45c493cf5b590..5575a41f36da21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", "PyYAML==6.0.3", - "requests==2.32.5", + "requests==2.33.1", "securetar==2026.2.0", "SQLAlchemy==2.0.41", "standard-aifc==3.13.0", diff --git a/requirements.txt b/requirements.txt index 3fd6558cc6caa1..fca3d009ed0712 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 -requests==2.32.5 +requests==2.33.1 securetar==2026.2.0 SQLAlchemy==2.0.41 standard-aifc==3.13.0 From 01324a84a87d3f895aa590ccfa098e4f67c43799 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 1 Apr 2026 06:02:21 +0200 Subject: [PATCH 0287/1707] Bump ZHA to 1.1.1 (#167025) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 31f0d0d9e83799..d36b5bedf9ed44 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==1.1.0", "serialx==0.6.2"], + "requirements": ["zha==1.1.1", "serialx==0.6.2"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index c99203b1b60966..047c82c41e0643 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3383,7 +3383,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.1.0 +zha==1.1.1 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f139f6eb7e378f..988586350e671c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2865,7 +2865,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.1.0 +zha==1.1.1 # homeassistant.components.zinvolt zinvolt==0.3.0 From 423b694a0d630ee6c71a17e658a7f6ff6af54fa4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Apr 2026 02:19:34 -0400 Subject: [PATCH 0288/1707] Bump serialx to 1.1.1 (#167023) Co-authored-by: TheJulianJES --- homeassistant/components/homeassistant_hardware/manifest.json | 2 +- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index be6de115b78faa..02963f796cf86e 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", "requirements": [ - "serialx==0.6.2", + "serialx==1.1.1", "universal-silabs-flasher==1.0.3", "ha-silabs-firmware-client==0.3.0" ] diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d36b5bedf9ed44..34afaecac7f96a 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==1.1.1", "serialx==0.6.2"], + "requirements": ["zha==1.1.1", "serialx==1.1.1"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index 047c82c41e0643..ae682a716e1366 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2927,7 +2927,7 @@ sentry-sdk==2.48.0 # homeassistant.components.homeassistant_hardware # homeassistant.components.zha -serialx==0.6.2 +serialx==1.1.1 # homeassistant.components.sfr_box sfrbox-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 988586350e671c..3db8712d1e99a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2484,7 +2484,7 @@ sentry-sdk==2.48.0 # homeassistant.components.homeassistant_hardware # homeassistant.components.zha -serialx==0.6.2 +serialx==1.1.1 # homeassistant.components.sfr_box sfrbox-api==0.1.1 From 899b776e546a17ee348a3d53c3a157515ed87ac1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 Apr 2026 08:27:06 +0200 Subject: [PATCH 0289/1707] Add BEGA brand (#166992) --- homeassistant/brands/bega.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/bega.json diff --git a/homeassistant/brands/bega.json b/homeassistant/brands/bega.json new file mode 100644 index 00000000000000..7ff9ece9715fcf --- /dev/null +++ b/homeassistant/brands/bega.json @@ -0,0 +1,5 @@ +{ + "domain": "bega", + "name": "BEGA", + "iot_standards": ["zigbee"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d5036074b7e50d..8f2a48cfdee69e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -736,6 +736,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "bega": { + "name": "BEGA", + "iot_standards": [ + "zigbee" + ] + }, "bge": { "name": "Baltimore Gas and Electric (BGE)", "integration_type": "virtual", From 2b1c93724f2b936a2c802677a22529ce21a701fe Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 1 Apr 2026 16:29:07 +1000 Subject: [PATCH 0290/1707] Use Tesla Fleet API for Tessie config flow validation (#167021) --- .../components/tessie/config_flow.py | 60 ++++++++-------- tests/components/tessie/test_config_flow.py | 69 +++++++++---------- 2 files changed, 64 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py index 14c6b93fdfd690..fc350856b0fcf0 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -3,15 +3,16 @@ from __future__ import annotations from collections.abc import Mapping -from http import HTTPStatus from typing import Any -from aiohttp import ClientConnectionError, ClientResponseError -from tessie_api import get_state_of_all_vehicles +from aiohttp import ClientConnectionError +from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError +from tesla_fleet_api.tessie import Tessie import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -23,6 +24,24 @@ } +async def _async_validate_access_token( + hass: HomeAssistant, access_token: str, *, only_active: bool = False +) -> dict[str, str]: + """Validate a Tessie access token.""" + try: + await Tessie(async_get_clientsession(hass), access_token).list_vehicles( + only_active=only_active + ) + except InvalidToken, MissingToken: + return {CONF_ACCESS_TOKEN: "invalid_access_token"} + except ClientConnectionError: + return {"base": "cannot_connect"} + except TeslaFleetError: + return {"base": "unknown"} + + return {} + + class TessieConfigFlow(ConfigFlow, domain=DOMAIN): """Config Tessie API connection.""" @@ -35,20 +54,10 @@ async def async_step_user( errors: dict[str, str] = {} if user_input: self._async_abort_entries_match(dict(user_input)) - try: - await get_state_of_all_vehicles( - session=async_get_clientsession(self.hass), - api_key=user_input[CONF_ACCESS_TOKEN], - only_active=True, - ) - except ClientResponseError as e: - if e.status == HTTPStatus.UNAUTHORIZED: - errors[CONF_ACCESS_TOKEN] = "invalid_access_token" - else: - errors["base"] = "unknown" - except ClientConnectionError: - errors["base"] = "cannot_connect" - else: + errors = await _async_validate_access_token( + self.hass, user_input[CONF_ACCESS_TOKEN], only_active=True + ) + if not errors: return self.async_create_entry( title="Tessie", data=user_input, @@ -74,19 +83,10 @@ async def async_step_reauth_confirm( errors: dict[str, str] = {} if user_input: - try: - await get_state_of_all_vehicles( - session=async_get_clientsession(self.hass), - api_key=user_input[CONF_ACCESS_TOKEN], - ) - except ClientResponseError as e: - if e.status == HTTPStatus.UNAUTHORIZED: - errors[CONF_ACCESS_TOKEN] = "invalid_access_token" - else: - errors["base"] = "unknown" - except ClientConnectionError: - errors["base"] = "cannot_connect" - else: + errors = await _async_validate_access_token( + self.hass, user_input[CONF_ACCESS_TOKEN] + ) + if not errors: return self.async_update_reload_and_abort( self._get_reauth_entry(), data=user_input ) diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py index d51d467002d71d..a958467374d5a0 100644 --- a/tests/components/tessie/test_config_flow.py +++ b/tests/components/tessie/test_config_flow.py @@ -1,8 +1,10 @@ """Test the Tessie config flow.""" -from unittest.mock import patch +from collections.abc import Iterator +from unittest.mock import AsyncMock, patch import pytest +from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError from homeassistant import config_entries from homeassistant.components.tessie.const import DOMAIN @@ -10,29 +12,23 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .common import ( - ERROR_AUTH, - ERROR_CONNECTION, - ERROR_UNKNOWN, - TEST_CONFIG, - TEST_STATE_OF_ALL_VEHICLES, -) +from .common import ERROR_CONNECTION, TEST_CONFIG, TEST_STATE_OF_ALL_VEHICLES from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def mock_config_flow_get_state_of_all_vehicles(): - """Mock get_state_of_all_vehicles in config flow.""" +def mock_config_flow_list_vehicles() -> Iterator[AsyncMock]: + """Mock Tessie.list_vehicles in config flow.""" with patch( - "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + "homeassistant.components.tessie.config_flow.Tessie.list_vehicles", return_value=TEST_STATE_OF_ALL_VEHICLES, - ) as mock_config_flow_get_state_of_all_vehicles: - yield mock_config_flow_get_state_of_all_vehicles + ) as mock_list_vehicles: + yield mock_list_vehicles @pytest.fixture(autouse=True) -def mock_async_setup_entry(): +def mock_async_setup_entry() -> Iterator[AsyncMock]: """Mock async_setup_entry.""" with patch( "homeassistant.components.tessie.async_setup_entry", @@ -43,8 +39,8 @@ def mock_async_setup_entry(): async def test_form( hass: HomeAssistant, - mock_config_flow_get_state_of_all_vehicles, - mock_async_setup_entry, + mock_config_flow_list_vehicles: AsyncMock, + mock_async_setup_entry: AsyncMock, ) -> None: """Test we get the form.""" @@ -60,7 +56,7 @@ async def test_form( ) await hass.async_block_till_done() assert len(mock_async_setup_entry.mock_calls) == 1 - assert len(mock_config_flow_get_state_of_all_vehicles.mock_calls) == 1 + assert len(mock_config_flow_list_vehicles.mock_calls) == 1 assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Tessie" @@ -69,8 +65,6 @@ async def test_form( async def test_abort( hass: HomeAssistant, - mock_config_flow_get_state_of_all_vehicles, - mock_async_setup_entry, ) -> None: """Test a duplicate entry aborts.""" @@ -97,13 +91,17 @@ async def test_abort( @pytest.mark.parametrize( ("side_effect", "error"), [ - (ERROR_AUTH, {CONF_ACCESS_TOKEN: "invalid_access_token"}), - (ERROR_UNKNOWN, {"base": "unknown"}), + (InvalidToken(), {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (MissingToken(), {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (TeslaFleetError(), {"base": "unknown"}), (ERROR_CONNECTION, {"base": "cannot_connect"}), ], ) async def test_form_errors( - hass: HomeAssistant, side_effect, error, mock_config_flow_get_state_of_all_vehicles + hass: HomeAssistant, + side_effect: BaseException, + error: dict[str, str], + mock_config_flow_list_vehicles: AsyncMock, ) -> None: """Test errors are handled.""" @@ -111,7 +109,7 @@ async def test_form_errors( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_config_flow_get_state_of_all_vehicles.side_effect = side_effect + mock_config_flow_list_vehicles.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], TEST_CONFIG, @@ -121,7 +119,7 @@ async def test_form_errors( assert result2["errors"] == error # Complete the flow - mock_config_flow_get_state_of_all_vehicles.side_effect = None + mock_config_flow_list_vehicles.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], TEST_CONFIG, @@ -132,8 +130,8 @@ async def test_form_errors( async def test_reauth( hass: HomeAssistant, - mock_config_flow_get_state_of_all_vehicles, - mock_async_setup_entry, + mock_config_flow_list_vehicles: AsyncMock, + mock_async_setup_entry: AsyncMock, ) -> None: """Test reauth flow.""" @@ -155,7 +153,7 @@ async def test_reauth( ) await hass.async_block_till_done() assert len(mock_async_setup_entry.mock_calls) == 1 - assert len(mock_config_flow_get_state_of_all_vehicles.mock_calls) == 1 + assert len(mock_config_flow_list_vehicles.mock_calls) == 1 assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" @@ -165,21 +163,22 @@ async def test_reauth( @pytest.mark.parametrize( ("side_effect", "error"), [ - (ERROR_AUTH, {CONF_ACCESS_TOKEN: "invalid_access_token"}), - (ERROR_UNKNOWN, {"base": "unknown"}), + (InvalidToken(), {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (MissingToken(), {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (TeslaFleetError(), {"base": "unknown"}), (ERROR_CONNECTION, {"base": "cannot_connect"}), ], ) async def test_reauth_errors( hass: HomeAssistant, - mock_config_flow_get_state_of_all_vehicles, - mock_async_setup_entry, - side_effect, - error, + mock_config_flow_list_vehicles: AsyncMock, + mock_async_setup_entry: AsyncMock, + side_effect: BaseException, + error: dict[str, str], ) -> None: """Test reauth flows that fail.""" - mock_config_flow_get_state_of_all_vehicles.side_effect = side_effect + mock_config_flow_list_vehicles.side_effect = side_effect mock_entry = MockConfigEntry( domain=DOMAIN, @@ -199,7 +198,7 @@ async def test_reauth_errors( assert result2["errors"] == error # Complete the flow - mock_config_flow_get_state_of_all_vehicles.side_effect = None + mock_config_flow_list_vehicles.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], TEST_CONFIG, From 2591cf2b3de92756d125cb89c17f01d5e6610643 Mon Sep 17 00:00:00 2001 From: Oluwatobi Mustapha Date: Wed, 1 Apr 2026 08:27:39 +0100 Subject: [PATCH 0291/1707] Migrate google_mail OAuth token refresh exception handling (#165371) Co-authored-by: Martin Hjelmare --- homeassistant/components/google_mail/api.py | 37 ++++---- tests/components/google_mail/test_init.py | 84 ++++++++++++++++++- tests/components/google_mail/test_sensor.py | 51 ++++++++++- tests/components/google_mail/test_services.py | 25 +++--- 4 files changed, 160 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index 3e455f645ad02e..8162a4d74d0fdb 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -2,8 +2,7 @@ from functools import partial -from aiohttp.client_exceptions import ClientError, ClientResponseError -from google.auth.exceptions import RefreshError +from aiohttp.client_exceptions import ClientError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build @@ -14,6 +13,8 @@ ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, ) from homeassistant.helpers import config_entry_oauth2_flow @@ -37,24 +38,26 @@ def access_token(self) -> str: async def check_and_refresh_token(self) -> str: """Check the token.""" + setup_in_progress = ( + self.oauth_session.config_entry.state is ConfigEntryState.SETUP_IN_PROGRESS + ) + try: await self.oauth_session.async_ensure_token_valid() - except (RefreshError, ClientResponseError, ClientError) as ex: - if ( - self.oauth_session.config_entry.state - is ConfigEntryState.SETUP_IN_PROGRESS - ): - if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from ex + except OAuth2TokenRequestReauthError as ex: + if setup_in_progress: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from ex + self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) + raise + except OAuth2TokenRequestError as ex: + if setup_in_progress: + raise ConfigEntryNotReady from ex + raise + except ClientError as ex: + if setup_in_progress: raise ConfigEntryNotReady from ex - if isinstance(ex, RefreshError) or ( - hasattr(ex, "status") and ex.status == 400 - ): - self.oauth_session.config_entry.async_start_reauth( - self.oauth_session.hass - ) raise HomeAssistantError(ex) from ex return self.access_token diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index 791ef6f8e88149..ead9868699696d 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -2,14 +2,19 @@ import http import time -from unittest.mock import patch +from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError import pytest from homeassistant.components.google_mail import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -76,13 +81,23 @@ async def test_expired_token_refresh_success( http.HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY, ), + ( + time.time() - 3600, + http.HTTPStatus.TOO_MANY_REQUESTS, + ConfigEntryState.SETUP_RETRY, + ), ( time.time() - 3600, http.HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_ERROR, ), ], - ids=["failure_requires_reauth", "transient_failure", "revoked_auth"], + ids=[ + "failure_requires_reauth", + "transient_failure", + "rate_limited", + "revoked_auth", + ], ) async def test_expired_token_refresh_failure( hass: HomeAssistant, @@ -123,6 +138,69 @@ async def test_expired_token_refresh_client_error( assert entries[0].state is ConfigEntryState.SETUP_RETRY +async def test_token_refresh_reauth_error_during_setup( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test setup starts reauth for OAuth reauth errors.""" + with patch( + "homeassistant.components.google_mail.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=Mock(), + domain=DOMAIN, + ), + ): + await setup_integration() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == SOURCE_REAUTH + + +async def test_token_refresh_transient_error_during_setup( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test setup retries for transient OAuth token refresh errors.""" + with patch( + "homeassistant.components.google_mail.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestTransientError( + request_info=Mock(), + domain=DOMAIN, + ), + ): + await setup_integration() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.SETUP_RETRY + assert not hass.config_entries.flow.async_progress() + + +async def test_token_refresh_error_during_setup( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test generic OAuth token refresh errors retry setup.""" + with patch( + "homeassistant.components.google_mail.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestError( + request_info=Mock(), + domain=DOMAIN, + ), + ): + await setup_integration() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state is ConfigEntryState.SETUP_RETRY + assert not hass.config_entries.flow.async_progress() + + async def test_device_info( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/google_mail/test_sensor.py b/tests/components/google_mail/test_sensor.py index 3b88cb327edbe4..21afa85bc65036 100644 --- a/tests/components/google_mail/test_sensor.py +++ b/tests/components/google_mail/test_sensor.py @@ -1,9 +1,9 @@ """Sensor tests for the Google Mail integration.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import Mock, patch -from google.auth.exceptions import RefreshError +from aiohttp.client_exceptions import ClientResponseError from httplib2 import Response import pytest @@ -12,6 +12,11 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) from homeassistant.util import dt as dt_util from .conftest import SENSOR, TOKEN, ComponentSetup @@ -56,12 +61,19 @@ async def test_sensors( async def test_sensor_reauth_trigger( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + setup_integration: ComponentSetup, ) -> None: """Test reauth is triggered after a refresh error.""" await setup_integration() - with patch(TOKEN, side_effect=RefreshError): + with patch( + TOKEN, + side_effect=OAuth2TokenRequestReauthError( + request_info=Mock(), + domain=DOMAIN, + ), + ): next_update = dt_util.utcnow() + timedelta(minutes=15) async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) @@ -73,3 +85,34 @@ async def test_sensor_reauth_trigger( assert flow["step_id"] == "reauth_confirm" assert flow["handler"] == DOMAIN assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + + +@pytest.mark.parametrize( + "side_effect", + [ + OAuth2TokenRequestTransientError(request_info=Mock(), domain=DOMAIN), + OAuth2TokenRequestError(request_info=Mock(), domain=DOMAIN), + ClientResponseError(request_info=Mock(), history=(), status=401), + ClientResponseError(request_info=Mock(), history=(), status=429), + ], + ids=[ + "oauth_transient_error", + "oauth_generic_error", + "legacy_client_response_4xx", + "legacy_rate_limited", + ], +) +async def test_sensor_token_error_no_reauth( + hass: HomeAssistant, + setup_integration: ComponentSetup, + side_effect: Exception | type[Exception], +) -> None: + """Test retryable/runtime token errors do not start reauth.""" + await setup_integration() + + with patch(TOKEN, side_effect=side_effect): + next_update = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done(wait_background_tasks=True) + + assert not hass.config_entries.flow.async_progress() diff --git a/tests/components/google_mail/test_services.py b/tests/components/google_mail/test_services.py index c8679de75e49d5..cae241e0b54431 100644 --- a/tests/components/google_mail/test_services.py +++ b/tests/components/google_mail/test_services.py @@ -1,15 +1,13 @@ """Services tests for the Google Mail integration.""" -from unittest.mock import patch +from unittest.mock import Mock, patch -from aiohttp.client_exceptions import ClientResponseError -from google.auth.exceptions import RefreshError import pytest from homeassistant import config_entries from homeassistant.components.google_mail import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, OAuth2TokenRequestReauthError from .conftest import BUILD, SENSOR, TOKEN, ComponentSetup @@ -60,22 +58,23 @@ async def test_set_vacation( assert len(mock_client.mock_calls) == 5 -@pytest.mark.parametrize( - ("side_effect"), - [ - (RefreshError,), - (ClientResponseError("", (), status=400),), - ], -) async def test_reauth_trigger( hass: HomeAssistant, setup_integration: ComponentSetup, - side_effect, ) -> None: """Test reauth is triggered after a refresh error during service call.""" await setup_integration() - with patch(TOKEN, side_effect=side_effect), pytest.raises(HomeAssistantError): + with ( + patch( + TOKEN, + side_effect=OAuth2TokenRequestReauthError( + request_info=Mock(), + domain=DOMAIN, + ), + ), + pytest.raises(HomeAssistantError), + ): await hass.services.async_call( DOMAIN, "set_vacation", From c7cf78952e9f652f41166794ca9d5a79c0740c0b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:34:05 +0200 Subject: [PATCH 0292/1707] Use runtime_data in omnilogic integration (#167038) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/omnilogic/__init__.py | 27 +++++-------------- .../components/omnilogic/coordinator.py | 7 +++-- homeassistant/components/omnilogic/sensor.py | 11 +++----- homeassistant/components/omnilogic/switch.py | 13 ++++----- 4 files changed, 20 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/omnilogic/__init__.py b/homeassistant/components/omnilogic/__init__.py index 19dffc1a051bf3..89bf1f3b85d3d6 100644 --- a/homeassistant/components/omnilogic/__init__.py +++ b/homeassistant/components/omnilogic/__init__.py @@ -4,27 +4,20 @@ from omnilogic import LoginException, OmniLogic, OmniLogicException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import ( - CONF_SCAN_INTERVAL, - COORDINATOR, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - OMNI_API, -) -from .coordinator import OmniLogicUpdateCoordinator +from .const import CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL +from .coordinator import OmniLogicConfigEntry, OmniLogicUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OmniLogicConfigEntry) -> bool: """Set up Omnilogic from a config entry.""" conf = entry.data @@ -56,21 +49,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR: coordinator, - OMNI_API: api, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OmniLogicConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/omnilogic/coordinator.py b/homeassistant/components/omnilogic/coordinator.py index 24c8cdf2554be7..11bbc6f835d4e2 100644 --- a/homeassistant/components/omnilogic/coordinator.py +++ b/homeassistant/components/omnilogic/coordinator.py @@ -15,17 +15,20 @@ _LOGGER = logging.getLogger(__name__) +type OmniLogicConfigEntry = ConfigEntry[OmniLogicUpdateCoordinator] + + class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): """Class to manage fetching update data from single endpoint.""" - config_entry: ConfigEntry + config_entry: OmniLogicConfigEntry def __init__( self, hass: HomeAssistant, api: OmniLogic, name: str, - config_entry: ConfigEntry, + config_entry: OmniLogicConfigEntry, polling_interval: int, ) -> None: """Initialize the global Omnilogic data updater.""" diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index 522dcc4f3cd3da..2778abba4645d0 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -3,7 +3,6 @@ from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -16,21 +15,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import check_guard -from .const import COORDINATOR, DEFAULT_PH_OFFSET, DOMAIN, PUMP_TYPES -from .coordinator import OmniLogicUpdateCoordinator +from .const import DEFAULT_PH_OFFSET, PUMP_TYPES +from .coordinator import OmniLogicConfigEntry, OmniLogicUpdateCoordinator from .entity import OmniLogicEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OmniLogicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator: OmniLogicUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data entities = [] for item_id, item in coordinator.data.items(): diff --git a/homeassistant/components/omnilogic/switch.py b/homeassistant/components/omnilogic/switch.py index 9583194f41badc..a1a7847eaf24e3 100644 --- a/homeassistant/components/omnilogic/switch.py +++ b/homeassistant/components/omnilogic/switch.py @@ -7,14 +7,13 @@ import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import check_guard -from .const import COORDINATOR, DOMAIN, PUMP_TYPES -from .coordinator import OmniLogicUpdateCoordinator +from .const import PUMP_TYPES +from .coordinator import OmniLogicConfigEntry, OmniLogicUpdateCoordinator from .entity import OmniLogicEntity SERVICE_SET_SPEED = "set_pump_speed" @@ -23,14 +22,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OmniLogicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the light platform.""" + """Set up the switch platform.""" - coordinator: OmniLogicUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data entities = [] for item_id, item in coordinator.data.items(): From 6dc391e16916c7620ddefcf535ebfba6a25b0fa5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:47:03 +0200 Subject: [PATCH 0293/1707] Use runtime_data in obihai integration (#167037) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/obihai/__init__.py | 14 ++++++++------ homeassistant/components/obihai/button.py | 10 +++++----- homeassistant/components/obihai/sensor.py | 8 ++++---- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 43fd3e3426b0e0..6262661d315e30 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -6,10 +6,12 @@ from homeassistant.helpers.device_registry import format_mac from .connectivity import ObihaiConnection -from .const import DOMAIN, LOGGER, PLATFORMS +from .const import LOGGER, PLATFORMS +type ObihaiConfigEntry = ConfigEntry[ObihaiConnection] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: ObihaiConfigEntry) -> bool: """Set up from a config entry.""" requester = ObihaiConnection( @@ -18,20 +20,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: password=entry.data[CONF_PASSWORD], ) await hass.async_add_executor_job(requester.update) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = requester + entry.runtime_data = requester await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: ObihaiConfigEntry) -> bool: """Migrate old entry.""" version = entry.version LOGGER.debug("Migrating from version %s", version) if version != 2: - requester: ObihaiConnection = hass.data[DOMAIN][entry.entry_id] + requester = entry.runtime_data device_mac = await hass.async_add_executor_job( requester.pyobihai.get_device_mac @@ -45,6 +47,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ObihaiConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py index 9cef92d3fce3ee..f1a244fee42165 100644 --- a/homeassistant/components/obihai/button.py +++ b/homeassistant/components/obihai/button.py @@ -7,14 +7,14 @@ ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform +from . import ObihaiConfigEntry from .connectivity import ObihaiConnection -from .const import DOMAIN, OBIHAI +from .const import OBIHAI BUTTON_DESCRIPTION = ButtonEntityDescription( key="reboot", @@ -26,12 +26,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ObihaiConfigEntry, async_add_entities: entity_platform.AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Obihai sensor entries.""" + """Set up the Obihai button entries.""" - requester: ObihaiConnection = hass.data[DOMAIN][entry.entry_id] + requester = entry.runtime_data buttons = [ObihaiButton(requester)] async_add_entities(buttons, update_before_add=True) diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index ec29238201a2fe..03a11c14001281 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -7,24 +7,24 @@ from requests.exceptions import RequestException from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import ObihaiConfigEntry from .connectivity import ObihaiConnection -from .const import DOMAIN, LOGGER, OBIHAI +from .const import LOGGER, OBIHAI SCAN_INTERVAL = datetime.timedelta(seconds=5) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ObihaiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Obihai sensor entries.""" - requester: ObihaiConnection = hass.data[DOMAIN][entry.entry_id] + requester = entry.runtime_data sensors = [ObihaiServiceSensors(requester, key) for key in requester.services] From df74d76ff298f3b2d6350d0a58e30496ab6280f5 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Wed, 1 Apr 2026 09:48:03 +0200 Subject: [PATCH 0294/1707] Rename component to integration in Aruba (#167035) --- homeassistant/components/aruba/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aruba/__init__.py b/homeassistant/components/aruba/__init__.py index cd52f7310f308e..14c0b67967e328 100644 --- a/homeassistant/components/aruba/__init__.py +++ b/homeassistant/components/aruba/__init__.py @@ -1 +1 @@ -"""The aruba component.""" +"""The Aruba integration.""" From b0201e893ec910d4a4474b32b1896c4c32348f98 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Wed, 1 Apr 2026 09:49:00 +0200 Subject: [PATCH 0295/1707] Rename component to integration in TEMPer (#167034) --- homeassistant/components/temper/__init__.py | 2 +- tests/components/temper/__init__.py | 2 +- tests/components/temper/test_sensor.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/temper/__init__.py b/homeassistant/components/temper/__init__.py index 587da1c6309fc3..79cabf609285c4 100644 --- a/homeassistant/components/temper/__init__.py +++ b/homeassistant/components/temper/__init__.py @@ -1 +1 @@ -"""The temper component.""" +"""The TEMPer integration.""" diff --git a/tests/components/temper/__init__.py b/tests/components/temper/__init__.py index 6ce341cabc95e2..69f581724a8bd7 100644 --- a/tests/components/temper/__init__.py +++ b/tests/components/temper/__init__.py @@ -1 +1 @@ -"""Tests for the temper integration.""" +"""Tests for the TEMPer integration.""" diff --git a/tests/components/temper/test_sensor.py b/tests/components/temper/test_sensor.py index 445adc0b5bd464..96ec3ba62ad582 100644 --- a/tests/components/temper/test_sensor.py +++ b/tests/components/temper/test_sensor.py @@ -1,4 +1,4 @@ -"""The tests for the temper (USB temperature sensor) component.""" +"""The tests for the TEMPer integration.""" from datetime import timedelta from unittest.mock import Mock, patch From 9cf6911b7f83c70369653eec6e8d8609c944a074 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Wed, 1 Apr 2026 09:49:34 +0200 Subject: [PATCH 0296/1707] Rename component to integration in Radio Thermostat (#167033) --- homeassistant/components/radiotherm/__init__.py | 2 +- homeassistant/components/radiotherm/data.py | 4 ++-- homeassistant/components/radiotherm/entity.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index 80dbcf44bc9252..1c5f7f571a659b 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -1,4 +1,4 @@ -"""The radiotherm component.""" +"""The Radio Thermostat integration.""" from __future__ import annotations diff --git a/homeassistant/components/radiotherm/data.py b/homeassistant/components/radiotherm/data.py index 4803cacd84b93a..92e2ee42273d39 100644 --- a/homeassistant/components/radiotherm/data.py +++ b/homeassistant/components/radiotherm/data.py @@ -1,4 +1,4 @@ -"""The radiotherm component data.""" +"""The Radio Thermostat integration data.""" from __future__ import annotations @@ -16,7 +16,7 @@ @dataclass class RadioThermUpdate: - """An update from a radiotherm device.""" + """An update from a Radio Thermostat device.""" tstat: dict[str, Any] humidity: int | None diff --git a/homeassistant/components/radiotherm/entity.py b/homeassistant/components/radiotherm/entity.py index 384c97cac2ce8e..7735336624852e 100644 --- a/homeassistant/components/radiotherm/entity.py +++ b/homeassistant/components/radiotherm/entity.py @@ -1,4 +1,4 @@ -"""The radiotherm integration base entity.""" +"""The Radio Thermostat integration base entity.""" from abc import abstractmethod @@ -12,7 +12,7 @@ class RadioThermostatEntity(CoordinatorEntity[RadioThermUpdateCoordinator]): - """Base class for radiotherm entities.""" + """Base class for Radio Thermostat entities.""" _attr_has_entity_name = True From 7bff0e2f3f6d3cd04123b55318a13b6f7138035c Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Wed, 1 Apr 2026 09:50:08 +0200 Subject: [PATCH 0297/1707] Rename component to integration in Hyperion (#167032) --- homeassistant/components/hyperion/__init__.py | 2 +- homeassistant/components/hyperion/camera.py | 2 +- tests/components/hyperion/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 60a53193acc828..83385b5ff19541 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -1,4 +1,4 @@ -"""The Hyperion component.""" +"""The Hyperion integration.""" from __future__ import annotations diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index bd96c9667ad582..a839263dd65cb7 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -1,4 +1,4 @@ -"""Switch platform for Hyperion.""" +"""Camera platform for Hyperion.""" from __future__ import annotations diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py index 36137ce0ddde59..d0874e65d99583 100644 --- a/tests/components/hyperion/__init__.py +++ b/tests/components/hyperion/__init__.py @@ -1,4 +1,4 @@ -"""Tests for the Hyperion component.""" +"""Tests for the Hyperion integration.""" from __future__ import annotations From a434a0ab9088745dd50cf4c11e6275bb66c8f860 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Wed, 1 Apr 2026 09:50:29 +0200 Subject: [PATCH 0298/1707] Rename component to integration in Kodi (#167031) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/kodi/__init__.py | 2 +- homeassistant/components/kodi/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/kodi/__init__.py b/homeassistant/components/kodi/__init__.py index b5c8aed7d3258f..02083bb832f630 100644 --- a/homeassistant/components/kodi/__init__.py +++ b/homeassistant/components/kodi/__init__.py @@ -1,4 +1,4 @@ -"""The kodi component.""" +"""The Kodi integration.""" from dataclasses import dataclass import logging diff --git a/homeassistant/components/kodi/const.py b/homeassistant/components/kodi/const.py index 1ac439b27c3ae0..a6d78410c6d4d8 100644 --- a/homeassistant/components/kodi/const.py +++ b/homeassistant/components/kodi/const.py @@ -1,4 +1,4 @@ -"""Constants for the Kodi platform.""" +"""Constants for the Kodi integration.""" DOMAIN = "kodi" From 996f9fdca23fae60f53c23c767caad1f05f49c48 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Wed, 1 Apr 2026 09:50:47 +0200 Subject: [PATCH 0299/1707] Rename component to integration in Hikvision (#167030) --- homeassistant/components/hikvisioncam/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hikvisioncam/__init__.py b/homeassistant/components/hikvisioncam/__init__.py index 32a2a86b28fae6..6f832338104997 100644 --- a/homeassistant/components/hikvisioncam/__init__.py +++ b/homeassistant/components/hikvisioncam/__init__.py @@ -1 +1 @@ -"""The hikvisioncam component.""" +"""The Hikvision integration.""" From 17abdd02d34f6234808eaebf9e9fa0e8239e23b0 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Wed, 1 Apr 2026 09:52:11 +0200 Subject: [PATCH 0300/1707] Rename component to integration in Ubiquiti mFi mPort (#166988) --- homeassistant/components/mfi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mfi/__init__.py b/homeassistant/components/mfi/__init__.py index de354dfbc37a66..df797caeeabf1c 100644 --- a/homeassistant/components/mfi/__init__.py +++ b/homeassistant/components/mfi/__init__.py @@ -1 +1 @@ -"""The mfi component.""" +"""The Ubiquiti mFI mPort integration.""" From 52050711a3d095706a698c729c0729e061aeab60 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Wed, 1 Apr 2026 09:52:32 +0200 Subject: [PATCH 0301/1707] Rename component to integration in Pushsafer (#166893) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/pushsafer/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/pushsafer/__init__.py b/homeassistant/components/pushsafer/__init__.py index 81dfc7e15fd11b..abb0c1bad61307 100644 --- a/homeassistant/components/pushsafer/__init__.py +++ b/homeassistant/components/pushsafer/__init__.py @@ -1 +1 @@ -"""The pushsafer component.""" +"""The Pushsafer integration.""" From 3b9a9ca6cb7626909de3a1995c007e7e29819b16 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Apr 2026 09:54:01 +0200 Subject: [PATCH 0302/1707] Store received backup in temp backup dir only (#166982) --- homeassistant/components/backup/manager.py | 7 ++- tests/components/backup/test_manager.py | 68 ++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 520ea8ea38b4df..a05a55bf4e93a9 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -12,7 +12,7 @@ import io from itertools import chain import json -from pathlib import Path, PurePath +from pathlib import Path, PurePath, PureWindowsPath import shutil import sys import tarfile @@ -1957,7 +1957,10 @@ async def async_receive_backup( suggested_filename: str, ) -> WrittenBackup: """Receive a backup.""" - temp_file = Path(self.temp_backup_dir, suggested_filename) + safe_filename = PureWindowsPath(suggested_filename).name + if not safe_filename or safe_filename == "..": + safe_filename = "backup.tar" + temp_file = Path(self.temp_backup_dir, safe_filename) async_add_executor_job = self._hass.async_add_executor_job await async_add_executor_job(make_backup_dir, self.temp_backup_dir) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 2a64bcd6843a30..4d162f12432691 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -23,6 +23,7 @@ patch, ) +from aiohttp import FormData from freezegun.api import FrozenDateTimeFactory import pytest from securetar import SecureTarArchive, SecureTarFile @@ -2013,6 +2014,73 @@ async def test_receive_backup( assert unlink_mock.call_count == temp_file_unlink_call_count +@pytest.mark.parametrize( + ("suggested_filename", "expected_filename"), + [ + ("backup.tar", "backup.tar"), + ("../traversal.tar", "traversal.tar"), + ("../../etc/passwd", "passwd"), + ("subdir/backup.tar", "backup.tar"), + (".", "backup.tar"), + ("..", "backup.tar"), + ("../..", "backup.tar"), + ("..\\traversal.tar", "traversal.tar"), + ("C:\\fakepath\\backup.tar", "backup.tar"), + ], +) +async def test_receive_backup_path_traversal( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + suggested_filename: str, + expected_filename: str, +) -> None: + """Test path traversal in suggested filename is prevented.""" + await setup_backup_integration(hass) + # Make sure we wait for Platform.EVENT and Platform.SENSOR to be fully processed, + # to avoid interference with the Path.open patching below which is used to verify + # that the file is written to the expected location. + await hass.async_block_till_done(True) + client = await hass_client() + + upload_data = "test" + open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + expected_path = Path(hass.config.path("tmp_backups"), expected_filename) + opened_paths: list[Path] = [] + + def track_open(self: Path, *args: Any, **kwargs: Any) -> Any: + opened_paths.append(self) + return open_mock(self, *args, **kwargs) + + with ( + patch("pathlib.Path.open", track_open), + patch("homeassistant.components.backup.manager.make_backup_dir"), + patch("shutil.move"), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP_ABC123, + ) as read_backup_mock, + patch("pathlib.Path.unlink"), + ): + data = FormData(quote_fields=False) + data.add_field( + "file", + upload_data, + filename=suggested_filename, + content_type="application/octet-stream", + ) + resp = await client.post( + "/api/backup/upload?agent_id=backup.local", + data=data, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + # Verify all file opens went to the expected safe path + assert opened_paths == [expected_path] + # read_backup is called with the temp_file path; verify it's sanitized + read_backup_mock.assert_called_once_with(expected_path) + + async def test_receive_backup_busy_manager( hass: HomeAssistant, hass_client: ClientSessionGenerator, From c8667addd87da9184348746822bd8b664f482cbf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:04:22 +0200 Subject: [PATCH 0303/1707] Use runtime_data in opensky integration (#167041) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/opensky/__init__.py | 14 ++++++-------- homeassistant/components/opensky/config_flow.py | 10 +++------- homeassistant/components/opensky/coordinator.py | 6 ++++-- homeassistant/components/opensky/sensor.py | 6 +++--- 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index c69cade5842bbd..7cead9fa56db74 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -6,17 +6,16 @@ from python_opensky import OpenSky from python_opensky.exceptions import OpenSkyError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_CONTRIBUTING_USER, DOMAIN, PLATFORMS -from .coordinator import OpenSkyDataUpdateCoordinator +from .const import CONF_CONTRIBUTING_USER, PLATFORMS +from .coordinator import OpenSkyConfigEntry, OpenSkyDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpenSkyConfigEntry) -> bool: """Set up opensky from a config entry.""" client = OpenSky(session=async_get_clientsession(hass)) @@ -34,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = OpenSkyDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -42,12 +41,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OpenSkyConfigEntry) -> bool: """Unload opensky config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: OpenSkyConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 5e53a805753dae..0aeed00860882c 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -9,12 +9,7 @@ from python_opensky.exceptions import OpenSkyUnauthenticatedError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, @@ -33,6 +28,7 @@ DEFAULT_NAME, DOMAIN, ) +from .coordinator import OpenSkyConfigEntry class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): @@ -41,7 +37,7 @@ class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: OpenSkyConfigEntry, ) -> OpenSkyOptionsFlowHandler: """Get the options flow for this handler.""" return OpenSkyOptionsFlowHandler() diff --git a/homeassistant/components/opensky/coordinator.py b/homeassistant/components/opensky/coordinator.py index f9aab88c904ad3..3e0ccbe380ee52 100644 --- a/homeassistant/components/opensky/coordinator.py +++ b/homeassistant/components/opensky/coordinator.py @@ -30,14 +30,16 @@ LOGGER, ) +type OpenSkyConfigEntry = ConfigEntry[OpenSkyDataUpdateCoordinator] + class OpenSkyDataUpdateCoordinator(DataUpdateCoordinator[int]): """An OpenSky Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: OpenSkyConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, opensky: OpenSky + self, hass: HomeAssistant, config_entry: OpenSkyConfigEntry, opensky: OpenSky ) -> None: """Initialize the OpenSky data coordinator.""" super().__init__( diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 0ab5b49f086f86..8e34e6581a07aa 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -10,17 +10,17 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER -from .coordinator import OpenSkyDataUpdateCoordinator +from .coordinator import OpenSkyConfigEntry, OpenSkyDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenSkyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the entries.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ OpenSkySensor( From dd1722b5d61d6939750fed87221ddc6e6b868102 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:29:50 +0200 Subject: [PATCH 0304/1707] Use runtime_data in ourgroceries integration (#167043) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/ourgroceries/__init__.py | 21 ++++++++----------- .../components/ourgroceries/coordinator.py | 10 +++++++-- homeassistant/components/ourgroceries/todo.py | 8 +++---- tests/components/ourgroceries/conftest.py | 2 +- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index a83430b3531889..8f604257685132 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -6,21 +6,19 @@ from ourgroceries import OurGroceries from ourgroceries.exceptions import InvalidLoginException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import OurGroceriesDataUpdateCoordinator +from .coordinator import OurGroceriesConfigEntry, OurGroceriesDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.TODO] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: OurGroceriesConfigEntry +) -> bool: """Set up OurGroceries from a config entry.""" - - hass.data.setdefault(DOMAIN, {}) data = entry.data og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) try: @@ -32,16 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = OurGroceriesDataUpdateCoordinator(hass, entry, og) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: OurGroceriesConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py index a822931e88c0f7..be9149e0162a74 100644 --- a/homeassistant/components/ourgroceries/coordinator.py +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -19,13 +19,19 @@ _LOGGER = logging.getLogger(__name__) +type OurGroceriesConfigEntry = ConfigEntry[OurGroceriesDataUpdateCoordinator] + + class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage fetching OurGroceries data.""" - config_entry: ConfigEntry + config_entry: OurGroceriesConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, og: OurGroceries + self, + hass: HomeAssistant, + config_entry: OurGroceriesConfigEntry, + og: OurGroceries, ) -> None: """Initialize global OurGroceries data updater.""" self.og = og diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index f257ef481c7481..eea7952eb4f883 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -9,22 +9,20 @@ TodoListEntity, TodoListEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import OurGroceriesDataUpdateCoordinator +from .coordinator import OurGroceriesConfigEntry, OurGroceriesDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OurGroceriesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OurGroceries todo platform config entry.""" - coordinator: OurGroceriesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( OurGroceriesTodoListEntity(coordinator, sl["id"], sl["name"]) for sl in coordinator.lists diff --git a/tests/components/ourgroceries/conftest.py b/tests/components/ourgroceries/conftest.py index b3fb4e9bcc60b8..a6880e9f9fc807 100644 --- a/tests/components/ourgroceries/conftest.py +++ b/tests/components/ourgroceries/conftest.py @@ -5,7 +5,7 @@ import pytest -from homeassistant.components.ourgroceries import DOMAIN +from homeassistant.components.ourgroceries.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component From 2341f8dd5a7198b92a05477e370d24527e2b9639 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:33:37 +0200 Subject: [PATCH 0305/1707] Use runtime_data in osoenergy integration (#167042) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/osoenergy/__init__.py | 16 +++++----------- .../components/osoenergy/binary_sensor.py | 7 +++---- homeassistant/components/osoenergy/sensor.py | 7 +++---- .../components/osoenergy/water_heater.py | 7 +++---- 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py index ca6d52941f794a..d4e4c881d8d553 100644 --- a/homeassistant/components/osoenergy/__init__.py +++ b/homeassistant/components/osoenergy/__init__.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import DOMAIN +type OSOEnergyConfigEntry = ConfigEntry[OSOEnergy] PLATFORMS = [ Platform.BINARY_SENSOR, @@ -26,7 +26,7 @@ } -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OSOEnergyConfigEntry) -> bool: """Set up OSO Energy from a config entry.""" subscription_key = entry.data[CONF_API_KEY] websession = aiohttp_client.async_get_clientsession(hass) @@ -34,8 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: osoenergy_config = dict(entry.data) - hass.data.setdefault(DOMAIN, {}) - try: devices: Any = await osoenergy.session.start_session(osoenergy_config) except HTTPException as error: @@ -43,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except OSOEnergyReauthRequired as err: raise ConfigEntryAuthFailed from err - hass.data[DOMAIN][entry.entry_id] = osoenergy + entry.runtime_data = osoenergy platforms = set() for ha_type, oso_type in PLATFORM_LOOKUP.items(): @@ -55,10 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OSOEnergyConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/osoenergy/binary_sensor.py b/homeassistant/components/osoenergy/binary_sensor.py index a2ba61ccbe4977..7ec6308e20930e 100644 --- a/homeassistant/components/osoenergy/binary_sensor.py +++ b/homeassistant/components/osoenergy/binary_sensor.py @@ -10,11 +10,10 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import OSOEnergyConfigEntry from .entity import OSOEnergyEntity @@ -46,11 +45,11 @@ class OSOEnergyBinarySensorEntityDescription(BinarySensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OSOEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy binary sensor.""" - osoenergy: OSOEnergy = hass.data[DOMAIN][entry.entry_id] + osoenergy = entry.runtime_data entities = [ OSOEnergyBinarySensor(osoenergy, sensor_type, dev) for dev in osoenergy.session.device_list.get("binary_sensor", []) diff --git a/homeassistant/components/osoenergy/sensor.py b/homeassistant/components/osoenergy/sensor.py index c2b1e75cd702e4..1b66f21e105e21 100644 --- a/homeassistant/components/osoenergy/sensor.py +++ b/homeassistant/components/osoenergy/sensor.py @@ -12,7 +12,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfEnergy, UnitOfPower, @@ -23,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import OSOEnergyConfigEntry from .entity import OSOEnergyEntity @@ -139,11 +138,11 @@ class OSOEnergySensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OSOEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy sensor.""" - osoenergy = hass.data[DOMAIN][entry.entry_id] + osoenergy = entry.runtime_data devices = osoenergy.session.device_list.get("sensor") entities = [] if devices: diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index 1f4ad9d06c52e7..e38e502fc76bbd 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -15,7 +15,6 @@ WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.helpers import config_validation as cv, entity_platform @@ -23,7 +22,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType -from .const import DOMAIN +from . import OSOEnergyConfigEntry from .entity import OSOEnergyEntity ATTR_DURATION_DAYS = "duration_days" @@ -52,11 +51,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OSOEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy heater based on a config entry.""" - osoenergy = hass.data[DOMAIN][entry.entry_id] + osoenergy = entry.runtime_data devices = osoenergy.session.device_list.get("water_heater") if not devices: return From 726857158754e17c229397fe7565dd4066f179dd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:33:55 +0200 Subject: [PATCH 0306/1707] Use runtime_data in opengarage integration (#167040) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/opengarage/__init__.py | 17 ++++++----------- .../components/opengarage/binary_sensor.py | 10 +++------- homeassistant/components/opengarage/button.py | 10 +++------- .../components/opengarage/coordinator.py | 7 +++++-- homeassistant/components/opengarage/cover.py | 8 +++----- homeassistant/components/opengarage/sensor.py | 10 +++------- 6 files changed, 23 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index f1f080b30f8b10..af494955375b7a 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -4,18 +4,17 @@ import opengarage -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_DEVICE_KEY, DOMAIN -from .coordinator import OpenGarageDataUpdateCoordinator +from .const import CONF_DEVICE_KEY +from .coordinator import OpenGarageConfigEntry, OpenGarageDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpenGarageConfigEntry) -> bool: """Set up OpenGarage from a config entry.""" open_garage_connection = opengarage.OpenGarage( f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", @@ -27,17 +26,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, open_garage_connection ) await open_garage_data_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = open_garage_data_coordinator + entry.runtime_data = open_garage_data_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OpenGarageConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/opengarage/binary_sensor.py b/homeassistant/components/opengarage/binary_sensor.py index 33420ab3fd5a67..d538f261db9c4f 100644 --- a/homeassistant/components/opengarage/binary_sensor.py +++ b/homeassistant/components/opengarage/binary_sensor.py @@ -9,12 +9,10 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import OpenGarageDataUpdateCoordinator +from .coordinator import OpenGarageConfigEntry, OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity _LOGGER = logging.getLogger(__name__) @@ -30,13 +28,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenGarageConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage binary sensors.""" - open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + open_garage_data_coordinator = entry.runtime_data async_add_entities( OpenGarageBinarySensor( open_garage_data_coordinator, diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py index 64a4f2f20e7a1c..24920e80e19a9c 100644 --- a/homeassistant/components/opengarage/button.py +++ b/homeassistant/components/opengarage/button.py @@ -13,13 +13,11 @@ ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import OpenGarageDataUpdateCoordinator +from .coordinator import OpenGarageConfigEntry, OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity @@ -42,13 +40,11 @@ class OpenGarageButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenGarageConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage button entities.""" - coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities( OpenGarageButtonEntity( diff --git a/homeassistant/components/opengarage/coordinator.py b/homeassistant/components/opengarage/coordinator.py index 5d5440d6b1bd4e..f384bd47d26887 100644 --- a/homeassistant/components/opengarage/coordinator.py +++ b/homeassistant/components/opengarage/coordinator.py @@ -18,15 +18,18 @@ _LOGGER = logging.getLogger(__name__) +type OpenGarageConfigEntry = ConfigEntry[OpenGarageDataUpdateCoordinator] + + class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Opengarage data.""" - config_entry: ConfigEntry + config_entry: OpenGarageConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenGarageConfigEntry, open_garage_connection: opengarage.OpenGarage, ) -> None: """Initialize global Opengarage data updater.""" diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 859e33827727d7..79b6200aeb1347 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -11,12 +11,10 @@ CoverEntityFeature, CoverState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import OpenGarageDataUpdateCoordinator +from .coordinator import OpenGarageConfigEntry, OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity _LOGGER = logging.getLogger(__name__) @@ -26,12 +24,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenGarageConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage covers.""" async_add_entities( - [OpenGarageCover(hass.data[DOMAIN][entry.entry_id], cast(str, entry.unique_id))] + [OpenGarageCover(entry.runtime_data, cast(str, entry.unique_id))] ) diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py index 14d14dd5d230e4..cf3625fabb19fa 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -11,7 +11,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -22,8 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import OpenGarageDataUpdateCoordinator +from .coordinator import OpenGarageConfigEntry from .entity import OpenGarageEntity _LOGGER = logging.getLogger(__name__) @@ -60,13 +58,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenGarageConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage sensors.""" - open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + open_garage_data_coordinator = entry.runtime_data async_add_entities( OpenGarageSensor( open_garage_data_coordinator, From 3369dfece15506e4e3b35bcc5231acf58f06fcd8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:33:57 +0200 Subject: [PATCH 0307/1707] Use runtime_data in ondilo_ico integration (#167039) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/ondilo_ico/__init__.py | 15 +++++---------- .../components/ondilo_ico/coordinator.py | 9 ++++++--- homeassistant/components/ondilo_ico/sensor.py | 6 +++--- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index 28bb6719c7f245..12a856d7360b8e 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -4,7 +4,6 @@ ClientCredential, async_import_client_credential, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -17,7 +16,7 @@ from .api import OndiloClient from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET -from .coordinator import OndiloIcoPoolsCoordinator +from .coordinator import OndiloIcoConfigEntry, OndiloIcoPoolsCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.SENSOR] @@ -35,7 +34,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OndiloIcoConfigEntry) -> bool: """Set up Ondilo ICO from a config entry.""" try: implementation = await async_get_config_entry_implementation(hass, entry) @@ -51,17 +50,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OndiloIcoConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index 7545f6d61e0c4a..3fbe82a536d8f9 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -16,8 +16,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from . import DOMAIN from .api import OndiloClient +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -41,13 +41,16 @@ class OndiloIcoMeasurementData: sensors: dict[str, Any] +type OndiloIcoConfigEntry = ConfigEntry[OndiloIcoPoolsCoordinator] + + class OndiloIcoPoolsCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoPoolData]]): """Fetch Ondilo ICO pools data from API.""" - config_entry: ConfigEntry + config_entry: OndiloIcoConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: OndiloClient + self, hass: HomeAssistant, config_entry: OndiloIcoConfigEntry, api: OndiloClient ) -> None: """Initialize.""" super().__init__( diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 42e65bd0db2a0c..61080d2577bec2 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -8,7 +8,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -24,6 +23,7 @@ from .const import DOMAIN from .coordinator import ( + OndiloIcoConfigEntry, OndiloIcoMeasuresCoordinator, OndiloIcoPoolData, OndiloIcoPoolsCoordinator, @@ -78,11 +78,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OndiloIcoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ondilo ICO sensors.""" - pools_coordinator: OndiloIcoPoolsCoordinator = hass.data[DOMAIN][entry.entry_id] + pools_coordinator = entry.runtime_data known_entities: set[str] = set() async_add_entities(get_new_entities(pools_coordinator, known_entities)) From d5c7a0475100312fdeb8aa3d48b61a3f220f7c99 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:34:10 +0200 Subject: [PATCH 0308/1707] Use runtime_data in openuv integration (#167029) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/openuv/__init__.py | 23 ++++++++----------- .../components/openuv/binary_sensor.py | 12 ++++------ .../components/openuv/config_flow.py | 7 ++++-- .../components/openuv/coordinator.py | 6 +++-- .../components/openuv/diagnostics.py | 8 +++---- homeassistant/components/openuv/sensor.py | 8 +++---- tests/components/openuv/conftest.py | 6 ++++- tests/components/openuv/test_config_flow.py | 6 ++++- 8 files changed, 40 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 6edb42427f31f1..be6c99b3288771 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -7,7 +7,6 @@ from pyopenuv import Client -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_BINARY_SENSORS, @@ -27,15 +26,18 @@ DATA_UV, DEFAULT_FROM_WINDOW, DEFAULT_TO_WINDOW, - DOMAIN, LOGGER, ) -from .coordinator import OpenUvCoordinator, OpenUvProtectionWindowCoordinator +from .coordinator import ( + OpenUvConfigEntry, + OpenUvCoordinator, + OpenUvProtectionWindowCoordinator, +) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpenUvConfigEntry) -> bool: """Set up OpenUV as config entry.""" websession = aiohttp_client.async_get_clientsession(hass) client = Client( @@ -78,24 +80,19 @@ async def async_update_protection_data() -> dict[str, Any]: ] await asyncio.gather(*init_tasks) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinators + entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OpenUvConfigEntry) -> bool: """Unload an OpenUV config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: OpenUvConfigEntry) -> bool: """Migrate the config entry upon new versions.""" version = entry.version data = {**entry.data} diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 8165c66e7ddef9..30418d8398e95e 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -4,13 +4,12 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import as_local -from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW -from .coordinator import OpenUvCoordinator +from .const import DATA_PROTECTION_WINDOW, LOGGER, TYPE_PROTECTION_WINDOW +from .coordinator import OpenUvConfigEntry from .entity import OpenUvEntity ATTR_PROTECTION_WINDOW_ENDING_TIME = "end_time" @@ -26,12 +25,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenUvConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - # Once we've successfully authenticated, we re-enable client request retries: - """Set up an OpenUV sensor based on a config entry.""" - coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + """Set up OpenUV binary sensors for a config entry.""" + coordinators = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 52e369fd6df02f..5d432d22e39604 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -10,7 +10,7 @@ from pyopenuv.errors import OpenUvError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, CONF_ELEVATION, @@ -31,6 +31,7 @@ DEFAULT_TO_WINDOW, DOMAIN, ) +from .coordinator import OpenUvConfigEntry STEP_REAUTH_SCHEMA = vol.Schema( { @@ -133,7 +134,9 @@ async def _async_verify( @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: + def async_get_options_flow( + config_entry: OpenUvConfigEntry, + ) -> SchemaOptionsFlowHandler: """Define the config flow to handle options.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py index b29d272b0ecfe8..7a3dc6cdb3d853 100644 --- a/homeassistant/components/openuv/coordinator.py +++ b/homeassistant/components/openuv/coordinator.py @@ -20,18 +20,20 @@ DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 +type OpenUvConfigEntry = ConfigEntry[dict[str, OpenUvCoordinator]] + class OpenUvCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an OpenUV data coordinator.""" - config_entry: ConfigEntry + config_entry: OpenUvConfigEntry update_method: Callable[[], Awaitable[dict[str, Any]]] def __init__( self, hass: HomeAssistant, *, - entry: ConfigEntry, + entry: OpenUvConfigEntry, name: str, latitude: str, longitude: str, diff --git a/homeassistant/components/openuv/diagnostics.py b/homeassistant/components/openuv/diagnostics.py index e16316d4148ec2..005d84f7629364 100644 --- a/homeassistant/components/openuv/diagnostics.py +++ b/homeassistant/components/openuv/diagnostics.py @@ -5,7 +5,6 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -14,8 +13,7 @@ ) from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import OpenUvCoordinator +from .coordinator import OpenUvConfigEntry CONF_COORDINATES = "coordinates" CONF_TITLE = "title" @@ -31,10 +29,10 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: OpenUvConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + coordinators = entry.runtime_data return async_redact_data( { diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 5b681655e2b166..7fdeaeb382ba59 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -12,7 +12,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UV_INDEX, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -20,7 +19,6 @@ from .const import ( DATA_UV, - DOMAIN, TYPE_CURRENT_OZONE_LEVEL, TYPE_CURRENT_UV_INDEX, TYPE_CURRENT_UV_LEVEL, @@ -32,7 +30,7 @@ TYPE_SAFE_EXPOSURE_TIME_5, TYPE_SAFE_EXPOSURE_TIME_6, ) -from .coordinator import OpenUvCoordinator +from .coordinator import OpenUvConfigEntry from .entity import OpenUvEntity ATTR_MAX_UV_TIME = "time" @@ -167,11 +165,11 @@ class OpenUvSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenUvConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a OpenUV sensor based on a config entry.""" - coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + coordinators = entry.runtime_data async_add_entities( [ diff --git a/tests/components/openuv/conftest.py b/tests/components/openuv/conftest.py index 739af42c87d7ac..c4ba6080693669 100644 --- a/tests/components/openuv/conftest.py +++ b/tests/components/openuv/conftest.py @@ -7,7 +7,11 @@ import pytest -from homeassistant.components.openuv import CONF_FROM_WINDOW, CONF_TO_WINDOW, DOMAIN +from homeassistant.components.openuv.const import ( + CONF_FROM_WINDOW, + CONF_TO_WINDOW, + DOMAIN, +) from homeassistant.const import ( CONF_API_KEY, CONF_ELEVATION, diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py index 182f66c887f3cb..eeee4e9882f0f7 100644 --- a/tests/components/openuv/test_config_flow.py +++ b/tests/components/openuv/test_config_flow.py @@ -6,7 +6,11 @@ import pytest import voluptuous as vol -from homeassistant.components.openuv import CONF_FROM_WINDOW, CONF_TO_WINDOW, DOMAIN +from homeassistant.components.openuv.const import ( + CONF_FROM_WINDOW, + CONF_TO_WINDOW, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, From df08d989f2994bb787efe430fde8ea0fda56682d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Apr 2026 11:59:50 +0200 Subject: [PATCH 0309/1707] Update frontend to 20260325.5 (#167050) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a87ec738fe02dc..ce977e3fd614b3 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.4"] + "requirements": ["home-assistant-frontend==20260325.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2b13550e5c4da7..b6a184eb8178c6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.4 +home-assistant-frontend==20260325.5 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ae682a716e1366..0c51f18fe2d69b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.4 +home-assistant-frontend==20260325.5 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3db8712d1e99a3..62f31678df636e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.84 # homeassistant.components.frontend -home-assistant-frontend==20260325.4 +home-assistant-frontend==20260325.5 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From f2001db68c27c78dde43822fdfbfd136ed893fef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:00:40 +0200 Subject: [PATCH 0310/1707] Use runtime_data in octoprint integration (#167028) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/octoprint/__init__.py | 29 ++++++------------- .../components/octoprint/binary_sensor.py | 10 ++----- homeassistant/components/octoprint/button.py | 12 +++----- homeassistant/components/octoprint/camera.py | 14 ++++----- .../components/octoprint/coordinator.py | 12 ++++---- homeassistant/components/octoprint/number.py | 11 +++---- homeassistant/components/octoprint/sensor.py | 10 ++----- tests/components/octoprint/test_button.py | 28 ++++++++---------- 8 files changed, 47 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index a582832d4776e8..a6c45ef26a3055 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -9,7 +9,7 @@ from pyoctoprintapi import OctoprintClient import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, CONF_BINARY_SENSORS, @@ -34,7 +34,7 @@ from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import CONF_BAUDRATE, DOMAIN, SERVICE_CONNECT -from .coordinator import OctoprintDataUpdateCoordinator +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -168,12 +168,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OctoprintConfigEntry) -> bool: """Set up OctoPrint from a config entry.""" - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if CONF_VERIFY_SSL not in entry.data: data = {**entry.data, CONF_VERIFY_SSL: True} hass.config_entries.async_update_entry(entry, data=data) @@ -210,10 +206,7 @@ def _async_close_websession(event: Event | None = None) -> None: await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - "coordinator": coordinator, - "client": client, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -237,14 +230,9 @@ async def async_printer_connect(call: ServiceCall) -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OctoprintConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def async_get_client_for_service_call( @@ -256,8 +244,9 @@ def async_get_client_for_service_call( if device_entry := device_registry.async_get(device_id): for entry_id in device_entry.config_entries: - if data := hass.data[DOMAIN].get(entry_id): - return cast(OctoprintClient, data["client"]) + if entry := hass.config_entries.async_get_entry(entry_id): + if entry.domain == DOMAIN and entry.state == ConfigEntryState.LOADED: + return cast(OctoprintConfigEntry, entry).runtime_data.octoprint raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index 4d12ef15a4e4b5..deb3059458f4d9 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -7,24 +7,20 @@ from pyoctoprintapi import OctoprintPrinterInfo from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint binary sensors.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] + coordinator = config_entry.runtime_data device_id = config_entry.unique_id assert device_id is not None diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index 3a128fcd7aa45f..fe167702745186 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -3,26 +3,22 @@ from pyoctoprintapi import OctoprintClient, OctoprintPrinterInfo from homeassistant.components.button import ButtonDeviceClass, ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Octoprint control buttons.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] - client: OctoprintClient = hass.data[DOMAIN][config_entry.entry_id]["client"] + coordinator = config_entry.runtime_data + client = coordinator.octoprint device_id = config_entry.unique_id assert device_id is not None diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 37347539d5b0a9..118f892ed5b792 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -2,29 +2,25 @@ from __future__ import annotations -from pyoctoprintapi import OctoprintClient, WebcamSettings +from pyoctoprintapi import WebcamSettings from homeassistant.components.mjpeg import MjpegCamera -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint camera.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] - client: OctoprintClient = hass.data[DOMAIN][config_entry.entry_id]["client"] + coordinator = config_entry.runtime_data + client = coordinator.octoprint device_id = config_entry.unique_id assert device_id is not None diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index bb006329ff1198..f37fbc82f5487a 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -20,19 +20,21 @@ from .const import DOMAIN +type OctoprintConfigEntry = ConfigEntry[OctoprintDataUpdateCoordinator] + _LOGGER = logging.getLogger(__name__) class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Octoprint data.""" - config_entry: ConfigEntry + config_entry: OctoprintConfigEntry def __init__( self, hass: HomeAssistant, octoprint: OctoprintClient, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, interval: int, ) -> None: """Initialize.""" @@ -43,7 +45,7 @@ def __init__( name=f"octoprint-{config_entry.entry_id}", update_interval=timedelta(seconds=interval), ) - self._octoprint = octoprint + self.octoprint = octoprint self._printer_offline = False self.data = {"printer": None, "job": None, "last_read_time": None} @@ -51,7 +53,7 @@ async def _async_update_data(self): """Update data via API.""" printer = None try: - job = await self._octoprint.get_job_info() + job = await self.octoprint.get_job_info() except UnauthorizedException as err: raise ConfigEntryAuthFailed from err except ApiError as err: @@ -61,7 +63,7 @@ async def _async_update_data(self): # printer will return a 409, so continue using the last # reading if there is one try: - printer = await self._octoprint.get_printer_info() + printer = await self.octoprint.get_printer_info() except PrinterOffline: if not self._printer_offline: _LOGGER.debug("Unable to retrieve printer information: Printer offline") diff --git a/homeassistant/components/octoprint/number.py b/homeassistant/components/octoprint/number.py index 93fa32a9e33f89..abe27006dfd68b 100644 --- a/homeassistant/components/octoprint/number.py +++ b/homeassistant/components/octoprint/number.py @@ -7,15 +7,14 @@ from pyoctoprintapi import OctoprintClient from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -37,14 +36,12 @@ def is_first_extruder(tool_name: str) -> bool: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OctoPrint number entities.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] - client: OctoprintClient = hass.data[DOMAIN][config_entry.entry_id]["client"] + coordinator = config_entry.runtime_data + client = coordinator.octoprint device_id = config_entry.unique_id assert device_id is not None diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 26ef8721d516e8..485126b4828da8 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -12,14 +12,12 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -35,13 +33,11 @@ def _is_printer_printing(printer: OctoprintPrinterInfo) -> bool: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint sensors.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] + coordinator = config_entry.runtime_data device_id = config_entry.unique_id assert device_id is not None diff --git a/tests/components/octoprint/test_button.py b/tests/components/octoprint/test_button.py index f3fff39dab1911..4ebb25362da195 100644 --- a/tests/components/octoprint/test_button.py +++ b/tests/components/octoprint/test_button.py @@ -8,10 +8,11 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.octoprint import OctoprintDataUpdateCoordinator from homeassistant.components.octoprint.button import InvalidPrinterState -from homeassistant.components.octoprint.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + @pytest.fixture def platform() -> Platform: @@ -19,12 +20,11 @@ def platform() -> Platform: return Platform.BUTTON -@pytest.mark.usefixtures("init_integration") -async def test_pause_job(hass: HomeAssistant) -> None: +async def test_pause_job( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: """Test the pause job button.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN]["uuid"][ - "coordinator" - ] + coordinator: OctoprintDataUpdateCoordinator = init_integration.runtime_data # Test pausing the printer when it is printing with patch("pyoctoprintapi.OctoprintClient.pause_job") as pause_command: @@ -77,12 +77,11 @@ async def test_pause_job(hass: HomeAssistant) -> None: ) -@pytest.mark.usefixtures("init_integration") -async def test_resume_job(hass: HomeAssistant) -> None: +async def test_resume_job( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: """Test the resume job button.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN]["uuid"][ - "coordinator" - ] + coordinator: OctoprintDataUpdateCoordinator = init_integration.runtime_data # Test resuming the printer when it is paused with patch("pyoctoprintapi.OctoprintClient.resume_job") as resume_command: @@ -135,12 +134,9 @@ async def test_resume_job(hass: HomeAssistant) -> None: ) -@pytest.mark.usefixtures("init_integration") -async def test_stop_job(hass: HomeAssistant) -> None: +async def test_stop_job(hass: HomeAssistant, init_integration: MockConfigEntry) -> None: """Test the stop job button.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN]["uuid"][ - "coordinator" - ] + coordinator: OctoprintDataUpdateCoordinator = init_integration.runtime_data # Test stopping the printer when it is paused with patch("pyoctoprintapi.OctoprintClient.cancel_job") as stop_command: From 0b67644b977e1fc105caa530a8e091338990246c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 1 Apr 2026 21:53:08 +1000 Subject: [PATCH 0311/1707] Migrate Tessie setup and coordinator to tesla_fleet_api (#167018) --- homeassistant/components/tessie/__init__.py | 98 +++++++++++-------- .../components/tessie/coordinator.py | 19 ++-- tests/components/tessie/conftest.py | 12 ++- tests/components/tessie/test_init.py | 37 ++++--- 4 files changed, 99 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 2077b05cdc5460..a9a7406e5d61a7 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -1,19 +1,21 @@ """Tessie integration.""" import asyncio -from http import HTTPStatus import logging -from aiohttp import ClientError, ClientResponseError from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, + GatewayTimeout, + InvalidResponse, InvalidToken, + MissingToken, + RateLimited, + ServiceUnavailable, SubscriptionRequired, TeslaFleetError, ) from tesla_fleet_api.tessie import Tessie -from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform @@ -54,57 +56,69 @@ type TessieConfigEntry = ConfigEntry[TessieData] +RETRY_EXCEPTIONS = ( + InvalidResponse, + RateLimited, + ServiceUnavailable, + GatewayTimeout, +) + async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bool: """Set up Tessie config.""" api_key = entry.data[CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) + tessie = Tessie(session, api_key) try: - state_of_all_vehicles = await get_state_of_all_vehicles( - session=session, - api_key=api_key, - only_active=True, - ) - except ClientResponseError as e: - if e.status == HTTPStatus.UNAUTHORIZED: - raise ConfigEntryAuthFailed from e - raise ConfigEntryError("Setup failed, unable to connect to Tessie") from e - except ClientError as e: + state_of_all_vehicles = await tessie.list_vehicles(only_active=True) + except (InvalidToken, MissingToken) as e: + raise ConfigEntryAuthFailed from e + except RETRY_EXCEPTIONS as e: raise ConfigEntryNotReady from e - - vehicles = [ - TessieVehicleData( - vin=vehicle["vin"], - data_coordinator=TessieStateUpdateCoordinator( - hass, - entry, - api_key=api_key, - vin=vehicle["vin"], - data=vehicle["last_state"], - ), - device=DeviceInfo( - identifiers={(DOMAIN, vehicle["vin"])}, - manufacturer="Tesla", - configuration_url="https://my.tessie.com/", - name=vehicle["last_state"]["display_name"], - model=MODELS.get( - vehicle["last_state"]["vehicle_config"]["car_type"], - vehicle["last_state"]["vehicle_config"]["car_type"], + except TeslaFleetError as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e + + vehicles: list[TessieVehicleData] = [] + for vehicle in state_of_all_vehicles["results"]: + if vehicle["last_state"] is None: + continue + + vin = vehicle["vin"] + vehicle_api = tessie.vehicles.create(vin) + vehicles.append( + TessieVehicleData( + vin=vin, + data_coordinator=TessieStateUpdateCoordinator( + hass, + entry, + api=vehicle_api, + api_key=api_key, + vin=vin, + data=vehicle["last_state"], ), - sw_version=vehicle["last_state"]["vehicle_state"]["car_version"].split( - " " - )[0], - hw_version=vehicle["last_state"]["vehicle_config"]["driver_assist"], - serial_number=vehicle["vin"], - ), + device=DeviceInfo( + identifiers={(DOMAIN, vin)}, + manufacturer="Tesla", + configuration_url="https://my.tessie.com/", + name=vehicle["last_state"]["display_name"], + model=MODELS.get( + vehicle["last_state"]["vehicle_config"]["car_type"], + vehicle["last_state"]["vehicle_config"]["car_type"], + ), + sw_version=vehicle["last_state"]["vehicle_state"][ + "car_version" + ].split(" ")[0], + hw_version=vehicle["last_state"]["vehicle_config"]["driver_assist"], + serial_number=vin, + ), + ) ) - for vehicle in state_of_all_vehicles["results"] - if vehicle["last_state"] is not None - ] # Energy Sites - tessie = Tessie(session, api_key) energysites: list[TessieEnergyData] = [] try: diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 2a0c0e07f94aad..cbb5d1d27bf4e9 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -10,8 +10,7 @@ from aiohttp import ClientError, ClientResponseError from tesla_fleet_api.const import TeslaEnergyPeriod from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError -from tesla_fleet_api.tessie import EnergySite -from tessie_api import get_state +from tesla_fleet_api.tessie import EnergySite, Vehicle from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -54,6 +53,7 @@ def __init__( self, hass: HomeAssistant, config_entry: TessieConfigEntry, + api: Vehicle, api_key: str, vin: str, data: dict[str, Any], @@ -66,6 +66,7 @@ def __init__( name="Tessie", update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), ) + self.api = api self.api_key = api_key self.vin = vin self.session = async_get_clientsession(hass) @@ -74,12 +75,14 @@ def __init__( async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Tessie API.""" try: - vehicle = await get_state( - session=self.session, - api_key=self.api_key, - vin=self.vin, - use_cache=True, - ) + vehicle = await self.api.state(use_cache=True) + except (InvalidToken, MissingToken) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e except ClientResponseError as e: if e.status == HTTPStatus.UNAUTHORIZED: raise ConfigEntryAuthFailed from e diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py index 306cb1597aea1d..1c56312237baae 100644 --- a/tests/components/tessie/conftest.py +++ b/tests/components/tessie/conftest.py @@ -3,7 +3,7 @@ from __future__ import annotations from copy import deepcopy -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -25,9 +25,10 @@ def mock_get_state(): """Mock get_state function.""" with patch( - "homeassistant.components.tessie.coordinator.get_state", - return_value=TEST_VEHICLE_STATE_ONLINE, + "tesla_fleet_api.tessie.Vehicle.state", + new_callable=AsyncMock, ) as mock_get_state: + mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE yield mock_get_state @@ -35,9 +36,10 @@ def mock_get_state(): def mock_get_state_of_all_vehicles(): """Mock get_state_of_all_vehicles function.""" with patch( - "homeassistant.components.tessie.get_state_of_all_vehicles", - return_value=TEST_STATE_OF_ALL_VEHICLES, + "tesla_fleet_api.tessie.Tessie.list_vehicles", + new_callable=AsyncMock, ) as mock_get_state_of_all_vehicles: + mock_get_state_of_all_vehicles.return_value = TEST_STATE_OF_ALL_VEHICLES yield mock_get_state_of_all_vehicles diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index e0ffd8fd57ed74..0e5b67375a3e56 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -1,13 +1,18 @@ """Test the Tessie init.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.exceptions import ( + InvalidRequest, + InvalidToken, + ServiceUnavailable, + TeslaFleetError, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .common import ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform +from .common import setup_platform async def test_load_unload(hass: HomeAssistant) -> None: @@ -20,32 +25,40 @@ async def test_load_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED +async def test_runtime_vehicle_api_handle_is_optional(hass: HomeAssistant) -> None: + """Test the runtime vehicle API handle remains optional during migration.""" + + entry = await setup_platform(hass) + assert all(vehicle.api is None for vehicle in entry.runtime_data.vehicles) + + async def test_auth_failure( - hass: HomeAssistant, mock_get_state_of_all_vehicles + hass: HomeAssistant, mock_get_state_of_all_vehicles: AsyncMock ) -> None: """Test init with an authentication error.""" - mock_get_state_of_all_vehicles.side_effect = ERROR_AUTH + mock_get_state_of_all_vehicles.side_effect = InvalidToken() entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_ERROR async def test_unknown_failure( - hass: HomeAssistant, mock_get_state_of_all_vehicles + hass: HomeAssistant, mock_get_state_of_all_vehicles: AsyncMock ) -> None: - """Test init with an client response error.""" + """Test init with a non-retryable fleet API error.""" - mock_get_state_of_all_vehicles.side_effect = ERROR_UNKNOWN + mock_get_state_of_all_vehicles.side_effect = InvalidRequest() entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.reason == "Failed to connect" -async def test_connection_failure( - hass: HomeAssistant, mock_get_state_of_all_vehicles +async def test_retryable_api_failure( + hass: HomeAssistant, mock_get_state_of_all_vehicles: AsyncMock ) -> None: - """Test init with a network connection error.""" + """Test init with a retryable fleet API error.""" - mock_get_state_of_all_vehicles.side_effect = ERROR_CONNECTION + mock_get_state_of_all_vehicles.side_effect = ServiceUnavailable() entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_RETRY From 713054f9f86d65aee18dad5c76200a73b19a12d5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 1 Apr 2026 13:55:23 +0200 Subject: [PATCH 0312/1707] Bump aioamazondevices to 13.3.2 (#167052) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 0401bb3828ecc8..0dcb3dd3415ac1 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.3.1"] + "requirements": ["aioamazondevices==13.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0c51f18fe2d69b..b4d1f769e60e45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.1 +aioamazondevices==13.3.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62f31678df636e..9e4cd36335a834 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.1 +aioamazondevices==13.3.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 49d63892d18ac3f9b5c40b45054e15064d67f361 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Wed, 1 Apr 2026 12:59:51 +0100 Subject: [PATCH 0313/1707] Validate set_system_mode params in code instead of by schema for Evohome (#165925) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/evohome/climate.py | 16 ++- .../components/evohome/coordinator.py | 3 + homeassistant/components/evohome/services.py | 136 ++++++++++-------- homeassistant/components/evohome/strings.json | 15 ++ tests/components/evohome/test_climate.py | 29 +++- tests/components/evohome/test_init.py | 4 +- tests/components/evohome/test_services.py | 53 ++++++- 7 files changed, 186 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 36a51edc3bc8d8..21b03844a2df51 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -361,7 +361,8 @@ def __init__( async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (system mode) for a controller. - Data validation is not required, it will have been done upstream. + Data validation is not required here; it is performed upstream by the service + handler (service schema plus runtime checks). """ if service == EvoService.RESET_SYSTEM: @@ -387,9 +388,16 @@ async def _set_tcs_mode( ) -> None: """Set a Controller to any of its native operating modes.""" until = dt_util.as_utc(until) if until else None - await self.coordinator.call_client_api( - self._evo_device.set_mode(mode, until=until) - ) + try: + await self.coordinator.call_client_api( + self._evo_device.set_mode(mode, until=until) + ) + except evo.InvalidSystemModeError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_system_mode", + translation_placeholders={"error": str(err)}, + ) from err @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 33af90089a4898..98e2b3b97df754 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -139,6 +139,9 @@ async def call_client_api( try: result = await client_api + except ec2.InvalidSystemModeError: + raise + except ec2.ApiRequestFailedError as err: self.logger.error(err) return None diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index e93ccce1df2145..b117ca6e4d7a0e 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -5,17 +5,18 @@ from datetime import timedelta from typing import Any, Final +from evohomeasync2 import ControlSystem from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE from evohomeasync2.schemas.const import ( S2_DURATION as SZ_DURATION, S2_PERIOD as SZ_PERIOD, - SystemMode as EvoSystemMode, ) import voluptuous as vol from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control @@ -23,8 +24,19 @@ from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, DOMAIN, EvoService from .coordinator import EvoDataUpdateCoordinator -# system mode schemas are built dynamically when the services are registered -# because supported modes can vary for edge-case systems +# System service schemas (registered as domain services) +SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { + # unsupported modes are rejected at runtime with ServiceValidationError + vol.Required(ATTR_MODE): cv.string, # avoid vol.In(SystemMode) + vol.Exclusive(ATTR_DURATION, "temporary"): vol.All( + cv.time_period, + vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), + ), + vol.Exclusive(ATTR_PERIOD, "temporary"): vol.All( + cv.time_period, + vol.Range(min=timedelta(days=1), max=timedelta(days=99)), + ), +} # Zone service schemas (registered as entity services) SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { @@ -59,16 +71,56 @@ def _register_zone_entity_services(hass: HomeAssistant) -> None: ) +def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None: + """Validate that a set_system_mode service call is properly formed.""" + + mode = data[ATTR_MODE] + tcs_modes = {m[SZ_SYSTEM_MODE]: m for m in tcs.allowed_system_modes} + + # Validation occurs here, instead of in the library, because it uses a slightly + # different schema (until instead of duration/period) for the method invoked + # via this service call + + if (mode_info := tcs_modes.get(mode)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mode_not_supported", + translation_placeholders={ATTR_MODE: mode}, + ) + + # voluptuous schema ensures that duration and period are not both present + + if not mode_info[SZ_CAN_BE_TEMPORARY]: + if ATTR_DURATION in data or ATTR_PERIOD in data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mode_cant_be_temporary", + translation_placeholders={ATTR_MODE: mode}, + ) + return + + timing_mode = mode_info.get(SZ_TIMING_MODE) # will not be None, as can_be_temporary + + if timing_mode == SZ_DURATION and ATTR_PERIOD in data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mode_cant_have_period", + translation_placeholders={ATTR_MODE: mode}, + ) + + if timing_mode == SZ_PERIOD and ATTR_DURATION in data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mode_cant_have_duration", + translation_placeholders={ATTR_MODE: mode}, + ) + + @callback def setup_service_functions( hass: HomeAssistant, coordinator: EvoDataUpdateCoordinator ) -> None: - """Set up the service handlers for the system/zone operating modes. - - Not all Honeywell TCC-compatible systems support all operating modes. In addition, - each mode will require any of four distinct service schemas. This has to be - enumerated before registering the appropriate handlers. - """ + """Set up the service handlers for Evohome systems.""" @verify_domain_control(DOMAIN) async def force_refresh(call: ServiceCall) -> None: @@ -77,7 +129,14 @@ async def force_refresh(call: ServiceCall) -> None: @verify_domain_control(DOMAIN) async def set_system_mode(call: ServiceCall) -> None: - """Set the system mode.""" + """Set the Evohome system mode or reset the system.""" + + # No additional validation for RESET_SYSTEM here, as the library method invoked + # via that service call may be able to emulate the reset even if the system + # doesn't support AutoWithReset natively + + if call.service == EvoService.SET_SYSTEM_MODE: + _validate_set_system_mode_params(coordinator.tcs, call.data) payload = { "unique_id": coordinator.tcs.id, @@ -86,59 +145,14 @@ async def set_system_mode(call: ServiceCall) -> None: } async_dispatcher_send(hass, DOMAIN, payload) - assert coordinator.tcs is not None # mypy - hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh) hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode) - # Enumerate which operating modes are supported by this system - modes = list(coordinator.tcs.allowed_system_modes) - - system_mode_schemas = [] - modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET] - - # Permanent-only modes will use this schema - perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] - if perm_modes: # any of: "Auto", "HeatingOff": permanent only - schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)}) - system_mode_schemas.append(schema) - - modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] - - # These modes are set for a number of hours (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_DURATION] - if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours - schema = vol.Schema( - { - vol.Required(ATTR_MODE): vol.In(temp_modes), - vol.Optional(ATTR_DURATION): vol.All( - cv.time_period, - vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), - ), - } - ) - system_mode_schemas.append(schema) - - # These modes are set for a number of days (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_PERIOD] - if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days - schema = vol.Schema( - { - vol.Required(ATTR_MODE): vol.In(temp_modes), - vol.Optional(ATTR_PERIOD): vol.All( - cv.time_period, - vol.Range(min=timedelta(days=1), max=timedelta(days=99)), - ), - } - ) - system_mode_schemas.append(schema) - - if system_mode_schemas: - hass.services.async_register( - DOMAIN, - EvoService.SET_SYSTEM_MODE, - set_system_mode, - schema=vol.Schema(vol.Any(*system_mode_schemas)), - ) + hass.services.async_register( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + set_system_mode, + schema=vol.Schema(SET_SYSTEM_MODE_SCHEMA), + ) _register_zone_entity_services(hass) diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 6e39b24f8a67e4..5f19ff49339760 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -1,5 +1,20 @@ { "exceptions": { + "invalid_system_mode": { + "message": "The requested system mode is not supported: {error}" + }, + "mode_cant_be_temporary": { + "message": "The mode `{mode}` does not support `duration` or `period`" + }, + "mode_cant_have_duration": { + "message": "The mode `{mode}` does not support `duration`; use `period` instead" + }, + "mode_cant_have_period": { + "message": "The mode `{mode}` does not support `period`; use `duration` instead" + }, + "mode_not_supported": { + "message": "The mode `{mode}` is not supported by this controller" + }, "zone_only_service": { "message": "Only zones support the `{service}` action" } diff --git a/tests/components/evohome/test_climate.py b/tests/components/evohome/test_climate.py index 60793ecef09162..ffb608043406dd 100644 --- a/tests/components/evohome/test_climate.py +++ b/tests/components/evohome/test_climate.py @@ -8,6 +8,7 @@ from datetime import timedelta from unittest.mock import patch +from evohomeasync2 import exceptions as evo_exc from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -28,7 +29,7 @@ SERVICE_TURN_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .conftest import setup_evohome from .const import TEST_INSTALLS @@ -189,6 +190,32 @@ async def test_ctl_turn_off( assert results == snapshot +@pytest.mark.parametrize("install", ["default"]) +async def test_ctl_invalid_system_mode( + hass: HomeAssistant, + ctl_id: str, +) -> None: + """Test translated exception when the requested system mode is invalid.""" + + with ( + patch( + "evohomeasync2.control_system.ControlSystem.set_mode", + side_effect=evo_exc.InvalidSystemModeError("Unsupported mode: xxx"), + ), + pytest.raises(ServiceValidationError) as exc_info, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: ctl_id, + }, + blocking=True, + ) + + assert exc_info.value.translation_key == "invalid_system_mode" + + @pytest.mark.parametrize("install", TEST_INSTALLS) async def test_ctl_turn_on( hass: HomeAssistant, diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index d3027ba7a9a502..87749df579ab4a 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import Mock, patch import aiohttp -from evohomeasync2 import EvohomeClient, exceptions as exc +from evohomeasync2 import EvohomeClient, exceptions as evo_exc import pytest from syrupy.assertion import SnapshotAssertion @@ -90,7 +90,7 @@ EXC_BAD_CONNECTION = aiohttp.ClientConnectionError( "Connection error", ) -EXC_BAD_CREDENTIALS = exc.AuthenticationFailedError( +EXC_BAD_CREDENTIALS = evo_exc.AuthenticationFailedError( "Authenticator response is invalid: {'error': 'invalid_grant'}", status=HTTPStatus.BAD_REQUEST, ) diff --git a/tests/components/evohome/test_services.py b/tests/components/evohome/test_services.py index b6876d35545245..c03793a8e8f54a 100644 --- a/tests/components/evohome/test_services.py +++ b/tests/components/evohome/test_services.py @@ -43,10 +43,10 @@ async def test_refresh_system( mock_fcn.assert_awaited_once_with() -@pytest.mark.parametrize("install", TEST_INSTALLS) # not all TCSs support AutoWithReset +@pytest.mark.parametrize("install", TEST_INSTALLS) # some don't support AutoWithReset +@pytest.mark.usefixtures("evohome") async def test_reset_system( hass: HomeAssistant, - ctl_id: str, ) -> None: """Test Evohome's reset_system service (for a temperature control system).""" @@ -270,3 +270,52 @@ async def test_zone_services_with_ctl_id( assert exc_info.value.translation_key == "zone_only_service" assert exc_info.value.translation_placeholders == {"service": service} + + +_SET_SYSTEM_MODE_VALIDATOR_PARAMS = [ + ( + {ATTR_MODE: "NotARealMode"}, + "mode_not_supported", + ), + ( + {ATTR_MODE: "Auto", ATTR_DURATION: {"hours": 1}}, + "mode_cant_be_temporary", + ), + ( + {ATTR_MODE: "AutoWithEco", ATTR_PERIOD: {"days": 1}}, + "mode_cant_have_period", + ), + ( + {ATTR_MODE: "DayOff", ATTR_DURATION: {"hours": 1}}, + "mode_cant_have_duration", + ), +] + + +@pytest.mark.parametrize("install", ["default"]) +@pytest.mark.parametrize( + ("service_data", "expected_translation_key"), + _SET_SYSTEM_MODE_VALIDATOR_PARAMS, + ids=[k for _, k in _SET_SYSTEM_MODE_VALIDATOR_PARAMS], +) +async def test_set_system_mode_validator( + hass: HomeAssistant, + evohome: EvohomeClient, + service_data: dict[str, Any], + expected_translation_key: str, +) -> None: + """Test ServiceValidationError for all controller system mode validation cases.""" + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + service_data, + target={}, + blocking=True, + ) + + assert exc_info.value.translation_key == expected_translation_key + assert exc_info.value.translation_placeholders == { + ATTR_MODE: service_data[ATTR_MODE] + } From 51977227332ab3ece709eb0b7060eb7d6ec03d96 Mon Sep 17 00:00:00 2001 From: Keith Roehrenbeck Date: Wed, 1 Apr 2026 07:05:20 -0500 Subject: [PATCH 0314/1707] Add keyboard text input services to Apple TV integration (#165638) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/apple_tv/__init__.py | 12 +- homeassistant/components/apple_tv/const.py | 2 + homeassistant/components/apple_tv/icons.json | 11 ++ homeassistant/components/apple_tv/services.py | 130 +++++++++++++ .../components/apple_tv/services.yaml | 31 +++ .../components/apple_tv/strings.json | 54 ++++++ tests/components/apple_tv/test_services.py | 181 ++++++++++++++++++ 7 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/apple_tv/services.py create mode 100644 homeassistant/components/apple_tv/services.yaml create mode 100644 tests/components/apple_tv/test_services.py diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 09b11f555cf8e1..0e2914a0eaa83f 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -30,9 +30,10 @@ ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CREDENTIALS, @@ -42,9 +43,12 @@ SIGNAL_CONNECTED, SIGNAL_DISCONNECTED, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + DEFAULT_NAME_TV = "Apple TV" DEFAULT_NAME_HP = "HomePod" @@ -77,6 +81,12 @@ type AppleTvConfigEntry = ConfigEntry[AppleTVManager] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Apple TV component.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool: """Set up a config entry for Apple TV.""" manager = AppleTVManager(hass, entry) diff --git a/homeassistant/components/apple_tv/const.py b/homeassistant/components/apple_tv/const.py index dd215337f1c212..eefd90eef64a9a 100644 --- a/homeassistant/components/apple_tv/const.py +++ b/homeassistant/components/apple_tv/const.py @@ -9,3 +9,5 @@ SIGNAL_CONNECTED = "apple_tv_connected" SIGNAL_DISCONNECTED = "apple_tv_disconnected" + +ATTR_TEXT = "text" diff --git a/homeassistant/components/apple_tv/icons.json b/homeassistant/components/apple_tv/icons.json index 8acb855e3c7eaa..96aec31cc77cba 100644 --- a/homeassistant/components/apple_tv/icons.json +++ b/homeassistant/components/apple_tv/icons.json @@ -8,5 +8,16 @@ } } } + }, + "services": { + "append_keyboard_text": { + "service": "mdi:keyboard" + }, + "clear_keyboard_text": { + "service": "mdi:keyboard-off" + }, + "set_keyboard_text": { + "service": "mdi:keyboard" + } } } diff --git a/homeassistant/components/apple_tv/services.py b/homeassistant/components/apple_tv/services.py new file mode 100644 index 00000000000000..cdf659796daee1 --- /dev/null +++ b/homeassistant/components/apple_tv/services.py @@ -0,0 +1,130 @@ +"""Define services for the Apple TV integration.""" + +from __future__ import annotations + +from pyatv.const import KeyboardFocusState +from pyatv.exceptions import NotSupportedError, ProtocolError +from pyatv.interface import AppleTV as AppleTVInterface +import voluptuous as vol + +from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, service + +from .const import ATTR_TEXT, DOMAIN + +SERVICE_SET_KEYBOARD_TEXT = "set_keyboard_text" +SERVICE_SET_KEYBOARD_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_TEXT): cv.string, + } +) + +SERVICE_APPEND_KEYBOARD_TEXT = "append_keyboard_text" +SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_TEXT): cv.string, + } +) + +SERVICE_CLEAR_KEYBOARD_TEXT = "clear_keyboard_text" +SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + } +) + + +def _get_atv(call: ServiceCall) -> AppleTVInterface: + """Get the AppleTVInterface for a service call.""" + entry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + atv: AppleTVInterface | None = entry.runtime_data.atv + if atv is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_connected", + ) + return atv + + +def _check_keyboard_focus(atv: AppleTVInterface) -> None: + """Check that keyboard is focused on the device.""" + try: + focus_state = atv.keyboard.text_focus_state + except NotSupportedError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="keyboard_not_available", + ) from err + if focus_state != KeyboardFocusState.Focused: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="keyboard_not_focused", + ) + + +async def _async_set_keyboard_text(call: ServiceCall) -> None: + """Set text in the keyboard input field on an Apple TV.""" + atv = _get_atv(call) + _check_keyboard_focus(atv) + try: + await atv.keyboard.text_set(call.data[ATTR_TEXT]) + except ProtocolError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="keyboard_error", + ) from err + + +async def _async_append_keyboard_text(call: ServiceCall) -> None: + """Append text to the keyboard input field on an Apple TV.""" + atv = _get_atv(call) + _check_keyboard_focus(atv) + try: + await atv.keyboard.text_append(call.data[ATTR_TEXT]) + except ProtocolError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="keyboard_error", + ) from err + + +async def _async_clear_keyboard_text(call: ServiceCall) -> None: + """Clear text in the keyboard input field on an Apple TV.""" + atv = _get_atv(call) + _check_keyboard_focus(atv) + try: + await atv.keyboard.text_clear() + except ProtocolError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="keyboard_error", + ) from err + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Apple TV integration.""" + hass.services.async_register( + DOMAIN, + SERVICE_SET_KEYBOARD_TEXT, + _async_set_keyboard_text, + schema=SERVICE_SET_KEYBOARD_TEXT_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_APPEND_KEYBOARD_TEXT, + _async_append_keyboard_text, + schema=SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_KEYBOARD_TEXT, + _async_clear_keyboard_text, + schema=SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA, + ) diff --git a/homeassistant/components/apple_tv/services.yaml b/homeassistant/components/apple_tv/services.yaml new file mode 100644 index 00000000000000..ce2914e4d0ee72 --- /dev/null +++ b/homeassistant/components/apple_tv/services.yaml @@ -0,0 +1,31 @@ +set_keyboard_text: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: apple_tv + text: + required: true + selector: + text: + +append_keyboard_text: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: apple_tv + text: + required: true + selector: + text: + +clear_keyboard_text: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: apple_tv diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index 98ff4b9acb7989..c8da75fb1e2d29 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -69,6 +69,20 @@ } } }, + "exceptions": { + "keyboard_error": { + "message": "An error occurred while sending text to the Apple TV" + }, + "keyboard_not_available": { + "message": "Keyboard input is not supported by this device" + }, + "keyboard_not_focused": { + "message": "No text input field is currently focused on the Apple TV" + }, + "not_connected": { + "message": "Apple TV is not connected" + } + }, "options": { "step": { "init": { @@ -78,5 +92,45 @@ "description": "Configure general device settings" } } + }, + "services": { + "append_keyboard_text": { + "description": "Appends text to the currently focused text input field on an Apple TV.", + "fields": { + "config_entry_id": { + "description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]", + "name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]" + }, + "text": { + "description": "The text to append.", + "name": "[%key:component::apple_tv::services::set_keyboard_text::fields::text::name%]" + } + }, + "name": "Append keyboard text" + }, + "clear_keyboard_text": { + "description": "Clears the text in the currently focused text input field on an Apple TV.", + "fields": { + "config_entry_id": { + "description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]", + "name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]" + } + }, + "name": "Clear keyboard text" + }, + "set_keyboard_text": { + "description": "Sets the text in the currently focused text input field on an Apple TV.", + "fields": { + "config_entry_id": { + "description": "The Apple TV to send text to.", + "name": "Apple TV" + }, + "text": { + "description": "The text to set.", + "name": "Text" + } + }, + "name": "Set keyboard text" + } } } diff --git a/tests/components/apple_tv/test_services.py b/tests/components/apple_tv/test_services.py new file mode 100644 index 00000000000000..d74383124bc1da --- /dev/null +++ b/tests/components/apple_tv/test_services.py @@ -0,0 +1,181 @@ +"""Tests for Apple TV keyboard services.""" + +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +from pyatv.const import DeviceModel, KeyboardFocusState, Protocol +from pyatv.exceptions import NotSupportedError, ProtocolError +import pytest + +from homeassistant.components.apple_tv.const import ATTR_TEXT, DOMAIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_ADDRESS, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from .common import create_conf, mrp_service + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_atv() -> AsyncMock: + """Create a mock Apple TV interface with keyboard support.""" + atv = AsyncMock() + atv.keyboard = AsyncMock() + atv.keyboard.text_focus_state = KeyboardFocusState.Focused + atv.device_info.model = DeviceModel.Gen4K + atv.device_info.raw_model = "AppleTV6,2" + atv.device_info.version = "15.0" + atv.device_info.mac = "AA:BB:CC:DD:EE:FF" + return atv + + +@pytest.fixture +async def mock_config_entry( + hass: HomeAssistant, + mock_async_zeroconf: MagicMock, + mock_atv: AsyncMock, +) -> MockConfigEntry: + """Set up Apple TV integration with mocked pyatv.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Living Room", + unique_id="mrpid", + data={ + CONF_ADDRESS: "127.0.0.1", + CONF_NAME: "Living Room", + "credentials": {str(Protocol.MRP.value): "mrp_creds"}, + "identifiers": ["mrpid"], + }, + ) + entry.add_to_hass(hass) + + scan_result = create_conf("127.0.0.1", "Living Room", mrp_service()) + + with ( + patch("homeassistant.components.apple_tv.scan", return_value=[scan_result]), + patch("homeassistant.components.apple_tv.connect", return_value=mock_atv), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +async def test_set_keyboard_text( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test setting keyboard text.""" + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "Star Wars"}, + blocking=True, + ) + mock_atv.keyboard.text_set.assert_called_once_with("Star Wars") + + +async def test_append_keyboard_text( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test appending keyboard text.""" + await hass.services.async_call( + DOMAIN, + "append_keyboard_text", + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_TEXT: " Episode IV", + }, + blocking=True, + ) + mock_atv.keyboard.text_append.assert_called_once_with(" Episode IV") + + +async def test_clear_keyboard_text( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test clearing keyboard text.""" + await hass.services.async_call( + DOMAIN, + "clear_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id}, + blocking=True, + ) + mock_atv.keyboard.text_clear.assert_called_once() + + +async def test_set_keyboard_text_not_connected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test error when device is not connected.""" + mock_config_entry.runtime_data.atv = None + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "test"}, + blocking=True, + ) + + +async def test_set_keyboard_text_not_focused( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test error when keyboard is not focused.""" + mock_atv.keyboard.text_focus_state = KeyboardFocusState.Unfocused + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "test"}, + blocking=True, + ) + + +async def test_set_keyboard_text_not_supported( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test error when keyboard is not supported by device.""" + with ( + patch.object( + type(mock_atv.keyboard), + "text_focus_state", + new_callable=PropertyMock, + side_effect=NotSupportedError("text_focus_state is not supported"), + create=True, + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "test"}, + blocking=True, + ) + + +async def test_set_keyboard_text_protocol_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_atv: AsyncMock, +) -> None: + """Test error when text_set raises a protocol error.""" + mock_atv.keyboard.text_set.side_effect = ProtocolError("send failed") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_keyboard_text", + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, ATTR_TEXT: "test"}, + blocking=True, + ) From c2065f1f14871f7cb843c3cfc515a98fa04ca26b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 14:06:15 +0200 Subject: [PATCH 0315/1707] Fix `switch_failed_off` exception wording in `honeywell` (#166987) --- homeassistant/components/honeywell/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 2c8e4397b8dee6..b057030ff30fb3 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -77,7 +77,7 @@ "message": "Honeywell could not stop hold mode" }, "switch_failed_off": { - "message": "Honeywell could turn off emergency heat mode." + "message": "Honeywell could not turn off emergency heat mode." }, "switch_failed_on": { "message": "Honeywell could not set system mode to emergency heat mode." From c6ec90c871ce16912f513954cad0807f64648dbf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:09:29 +0200 Subject: [PATCH 0316/1707] Move OVO Energy DataUpdateCoordinator to separate module (#167048) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/ovo_energy/__init__.py | 34 +---------- .../components/ovo_energy/coordinator.py | 61 +++++++++++++++++++ homeassistant/components/ovo_energy/entity.py | 11 ++-- homeassistant/components/ovo_energy/sensor.py | 11 ++-- 4 files changed, 72 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/ovo_energy/coordinator.py diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index 436180407f49ad..ec5d1c7cafa1a3 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -2,23 +2,19 @@ from __future__ import annotations -import asyncio -from datetime import timedelta import logging import aiohttp from ovoenergy import OVOEnergy -from ovoenergy.models import OVODailyUsage from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util from .const import CONF_ACCOUNT, DATA_CLIENT, DATA_COORDINATOR, DOMAIN +from .coordinator import OVOEnergyDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -47,33 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.warning(exception) raise ConfigEntryNotReady from exception - async def async_update_data() -> OVODailyUsage: - """Fetch data from OVO Energy.""" - if (custom_account := entry.data.get(CONF_ACCOUNT)) is not None: - client.custom_account_id = custom_account - - async with asyncio.timeout(10): - try: - authenticated = await client.authenticate( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) - except aiohttp.ClientError as exception: - raise UpdateFailed(exception) from exception - if not authenticated: - raise ConfigEntryAuthFailed("Not authenticated with OVO Energy") - return await client.get_daily_usage(dt_util.utcnow().strftime("%Y-%m")) - - coordinator = DataUpdateCoordinator[OVODailyUsage]( - hass, - _LOGGER, - config_entry=entry, - # Name of the data. For logging purposes. - name="sensor", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=3600), - ) + coordinator = OVOEnergyDataUpdateCoordinator(hass, entry, client) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { diff --git a/homeassistant/components/ovo_energy/coordinator.py b/homeassistant/components/ovo_energy/coordinator.py new file mode 100644 index 00000000000000..6d06fd56092c95 --- /dev/null +++ b/homeassistant/components/ovo_energy/coordinator.py @@ -0,0 +1,61 @@ +"""Coordinator for the OVO Energy integration.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +import aiohttp +from ovoenergy import OVOEnergy +from ovoenergy.models import OVODailyUsage + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import CONF_ACCOUNT + +_LOGGER = logging.getLogger(__name__) + + +class OVOEnergyDataUpdateCoordinator(DataUpdateCoordinator[OVODailyUsage]): + """Class to manage fetching OVO Energy data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + client: OVOEnergy, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="sensor", + update_interval=timedelta(seconds=3600), + ) + self.client = client + + async def _async_update_data(self) -> OVODailyUsage: + """Fetch data from OVO Energy.""" + if (custom_account := self.config_entry.data.get(CONF_ACCOUNT)) is not None: + self.client.custom_account_id = custom_account + + async with asyncio.timeout(10): + try: + authenticated = await self.client.authenticate( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + except aiohttp.ClientError as exception: + raise UpdateFailed(exception) from exception + if not authenticated: + raise ConfigEntryAuthFailed("Not authenticated with OVO Energy") + return await self.client.get_daily_usage(dt_util.utcnow().strftime("%Y-%m")) diff --git a/homeassistant/components/ovo_energy/entity.py b/homeassistant/components/ovo_energy/entity.py index ed8a24b05425a3..1839f0bae4c341 100644 --- a/homeassistant/components/ovo_energy/entity.py +++ b/homeassistant/components/ovo_energy/entity.py @@ -3,25 +3,22 @@ from __future__ import annotations from ovoenergy import OVOEnergy -from ovoenergy.models import OVODailyUsage from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import OVOEnergyDataUpdateCoordinator -class OVOEnergyEntity(CoordinatorEntity[DataUpdateCoordinator[OVODailyUsage]]): +class OVOEnergyEntity(CoordinatorEntity[OVOEnergyDataUpdateCoordinator]): """Defines a base OVO Energy entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[OVODailyUsage], + coordinator: OVOEnergyDataUpdateCoordinator, client: OVOEnergy, ) -> None: """Initialize the OVO Energy entity.""" diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index e2ac9410cbc4fc..a42e193e4b5dd0 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -21,10 +21,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN +from .coordinator import OVOEnergyDataUpdateCoordinator from .entity import OVOEnergyDeviceEntity SCAN_INTERVAL = timedelta(seconds=300) @@ -118,9 +118,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OVO Energy sensor based on a config entry.""" - coordinator: DataUpdateCoordinator[OVODailyUsage] = hass.data[DOMAIN][ - entry.entry_id - ][DATA_COORDINATOR] + coordinator: OVOEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] client: OVOEnergy = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] entities = [] @@ -161,12 +161,11 @@ async def async_setup_entry( class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): """Define a OVO Energy sensor.""" - coordinator: DataUpdateCoordinator[DataUpdateCoordinator[OVODailyUsage]] entity_description: OVOEnergySensorEntityDescription def __init__( self, - coordinator: DataUpdateCoordinator[OVODailyUsage], + coordinator: OVOEnergyDataUpdateCoordinator, description: OVOEnergySensorEntityDescription, client: OVOEnergy, ) -> None: From fa7576dc5a438a2decd9b65857a5497cfe5d83c7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:38:53 +0200 Subject: [PATCH 0317/1707] Simplify PLATFORMS patching in Tuya test (#167054) --- .../components/tuya/test_alarm_control_panel.py | 12 +++++++++--- tests/components/tuya/test_binary_sensor.py | 10 +++++++--- tests/components/tuya/test_button.py | 9 +++++++-- tests/components/tuya/test_camera.py | 16 ++++++++++++++-- tests/components/tuya/test_climate.py | 9 +++++++-- tests/components/tuya/test_cover.py | 13 +++++++------ tests/components/tuya/test_event.py | 10 +++++++--- tests/components/tuya/test_fan.py | 9 +++++++-- tests/components/tuya/test_humidifier.py | 8 +++++++- tests/components/tuya/test_light.py | 8 +++++++- tests/components/tuya/test_number.py | 9 +++++++-- tests/components/tuya/test_select.py | 9 +++++++-- tests/components/tuya/test_sensor.py | 10 +++++++--- tests/components/tuya/test_siren.py | 10 +++++++--- tests/components/tuya/test_switch.py | 11 +++++++---- tests/components/tuya/test_vacuum.py | 8 +++++++- tests/components/tuya/test_valve.py | 11 +++++++---- 17 files changed, 128 insertions(+), 44 deletions(-) diff --git a/tests/components/tuya/test_alarm_control_panel.py b/tests/components/tuya/test_alarm_control_panel.py index 4a60f0a2a741c4..a07065fd8e56c6 100644 --- a/tests/components/tuya/test_alarm_control_panel.py +++ b/tests/components/tuya/test_alarm_control_panel.py @@ -26,7 +26,15 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch( + "homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -41,7 +49,6 @@ async def test_platform_setup_and_discovery( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) @pytest.mark.parametrize( ("mock_device_code", "entity_id"), [ @@ -83,7 +90,6 @@ async def test_service( mock_manager.send_commands.assert_called_once_with(mock_device.id, [command]) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) @pytest.mark.parametrize( ("mock_device_code", "entity_id"), [ diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index 1121b2ffbf610b..3071507f35ef4d 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -19,7 +19,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_platform_setup_and_discovery( hass: HomeAssistant, @@ -55,7 +61,6 @@ async def test_platform_setup_and_discovery( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) @pytest.mark.freeze_time("2024-01-01") async def test_selective_state_update( hass: HomeAssistant, @@ -98,7 +103,6 @@ async def test_selective_state_update( (0x83, "on", "on", "on"), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_bitmap( hass: HomeAssistant, mock_manager: Manager, diff --git a/tests/components/tuya/test_button.py b/tests/components/tuya/test_button.py index 391691ff931bd9..7e7ca6bf8dce50 100644 --- a/tests/components/tuya/test_button.py +++ b/tests/components/tuya/test_button.py @@ -18,7 +18,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]): + yield + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_platform_setup_and_discovery( hass: HomeAssistant, @@ -34,7 +40,6 @@ async def test_platform_setup_and_discovery( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) @pytest.mark.parametrize( "mock_device_code", ["sd_lr33znaodtyarrrz"], diff --git a/tests/components/tuya/test_camera.py b/tests/components/tuya/test_camera.py index 186c411e0ce4dd..e2fddd4bac4a39 100644 --- a/tests/components/tuya/test_camera.py +++ b/tests/components/tuya/test_camera.py @@ -23,6 +23,20 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with ( + patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]), + # Mock camera access token which normally is randomized. + patch( + "homeassistant.components.camera.SystemRandom.getrandbits", + return_value=1, + ), + ): + yield + + @pytest.fixture(autouse=True) def mock_getrandbits(): """Mock camera access token which normally is randomized.""" @@ -33,7 +47,6 @@ def mock_getrandbits(): yield -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -54,7 +67,6 @@ async def test_platform_setup_and_discovery( ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) @pytest.mark.parametrize( "mock_device_code", ["sp_rudejjigkywujjvs"], diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index cbde579d34d415..30880d0aa4ccd0 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -43,7 +43,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -58,7 +64,6 @@ async def test_platform_setup_and_discovery( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE]) async def test_us_customary_system( hass: HomeAssistant, mock_manager: Manager, diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 3124e79f81b83b..b33ba332aa7b5d 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -36,7 +36,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -112,7 +118,6 @@ async def test_platform_setup_and_discovery( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_action( hass: HomeAssistant, mock_manager: Manager, @@ -154,7 +159,6 @@ async def test_action( (50, 25), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_percent_state_on_cover( hass: HomeAssistant, mock_manager: Manager, @@ -218,7 +222,6 @@ async def test_set_tilt_position_not_supported( (100, "open", 100), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_clkg_wltqkykhni0papzj_state( hass: HomeAssistant, mock_manager: Manager, @@ -275,7 +278,6 @@ async def test_clkg_wltqkykhni0papzj_state( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_clkg_wltqkykhni0papzj_action( hass: HomeAssistant, mock_manager: Manager, @@ -321,7 +323,6 @@ async def test_clkg_wltqkykhni0papzj_action( ("close", STATE_CLOSED), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_cl_n3xgr5pdmpinictg_state( hass: HomeAssistant, mock_manager: Manager, diff --git a/tests/components/tuya/test_event.py b/tests/components/tuya/test_event.py index 7f6fec48f1c7b3..b269eb68ef29fc 100644 --- a/tests/components/tuya/test_event.py +++ b/tests/components/tuya/test_event.py @@ -19,8 +19,14 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]): + yield + + @pytest.mark.freeze_time("2023-11-01 13:14:15+01:00") -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -59,7 +65,6 @@ async def test_platform_setup_and_discovery( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) async def test_alarm_message_event( hass: HomeAssistant, mock_manager: Manager, @@ -88,7 +93,6 @@ async def test_alarm_message_event( "mock_device_code", ["wxkg_l8yaz4um5b3pwyvf"], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) @pytest.mark.freeze_time("2024-01-01") async def test_selective_state_update( hass: HomeAssistant, diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py index d65db472f590e0..9c6e5a5b7123f3 100644 --- a/tests/components/tuya/test_fan.py +++ b/tests/components/tuya/test_fan.py @@ -26,7 +26,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -41,7 +47,6 @@ async def test_platform_setup_and_discovery( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) @pytest.mark.parametrize( ("mock_device_code", "entity_id", "service", "service_data", "expected_commands"), [ diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index 8a4d193a56b227..63bc33c033846f 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -26,7 +26,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.HUMIDIFIER]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.HUMIDIFIER]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index 28d53881bdf984..447b6b5a05b299 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -27,7 +27,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index d4ed776c56b0fd..3875b8bd6b50c2 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -24,7 +24,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.NUMBER]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.NUMBER]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -59,7 +65,6 @@ async def test_platform_setup_and_discovery( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.NUMBER]) @pytest.mark.freeze_time("2024-01-01") async def test_selective_state_update( hass: HomeAssistant, diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index 66a58ea8b831d5..731aa649b20598 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -25,7 +25,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -60,7 +66,6 @@ async def test_platform_setup_and_discovery( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]) @pytest.mark.freeze_time("2024-01-01") async def test_selective_state_update( hass: HomeAssistant, diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py index 80d275b893e5e2..d2a59216161434 100644 --- a/tests/components/tuya/test_sensor.py +++ b/tests/components/tuya/test_sensor.py @@ -20,7 +20,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]): + yield + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_platform_setup_and_discovery( hass: HomeAssistant, @@ -56,7 +62,6 @@ async def test_platform_setup_and_discovery( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) @pytest.mark.freeze_time("2024-01-01") async def test_selective_state_update( hass: HomeAssistant, @@ -85,7 +90,6 @@ async def test_selective_state_update( ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) @pytest.mark.parametrize("mock_device_code", ["cz_guitoc9iylae4axs"]) async def test_delta_report_sensor( hass: HomeAssistant, diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py index e4abcaa293d580..49771dd5e6af71 100644 --- a/tests/components/tuya/test_siren.py +++ b/tests/components/tuya/test_siren.py @@ -24,7 +24,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -59,7 +65,6 @@ async def test_platform_setup_and_discovery( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) @pytest.mark.freeze_time("2024-01-01") async def test_selective_state_update( hass: HomeAssistant, @@ -88,7 +93,6 @@ async def test_selective_state_update( ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) @pytest.mark.parametrize( "mock_device_code", ["sp_sdd5f5f2dl5wydjf"], diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py index 2e5bbc08609134..b9705892e433af 100644 --- a/tests/components/tuya/test_switch.py +++ b/tests/components/tuya/test_switch.py @@ -24,7 +24,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -59,7 +65,6 @@ async def test_platform_setup_and_discovery( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) @pytest.mark.freeze_time("2024-01-01") async def test_selective_state_update( hass: HomeAssistant, @@ -88,7 +93,6 @@ async def test_selective_state_update( ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) @pytest.mark.parametrize( "mock_device_code", ["cz_PGEkBctAbtzKOZng"], @@ -133,7 +137,6 @@ async def test_action( ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) @pytest.mark.parametrize( "mock_device_code", ["cz_PGEkBctAbtzKOZng"], diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py index 3b3bd3d3398b6f..18ffafe8dc12f4 100644 --- a/tests/components/tuya/test_vacuum.py +++ b/tests/components/tuya/test_vacuum.py @@ -28,7 +28,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, diff --git a/tests/components/tuya/test_valve.py b/tests/components/tuya/test_valve.py index 8791ae499e9cc1..b0fa504dc97225 100644 --- a/tests/components/tuya/test_valve.py +++ b/tests/components/tuya/test_valve.py @@ -24,7 +24,13 @@ from tests.common import MockConfigEntry, snapshot_platform -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) +@pytest.fixture(autouse=True) +def platform_autouse(): + """Platform fixture.""" + with patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]): + yield + + async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -59,7 +65,6 @@ async def test_platform_setup_and_discovery( ), ], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) @pytest.mark.freeze_time("2024-01-01") async def test_selective_state_update( hass: HomeAssistant, @@ -88,7 +93,6 @@ async def test_selective_state_update( ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) @pytest.mark.parametrize( "mock_device_code", ["sfkzq_ed7frwissyqrejic"], @@ -133,7 +137,6 @@ async def test_action( ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) @pytest.mark.parametrize( "mock_device_code", ["sfkzq_ed7frwissyqrejic"], From 2e2ad0aaec207ef5fa0aaf37c3cfc26c36ea8f61 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 1 Apr 2026 14:41:39 +0200 Subject: [PATCH 0318/1707] Fix patching for DNS queries in Obihai (#166790) --- tests/components/obihai/test_config_flow.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py index 9b73cd2db2753f..3b2b972d00c229 100644 --- a/tests/components/obihai/test_config_flow.py +++ b/tests/components/obihai/test_config_flow.py @@ -144,15 +144,18 @@ async def test_dhcp_flow_auth_failure(hass: HomeAssistant) -> None: == DHCP_SERVICE_INFO.ip ) - # patch_gethostbyname fixture is active - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: DHCP_SERVICE_INFO.ip, - CONF_USERNAME: "", - CONF_PASSWORD: "", - }, - ) + with ( + patch("homeassistant.components.obihai.config_flow.gethostbyname"), + ): + # Verify we get dropped into the normal user flow with non-default credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: DHCP_SERVICE_INFO.ip, + CONF_USERNAME: "", + CONF_PASSWORD: "", + }, + ) assert result["errors"]["base"] == "invalid_auth" assert result["step_id"] == "user" From 4870bb749c4fc083fe97d94fd870cf5bf64b0381 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 1 Apr 2026 14:44:15 +0200 Subject: [PATCH 0319/1707] 100% coverage of services for Alexa Devices (#165826) --- .../components/alexa_devices/test_services.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py index 42a2ee36c5be1f..1a500da5ea8509 100644 --- a/tests/components/alexa_devices/test_services.py +++ b/tests/components/alexa_devices/test_services.py @@ -282,7 +282,7 @@ async def test_invalid_config_entry( mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test invalid config entry.""" + """Test that a non-existing entry ID in device config entries is skipped.""" await setup_integration(hass, mock_config_entry) @@ -291,21 +291,23 @@ async def test_invalid_config_entry( ) assert device_entry + device_entry.config_entries.clear() device_entry.config_entries.add("non_existing_entry_id") - await hass.async_block_till_done() - # Call Service - await hass.services.async_call( - DOMAIN, - "send_sound", - { - ATTR_SOUND: "bell_02", - ATTR_DEVICE_ID: device_entry.id, - }, - blocking=True, - ) - # No exception should be raised - assert mock_amazon_devices_client.call_alexa_sound.call_count == 1 + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + "send_sound", + { + ATTR_SOUND: "bell_02", + ATTR_DEVICE_ID: device_entry.id, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "config_entry_not_found" + assert exc_info.value.translation_placeholders == {"device_id": device_entry.id} async def test_missing_config_entry( From ef66446a0d3914541c40ec3b400191e11e6783ba Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:46:13 +0200 Subject: [PATCH 0320/1707] Add entity descriptions to Tuya camera/fan/vacuum (#167056) --- homeassistant/components/tuya/camera.py | 23 +++++++++++++++-------- homeassistant/components/tuya/entity.py | 8 +++----- homeassistant/components/tuya/fan.py | 22 ++++++++++++---------- homeassistant/components/tuya/vacuum.py | 14 +++++++++++--- 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 3790f470b78255..36b69885b2e564 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -9,7 +9,11 @@ from tuya_sharing import CustomerDevice, Manager from homeassistant.components import ffmpeg -from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature +from homeassistant.components.camera import ( + Camera as CameraEntity, + CameraEntityDescription, + CameraEntityFeature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -18,10 +22,10 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory from .entity import TuyaEntity -CAMERAS: tuple[DeviceCategory, ...] = ( - DeviceCategory.DGHSXJ, - DeviceCategory.SP, -) +CAMERAS: dict[DeviceCategory, CameraEntityDescription] = { + DeviceCategory.DGHSXJ: CameraEntityDescription(key=""), + DeviceCategory.SP: CameraEntityDescription(key=""), +} async def async_setup_entry( @@ -38,9 +42,11 @@ def async_discover_device(device_ids: list[str]) -> None: entities: list[TuyaCameraEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] - if device.category in CAMERAS: + if description := CAMERAS.get(device.category): entities.append( - TuyaCameraEntity(device, manager, get_default_definition(device)) + TuyaCameraEntity( + device, manager, description, get_default_definition(device) + ) ) async_add_entities(entities) @@ -63,10 +69,11 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, + description: CameraEntityDescription, definition: TuyaCameraDefinition, ) -> None: """Init Tuya Camera.""" - super().__init__(device, device_manager) + super().__init__(device, device_manager, description) CameraEntity.__init__(self) self._attr_model = device.product_name self._motion_detection_switch = definition.motion_detection_switch diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 33c729e91793eb..7ebe9aaf416fd4 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -24,17 +24,15 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: EntityDescription | None = None, + description: EntityDescription, ) -> None: """Init TuyaHaEntity.""" - self._attr_unique_id = f"tuya.{device.id}" + self._attr_unique_id = f"tuya.{device.id}{description.key}" + self.entity_description = description # TuyaEntity initialize mq can subscribe device.set_up = True self.device = device self.device_manager = device_manager - if description: - self._attr_unique_id = f"tuya.{device.id}{description.key}" - self.entity_description = description @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index baf3b74e71a7fb..5d0db0adc67571 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -15,6 +15,7 @@ DIRECTION_FORWARD, DIRECTION_REVERSE, FanEntity, + FanEntityDescription, FanEntityFeature, ) from homeassistant.core import HomeAssistant, callback @@ -25,13 +26,13 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory from .entity import TuyaEntity -TUYA_SUPPORT_TYPE: set[DeviceCategory] = { - DeviceCategory.CS, - DeviceCategory.FS, - DeviceCategory.FSD, - DeviceCategory.FSKG, - DeviceCategory.KJ, - DeviceCategory.KS, +FANS: dict[DeviceCategory, FanEntityDescription] = { + DeviceCategory.CS: FanEntityDescription(key=""), + DeviceCategory.FS: FanEntityDescription(key=""), + DeviceCategory.FSD: FanEntityDescription(key=""), + DeviceCategory.FSKG: FanEntityDescription(key=""), + DeviceCategory.KJ: FanEntityDescription(key=""), + DeviceCategory.KS: FanEntityDescription(key=""), } _TUYA_TO_HA_DIRECTION_MAPPINGS = { @@ -57,10 +58,10 @@ def async_discover_device(device_ids: list[str]) -> None: entities: list[TuyaFanEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] - if device.category in TUYA_SUPPORT_TYPE and ( + if (description := FANS.get(device.category)) and ( definition := get_default_definition(device) ): - entities.append(TuyaFanEntity(device, manager, definition)) + entities.append(TuyaFanEntity(device, manager, description, definition)) async_add_entities(entities) async_discover_device([*manager.device_map]) @@ -79,10 +80,11 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, + description: FanEntityDescription, definition: TuyaFanDefinition, ) -> None: """Init Tuya Fan Device.""" - super().__init__(device, device_manager) + super().__init__(device, device_manager, description) self._direction_wrapper = definition.direction_wrapper self._mode_wrapper = definition.mode_wrapper self._oscillate_wrapper = definition.oscillate_wrapper diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index f6e7b79bcddbc4..ef2eba4a5fa80d 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -16,6 +16,7 @@ from homeassistant.components.vacuum import ( StateVacuumEntity, + StateVacuumEntityDescription, VacuumActivity, VacuumEntityFeature, ) @@ -36,6 +37,10 @@ TuyaVacuumActivity.ERROR: VacuumActivity.ERROR, } +VACUUMS: dict[DeviceCategory, StateVacuumEntityDescription] = { + DeviceCategory.SD: StateVacuumEntityDescription(key=""), +} + async def async_setup_entry( hass: HomeAssistant, @@ -51,9 +56,11 @@ def async_discover_device(device_ids: list[str]) -> None: entities: list[TuyaVacuumEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] - if device.category == DeviceCategory.SD: + if description := VACUUMS.get(device.category): entities.append( - TuyaVacuumEntity(device, manager, get_default_definition(device)) + TuyaVacuumEntity( + device, manager, description, get_default_definition(device) + ) ) async_add_entities(entities) @@ -73,10 +80,11 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, + description: StateVacuumEntityDescription, definition: TuyaVacuumDefinition, ) -> None: """Init Tuya vacuum.""" - super().__init__(device, device_manager) + super().__init__(device, device_manager, description) self._action_wrapper = definition.action_wrapper self._activity_wrapper = definition.activity_wrapper self._fan_speed_wrapper = definition.fan_speed_wrapper From 18cd48862290d10072a9e19b57cc57abcc0ddf60 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 1 Apr 2026 15:00:54 +0200 Subject: [PATCH 0321/1707] Hassfest requirements.py optimization (#166514) --- script/hassfest/requirements.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 398723305146a7..fbdacc552f35d2 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -502,6 +502,12 @@ def get_pipdeptree() -> dict[str, dict[str, Any]]: return deptree +@cache +def metadata_cache(package: str) -> dict: + """Return package metadata, cached.""" + return metadata(package) + + def get_requirements(integration: Integration, packages: set[str]) -> set[str]: """Return all (recursively) requirements for an integration.""" deptree = get_pipdeptree() @@ -550,7 +556,7 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: continue # Check for restrictive version limits on Python - if (requires_python := metadata(package)["Requires-Python"]) and not all( + if (requires_python := metadata_cache(package)["Requires-Python"]) and not all( _is_dependency_version_range_valid(version_part, "SemVer") for version_part in requires_python.split(",") ): From 36045c4bd3c650752e952980c9d74a20b7f24185 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 Apr 2026 15:18:03 +0200 Subject: [PATCH 0322/1707] Add ConfigEntry method to get subentries by type (#167055) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Erik Montnemery --- homeassistant/config_entries.py | 8 ++++++ tests/test_config_entries.py | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ab4c2d7d7b334c..85e1d1d3ffe299 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -625,6 +625,14 @@ def supported_subentry_types(self) -> dict[str, dict[str, bool]]: ) return self._supported_subentry_types or {} + def get_subentries_of_type(self, subentry_type: str) -> list[ConfigSubentry]: + """Return subentries of a specified subentry type.""" + return [ + subentry + for subentry in self.subentries.values() + if subentry.subentry_type == subentry_type + ] + def clear_state_cache(self) -> None: """Clear cached properties that are included in as_json_fragment.""" self.__dict__.pop("as_json_fragment", None) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 38ec0673ae0f26..2ef3ff0785cb46 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7745,6 +7745,51 @@ async def test_updating_non_added_subentry_raises(hass: HomeAssistant) -> None: hass.config_entries.async_update_subentry(entry, subentry, unique_id="new_id") +async def test_get_subentries_of_type(hass: HomeAssistant) -> None: + """Test getting subentries by type.""" + entry = MockConfigEntry( + domain="test", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_type="test", + title="Mock title", + unique_id="unique", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_type="test", + title="Mock title 2", + unique_id="very_very_unique", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_type="test_test", + title="Mock title 3", + unique_id="very_unique", + ), + ], + ) + + test_subentries = entry.get_subentries_of_type("test") + assert len(test_subentries) == 2 + assert [subentry.unique_id for subentry in test_subentries] == [ + "unique", + "very_very_unique", + ] + assert all(subentry.subentry_type == "test" for subentry in test_subentries) + assert len({subentry.subentry_id for subentry in test_subentries}) == len( + test_subentries + ) + + test_test_subentries = entry.get_subentries_of_type("test_test") + assert len(test_test_subentries) == 1 + assert test_test_subentries[0].unique_id == "very_unique" + assert test_test_subentries[0].subentry_type == "test_test" + + assert entry.get_subentries_of_type("unknown") == [] + + async def test_reload_during_setup(hass: HomeAssistant) -> None: """Test reload during setup waits.""" entry = MockConfigEntry(domain="comp", data={"value": "initial"}) From 783e2f0a000b18f4993d000f20e00cc48e630cec Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 15:56:04 +0200 Subject: [PATCH 0323/1707] Fix spelling of "Shut down" button label in `proxmoxve` (#167059) --- .../components/proxmoxve/strings.json | 2 +- .../proxmoxve/snapshots/test_button.ambr | 42 +++++++++---------- tests/components/proxmoxve/test_button.py | 6 +-- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 12ee765d9f23ed..cc5571c168adf5 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -141,7 +141,7 @@ "name": "Reset" }, "shutdown": { - "name": "Shutdown" + "name": "Shut down" }, "snapshot_create": { "name": "Create snapshot" diff --git a/tests/components/proxmoxve/snapshots/test_button.ambr b/tests/components/proxmoxve/snapshots/test_button.ambr index 0b3e2c53238d49..14a3745a56fb2b 100644 --- a/tests/components/proxmoxve/snapshots/test_button.ambr +++ b/tests/components/proxmoxve/snapshots/test_button.ambr @@ -452,7 +452,7 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.pve1_shutdown-entry] +# name: test_all_button_entities[button.pve1_shut_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -466,7 +466,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.pve1_shutdown', + 'entity_id': 'button.pve1_shut_down', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -474,12 +474,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Shutdown', + 'object_id_base': 'Shut down', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Shutdown', + 'original_name': 'Shut down', 'platform': 'proxmoxve', 'previous_unique_id': None, 'suggested_object_id': None, @@ -489,13 +489,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_button_entities[button.pve1_shutdown-state] +# name: test_all_button_entities[button.pve1_shut_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'pve1 Shutdown', + 'friendly_name': 'pve1 Shut down', }), 'context': , - 'entity_id': 'button.pve1_shutdown', + 'entity_id': 'button.pve1_shut_down', 'last_changed': , 'last_reported': , 'last_updated': , @@ -853,7 +853,7 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.vm_db_shutdown-entry] +# name: test_all_button_entities[button.vm_db_shut_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -867,7 +867,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.vm_db_shutdown', + 'entity_id': 'button.vm_db_shut_down', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -875,12 +875,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Shutdown', + 'object_id_base': 'Shut down', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Shutdown', + 'original_name': 'Shut down', 'platform': 'proxmoxve', 'previous_unique_id': None, 'suggested_object_id': None, @@ -890,13 +890,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_button_entities[button.vm_db_shutdown-state] +# name: test_all_button_entities[button.vm_db_shut_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'vm-db Shutdown', + 'friendly_name': 'vm-db Shut down', }), 'context': , - 'entity_id': 'button.vm_db_shutdown', + 'entity_id': 'button.vm_db_shut_down', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1204,7 +1204,7 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.vm_web_shutdown-entry] +# name: test_all_button_entities[button.vm_web_shut_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1218,7 +1218,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.vm_web_shutdown', + 'entity_id': 'button.vm_web_shut_down', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1226,12 +1226,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Shutdown', + 'object_id_base': 'Shut down', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Shutdown', + 'original_name': 'Shut down', 'platform': 'proxmoxve', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1241,13 +1241,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_button_entities[button.vm_web_shutdown-state] +# name: test_all_button_entities[button.vm_web_shut_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'vm-web Shutdown', + 'friendly_name': 'vm-web Shut down', }), 'context': , - 'entity_id': 'button.vm_web_shutdown', + 'entity_id': 'button.vm_web_shut_down', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index fb2c5a8850824c..cd0de10923fd41 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -50,7 +50,7 @@ async def test_all_button_entities( ("entity_id", "command"), [ ("button.pve1_restart", "reboot"), - ("button.pve1_shutdown", "shutdown"), + ("button.pve1_shut_down", "shutdown"), ], ) async def test_node_buttons( @@ -116,7 +116,7 @@ async def test_node_all_actions_buttons( ("button.vm_web_restart", 100, "reboot"), ("button.vm_web_hibernate", 100, "hibernate"), ("button.vm_web_reset", 100, "reset"), - ("button.vm_web_shutdown", 100, "shutdown"), + ("button.vm_web_shut_down", 100, "shutdown"), ], ) async def test_vm_buttons( @@ -230,7 +230,7 @@ async def test_container_buttons( ("button.pve1_restart", AuthenticationError("auth failed")), ("button.pve1_restart", SSLError("ssl error")), ("button.pve1_restart", ConnectTimeout("timeout")), - ("button.pve1_shutdown", ResourceException(500, "error", {})), + ("button.pve1_shut_down", ResourceException(500, "error", {})), ], ) async def test_node_buttons_exceptions( From c1a9f293a70f6ddd55627b8c3a8533829597c2f4 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:08:40 +0800 Subject: [PATCH 0324/1707] Add fan speed percentage control to SwitchBot Air Purifier (#166953) --- .../components/switchbot/__init__.py | 7 ++-- homeassistant/components/switchbot/const.py | 13 ++++++++ homeassistant/components/switchbot/fan.py | 16 ++++++++++ tests/components/switchbot/__init__.py | 32 +++++++++---------- tests/components/switchbot/test_fan.py | 29 ++++++++++------- tests/components/switchbot/test_init.py | 16 +++++----- 6 files changed, 75 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index a2768c202b77f6..0d47f7752a70ae 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -114,7 +114,10 @@ SupportedModels.AIR_PURIFIER_US.value: [Platform.FAN, Platform.SENSOR], SupportedModels.AIR_PURIFIER_TABLE_JP.value: [Platform.FAN, Platform.SENSOR], SupportedModels.AIR_PURIFIER_TABLE_US.value: [Platform.FAN, Platform.SENSOR], - SupportedModels.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR], + SupportedModels.EVAPORATIVE_HUMIDIFIER.value: [ + Platform.HUMIDIFIER, + Platform.SENSOR, + ], SupportedModels.FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], @@ -171,7 +174,7 @@ SupportedModels.AIR_PURIFIER_US.value: switchbot.SwitchbotAirPurifier, SupportedModels.AIR_PURIFIER_TABLE_JP.value: switchbot.SwitchbotAirPurifier, SupportedModels.AIR_PURIFIER_TABLE_US.value: switchbot.SwitchbotAirPurifier, - SupportedModels.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, + SupportedModels.EVAPORATIVE_HUMIDIFIER.value: switchbot.SwitchbotEvaporativeHumidifier, SupportedModels.FLOOR_LAMP.value: switchbot.SwitchbotStripLight3, SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3, SupportedModels.RGBICWW_FLOOR_LAMP.value: switchbot.SwitchbotRgbicLight, diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index d871f18d964c69..142b13befcc082 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -206,3 +206,16 @@ class SupportedModels(StrEnum): CONF_ENCRYPTION_KEY = "encryption_key" CONF_LOCK_NIGHTLATCH = "lock_force_nightlatch" CONF_CURTAIN_SPEED = "curtain_speed" + +AIRPURIFIER_BASIC_MODELS = { + SwitchbotModel.AIR_PURIFIER_JP, + SwitchbotModel.AIR_PURIFIER_US, +} +AIRPURIFIER_TABLE_MODELS = { + SwitchbotModel.AIR_PURIFIER_TABLE_JP, + SwitchbotModel.AIR_PURIFIER_TABLE_US, +} +AIRPURIFIER_PM25_MODELS = { + SwitchbotModel.AIR_PURIFIER_US, + SwitchbotModel.AIR_PURIFIER_TABLE_US, +} diff --git a/homeassistant/components/switchbot/fan.py b/homeassistant/components/switchbot/fan.py index 9a7260f592542f..66d407eed2e64e 100644 --- a/homeassistant/components/switchbot/fan.py +++ b/homeassistant/components/switchbot/fan.py @@ -131,6 +131,7 @@ class SwitchBotAirPurifierEntity(SwitchbotEntity, FanEntity): _device: switchbot.SwitchbotAirPurifier _attr_supported_features = ( FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) @@ -148,6 +149,11 @@ def preset_mode(self) -> str | None: """Return the current preset mode.""" return self._device.get_current_mode() + @property + def percentage(self) -> int | None: + """Return the speed percentage of the air purifier.""" + return self._device.get_current_percentage() + @exception_handler async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the air purifier.""" @@ -160,6 +166,16 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: self._last_run_success = bool(await self._device.set_preset_mode(preset_mode)) self.async_write_ha_state() + @exception_handler + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the air purifier.""" + + _LOGGER.debug( + "Switchbot air purifier to set percentage %d %s", percentage, self._address + ) + await self._device.set_percentage(percentage) + self.async_write_ha_state() + @exception_handler async def async_turn_on( self, diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 00f10ed1a720ff..40038d7d7c3762 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -785,8 +785,8 @@ def make_advertisement( ) -AIR_PURIFIER_TABLE_PM25_SERVICE_INFO = BluetoothServiceInfoBleak( - name="Air Purifier Table PM25", +AIR_PURIFIER_TABLE_US_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Air Purifier Table US", manufacturer_data={ 2409: b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00", }, @@ -796,22 +796,22 @@ def make_advertisement( rssi=-60, source="local", advertisement=generate_advertisement_data( - local_name="Air Purifier Table PM25", + local_name="Air Purifier Table US", manufacturer_data={ 2409: b"\xf0\x9e\x9e\x96j\xd6\xa1\x81\x88\xe4\x00\x01\x95\x00\x00", }, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"7\x00\x00\x95-\x00"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier Table PM25"), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier Table US"), time=0, connectable=True, tx_power=-127, ) -AIR_PURIFIER_PM25_SERVICE_INFO = BluetoothServiceInfoBleak( - name="Air Purifier PM25", +AIR_PURIFIER_US_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Air Purifier US", manufacturer_data={ 2409: b'\xcc\x8d\xa2\xa7\x92>\t"\x80\x000\x00\x0f\x00\x00', }, @@ -821,22 +821,22 @@ def make_advertisement( rssi=-60, source="local", advertisement=generate_advertisement_data( - local_name="Air Purifier PM25", + local_name="Air Purifier US", manufacturer_data={ 2409: b'\xcc\x8d\xa2\xa7\x92>\t"\x80\x000\x00\x0f\x00\x00', }, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"*\x00\x00\x15\x04\x00"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier PM25"), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier US"), time=0, connectable=True, tx_power=-127, ) -AIR_PURIFIER_VOC_SERVICE_INFO = BluetoothServiceInfoBleak( - name="Air Purifier VOC", +AIR_PURIFIER_JP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Air Purifier JP", manufacturer_data={ 2409: b"\xcc\x8d\xa2\xa7\xe4\xa6\x0b\x83\x88d\x00\xea`\x00\x00", }, @@ -846,22 +846,22 @@ def make_advertisement( rssi=-60, source="local", advertisement=generate_advertisement_data( - local_name="Air Purifier VOC", + local_name="Air Purifier JP", manufacturer_data={ 2409: b"\xcc\x8d\xa2\xa7\xe4\xa6\x0b\x83\x88d\x00\xea`\x00\x00", }, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"+\x00\x00\x15\x04\x00"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier VOC"), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier JP"), time=0, connectable=True, tx_power=-127, ) -AIR_PURIFIER_TABLE_VOC_SERVICE_INFO = BluetoothServiceInfoBleak( - name="Air Purifier Table VOC", +AIR_PURIFIER_TABLE_JP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Air Purifier Table JP", manufacturer_data={ 2409: b"\xcc\x8d\xa2\xa7\xc1\xae\x9b\x81\x8c\xb2\x00\x01\x94\x00\x00", }, @@ -871,14 +871,14 @@ def make_advertisement( rssi=-60, source="local", advertisement=generate_advertisement_data( - local_name="Air Purifier Table VOC", + local_name="Air Purifier Table JP", manufacturer_data={ 2409: b"\xcc\x8d\xa2\xa7\xc1\xae\x9b\x81\x8c\xb2\x00\x01\x94\x00\x00", }, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"8\x00\x00\x95-\x00"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier Table VOC"), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Air Purifier Table JP"), time=0, connectable=True, tx_power=-127, diff --git a/tests/components/switchbot/test_fan.py b/tests/components/switchbot/test_fan.py index 4e8fe669fd264c..f70899c37c7d71 100644 --- a/tests/components/switchbot/test_fan.py +++ b/tests/components/switchbot/test_fan.py @@ -21,10 +21,10 @@ from homeassistant.exceptions import HomeAssistantError from . import ( - AIR_PURIFIER_PM25_SERVICE_INFO, - AIR_PURIFIER_TABLE_PM25_SERVICE_INFO, - AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, - AIR_PURIFIER_VOC_SERVICE_INFO, + AIR_PURIFIER_JP_SERVICE_INFO, + AIR_PURIFIER_TABLE_JP_SERVICE_INFO, + AIR_PURIFIER_TABLE_US_SERVICE_INFO, + AIR_PURIFIER_US_SERVICE_INFO, CIRCULATOR_FAN_SERVICE_INFO, ) @@ -103,10 +103,10 @@ async def test_circulator_fan_controlling( @pytest.mark.parametrize( ("service_info", "sensor_type"), [ - (AIR_PURIFIER_VOC_SERVICE_INFO, "air_purifier_jp"), - (AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, "air_purifier_table_jp"), - (AIR_PURIFIER_PM25_SERVICE_INFO, "air_purifier_us"), - (AIR_PURIFIER_TABLE_PM25_SERVICE_INFO, "air_purifier_table_us"), + (AIR_PURIFIER_JP_SERVICE_INFO, "air_purifier_jp"), + (AIR_PURIFIER_TABLE_JP_SERVICE_INFO, "air_purifier_table_jp"), + (AIR_PURIFIER_US_SERVICE_INFO, "air_purifier_us"), + (AIR_PURIFIER_TABLE_US_SERVICE_INFO, "air_purifier_table_us"), ], ) @pytest.mark.parametrize( @@ -117,6 +117,11 @@ async def test_circulator_fan_controlling( {ATTR_PRESET_MODE: "sleep"}, "set_preset_mode", ), + ( + SERVICE_SET_PERCENTAGE, + {ATTR_PERCENTAGE: 27}, + "set_percentage", + ), ( SERVICE_TURN_OFF, {}, @@ -169,10 +174,10 @@ async def test_air_purifier_controlling( @pytest.mark.parametrize( ("service_info", "sensor_type"), [ - (AIR_PURIFIER_VOC_SERVICE_INFO, "air_purifier_jp"), - (AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, "air_purifier_table_jp"), - (AIR_PURIFIER_PM25_SERVICE_INFO, "air_purifier_us"), - (AIR_PURIFIER_TABLE_PM25_SERVICE_INFO, "air_purifier_table_us"), + (AIR_PURIFIER_JP_SERVICE_INFO, "air_purifier_jp"), + (AIR_PURIFIER_TABLE_JP_SERVICE_INFO, "air_purifier_table_jp"), + (AIR_PURIFIER_US_SERVICE_INFO, "air_purifier_us"), + (AIR_PURIFIER_TABLE_US_SERVICE_INFO, "air_purifier_table_us"), ], ) @pytest.mark.parametrize( diff --git a/tests/components/switchbot/test_init.py b/tests/components/switchbot/test_init.py index 15aae12800d3bf..a0bdea2d0a3cb6 100644 --- a/tests/components/switchbot/test_init.py +++ b/tests/components/switchbot/test_init.py @@ -20,10 +20,10 @@ from homeassistant.core import HomeAssistant from . import ( - AIR_PURIFIER_PM25_SERVICE_INFO, - AIR_PURIFIER_TABLE_PM25_SERVICE_INFO, - AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, - AIR_PURIFIER_VOC_SERVICE_INFO, + AIR_PURIFIER_JP_SERVICE_INFO, + AIR_PURIFIER_TABLE_JP_SERVICE_INFO, + AIR_PURIFIER_TABLE_US_SERVICE_INFO, + AIR_PURIFIER_US_SERVICE_INFO, HUBMINI_MATTER_SERVICE_INFO, LOCK_SERVICE_INFO, WOCURTAIN_SERVICE_INFO, @@ -253,22 +253,22 @@ async def test_migrate_entry_fails_for_future_version( [ ( DEPRECATED_SENSOR_TYPE_AIR_PURIFIER, - AIR_PURIFIER_VOC_SERVICE_INFO, + AIR_PURIFIER_JP_SERVICE_INFO, "air_purifier_jp", ), ( DEPRECATED_SENSOR_TYPE_AIR_PURIFIER, - AIR_PURIFIER_PM25_SERVICE_INFO, + AIR_PURIFIER_US_SERVICE_INFO, "air_purifier_us", ), ( DEPRECATED_SENSOR_TYPE_AIR_PURIFIER_TABLE, - AIR_PURIFIER_TABLE_VOC_SERVICE_INFO, + AIR_PURIFIER_TABLE_JP_SERVICE_INFO, "air_purifier_table_jp", ), ( DEPRECATED_SENSOR_TYPE_AIR_PURIFIER_TABLE, - AIR_PURIFIER_TABLE_PM25_SERVICE_INFO, + AIR_PURIFIER_TABLE_US_SERVICE_INFO, "air_purifier_table_us", ), ], From 99a186fad79273443c21cb4493e398826c93ee2f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:10:14 +0200 Subject: [PATCH 0325/1707] Fix lingering tasks in condition and trigger tests (#166967) --- tests/helpers/test_condition.py | 8 ++++++++ tests/helpers/test_trigger.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index ab5fe80825c18d..2616c0677e7c5d 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2830,6 +2830,8 @@ def _load_yaml(fname, secrets=None): # Verify the cache returns the same object assert await condition.async_get_all_descriptions(hass) is new_descriptions + await hass.data["entity_components"][SUN_DOMAIN]._async_reset() + @pytest.mark.parametrize( ("yaml_error", "expected_message"), @@ -2870,6 +2872,8 @@ def _load_yaml_dict(fname, secrets=None): assert expected_message in caplog.text + await hass.data["entity_components"][SUN_DOMAIN]._async_reset() + async def test_async_get_all_descriptions_with_bad_description( hass: HomeAssistant, @@ -2904,6 +2908,8 @@ def _load_yaml(fname, secrets=None): "expected a dictionary for dictionary value @ data['_']['fields']" ) in caplog.text + await hass.data["entity_components"][SUN_DOMAIN]._async_reset() + async def test_invalid_condition_platform( hass: HomeAssistant, @@ -2961,6 +2967,8 @@ async def good_subscriber(new_conditions: set[str]): assert condition_events == [{"sun"}] assert "Error while notifying condition platform listener" in caplog.text + await hass.data["entity_components"][SUN_DOMAIN]._async_reset() + @patch("annotatedyaml.loader.load_yaml") @patch.object(Integration, "has_conditions", return_value=True) diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index e9122a20de331e..45353df528135a 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -956,6 +956,8 @@ def _load_yaml(fname, secrets=None): # Verify the cache returns the same object assert await trigger.async_get_all_descriptions(hass) is new_descriptions + await hass.data["entity_components"][SUN_DOMAIN]._async_reset() + @pytest.mark.parametrize( ("yaml_error", "expected_message"), @@ -996,6 +998,8 @@ def _load_yaml_dict(fname, secrets=None): assert expected_message in caplog.text + await hass.data["entity_components"][SUN_DOMAIN]._async_reset() + async def test_async_get_all_descriptions_with_bad_description( hass: HomeAssistant, @@ -1030,6 +1034,8 @@ def _load_yaml(fname, secrets=None): "expected a dictionary for dictionary value @ data['_']['fields']" ) in caplog.text + await hass.data["entity_components"][SUN_DOMAIN]._async_reset() + async def test_invalid_trigger_platform( hass: HomeAssistant, @@ -1084,6 +1090,8 @@ async def good_subscriber(new_triggers: set[str]): assert trigger_events == [{"sun"}] assert "Error while notifying trigger platform listener" in caplog.text + await hass.data["entity_components"][SUN_DOMAIN]._async_reset() + @patch("annotatedyaml.loader.load_yaml") @patch.object(Integration, "has_triggers", return_value=True) From 8fe09e18370fe2cfa91a71ee2563bd9aec1ebe3a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 2 Apr 2026 00:12:08 +1000 Subject: [PATCH 0326/1707] Add Claude Code agent for PR creation (#160759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.5 Co-authored-by: Abílio Costa Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .claude/agents/raise-pull-request.md | 228 +++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 .claude/agents/raise-pull-request.md diff --git a/.claude/agents/raise-pull-request.md b/.claude/agents/raise-pull-request.md new file mode 100644 index 00000000000000..102fe669fb89ed --- /dev/null +++ b/.claude/agents/raise-pull-request.md @@ -0,0 +1,228 @@ +--- +name: raise-pull-request +description: | + Use this agent when creating a pull request for the Home Assistant core repository after completing implementation work. This agent automates the PR creation process including running tests, formatting checks, and proper checkbox handling. +model: inherit +color: green +tools: Read, Bash, Grep, Glob +--- + +You are an expert at creating pull requests for the Home Assistant core repository. You will automate the PR creation process with proper verification, formatting, testing, and checkbox handling. + +**Execute each step in order. Do not skip steps.** + +## Step 1: Gather Information + +Run these commands in parallel to analyze the changes: + +```bash +# Get current branch and remote +git branch --show-current +git remote -v | grep push + +# Determine the best available dev reference +if git rev-parse --verify --quiet upstream/dev >/dev/null; then + BASE_REF="upstream/dev" +elif git rev-parse --verify --quiet origin/dev >/dev/null; then + BASE_REF="origin/dev" +elif git rev-parse --verify --quiet dev >/dev/null; then + BASE_REF="dev" +else + echo "Could not find upstream/dev, origin/dev, or local dev" + exit 1 +fi + +BASE_SHA="$(git merge-base "$BASE_REF" HEAD)" +echo "BASE_REF=$BASE_REF" +echo "BASE_SHA=$BASE_SHA" + +# Get commit info for this branch vs dev +git log "${BASE_SHA}..HEAD" --oneline + +# Check what files changed +git diff "${BASE_SHA}..HEAD" --name-only + +# Check if test files were added/modified +git diff "${BASE_SHA}..HEAD" --name-only | grep -E "^tests/.*\.py$" || echo "NO_TESTS_CHANGED" + +# Check if manifest.json changed +git diff "${BASE_SHA}..HEAD" --name-only | grep "manifest.json" || echo "NO_MANIFEST_CHANGED" +``` + +From the file paths, extract the **integration domain** from `homeassistant/components/{integration}/` or `tests/components/{integration}/`. + +**Track results:** +- `BASE_REF`: the dev reference used for comparison +- `BASE_SHA`: the merge-base commit used for diff-based checks +- `TESTS_CHANGED`: true if test files were added or modified +- `MANIFEST_CHANGED`: true if manifest.json was modified + +**If no suitable dev reference is available, STOP and tell the user to fetch `upstream/dev`, `origin/dev`, or a local `dev` branch before continuing.** + +## Step 2: Run Code Quality Checks + +Run `prek` to perform code quality checks (formatting, linting, hassfest, etc.) on the files changed since `BASE_SHA`: + +```bash +prek run --from-ref "$BASE_SHA" --to-ref HEAD +``` + +**Track results:** +- `PREK_PASSED`: true if `prek run` exits with code 0 + +**If `prek` fails or is not available, STOP and report the failure to the user. Do not proceed with PR creation. If the failure appears to be an environment setup issue (e.g., missing tools, command not found, venv not activated), also point the user to https://developers.home-assistant.io/docs/development_environment.** + +## Step 3: Stage Any Changes from Checks + +If `prek` made any formatting or generated file changes, stage and commit them as a separate commit: + +```bash +git status --porcelain +# If changes exist: +git add -A +git commit -m "Apply prek formatting and generated file updates" +``` + +## Step 4: Run Tests + +Run pytest for the specific integration: + +```bash +pytest tests/components/{integration} \ + --timeout=60 \ + --durations-min=1 \ + --durations=0 \ + -q +``` + +**Track results:** +- `TESTS_PASSED`: true if pytest exits with code 0 + +**If tests fail, STOP and report the failures to the user. Do not proceed with PR creation.** + +## Step 5: Identify PR Metadata + +Write a release-note-style PR title summarizing the change. The title becomes the release notes entry, so it should be a complete sentence fragment describing what changed in imperative mood. + +**PR Title Examples by Type:** +| Type | Example titles | +|------|----------------| +| Bugfix | `Fix Hikvision NVR binary sensors not being detected` | +| | `Fix JSON serialization of time objects in anthropic tool results` | +| | `Fix config flow bug in Tesla Fleet` | +| Dependency | `Bump eheimdigital to 1.5.0` | +| | `Bump python-otbr-api to 2.7.1` | +| New feature | `Add asyncio-level timeout to Backblaze B2 uploads` | +| | `Add Nettleie optimization option` | +| Code quality | `Add exception translations to Teslemetry` | +| | `Improve test coverage of Tesla Fleet` | +| | `Refactor adguard tests to use proper fixtures for mocking` | +| | `Simplify entity init in Proxmox` | + +## Step 6: Verify Development Checklist + +Check each item from the [development checklist](https://developers.home-assistant.io/docs/development_checklist/): + +| Item | How to verify | +|------|---------------| +| External libraries on PyPI | Check manifest.json requirements - all should be PyPI packages | +| Dependencies in requirements_all.txt | Only if dependency declarations changed (the `requirements` field in `manifest.json` or `requirements_all.txt`), run `python -m script.gen_requirements_all` | +| Codeowners updated | If this is a new integration, ensure its `manifest.json` includes a `codeowners` field with one or more GitHub usernames | +| No commented out code | Visually scan the diff for blocks of commented-out code | + +**Track results:** +- `NO_COMMENTED_CODE`: true if no blocks of commented-out code found in the diff +- `DEPENDENCIES_CHANGED`: true if the diff changes the `requirements` field in `manifest.json` or changes `requirements_all.txt` +- `REQUIREMENTS_UPDATED`: true if `DEPENDENCIES_CHANGED` is true and requirements_all.txt was regenerated successfully; not applicable if `DEPENDENCIES_CHANGED` is false +- `CHECKLIST_PASSED`: true if all items above pass + +## Step 7: Determine Type of Change + +Select exactly ONE based on the changes. Mark the selected type with `[x]` and all others with `[ ]` (space): + +| Type | Condition | +|------|-----------| +| Dependency upgrade | Only manifest.json/requirements changes | +| Bugfix | Fixes broken behavior, no new features | +| New integration | New folder in components/ | +| New feature | Adds capability to existing integration | +| Deprecation | Adds deprecation warnings for future breaking change | +| Breaking change | Removes or changes existing functionality | +| Code quality | Only refactoring or test additions, no functional change | + +**Track results:** +- `CHANGE_TYPE`: the selected type (e.g., "Bugfix", "New feature", "Code quality", etc.) + +**Important:** All seven type options must remain in the PR body. Only the selected type gets `[x]`, all others get `[ ]`. + +## Step 8: Determine Checkbox States + +Based on the verification steps above, determine checkbox states: + +| Checkbox | Condition to tick | +|----------|-------------------| +| The code change is tested and works locally | Leave unchecked for the contributor to verify manually (this refers to manual testing, not unit tests) | +| Local tests pass | Tick only if `TESTS_PASSED` is true | +| I understand the code I am submitting and can explain how it works | Leave unchecked for the contributor to review and set manually | +| There is no commented out code | Tick only if `NO_COMMENTED_CODE` is true | +| Development checklist | Tick only if `CHECKLIST_PASSED` is true | +| Perfect PR recommendations | Tick only if the PR affects a single integration or closely related modules, represents one primary type of change, and has a clear, self-contained scope | +| Formatted using Ruff | Tick only if `PREK_PASSED` is true | +| Tests have been added | Tick only if `TESTS_CHANGED` is true AND the changes exercise new or changed functionality (not only cosmetic test changes) | +| Documentation added/updated | Tick if documentation PR created (or not applicable) | +| Manifest file fields filled out | Tick if `PREK_PASSED` is true (or not applicable) | +| Dependencies in requirements_all.txt | Tick only if `DEPENDENCIES_CHANGED` is false, or if `DEPENDENCIES_CHANGED` is true and `REQUIREMENTS_UPDATED` is true | +| Dependency changelog linked | Tick if dependency changelog linked in PR description (or not applicable) | +| Any generated code has been carefully reviewed | Leave unchecked for the contributor to review and set manually | + +## Step 9: Breaking Change Section + +**If `CHANGE_TYPE` is NOT "Breaking change" or "Deprecation": REMOVE the entire "## Breaking change" section from the PR body (including the heading).** + +If `CHANGE_TYPE` IS "Breaking change" or "Deprecation", keep the `## Breaking change` section and describe: +- What breaks +- How users can fix it +- Why it was necessary + +## Step 10: Push Branch and Create PR + +```bash +# Get branch name and GitHub username +BRANCH=$(git branch --show-current) +PUSH_REMOTE=$(git config "branch.$BRANCH.remote" 2>/dev/null || git remote | head -1) +GITHUB_USER=$(gh api user --jq .login 2>/dev/null || git remote get-url "$PUSH_REMOTE" | sed -E 's#.*[:/]([^/]+)/([^/]+)(\.git)?$#\1#') + +# Create PR (gh pr create pushes the branch automatically) +gh pr create --repo home-assistant/core --base dev \ + --head "$GITHUB_USER:$BRANCH" \ + --title "TITLE_HERE" \ + --body "$(cat <<'EOF' +BODY_HERE +EOF +)" +``` + +### PR Body Template + +Read the PR template from `.github/PULL_REQUEST_TEMPLATE.md` and use it as the basis for the PR body. **Do not hardcode the template — always read it from the file to stay in sync with upstream changes.** + +Use any HTML comments (``) in the template as guidance to understand what to fill in. For the final PR body sent to GitHub, keep the template text intact — do not delete any text from the template unless it explicitly instructs removal (e.g., the breaking change section when not applicable). Then fill in the sections: + +1. **Breaking change section**: If the type is NOT "Breaking change" or "Deprecation", remove the entire `## Breaking change` section (heading and body). Otherwise, describe what breaks, how users can fix it, and why. +2. **Proposed change section**: Fill in a description of the change extracted from commit messages. +3. **Type of change**: Check exactly ONE checkbox matching the determined type from Step 7. Leave all others unchecked. +4. **Additional information**: Fill in any related issue numbers if known. +5. **Checklist**: Check boxes based on the conditions in Step 8. Leave manual-verification boxes unchecked for the contributor. + +**Important:** Preserve all template structure, options, and link references exactly as they appear in the file — only modify checkbox states and fill in content sections. + +## Step 11: Report Result + +Provide the user with: +1. **PR URL** - The created pull request link +2. **Verification Summary** - Which checks passed/failed +3. **Unchecked Items** - List any checkboxes left unchecked and why +4. **User Action Required** - Remind user to: + - Review and set manual-verification checkboxes ("I understand the code..." and "Any generated code...") as applicable + - Consider reviewing two other open PRs + - Add any related issue numbers if applicable From 524c2129eb52ad2bbb913204ea592e442379b2d3 Mon Sep 17 00:00:00 2001 From: Daniel Jolly Date: Wed, 1 Apr 2026 10:28:59 -0400 Subject: [PATCH 0327/1707] Fix ToDo List Intents item casing (#160177) Co-authored-by: Erik Montnemery Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- homeassistant/components/todo/intent.py | 13 ++++++++----- tests/components/todo/test_intent.py | 12 ++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index 883f7fac6f17cb..cc86b7a095f5d0 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -91,7 +91,7 @@ async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None # Add to list await target_list.async_create_todo_item( - TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) + TodoItem(summary=item.capitalize(), status=TodoItemStatus.NEEDS_ACTION) ) @@ -108,9 +108,9 @@ async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None matching_item = None for todo_item in target_list.todo_items or (): if ( - item in (todo_item.uid, todo_item.summary) - and todo_item.status == TodoItemStatus.NEEDS_ACTION - ): + item == todo_item.uid + or item.casefold() == (todo_item.summary or "").casefold() + ) and todo_item.status == TodoItemStatus.NEEDS_ACTION: matching_item = todo_item break if not matching_item or not matching_item.uid: @@ -140,7 +140,10 @@ async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None # Find item in list matching_item = None for todo_item in target_list.todo_items or (): - if item in (todo_item.uid, todo_item.summary): + if ( + item == todo_item.uid + or item.casefold() == (todo_item.summary or "").casefold() + ): matching_item = todo_item break if not matching_item or not matching_item.uid: diff --git a/tests/components/todo/test_intent.py b/tests/components/todo/test_intent.py index 0b7ba0c628d593..78953c47acce84 100644 --- a/tests/components/todo/test_intent.py +++ b/tests/components/todo/test_intent.py @@ -66,7 +66,7 @@ async def test_add_item_intent( assert len(entity1.items) == 1 assert len(entity2.items) == 0 - assert entity1.items[0].summary == "beer" # summary is trimmed + assert entity1.items[0].summary == "Beer" # summary is trimmed and capitalized assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION entity1.items.clear() @@ -82,7 +82,7 @@ async def test_add_item_intent( assert len(entity1.items) == 0 assert len(entity2.items) == 1 - assert entity2.items[0].summary == "cheese" + assert entity2.items[0].summary == "Cheese" assert entity2.items[0].status == TodoItemStatus.NEEDS_ACTION # List name is case insensitive @@ -97,7 +97,7 @@ async def test_add_item_intent( assert len(entity1.items) == 0 assert len(entity2.items) == 2 - assert entity2.items[1].summary == "wine" + assert entity2.items[1].summary == "Wine" assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION # Should fail if lists are not exposed @@ -187,8 +187,8 @@ async def test_complete_item_intent( """Test the complete item intent.""" entity1 = MockTodoListEntity( [ - TodoItem(summary="beer", uid="1", status=TodoItemStatus.NEEDS_ACTION), - TodoItem(summary="wine", uid="2", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Beer", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Wine", uid="2", status=TodoItemStatus.NEEDS_ACTION), ] ) entity1._attr_name = "List 1" @@ -298,7 +298,7 @@ async def test_remove_item_intent( """Test the remove item intent.""" entity1 = MockTodoListEntity( [ - TodoItem(summary="beer", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Beer", uid="1", status=TodoItemStatus.NEEDS_ACTION), TodoItem(summary="wine", uid="2", status=TodoItemStatus.NEEDS_ACTION), TodoItem(summary="beer", uid="3", status=TodoItemStatus.COMPLETED), ] From 1b4286381de5fd5e5437290552b60289bd84e10e Mon Sep 17 00:00:00 2001 From: CLN <7887972+cln-io@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:43:20 +0200 Subject: [PATCH 0328/1707] Bump aiounifi to 90 (#166918) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f9954e9743efb1..f025caaaa97634 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==88"], + "requirements": ["aiounifi==90"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index b4d1f769e60e45..1e980dce827724 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ aiotedee==0.3.0 aiotractive==1.0.1 # homeassistant.components.unifi -aiounifi==88 +aiounifi==90 # homeassistant.components.usb aiousbwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e4cd36335a834..bf9fb7b6a9e963 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -413,7 +413,7 @@ aiotedee==0.3.0 aiotractive==1.0.1 # homeassistant.components.unifi -aiounifi==88 +aiounifi==90 # homeassistant.components.usb aiousbwatcher==1.1.1 From b056723b986ed9b0220587b5e865b9ea8abbeb7d Mon Sep 17 00:00:00 2001 From: Artem Khvastunov Date: Wed, 1 Apr 2026 17:42:17 +0200 Subject: [PATCH 0329/1707] Add multi-plane support for Forecast.Solar integration (#160058) Co-authored-by: Junie Co-authored-by: Junie Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/forecast_solar/__init__.py | 67 +++- .../components/forecast_solar/config_flow.py | 256 +++++++++++--- .../components/forecast_solar/const.py | 6 + .../components/forecast_solar/coordinator.py | 32 +- .../components/forecast_solar/diagnostics.py | 7 + .../components/forecast_solar/strings.json | 45 ++- tests/components/forecast_solar/conftest.py | 43 ++- .../snapshots/test_diagnostics.ambr | 13 +- .../forecast_solar/snapshots/test_init.ambr | 32 -- .../forecast_solar/test_config_flow.py | 321 ++++++++++++++++-- .../components/forecast_solar/test_energy.py | 5 + tests/components/forecast_solar/test_init.py | 230 ++++++++++++- 12 files changed, 908 insertions(+), 149 deletions(-) delete mode 100644 tests/components/forecast_solar/snapshots/test_init.ambr diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 7b534b805005a0..a684b766b61a82 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -2,14 +2,26 @@ from __future__ import annotations -from homeassistant.const import Platform +from types import MappingProxyType + +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError from .const import ( + CONF_AZIMUTH, CONF_DAMPING, CONF_DAMPING_EVENING, CONF_DAMPING_MORNING, + CONF_DECLINATION, CONF_MODULES_POWER, + DEFAULT_AZIMUTH, + DEFAULT_DAMPING, + DEFAULT_DECLINATION, + DEFAULT_MODULES_POWER, + DOMAIN, + SUBENTRY_TYPE_PLANE, ) from .coordinator import ForecastSolarConfigEntry, ForecastSolarDataUpdateCoordinator @@ -25,14 +37,41 @@ async def async_migrate_entry( new_options = entry.options.copy() new_options |= { CONF_MODULES_POWER: new_options.pop("modules power"), - CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, 0.0), - CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, 0.0), + CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, DEFAULT_DAMPING), + CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, DEFAULT_DAMPING), } hass.config_entries.async_update_entry( entry, data=entry.data, options=new_options, version=2 ) + if entry.version == 2: + # Migrate the main plane from options to a subentry + declination = entry.options.get(CONF_DECLINATION, DEFAULT_DECLINATION) + azimuth = entry.options.get(CONF_AZIMUTH, DEFAULT_AZIMUTH) + modules_power = entry.options.get(CONF_MODULES_POWER, DEFAULT_MODULES_POWER) + + subentry = ConfigSubentry( + data=MappingProxyType( + { + CONF_DECLINATION: declination, + CONF_AZIMUTH: azimuth, + CONF_MODULES_POWER: modules_power, + } + ), + subentry_type=SUBENTRY_TYPE_PLANE, + title=f"{declination}° / {azimuth}° / {modules_power}W", + unique_id=None, + ) + hass.config_entries.async_add_subentry(entry, subentry) + + new_options = dict(entry.options) + new_options.pop(CONF_DECLINATION, None) + new_options.pop(CONF_AZIMUTH, None) + new_options.pop(CONF_MODULES_POWER, None) + + hass.config_entries.async_update_entry(entry, options=new_options, version=3) + return True @@ -40,6 +79,19 @@ async def async_setup_entry( hass: HomeAssistant, entry: ForecastSolarConfigEntry ) -> bool: """Set up Forecast.Solar from a config entry.""" + plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + if not plane_subentries: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="no_plane", + ) + + if len(plane_subentries) > 1 and not entry.options.get(CONF_API_KEY): + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="api_key_required", + ) + coordinator = ForecastSolarDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -47,9 +99,18 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True +async def _async_update_listener( + hass: HomeAssistant, entry: ForecastSolarConfigEntry +) -> None: + """Handle config entry updates (options or subentry changes).""" + hass.config_entries.async_schedule_reload(entry.entry_id) + + async def async_unload_entry( hass: HomeAssistant, entry: ForecastSolarConfigEntry ) -> bool: diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 031764a0d0a3f5..7fb1bde2d5a439 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -11,11 +11,13 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithReload, + ConfigSubentryFlow, + OptionsFlow, + SubentryFlowResult, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from .const import ( CONF_AZIMUTH, @@ -24,16 +26,51 @@ CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, + DEFAULT_AZIMUTH, + DEFAULT_DAMPING, + DEFAULT_DECLINATION, + DEFAULT_MODULES_POWER, DOMAIN, + MAX_PLANES, + SUBENTRY_TYPE_PLANE, ) RE_API_KEY = re.compile(r"^[a-zA-Z0-9]{16}$") +PLANE_SCHEMA = vol.Schema( + { + vol.Required(CONF_DECLINATION): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=90, step=1, mode=selector.NumberSelectorMode.BOX + ), + ), + vol.Coerce(int), + ), + vol.Required(CONF_AZIMUTH): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=360, step=1, mode=selector.NumberSelectorMode.BOX + ), + ), + vol.Coerce(int), + ), + vol.Required(CONF_MODULES_POWER): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, step=1, mode=selector.NumberSelectorMode.BOX + ), + ), + vol.Coerce(int), + ), + } +) + class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Forecast.Solar.""" - VERSION = 2 + VERSION = 3 @staticmethod @callback @@ -43,6 +80,14 @@ def async_get_options_flow( """Get the options flow for this handler.""" return ForecastSolarOptionFlowHandler() + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {SUBENTRY_TYPE_PLANE: PlaneSubentryFlowHandler} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -54,94 +99,112 @@ async def async_step_user( CONF_LATITUDE: user_input[CONF_LATITUDE], CONF_LONGITUDE: user_input[CONF_LONGITUDE], }, - options={ - CONF_AZIMUTH: user_input[CONF_AZIMUTH], - CONF_DECLINATION: user_input[CONF_DECLINATION], - CONF_MODULES_POWER: user_input[CONF_MODULES_POWER], - }, + subentries=[ + { + "subentry_type": SUBENTRY_TYPE_PLANE, + "data": { + CONF_DECLINATION: user_input[CONF_DECLINATION], + CONF_AZIMUTH: user_input[CONF_AZIMUTH], + CONF_MODULES_POWER: user_input[CONF_MODULES_POWER], + }, + "title": f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W", + "unique_id": None, + }, + ], ) return self.async_show_form( step_id="user", - data_schema=vol.Schema( + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + } + ).extend(PLANE_SCHEMA.schema), { - vol.Required( - CONF_NAME, default=self.hass.config.location_name - ): str, - vol.Required( - CONF_LATITUDE, default=self.hass.config.latitude - ): cv.latitude, - vol.Required( - CONF_LONGITUDE, default=self.hass.config.longitude - ): cv.longitude, - vol.Required(CONF_DECLINATION, default=25): vol.All( - vol.Coerce(int), vol.Range(min=0, max=90) - ), - vol.Required(CONF_AZIMUTH, default=180): vol.All( - vol.Coerce(int), vol.Range(min=0, max=360) - ), - vol.Required(CONF_MODULES_POWER): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - } + CONF_NAME: self.hass.config.location_name, + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_DECLINATION: DEFAULT_DECLINATION, + CONF_AZIMUTH: DEFAULT_AZIMUTH, + CONF_MODULES_POWER: DEFAULT_MODULES_POWER, + }, ), ) -class ForecastSolarOptionFlowHandler(OptionsFlowWithReload): +class ForecastSolarOptionFlowHandler(OptionsFlow): """Handle options.""" async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" - errors = {} + errors: dict[str, str] = {} + planes_count = len( + self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + ) + if user_input is not None: - if (api_key := user_input.get(CONF_API_KEY)) and RE_API_KEY.match( - api_key - ) is None: + api_key = user_input.get(CONF_API_KEY) + if planes_count > 1 and not api_key: + errors[CONF_API_KEY] = "api_key_required" + elif api_key and RE_API_KEY.match(api_key) is None: errors[CONF_API_KEY] = "invalid_api_key" else: return self.async_create_entry( title="", data=user_input | {CONF_API_KEY: api_key or None} ) + suggested_api_key = self.config_entry.options.get(CONF_API_KEY, "") + return self.async_show_form( step_id="init", data_schema=vol.Schema( { - vol.Optional( + vol.Required( CONF_API_KEY, - description={ - "suggested_value": self.config_entry.options.get( - CONF_API_KEY, "" - ) - }, + default=suggested_api_key, + ) + if planes_count > 1 + else vol.Optional( + CONF_API_KEY, + description={"suggested_value": suggested_api_key}, ): str, - vol.Required( - CONF_DECLINATION, - default=self.config_entry.options[CONF_DECLINATION], - ): vol.All(vol.Coerce(int), vol.Range(min=0, max=90)), - vol.Required( - CONF_AZIMUTH, - default=self.config_entry.options.get(CONF_AZIMUTH), - ): vol.All(vol.Coerce(int), vol.Range(min=-0, max=360)), - vol.Required( - CONF_MODULES_POWER, - default=self.config_entry.options[CONF_MODULES_POWER], - ): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional( CONF_DAMPING_MORNING, default=self.config_entry.options.get( - CONF_DAMPING_MORNING, 0.0 + CONF_DAMPING_MORNING, DEFAULT_DAMPING ), - ): vol.Coerce(float), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=1, + step=0.01, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Coerce(float), + ), vol.Optional( CONF_DAMPING_EVENING, default=self.config_entry.options.get( - CONF_DAMPING_EVENING, 0.0 + CONF_DAMPING_EVENING, DEFAULT_DAMPING ), - ): vol.Coerce(float), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=1, + step=0.01, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Coerce(float), + ), vol.Optional( CONF_INVERTER_SIZE, description={ @@ -149,8 +212,89 @@ async def async_step_init( CONF_INVERTER_SIZE ) }, - ): vol.Coerce(int), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + step=1, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Coerce(int), + ), } ), errors=errors, ) + + +class PlaneSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for adding/editing a plane.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the user step to add a new plane.""" + entry = self._get_entry() + planes_count = len(entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)) + if planes_count >= MAX_PLANES: + return self.async_abort(reason="max_planes") + if planes_count >= 1 and not entry.options.get(CONF_API_KEY): + return self.async_abort(reason="api_key_required") + + if user_input is not None: + return self.async_create_entry( + title=f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W", + data={ + CONF_DECLINATION: user_input[CONF_DECLINATION], + CONF_AZIMUTH: user_input[CONF_AZIMUTH], + CONF_MODULES_POWER: user_input[CONF_MODULES_POWER], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + PLANE_SCHEMA, + { + CONF_DECLINATION: DEFAULT_DECLINATION, + CONF_AZIMUTH: DEFAULT_AZIMUTH, + CONF_MODULES_POWER: DEFAULT_MODULES_POWER, + }, + ), + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of an existing plane.""" + subentry = self._get_reconfigure_subentry() + + if user_input is not None: + entry = self._get_entry() + if self._async_update( + entry, + subentry, + data={ + CONF_DECLINATION: user_input[CONF_DECLINATION], + CONF_AZIMUTH: user_input[CONF_AZIMUTH], + CONF_MODULES_POWER: user_input[CONF_MODULES_POWER], + }, + title=f"{user_input[CONF_DECLINATION]}° / {user_input[CONF_AZIMUTH]}° / {user_input[CONF_MODULES_POWER]}W", + ): + if not entry.update_listeners: + self.hass.config_entries.async_schedule_reload(entry.entry_id) + + return self.async_abort(reason="reconfigure_successful") + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + PLANE_SCHEMA, + { + CONF_DECLINATION: subentry.data[CONF_DECLINATION], + CONF_AZIMUTH: subentry.data[CONF_AZIMUTH], + CONF_MODULES_POWER: subentry.data[CONF_MODULES_POWER], + }, + ), + ) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index ac80b64b869928..22d0794ba7ee81 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -14,3 +14,9 @@ CONF_DAMPING_MORNING = "damping_morning" CONF_DAMPING_EVENING = "damping_evening" CONF_INVERTER_SIZE = "inverter_size" +DEFAULT_DECLINATION = 25 +DEFAULT_AZIMUTH = 180 +DEFAULT_MODULES_POWER = 10000 +DEFAULT_DAMPING = 0.0 +MAX_PLANES = 4 +SUBENTRY_TYPE_PLANE = "plane" diff --git a/homeassistant/components/forecast_solar/coordinator.py b/homeassistant/components/forecast_solar/coordinator.py index efed954e4900f1..65e699c8f38f2e 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -4,7 +4,7 @@ from datetime import timedelta -from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError +from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError, Plane from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE @@ -19,8 +19,10 @@ CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, + DEFAULT_DAMPING, DOMAIN, LOGGER, + SUBENTRY_TYPE_PLANE, ) type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] @@ -30,6 +32,7 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): """The Forecast.Solar Data Update Coordinator.""" config_entry: ForecastSolarConfigEntry + forecast: ForecastSolar def __init__(self, hass: HomeAssistant, entry: ForecastSolarConfigEntry) -> None: """Initialize the Forecast.Solar coordinator.""" @@ -43,17 +46,34 @@ def __init__(self, hass: HomeAssistant, entry: ForecastSolarConfigEntry) -> None ) is not None and inverter_size > 0: inverter_size = inverter_size / 1000 + # Build the list of planes from subentries. + plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + + # The first plane subentry is the main plane + main_plane = plane_subentries[0] + + # Additional planes + planes: list[Plane] = [ + Plane( + declination=subentry.data[CONF_DECLINATION], + azimuth=(subentry.data[CONF_AZIMUTH] - 180), + kwp=(subentry.data[CONF_MODULES_POWER] / 1000), + ) + for subentry in plane_subentries[1:] + ] + self.forecast = ForecastSolar( api_key=api_key, session=async_get_clientsession(hass), latitude=entry.data[CONF_LATITUDE], longitude=entry.data[CONF_LONGITUDE], - declination=entry.options[CONF_DECLINATION], - azimuth=(entry.options[CONF_AZIMUTH] - 180), - kwp=(entry.options[CONF_MODULES_POWER] / 1000), - damping_morning=entry.options.get(CONF_DAMPING_MORNING, 0.0), - damping_evening=entry.options.get(CONF_DAMPING_EVENING, 0.0), + declination=main_plane.data[CONF_DECLINATION], + azimuth=(main_plane.data[CONF_AZIMUTH] - 180), + kwp=(main_plane.data[CONF_MODULES_POWER] / 1000), + damping_morning=entry.options.get(CONF_DAMPING_MORNING, DEFAULT_DAMPING), + damping_evening=entry.options.get(CONF_DAMPING_EVENING, DEFAULT_DAMPING), inverter=inverter_size, + planes=planes, ) # Free account have a resolution of 1 hour, using that as the default diff --git a/homeassistant/components/forecast_solar/diagnostics.py b/homeassistant/components/forecast_solar/diagnostics.py index cb33ac5dc5a982..80e412dd1a8c7d 100644 --- a/homeassistant/components/forecast_solar/diagnostics.py +++ b/homeassistant/components/forecast_solar/diagnostics.py @@ -28,6 +28,13 @@ async def async_get_config_entry_diagnostics( "title": entry.title, "data": async_redact_data(entry.data, TO_REDACT), "options": async_redact_data(entry.options, TO_REDACT), + "subentries": [ + { + "data": dict(subentry.data), + "title": subentry.title, + } + for subentry in entry.subentries.values() + ], }, "data": { "energy_production_today": coordinator.data.energy_production_today, diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index b6cc406877fb93..3ed3f146a110ab 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -14,6 +14,37 @@ } } }, + "config_subentries": { + "plane": { + "abort": { + "api_key_required": "An API key is required to add more than one plane. You can configure it in the integration options.", + "max_planes": "You can add a maximum of 4 planes.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "entry_type": "Plane", + "initiate_flow": { + "user": "Add plane" + }, + "step": { + "reconfigure": { + "data": { + "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", + "declination": "[%key:component::forecast_solar::config::step::user::data::declination%]", + "modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" + }, + "description": "Edit the solar plane configuration." + }, + "user": { + "data": { + "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", + "declination": "[%key:component::forecast_solar::config::step::user::data::declination%]", + "modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" + }, + "description": "Add a solar plane. Multiple planes are supported with a Forecast.Solar API subscription." + } + } + } + }, "entity": { "sensor": { "energy_current_hour": { @@ -51,20 +82,26 @@ } } }, + "exceptions": { + "api_key_required": { + "message": "An API key is required when more than one plane is configured" + }, + "no_plane": { + "message": "No plane configured, cannot set up Forecast.Solar" + } + }, "options": { "error": { + "api_key_required": "An API key is required to add more than one plane. You can configure it in the integration options.", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" }, "step": { "init": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", - "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", "damping_evening": "Damping factor: adjusts the results in the evening", "damping_morning": "Damping factor: adjusts the results in the morning", - "declination": "[%key:component::forecast_solar::config::step::user::data::declination%]", - "inverter_size": "Inverter size (Watt)", - "modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" + "inverter_size": "Inverter size (Watt)" }, "description": "These values allow tweaking the Forecast.Solar result. Please refer to the documentation if a field is unclear." } diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 01c1f6d8d32691..e8852114f1f959 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -2,6 +2,7 @@ from collections.abc import Generator from datetime import datetime, timedelta +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from forecast_solar import models @@ -15,7 +16,9 @@ CONF_INVERTER_SIZE, CONF_MODULES_POWER, DOMAIN, + SUBENTRY_TYPE_PLANE, ) +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -33,26 +36,44 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def api_key_present() -> bool: + """Return whether an API key should be present in the config entry options.""" + return True + + +@pytest.fixture +def mock_config_entry(api_key_present: bool) -> MockConfigEntry: """Return the default mocked config entry.""" + options: dict[str, Any] = { + CONF_DAMPING_MORNING: 0.5, + CONF_DAMPING_EVENING: 0.5, + CONF_INVERTER_SIZE: 2000, + } + if api_key_present: + options[CONF_API_KEY] = "abcdef1234567890" return MockConfigEntry( title="Green House", unique_id="unique", - version=2, + version=3, domain=DOMAIN, data={ CONF_LATITUDE: 52.42, CONF_LONGITUDE: 4.42, }, - options={ - CONF_API_KEY: "abcdef12345", - CONF_DECLINATION: 30, - CONF_AZIMUTH: 190, - CONF_MODULES_POWER: 5100, - CONF_DAMPING_MORNING: 0.5, - CONF_DAMPING_EVENING: 0.5, - CONF_INVERTER_SIZE: 2000, - }, + options=options, + subentries_data=[ + ConfigSubentryData( + data={ + CONF_DECLINATION: 30, + CONF_AZIMUTH: 190, + CONF_MODULES_POWER: 5100, + }, + subentry_id="mock_plane_id", + subentry_type=SUBENTRY_TYPE_PLANE, + title="30° / 190° / 5100W", + unique_id=None, + ), + ], ) diff --git a/tests/components/forecast_solar/snapshots/test_diagnostics.ambr b/tests/components/forecast_solar/snapshots/test_diagnostics.ambr index 686721a9d4ae82..5dd7276e68b777 100644 --- a/tests/components/forecast_solar/snapshots/test_diagnostics.ambr +++ b/tests/components/forecast_solar/snapshots/test_diagnostics.ambr @@ -32,13 +32,20 @@ }), 'options': dict({ 'api_key': '**REDACTED**', - 'azimuth': 190, 'damping_evening': 0.5, 'damping_morning': 0.5, - 'declination': 30, 'inverter_size': 2000, - 'modules_power': 5100, }), + 'subentries': list([ + dict({ + 'data': dict({ + 'azimuth': 190, + 'declination': 30, + 'modules_power': 5100, + }), + 'title': '30° / 190° / 5100W', + }), + ]), 'title': 'Green House', }), }) diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr deleted file mode 100644 index c0db54c2d4e68a..00000000000000 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ /dev/null @@ -1,32 +0,0 @@ -# serializer version: 1 -# name: test_migration - ConfigEntrySnapshot({ - 'data': dict({ - 'latitude': 52.42, - 'longitude': 4.42, - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'forecast_solar', - 'entry_id': , - 'minor_version': 1, - 'options': dict({ - 'api_key': 'abcdef12345', - 'azimuth': 190, - 'damping_evening': 0.5, - 'damping_morning': 0.5, - 'declination': 30, - 'inverter_size': 2000, - 'modules_power': 5100, - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'subentries': list([ - ]), - 'title': 'Green House', - 'unique_id': 'unique', - 'version': 2, - }) -# --- diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py index 8fffb5096bcda6..d560fe0dc16960 100644 --- a/tests/components/forecast_solar/test_config_flow.py +++ b/tests/components/forecast_solar/test_config_flow.py @@ -12,8 +12,13 @@ CONF_INVERTER_SIZE, CONF_MODULES_POWER, DOMAIN, + SUBENTRY_TYPE_PLANE, +) +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigSubentryData, ) -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -51,11 +56,19 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No CONF_LATITUDE: 52.42, CONF_LONGITUDE: 4.42, } - assert config_entry.options == { - CONF_AZIMUTH: 142, + assert config_entry.options == {} + + # Verify a plane subentry was created + plane_subentries = config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + assert len(plane_subentries) == 1 + subentry = plane_subentries[0] + assert subentry.subentry_type == SUBENTRY_TYPE_PLANE + assert subentry.data == { CONF_DECLINATION: 42, + CONF_AZIMUTH: 142, CONF_MODULES_POWER: 4242, } + assert subentry.title == "42° / 142° / 4242W" assert len(mock_setup_entry.mock_calls) == 1 @@ -79,15 +92,11 @@ async def test_options_flow_invalid_api( result["flow_id"], user_input={ CONF_API_KEY: "solarPOWER!", - CONF_DECLINATION: 21, - CONF_AZIMUTH: 22, - CONF_MODULES_POWER: 2122, CONF_DAMPING_MORNING: 0.25, CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} @@ -97,22 +106,15 @@ async def test_options_flow_invalid_api( result["flow_id"], user_input={ CONF_API_KEY: "SolarForecast150", - CONF_DECLINATION: 21, - CONF_AZIMUTH: 22, - CONF_MODULES_POWER: 2122, CONF_DAMPING_MORNING: 0.25, CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_API_KEY: "SolarForecast150", - CONF_DECLINATION: 21, - CONF_AZIMUTH: 22, - CONF_MODULES_POWER: 2122, CONF_DAMPING_MORNING: 0.25, CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, @@ -139,22 +141,15 @@ async def test_options_flow( result["flow_id"], user_input={ CONF_API_KEY: "SolarForecast150", - CONF_DECLINATION: 21, - CONF_AZIMUTH: 22, - CONF_MODULES_POWER: 2122, CONF_DAMPING_MORNING: 0.25, CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_API_KEY: "SolarForecast150", - CONF_DECLINATION: 21, - CONF_AZIMUTH: 22, - CONF_MODULES_POWER: 2122, CONF_DAMPING_MORNING: 0.25, CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, @@ -180,23 +175,293 @@ async def test_options_flow_without_key( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - CONF_DECLINATION: 21, - CONF_AZIMUTH: 22, - CONF_MODULES_POWER: 2122, CONF_DAMPING_MORNING: 0.25, CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_API_KEY: None, - CONF_DECLINATION: 21, - CONF_AZIMUTH: 22, - CONF_MODULES_POWER: 2122, CONF_DAMPING_MORNING: 0.25, CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_options_flow_required_api_key( + hass: HomeAssistant, +) -> None: + """Test config flow options requires API key when multiple planes are present.""" + mock_config_entry = MockConfigEntry( + title="Green House", + unique_id="unique", + version=3, + domain=DOMAIN, + data={ + CONF_LATITUDE: 52.42, + CONF_LONGITUDE: 4.42, + }, + options={ + CONF_DAMPING_MORNING: 0.5, + CONF_DAMPING_EVENING: 0.5, + CONF_INVERTER_SIZE: 2000, + CONF_API_KEY: "abcdef1234567890", + }, + subentries_data=[ + ConfigSubentryData( + data={ + CONF_DECLINATION: 30, + CONF_AZIMUTH: 190, + CONF_MODULES_POWER: 5100, + }, + subentry_id="mock_plane_id", + subentry_type=SUBENTRY_TYPE_PLANE, + title="30° / 190° / 5100W", + unique_id=None, + ), + ConfigSubentryData( + data={ + CONF_DECLINATION: 45, + CONF_AZIMUTH: 270, + CONF_MODULES_POWER: 3000, + }, + subentry_id="second_plane_id", + subentry_type=SUBENTRY_TYPE_PLANE, + title="45° / 270° / 3000W", + unique_id=None, + ), + ], + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Try to save with an empty API key + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "", + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, + CONF_INVERTER_SIZE: 2000, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_API_KEY: "api_key_required"} + + # Now provide an API key + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "SolarForecast150", + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, + CONF_INVERTER_SIZE: 2000, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_API_KEY: "SolarForecast150", + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, + CONF_INVERTER_SIZE: 2000, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_subentry_flow_add_plane( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test adding a plane via subentry flow.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + CONF_DECLINATION: 45, + CONF_AZIMUTH: 270, + CONF_MODULES_POWER: 3000, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "45° / 270° / 3000W" + assert result["data"] == { + CONF_DECLINATION: 45, + CONF_AZIMUTH: 270, + CONF_MODULES_POWER: 3000, + } + + assert len(mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)) == 2 + + +@pytest.mark.usefixtures("mock_forecast_solar") +async def test_subentry_flow_reconfigure_plane( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring a plane via subentry flow.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get the existing plane subentry id + subentry_id = mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)[ + 0 + ].subentry_id + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE), + context={"source": SOURCE_RECONFIGURE, "subentry_id": subentry_id}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + CONF_DECLINATION: 50, + CONF_AZIMUTH: 200, + CONF_MODULES_POWER: 6000, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + plane_subentries = mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + assert len(plane_subentries) == 1 + subentry = plane_subentries[0] + assert subentry.data == { + CONF_DECLINATION: 50, + CONF_AZIMUTH: 200, + CONF_MODULES_POWER: 6000, + } + assert subentry.title == "50° / 200° / 6000W" + + +@pytest.mark.parametrize("api_key_present", [False]) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_subentry_flow_no_api_key( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that adding more than one plane without API key is not allowed.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_key_required" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_subentry_flow_max_planes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that adding more than 4 planes is not allowed.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # mock_config_entry already has 1 plane subentry; add 3 more to reach the limit + for i in range(3): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + CONF_DECLINATION: 10 * (i + 1), + CONF_AZIMUTH: 90 * (i + 1), + CONF_MODULES_POWER: 1000 * (i + 1), + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert len(mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)) == 4 + + # Attempt to add a 5th plane should be aborted + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "max_planes" + + +async def test_subentry_flow_reconfigure_plane_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring a plane via subentry flow when entry is not loaded.""" + mock_config_entry.add_to_hass(hass) + # Entry is not loaded, so it has no update listeners + + # Get the existing plane subentry id + subentry_id = mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)[ + 0 + ].subentry_id + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PLANE), + context={"source": SOURCE_RECONFIGURE, "subentry_id": subentry_id}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + CONF_DECLINATION: 50, + CONF_AZIMUTH: 200, + CONF_MODULES_POWER: 6000, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + plane_subentries = mock_config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + assert len(plane_subentries) == 1 + subentry = plane_subentries[0] + assert subentry.data == { + CONF_DECLINATION: 50, + CONF_AZIMUTH: 200, + CONF_MODULES_POWER: 6000, + } + assert subentry.title == "50° / 200° / 6000W" diff --git a/tests/components/forecast_solar/test_energy.py b/tests/components/forecast_solar/test_energy.py index dc0b5c574308be..813e3f84eebb0b 100644 --- a/tests/components/forecast_solar/test_energy.py +++ b/tests/components/forecast_solar/test_energy.py @@ -65,3 +65,8 @@ async def test_energy_solar_forecast_filters_midnight_utc_zeros( "2021-06-27T15:00:00+00:00": 292, } } + + +async def test_energy_solar_forecast_invalid_id(hass: HomeAssistant) -> None: + """Test the Forecast.Solar energy platform with invalid config entry ID.""" + assert await energy.async_get_solar_forecast(hass, "invalid_id") is None diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index 680a30580cb5e1..50f87015ad68c8 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -2,17 +2,20 @@ from unittest.mock import MagicMock, patch -from forecast_solar import ForecastSolarConnectionError -from syrupy.assertion import SnapshotAssertion +from forecast_solar import ForecastSolarConnectionError, Plane from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, CONF_DECLINATION, CONF_INVERTER_SIZE, + CONF_MODULES_POWER, DOMAIN, + SUBENTRY_TYPE_PLANE, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -55,12 +58,16 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_migration(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: - """Test config entry version 1 -> 2 migration.""" +async def test_migration_from_v1( + hass: HomeAssistant, + mock_forecast_solar: MagicMock, +) -> None: + """Test config entry migration from version 1.""" mock_config_entry = MockConfigEntry( title="Green House", unique_id="unique", domain=DOMAIN, + version=1, data={ CONF_LATITUDE: 52.42, CONF_LONGITUDE: 4.42, @@ -78,4 +85,215 @@ async def test_migration(hass: HomeAssistant, snapshot: SnapshotAssertion) -> No await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) == snapshot + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry.version == 3 + assert entry.options == { + CONF_API_KEY: "abcdef12345", + "damping_morning": 0.5, + "damping_evening": 0.5, + CONF_INVERTER_SIZE: 2000, + } + plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + assert len(plane_subentries) == 1 + subentry = plane_subentries[0] + assert subentry.subentry_type == SUBENTRY_TYPE_PLANE + assert subentry.data == { + CONF_DECLINATION: 30, + CONF_AZIMUTH: 190, + CONF_MODULES_POWER: 5100, + } + assert subentry.title == "30° / 190° / 5100W" + + +async def test_migration_from_v2( + hass: HomeAssistant, + mock_forecast_solar: MagicMock, +) -> None: + """Test config entry migration from version 2.""" + mock_config_entry = MockConfigEntry( + title="Green House", + unique_id="unique", + domain=DOMAIN, + version=2, + data={ + CONF_LATITUDE: 52.42, + CONF_LONGITUDE: 4.42, + }, + options={ + CONF_API_KEY: "abcdef12345", + CONF_DECLINATION: 30, + CONF_AZIMUTH: 190, + CONF_MODULES_POWER: 5100, + CONF_INVERTER_SIZE: 2000, + }, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry.version == 3 + assert entry.options == { + CONF_API_KEY: "abcdef12345", + CONF_INVERTER_SIZE: 2000, + } + plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + assert len(plane_subentries) == 1 + subentry = plane_subentries[0] + assert subentry.subentry_type == SUBENTRY_TYPE_PLANE + assert subentry.data == { + CONF_DECLINATION: 30, + CONF_AZIMUTH: 190, + CONF_MODULES_POWER: 5100, + } + assert subentry.title == "30° / 190° / 5100W" + + +async def test_setup_entry_no_planes( + hass: HomeAssistant, + mock_forecast_solar: MagicMock, +) -> None: + """Test setup fails when all plane subentries have been removed.""" + mock_config_entry = MockConfigEntry( + title="Green House", + unique_id="unique", + version=3, + domain=DOMAIN, + data={ + CONF_LATITUDE: 52.42, + CONF_LONGITUDE: 4.42, + }, + options={ + CONF_API_KEY: "abcdef1234567890", + }, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_multiple_planes_no_api_key( + hass: HomeAssistant, + mock_forecast_solar: MagicMock, +) -> None: + """Test setup fails when multiple planes are configured without an API key.""" + mock_config_entry = MockConfigEntry( + title="Green House", + unique_id="unique", + version=3, + domain=DOMAIN, + data={ + CONF_LATITUDE: 52.42, + CONF_LONGITUDE: 4.42, + }, + options={}, + subentries_data=[ + ConfigSubentryData( + data={ + CONF_DECLINATION: 30, + CONF_AZIMUTH: 190, + CONF_MODULES_POWER: 5100, + }, + subentry_id="plane_1", + subentry_type=SUBENTRY_TYPE_PLANE, + title="30° / 190° / 5100W", + unique_id=None, + ), + ConfigSubentryData( + data={ + CONF_DECLINATION: 45, + CONF_AZIMUTH: 90, + CONF_MODULES_POWER: 3000, + }, + subentry_id="plane_2", + subentry_type=SUBENTRY_TYPE_PLANE, + title="45° / 90° / 3000W", + unique_id=None, + ), + ], + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_coordinator_multi_plane_initialization( + hass: HomeAssistant, + mock_forecast_solar: MagicMock, +) -> None: + """Test the Forecast.Solar coordinator multi-plane initialization.""" + options = { + CONF_API_KEY: "abcdef1234567890", + CONF_DAMPING_MORNING: 0.5, + CONF_DAMPING_EVENING: 0.5, + CONF_INVERTER_SIZE: 2000, + } + + mock_config_entry = MockConfigEntry( + title="Green House", + unique_id="unique", + version=3, + domain=DOMAIN, + data={ + CONF_LATITUDE: 52.42, + CONF_LONGITUDE: 4.42, + }, + options=options, + subentries_data=[ + ConfigSubentryData( + data={ + CONF_DECLINATION: 30, + CONF_AZIMUTH: 190, + CONF_MODULES_POWER: 5100, + }, + subentry_id="plane_1", + subentry_type=SUBENTRY_TYPE_PLANE, + title="30° / 190° / 5100W", + unique_id=None, + ), + ConfigSubentryData( + data={ + CONF_DECLINATION: 45, + CONF_AZIMUTH: 270, + CONF_MODULES_POWER: 3000, + }, + subentry_id="plane_2", + subentry_type=SUBENTRY_TYPE_PLANE, + title="45° / 270° / 3000W", + unique_id=None, + ), + ], + ) + + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.forecast_solar.coordinator.ForecastSolar", + return_value=mock_forecast_solar, + ) as forecast_solar_mock: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + forecast_solar_mock.assert_called_once() + _, kwargs = forecast_solar_mock.call_args + + assert kwargs["latitude"] == 52.42 + assert kwargs["longitude"] == 4.42 + assert kwargs["api_key"] == "abcdef1234567890" + + # Main plane (plane_1) + assert kwargs["declination"] == 30 + assert kwargs["azimuth"] == 10 # 190 - 180 + assert kwargs["kwp"] == 5.1 # 5100 / 1000 + + # Additional planes (plane_2) + planes = kwargs["planes"] + assert len(planes) == 1 + assert isinstance(planes[0], Plane) + assert planes[0].declination == 45 + assert planes[0].azimuth == 90 # 270 - 180 + assert planes[0].kwp == 3.0 # 3000 / 1000 From dc00fcaf60d9a7b6002377c1da6eecd54c497f5b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 2 Apr 2026 01:47:12 +1000 Subject: [PATCH 0330/1707] Fix Tesla Fleet OAuth scope refresh during reauth (#166920) --- homeassistant/components/tesla_fleet/oauth.py | 6 +++++- homeassistant/components/tesla_fleet/strings.json | 4 ++-- tests/components/tesla_fleet/test_config_flow.py | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tesla_fleet/oauth.py b/homeassistant/components/tesla_fleet/oauth.py index b25c52160097bd..93d8e792e92440 100644 --- a/homeassistant/components/tesla_fleet/oauth.py +++ b/homeassistant/components/tesla_fleet/oauth.py @@ -30,4 +30,8 @@ def __init__( @property def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - return {"prompt": "login", "scope": " ".join(SCOPES)} + return { + "prompt": "login", + "prompt_missing_scopes": "true", + "scope": " ".join(SCOPES), + } diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 14927768331cc8..3e36a827e5c9f7 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -50,7 +50,7 @@ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, "reauth_confirm": { - "description": "The {name} integration needs to re-authenticate your account", + "description": "The {name} integration needs to re-authenticate your account. Reauthentication refreshes the Tesla API permissions granted to Home Assistant, including any newly enabled scopes.", "title": "[%key:common::config_flow::title::reauth%]" }, "registration_complete": { @@ -60,7 +60,7 @@ "data_description": { "qr_code": "Scan this QR code with your phone to set up the virtual key." }, - "description": "To enable command signing, you must open the Tesla app, select your vehicle, and then visit the following URL to set up a virtual key. You must repeat this process for each vehicle.\n\n{virtual_key_url}", + "description": "To enable command signing, you must open the Tesla app, select your vehicle, and then visit the following URL to set up a virtual key. You must repeat this process for each vehicle.\n\n{virtual_key_url}\n\nIf you later enable additional Tesla API permissions, reauthenticate the integration to refresh the granted scopes.", "title": "Command signing" } } diff --git a/tests/components/tesla_fleet/test_config_flow.py b/tests/components/tesla_fleet/test_config_flow.py index c54c3f6c6555dc..9e8c9542c13f3f 100644 --- a/tests/components/tesla_fleet/test_config_flow.py +++ b/tests/components/tesla_fleet/test_config_flow.py @@ -233,6 +233,7 @@ async def test_full_flow_with_domain_registration( assert parsed_query["client_id"][0] == "user_client_id" assert parsed_query["redirect_uri"][0] == REDIRECT assert parsed_query["state"][0] == state + assert parsed_query["prompt_missing_scopes"][0] == "true" assert parsed_query["scope"][0] == " ".join(SCOPES) assert "code_challenge" not in parsed_query From 49a8c73f724a289715e4d6a256d57dea84201ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Farkasdi?= <93778865+farkasdi@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:47:33 +0200 Subject: [PATCH 0331/1707] netatmo: NDB test addition and camera fix (#165375) --- homeassistant/components/netatmo/camera.py | 41 ++++++++++--------- homeassistant/components/netatmo/const.py | 12 +++++- .../netatmo/snapshots/test_camera.ambr | 6 +-- tests/components/netatmo/test_camera.py | 30 +++++++++----- 4 files changed, 56 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index b181ebb4af2200..9baca23cfa4f9b 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -34,6 +34,7 @@ EVENT_TYPE_OFF, EVENT_TYPE_ON, MANUFACTURER, + NETATMO_ALIM_STATUS_ONLINE, NETATMO_CREATE_CAMERA, SERVICE_SET_CAMERA_LIGHT, SERVICE_SET_PERSON_AWAY, @@ -174,18 +175,16 @@ def handle_event(self, event: dict) -> None: self._monitoring = False elif event_type in [EVENT_TYPE_CONNECTION, EVENT_TYPE_ON]: _LOGGER.debug( - "Camera %s has received %s event, turning on and enabling streaming", + "Camera %s has received %s event, turning on and enabling streaming if applicable", data["camera_id"], event_type, ) - self._attr_is_streaming = True + if self.device_type != "NDB": + self._attr_is_streaming = True self._monitoring = True elif event_type == EVENT_TYPE_LIGHT_MODE: if data.get("sub_type"): self._light_state = data["sub_type"] - self._attr_extra_state_attributes.update( - {"light_state": self._light_state} - ) else: _LOGGER.debug( "Camera %s has received light mode event without sub_type", @@ -225,6 +224,20 @@ def supported_features(self) -> CameraEntityFeature: supported_features |= CameraEntityFeature.STREAM return supported_features + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return entity specific state attributes.""" + return { + "id": self.device.entity_id, + "monitoring": self._monitoring, + "sd_status": self.device.sd_status, + "alim_status": self.device.alim_status, + "is_local": self.device.is_local, + "vpn_url": self.device.vpn_url, + "local_url": self.device.local_url, + "light_state": self._light_state, + } + async def async_turn_off(self) -> None: """Turn off camera.""" await self.device.async_monitoring_off() @@ -248,7 +261,10 @@ def async_update_callback(self) -> None: self._attr_is_on = self.device.alim_status is not None self._attr_available = self.device.alim_status is not None - if self.device.monitoring is not None: + if self.device_type == "NDB": + self._monitoring = self.device.alim_status == NETATMO_ALIM_STATUS_ONLINE + elif self.device.monitoring is not None: + self._monitoring = self.device.monitoring self._attr_is_streaming = self.device.monitoring self._attr_motion_detection_enabled = self.device.monitoring @@ -256,19 +272,6 @@ def async_update_callback(self) -> None: self.process_events(self.device.events) ) - self._attr_extra_state_attributes.update( - { - "id": self.device.entity_id, - "monitoring": self._monitoring, - "sd_status": self.device.sd_status, - "alim_status": self.device.alim_status, - "is_local": self.device.is_local, - "vpn_url": self.device.vpn_url, - "local_url": self.device.local_url, - "light_state": self._light_state, - } - ) - def process_events(self, event_list: list[NaEvent]) -> dict: """Add meta data to events.""" events = {} diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 35ef790684a7ae..e8812407e4794f 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -215,5 +215,15 @@ WEBHOOK_DEACTIVATION = "webhook_deactivation" WEBHOOK_NACAMERA_CONNECTION = "NACamera-connection" WEBHOOK_NOCAMERA_CONNECTION = "NOC-connection" +WEBHOOK_NDB_CONNECTION = "NDB-connection" WEBHOOK_PUSH_TYPE = "push_type" -CAMERA_CONNECTION_WEBHOOKS = [WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_NOCAMERA_CONNECTION] +CAMERA_CONNECTION_WEBHOOKS = [ + WEBHOOK_NACAMERA_CONNECTION, + WEBHOOK_NOCAMERA_CONNECTION, + WEBHOOK_NDB_CONNECTION, +] + +# Alimentation status (alim_status) for cameras and door bells (NDB). +# For NDB there is no monitoring attribute in status but only alim_status. +# 2 = Full power/online for NDB (and also Correct power adapter for NACamera). +NETATMO_ALIM_STATUS_ONLINE = 2 diff --git a/tests/components/netatmo/snapshots/test_camera.ambr b/tests/components/netatmo/snapshots/test_camera.ambr index c7fef117359a1e..72fb74ea051b76 100644 --- a/tests/components/netatmo/snapshots/test_camera.ambr +++ b/tests/components/netatmo/snapshots/test_camera.ambr @@ -49,7 +49,7 @@ 'is_local': False, 'light_state': None, 'local_url': None, - 'monitoring': None, + 'monitoring': True, 'motion_detection': True, 'sd_status': 4, 'supported_features': , @@ -113,7 +113,7 @@ 'is_local': True, 'light_state': None, 'local_url': 'http://192.168.0.123/678460a0d47e5618699fb31169e2b47d', - 'monitoring': None, + 'monitoring': True, 'motion_detection': True, 'sd_status': 4, 'supported_features': , @@ -177,7 +177,7 @@ 'is_local': None, 'light_state': None, 'local_url': None, - 'monitoring': None, + 'monitoring': True, 'sd_status': 4, 'supported_features': , 'vpn_url': 'https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,', diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index f301200e26bee3..686484a3e0e82d 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -54,10 +54,11 @@ async def test_entity( @pytest.mark.parametrize( - ("camera_type", "camera_id", "camera_entity"), + ("camera_type", "camera_id", "camera_entity", "expected_state"), [ - ("NACamera", "12:34:56:00:f1:62", "camera.hall"), - ("NOC", "12:34:56:10:b9:0e", "camera.front"), + ("NACamera", "12:34:56:00:f1:62", "camera.hall", "streaming"), + ("NOC", "12:34:56:10:b9:0e", "camera.front", "streaming"), + ("NDB", "12:34:56:10:f1:66", "camera.netatmo_doorbell", "idle"), ], ) async def test_setup_component_with_webhook( @@ -67,6 +68,7 @@ async def test_setup_component_with_webhook( camera_type: str, camera_id: str, camera_entity: str, + expected_state: str, ) -> None: """Test setup with webhook.""" with selected_platforms([Platform.CAMERA]): @@ -78,7 +80,8 @@ async def test_setup_component_with_webhook( await hass.async_block_till_done() # Test on/off camera events - assert hass.states.get(camera_entity).state == "streaming" + assert hass.states.get(camera_entity).state == expected_state + assert hass.states.get(camera_entity).attributes.get("monitoring") is True response = { "event_type": "off", "device_id": camera_id, @@ -89,6 +92,7 @@ async def test_setup_component_with_webhook( await simulate_webhook(hass, webhook_id, response) assert hass.states.get(camera_entity).state == "idle" + assert hass.states.get(camera_entity).attributes.get("monitoring") is False response = { "event_type": "on", @@ -99,7 +103,8 @@ async def test_setup_component_with_webhook( } await simulate_webhook(hass, webhook_id, response) - assert hass.states.get(camera_entity).state == "streaming" + assert hass.states.get(camera_entity).state == expected_state + assert hass.states.get(camera_entity).attributes.get("monitoring") is True # Test turn_on/turn_off services with patch("pyatmo.home.Home.async_set_state") as mock_set_state: @@ -441,10 +446,11 @@ async def test_service_set_camera_light_invalid_type( @pytest.mark.parametrize( - ("camera_type", "camera_id", "camera_entity"), + ("camera_type", "camera_id", "camera_entity", "expected_state"), [ - ("NACamera", "12:34:56:00:f1:62", "camera.hall"), - ("NOC", "12:34:56:10:b9:0e", "camera.front"), + ("NACamera", "12:34:56:00:f1:62", "camera.hall", "streaming"), + ("NOC", "12:34:56:10:b9:0e", "camera.front", "streaming"), + ("NDB", "12:34:56:10:f1:66", "camera.netatmo_doorbell", "idle"), ], ) async def test_camera_reconnect_webhook( @@ -453,6 +459,7 @@ async def test_camera_reconnect_webhook( camera_type: str, camera_id: str, camera_entity: str, + expected_state: str, ) -> None: """Test webhook event on camera reconnect.""" fake_post_hits = 0 @@ -511,7 +518,8 @@ async def fake_post(*args: Any, **kwargs: Any): assert fake_post_hits >= calls # Real camera disconnect - assert hass.states.get(camera_entity).state == "streaming" + assert hass.states.get(camera_entity).state == expected_state + assert hass.states.get(camera_entity).attributes.get("monitoring") is True response = { "event_type": "disconnection", "device_id": camera_id, @@ -522,6 +530,7 @@ async def fake_post(*args: Any, **kwargs: Any): await simulate_webhook(hass, webhook_id, response) assert hass.states.get(camera_entity).state == "idle" + assert hass.states.get(camera_entity).attributes.get("monitoring") is False response = { "event_type": "connection", @@ -532,7 +541,8 @@ async def fake_post(*args: Any, **kwargs: Any): } await simulate_webhook(hass, webhook_id, response) - assert hass.states.get(camera_entity).state == "streaming" + assert hass.states.get(camera_entity).state == expected_state + assert hass.states.get(camera_entity).attributes.get("monitoring") is True @pytest.mark.parametrize( From d680c72c7ccb93c46a3650b846d8f0d64eb19063 Mon Sep 17 00:00:00 2001 From: g4bri3lDev Date: Wed, 1 Apr 2026 18:01:26 +0200 Subject: [PATCH 0332/1707] Add sensor platform for OpenDisplay (#164998) Co-authored-by: Joost Lekkerkerker --- .../components/opendisplay/__init__.py | 35 ++- .../components/opendisplay/coordinator.py | 86 +++++++ .../components/opendisplay/entity.py | 31 +++ .../components/opendisplay/quality_scale.yaml | 48 +--- .../components/opendisplay/sensor.py | 106 ++++++++ .../components/opendisplay/strings.json | 7 + .../opendisplay/snapshots/test_sensor.ambr | 230 ++++++++++++++++++ tests/components/opendisplay/test_sensor.py | 226 +++++++++++++++++ 8 files changed, 724 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/opendisplay/coordinator.py create mode 100644 homeassistant/components/opendisplay/entity.py create mode 100644 homeassistant/components/opendisplay/sensor.py create mode 100644 tests/components/opendisplay/snapshots/test_sensor.ambr create mode 100644 tests/components/opendisplay/test_sensor.py diff --git a/homeassistant/components/opendisplay/__init__.py b/homeassistant/components/opendisplay/__init__.py index 53f161a6c70b4e..346f64b5c52729 100644 --- a/homeassistant/components/opendisplay/__init__.py +++ b/homeassistant/components/opendisplay/__init__.py @@ -17,6 +17,7 @@ from homeassistant.components.bluetooth import async_ble_device_from_address from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -27,15 +28,20 @@ from opendisplay.models import FirmwareVersion from .const import DOMAIN +from .coordinator import OpenDisplayCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_BASE_PLATFORMS: list[Platform] = [] +_FLEX_PLATFORMS = [Platform.SENSOR] + @dataclass class OpenDisplayRuntimeData: """Runtime data for an OpenDisplay config entry.""" + coordinator: OpenDisplayCoordinator firmware: FirmwareVersion device_config: GlobalConfig is_flex: bool @@ -77,13 +83,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) if TYPE_CHECKING: assert device_config is not None - entry.runtime_data = OpenDisplayRuntimeData( - firmware=fw, - device_config=device_config, - is_flex=is_flex, - ) + coordinator = OpenDisplayCoordinator(hass, address) - # Will be moved to DeviceInfo object in entity.py once entities are added manufacturer = device_config.manufacturer display = device_config.displays[0] color_scheme_enum = display.color_scheme_enum @@ -97,14 +98,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) if display.screen_diagonal_inches is not None else f"{display.pixel_width}x{display.pixel_height}" ) - dr.async_get(hass).async_get_or_create( config_entry_id=entry.entry_id, connections={(CONNECTION_BLUETOOTH, address)}, manufacturer=manufacturer.manufacturer_name, model=f"{size} {color_scheme}", sw_version=f"{fw['major']}.{fw['minor']}", - hw_version=f"{manufacturer.board_type_name or manufacturer.board_type} rev. {manufacturer.board_revision}" + hw_version=( + f"{manufacturer.board_type_name or manufacturer.board_type}" + f" rev. {manufacturer.board_revision}" + ) if is_flex else None, configuration_url="https://opendisplay.org/firmware/config/" @@ -112,6 +115,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) else None, ) + entry.runtime_data = OpenDisplayRuntimeData( + coordinator=coordinator, + firmware=fw, + device_config=device_config, + is_flex=is_flex, + ) + + await hass.config_entries.async_forward_entry_setups( + entry, _FLEX_PLATFORMS if is_flex else _BASE_PLATFORMS + ) + entry.async_on_unload(coordinator.async_start()) + return True @@ -124,4 +139,6 @@ async def async_unload_entry( with contextlib.suppress(asyncio.CancelledError): await task - return True + return await hass.config_entries.async_unload_platforms( + entry, _FLEX_PLATFORMS if entry.runtime_data.is_flex else _BASE_PLATFORMS + ) diff --git a/homeassistant/components/opendisplay/coordinator.py b/homeassistant/components/opendisplay/coordinator.py new file mode 100644 index 00000000000000..d7c9431c57f2ed --- /dev/null +++ b/homeassistant/components/opendisplay/coordinator.py @@ -0,0 +1,86 @@ +"""Passive BLE coordinator for OpenDisplay devices.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from opendisplay import MANUFACTURER_ID, parse_advertisement +from opendisplay.models.advertisement import AdvertisementData + +from homeassistant.components.bluetooth import ( + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) +from homeassistant.components.bluetooth.passive_update_coordinator import ( + PassiveBluetoothDataUpdateCoordinator, +) +from homeassistant.core import HomeAssistant, callback + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +@dataclass +class OpenDisplayUpdate: + """Parsed advertisement data for one OpenDisplay device.""" + + address: str + advertisement: AdvertisementData + + +class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator): + """Coordinator for passive BLE advertisement updates from an OpenDisplay device.""" + + def __init__(self, hass: HomeAssistant, address: str) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + address, + BluetoothScanningMode.PASSIVE, + connectable=True, + ) + self.data: OpenDisplayUpdate | None = None + + @callback + def _async_handle_unavailable( + self, service_info: BluetoothServiceInfoBleak + ) -> None: + """Handle the device going unavailable.""" + if self._available: + _LOGGER.info("%s: Device is unavailable", service_info.address) + super()._async_handle_unavailable(service_info) + + @callback + def _async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfoBleak, + change: BluetoothChange, + ) -> None: + """Handle a Bluetooth advertisement event.""" + if not self._available: + _LOGGER.info("%s: Device is available again", service_info.address) + + if MANUFACTURER_ID not in service_info.manufacturer_data: + super()._async_handle_bluetooth_event(service_info, change) + return + + try: + advertisement = parse_advertisement( + service_info.manufacturer_data[MANUFACTURER_ID] + ) + except ValueError as err: + _LOGGER.debug( + "%s: Failed to parse advertisement data: %s", + service_info.address, + err, + exc_info=True, + ) + else: + self.data = OpenDisplayUpdate( + address=service_info.address, + advertisement=advertisement, + ) + + super()._async_handle_bluetooth_event(service_info, change) diff --git a/homeassistant/components/opendisplay/entity.py b/homeassistant/components/opendisplay/entity.py new file mode 100644 index 00000000000000..863fdd7214cb2f --- /dev/null +++ b/homeassistant/components/opendisplay/entity.py @@ -0,0 +1,31 @@ +"""Base entity for OpenDisplay devices.""" + +from __future__ import annotations + +from homeassistant.components.bluetooth.passive_update_coordinator import ( + PassiveBluetoothCoordinatorEntity, +) +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.entity import EntityDescription + +from .coordinator import OpenDisplayCoordinator + + +class OpenDisplayEntity(PassiveBluetoothCoordinatorEntity[OpenDisplayCoordinator]): + """Base class for all OpenDisplay entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OpenDisplayCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.address}-{description.key}" + + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, coordinator.address)}, + ) diff --git a/homeassistant/components/opendisplay/quality_scale.yaml b/homeassistant/components/opendisplay/quality_scale.yaml index 720ec101aac442..07239f26d9bd76 100644 --- a/homeassistant/components/opendisplay/quality_scale.yaml +++ b/homeassistant/components/opendisplay/quality_scale.yaml @@ -6,9 +6,7 @@ rules: comment: | The `opendisplay` integration is a `local_push` integration that does not perform periodic polling. brands: done - common-modules: - status: exempt - comment: Integration does not currently use entities or a DataUpdateCoordinator. + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done @@ -16,15 +14,9 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: Integration does not currently provide any entities. - entity-unique-id: - status: exempt - comment: Integration does not currently provide any entities. - has-entity-name: - status: exempt - comment: Integration does not currently provide any entities. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -37,16 +29,10 @@ rules: status: exempt comment: Integration has no options flow. docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: Integration does not currently provide any entities. + entity-unavailable: done integration-owner: done - log-when-unavailable: - status: exempt - comment: Integration does not currently implement any entities or background polling. - parallel-updates: - status: exempt - comment: Integration does not provide any entities. + log-when-unavailable: done + parallel-updates: done reauthentication-flow: status: exempt comment: Devices do not require authentication. @@ -59,9 +45,7 @@ rules: status: exempt comment: The device's BLE MAC address is both its unique identifier and does not change. discovery: done - docs-data-update: - status: exempt - comment: Integration does not poll or push data to entities. + docs-data-update: todo docs-examples: todo docs-known-limitations: todo docs-supported-devices: todo @@ -71,18 +55,10 @@ rules: dynamic-devices: status: exempt comment: Only one device per config entry. New devices are set up as new entries. - entity-category: - status: exempt - comment: Integration does not provide any entities. - entity-device-class: - status: exempt - comment: Integration does not provide any entities. - entity-disabled-by-default: - status: exempt - comment: Integration does not provide any entities. - entity-translations: - status: exempt - comment: Integration does not provide any entities. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: diff --git a/homeassistant/components/opendisplay/sensor.py b/homeassistant/components/opendisplay/sensor.py new file mode 100644 index 00000000000000..2f230ff6c76760 --- /dev/null +++ b/homeassistant/components/opendisplay/sensor.py @@ -0,0 +1,106 @@ +"""Sensor platform for OpenDisplay devices.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from opendisplay import voltage_to_percent +from opendisplay.models.advertisement import AdvertisementData +from opendisplay.models.enums import CapacityEstimator, PowerMode + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricPotential, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OpenDisplayConfigEntry +from .entity import OpenDisplayEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OpenDisplaySensorEntityDescription(SensorEntityDescription): + """Describes an OpenDisplay sensor entity.""" + + value_fn: Callable[[AdvertisementData], float | int | None] + + +_TEMPERATURE_DESCRIPTION = OpenDisplaySensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda adv: adv.temperature_c, +) + +_BATTERY_POWER_MODES = {PowerMode.BATTERY, PowerMode.SOLAR} + +_BATTERY_VOLTAGE_DESCRIPTION = OpenDisplaySensorEntityDescription( + key="battery_voltage", + translation_key="battery_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda adv: adv.battery_mv, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenDisplayConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenDisplay sensor entities.""" + coordinator = entry.runtime_data.coordinator + power_config = entry.runtime_data.device_config.power + descriptions: list[OpenDisplaySensorEntityDescription] = [_TEMPERATURE_DESCRIPTION] + + if power_config.power_mode_enum in _BATTERY_POWER_MODES: + capacity_estimator = power_config.capacity_estimator or CapacityEstimator.LI_ION + descriptions += [ + _BATTERY_VOLTAGE_DESCRIPTION, + OpenDisplaySensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda adv: voltage_to_percent( + adv.battery_mv, capacity_estimator + ), + ), + ] + + async_add_entities( + OpenDisplaySensorEntity(coordinator, description) + for description in descriptions + ) + + +class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity): + """A sensor entity for an OpenDisplay device.""" + + entity_description: OpenDisplaySensorEntityDescription + + @property + def native_value(self) -> float | int | None: + """Return the sensor value.""" + if self.coordinator.data is None: + return None + return self.entity_description.value_fn(self.coordinator.data.advertisement) diff --git a/homeassistant/components/opendisplay/strings.json b/homeassistant/components/opendisplay/strings.json index 85f1236a60f2bd..751ba8ccfec295 100644 --- a/homeassistant/components/opendisplay/strings.json +++ b/homeassistant/components/opendisplay/strings.json @@ -27,6 +27,13 @@ } } }, + "entity": { + "sensor": { + "battery_voltage": { + "name": "Battery voltage" + } + } + }, "exceptions": { "device_not_found": { "message": "Could not find Bluetooth device with address `{address}`." diff --git a/tests/components/opendisplay/snapshots/test_sensor.ambr b/tests/components/opendisplay/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..ca5157326d1e71 --- /dev/null +++ b/tests/components/opendisplay/snapshots/test_sensor.ambr @@ -0,0 +1,230 @@ +# serializer version: 1 +# name: test_sensor_entities_battery_device[sensor.opendisplay_1234_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opendisplay_1234_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'opendisplay', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:BB:CC:DD:EE:FF-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_entities_battery_device[sensor.opendisplay_1234_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'OpenDisplay 1234 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.opendisplay_1234_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39', + }) +# --- +# name: test_sensor_entities_battery_device[sensor.opendisplay_1234_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opendisplay_1234_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'opendisplay', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'AA:BB:CC:DD:EE:FF-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_battery_device[sensor.opendisplay_1234_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'OpenDisplay 1234 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opendisplay_1234_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3700', + }) +# --- +# name: test_sensor_entities_battery_device[sensor.opendisplay_1234_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opendisplay_1234_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'opendisplay', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:BB:CC:DD:EE:FF-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_battery_device[sensor.opendisplay_1234_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenDisplay 1234 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opendisplay_1234_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_entities_usb_device[sensor.opendisplay_1234_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.opendisplay_1234_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'opendisplay', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:BB:CC:DD:EE:FF-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_usb_device[sensor.opendisplay_1234_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'OpenDisplay 1234 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.opendisplay_1234_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- diff --git a/tests/components/opendisplay/test_sensor.py b/tests/components/opendisplay/test_sensor.py new file mode 100644 index 00000000000000..d20d020e6bc60f --- /dev/null +++ b/tests/components/opendisplay/test_sensor.py @@ -0,0 +1,226 @@ +"""Test the OpenDisplay sensor platform.""" + +from copy import deepcopy +from datetime import timedelta +import time +from unittest.mock import MagicMock + +from habluetooth import CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS +from opendisplay import voltage_to_percent +from opendisplay.models.config import PowerOption +from opendisplay.models.enums import CapacityEstimator, PowerMode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import DEVICE_CONFIG, TEST_ADDRESS, VALID_SERVICE_INFO, make_service_info + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, + patch_bluetooth_time, +) + +pytestmark = pytest.mark.usefixtures("entity_registry_enabled_by_default") + + +async def _setup_entry(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Set up the integration and wait for entities to be created.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_sensors_before_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that sensors are created but unavailable before data arrives.""" + await _setup_entry(hass, mock_config_entry) + + # All sensors exist but coordinator has no data yet + assert hass.states.get("sensor.opendisplay_1234_temperature") is not None + assert ( + hass.states.get("sensor.opendisplay_1234_temperature").state + == STATE_UNAVAILABLE + ) + + +async def test_sensor_entities_usb_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor entities for a USB-powered Flex device.""" + await _setup_entry(hass, mock_config_entry) + + inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensor_entities_battery_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opendisplay_device: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor entities for a battery-powered Flex device with LI_ION chemistry.""" + device_config = deepcopy(DEVICE_CONFIG) + power = device_config.power + device_config.power = PowerOption( + power_mode=PowerMode.BATTERY, + battery_capacity_mah=power.battery_capacity_mah, + sleep_timeout_ms=power.sleep_timeout_ms, + tx_power=power.tx_power, + sleep_flags=power.sleep_flags, + battery_sense_pin=power.battery_sense_pin, + battery_sense_enable_pin=power.battery_sense_enable_pin, + battery_sense_flags=power.battery_sense_flags, + capacity_estimator=1, # LI_ION + voltage_scaling_factor=power.voltage_scaling_factor, + deep_sleep_current_ua=power.deep_sleep_current_ua, + deep_sleep_time_seconds=power.deep_sleep_time_seconds, + reserved=power.reserved, + ) + mock_opendisplay_device.config = device_config + + await _setup_entry(hass, mock_config_entry) + + inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_battery_sensors_not_created_for_usb_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test battery sensors are not created for USB-powered devices.""" + await _setup_entry(hass, mock_config_entry) + + inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) + await hass.async_block_till_done() + + assert entity_registry.async_get("sensor.opendisplay_1234_battery") is None + assert entity_registry.async_get("sensor.opendisplay_1234_battery_voltage") is None + + +async def test_no_sensors_for_non_flex_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opendisplay_device: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test that no sensor entities are created for non-Flex devices.""" + mock_opendisplay_device.is_flex = False + await _setup_entry(hass, mock_config_entry) + + assert entity_registry.async_get("sensor.opendisplay_1234_temperature") is None + assert entity_registry.async_get("sensor.opendisplay_1234_battery") is None + assert entity_registry.async_get("sensor.opendisplay_1234_battery_voltage") is None + + +async def test_coordinator_ignores_unknown_manufacturer( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that advertisements from an unknown manufacturer ID are ignored.""" + await _setup_entry(hass, mock_config_entry) + + unknown_service_info = make_service_info( + address=TEST_ADDRESS, + manufacturer_data={0x9999: b"\x00" * 14}, + ) + inject_bluetooth_service_info(hass, unknown_service_info) + await hass.async_block_till_done() + + # Coordinator has no data; device is visible but no OpenDisplay data parsed + assert hass.states.get("sensor.opendisplay_1234_temperature").state == STATE_UNKNOWN + + +async def test_sensor_goes_unavailable_when_device_disappears( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that sensors become unavailable when the device stops advertising.""" + start_monotonic = time.monotonic() + await _setup_entry(hass, mock_config_entry) + + inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.opendisplay_1234_temperature").state + != STATE_UNAVAILABLE + ) + + # Must exceed both the connectable stale threshold (195s) and the + # unavailability polling interval (300s) to trigger the callback. + advance = ( + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + + UNAVAILABLE_TRACK_SECONDS + + 1 + ) + monotonic_now = start_monotonic + advance + with ( + patch_bluetooth_time(monotonic_now), + patch_all_discovered_devices([]), + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=advance), + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.opendisplay_1234_temperature").state + == STATE_UNAVAILABLE + ) + + +async def test_battery_sensor_defaults_to_liion_when_capacity_estimator_unset( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opendisplay_device: MagicMock, +) -> None: + """Test battery % sensor uses LI_ION when capacity_estimator is 0 (not configured).""" + device_config = deepcopy(DEVICE_CONFIG) + power = device_config.power + device_config.power = PowerOption( + power_mode=PowerMode.BATTERY, + battery_capacity_mah=power.battery_capacity_mah, + sleep_timeout_ms=power.sleep_timeout_ms, + tx_power=power.tx_power, + sleep_flags=power.sleep_flags, + battery_sense_pin=power.battery_sense_pin, + battery_sense_enable_pin=power.battery_sense_enable_pin, + battery_sense_flags=power.battery_sense_flags, + capacity_estimator=0, # not configured — defaults to LI_ION in sensor.py + voltage_scaling_factor=power.voltage_scaling_factor, + deep_sleep_current_ua=power.deep_sleep_current_ua, + deep_sleep_time_seconds=power.deep_sleep_time_seconds, + reserved=power.reserved, + ) + mock_opendisplay_device.config = device_config + + await _setup_entry(hass, mock_config_entry) + inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) + await hass.async_block_till_done() + + battery_state = hass.states.get("sensor.opendisplay_1234_battery") + assert battery_state is not None + # capacity_estimator=0 should fall back to LI_ION, producing the same value as explicit LI_ION + expected = voltage_to_percent(3700, CapacityEstimator.LI_ION) + assert battery_state.state == str(expected) From d50d6db1bd9dae79072c0914aa07b3f402386cc2 Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Wed, 1 Apr 2026 12:15:38 -0400 Subject: [PATCH 0333/1707] Add battery sensors to Casper Glow (#166801) --- .../components/casper_glow/__init__.py | 1 + .../components/casper_glow/binary_sensor.py | 45 ++++++++++- .../components/casper_glow/quality_scale.yaml | 8 +- .../components/casper_glow/sensor.py | 61 +++++++++++++++ .../snapshots/test_binary_sensor.ambr | 51 ++++++++++++ .../casper_glow/snapshots/test_sensor.ambr | 56 +++++++++++++ .../casper_glow/test_binary_sensor.py | 78 +++++++++++++++---- tests/components/casper_glow/test_select.py | 2 +- tests/components/casper_glow/test_sensor.py | 72 +++++++++++++++++ 9 files changed, 351 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/casper_glow/sensor.py create mode 100644 tests/components/casper_glow/snapshots/test_sensor.ambr create mode 100644 tests/components/casper_glow/test_sensor.py diff --git a/homeassistant/components/casper_glow/__init__.py b/homeassistant/components/casper_glow/__init__.py index 216379cb4a012d..e4e114fd245587 100644 --- a/homeassistant/components/casper_glow/__init__.py +++ b/homeassistant/components/casper_glow/__init__.py @@ -16,6 +16,7 @@ Platform.BUTTON, Platform.LIGHT, Platform.SELECT, + Platform.SENSOR, ] diff --git a/homeassistant/components/casper_glow/binary_sensor.py b/homeassistant/components/casper_glow/binary_sensor.py index 9da8bcfe984e3b..0180ccbcc6e9f9 100644 --- a/homeassistant/components/casper_glow/binary_sensor.py +++ b/homeassistant/components/casper_glow/binary_sensor.py @@ -4,7 +4,11 @@ from pycasperglow import GlowState -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,7 +25,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform for Casper Glow.""" - async_add_entities([CasperGlowPausedBinarySensor(entry.runtime_data)]) + async_add_entities( + [ + CasperGlowPausedBinarySensor(entry.runtime_data), + CasperGlowChargingBinarySensor(entry.runtime_data), + ] + ) class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity): @@ -46,6 +55,34 @@ async def async_added_to_hass(self) -> None: @callback def _async_handle_state_update(self, state: GlowState) -> None: """Handle a state update from the device.""" - if state.is_paused is not None: + if state.is_paused is not None and state.is_paused != self._attr_is_on: self._attr_is_on = state.is_paused - self.async_write_ha_state() + self.async_write_ha_state() + + +class CasperGlowChargingBinarySensor(CasperGlowEntity, BinarySensorEntity): + """Binary sensor indicating whether the Casper Glow is charging.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the charging binary sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{format_mac(coordinator.device.address)}_charging" + if coordinator.device.state.is_charging is not None: + self._attr_is_on = coordinator.device.state.is_charging + + async def async_added_to_hass(self) -> None: + """Register state update callback when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + if state.is_charging is not None and state.is_charging != self._attr_is_on: + self._attr_is_on = state.is_charging + self.async_write_ha_state() diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index 3d2cfceaf7c295..45dec1b1cc3aae 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -53,15 +53,15 @@ rules: docs-use-cases: todo dynamic-devices: todo entity-category: done - entity-device-class: - status: exempt - comment: No applicable device classes for binary_sensor, button, light, or select entities. + entity-device-class: done entity-disabled-by-default: todo entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: todo - repair-issues: todo + repair-issues: + status: exempt + comment: Integration does not register repair issues. stale-devices: todo # Platinum diff --git a/homeassistant/components/casper_glow/sensor.py b/homeassistant/components/casper_glow/sensor.py new file mode 100644 index 00000000000000..8ecc26dad84c72 --- /dev/null +++ b/homeassistant/components/casper_glow/sensor.py @@ -0,0 +1,61 @@ +"""Casper Glow integration sensor platform.""" + +from __future__ import annotations + +from pycasperglow import GlowState + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator +from .entity import CasperGlowEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CasperGlowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform for Casper Glow.""" + async_add_entities([CasperGlowBatterySensor(entry.runtime_data)]) + + +class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity): + """Sensor entity for Casper Glow battery level.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the battery sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{format_mac(coordinator.device.address)}_battery" + if coordinator.device.state.battery_level is not None: + self._attr_native_value = coordinator.device.state.battery_level.percentage + + async def async_added_to_hass(self) -> None: + """Register state update callback when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + if state.battery_level is not None: + new_value = state.battery_level.percentage + if new_value != self._attr_native_value: + self._attr_native_value = new_value + self.async_write_ha_state() diff --git a/tests/components/casper_glow/snapshots/test_binary_sensor.ambr b/tests/components/casper_glow/snapshots/test_binary_sensor.ambr index 0c336383d4187b..705d8efd676173 100644 --- a/tests/components/casper_glow/snapshots/test_binary_sensor.ambr +++ b/tests/components/casper_glow/snapshots/test_binary_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_entities[binary_sensor.jar_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.jar_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'casper_glow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.jar_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Jar Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.jar_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_entities[binary_sensor.jar_dimming_paused-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/casper_glow/snapshots/test_sensor.ambr b/tests/components/casper_glow/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..63da94d743cee8 --- /dev/null +++ b/tests/components/casper_glow/snapshots/test_sensor.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_entities[sensor.jar_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.jar_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'casper_glow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.jar_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Jar Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.jar_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/casper_glow/test_binary_sensor.py b/tests/components/casper_glow/test_binary_sensor.py index 010e11b78394f6..cca51cb965997c 100644 --- a/tests/components/casper_glow/test_binary_sensor.py +++ b/tests/components/casper_glow/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Casper Glow binary sensor platform.""" +from collections.abc import Callable from unittest.mock import MagicMock, patch from pycasperglow import GlowState @@ -14,7 +15,8 @@ from tests.common import MockConfigEntry, snapshot_platform -ENTITY_ID = "binary_sensor.jar_dimming_paused" +PAUSED_ENTITY_ID = "binary_sensor.jar_dimming_paused" +CHARGING_ENTITY_ID = "binary_sensor.jar_charging" async def test_entities( @@ -37,31 +39,31 @@ async def test_entities( [(True, STATE_ON), (False, STATE_OFF)], ids=["paused", "not-paused"], ) -async def test_binary_sensor_state_update( +async def test_paused_state_update( hass: HomeAssistant, mock_casper_glow: MagicMock, mock_config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], is_paused: bool, expected_state: str, ) -> None: - """Test that the binary sensor reflects is_paused state changes.""" + """Test that the paused binary sensor reflects is_paused state changes.""" with patch( "homeassistant.components.casper_glow.PLATFORMS", [Platform.BINARY_SENSOR] ): await setup_integration(hass, mock_config_entry) - cb = mock_casper_glow.register_callback.call_args[0][0] - - cb(GlowState(is_paused=is_paused)) - state = hass.states.get(ENTITY_ID) + fire_callbacks(GlowState(is_paused=is_paused)) + state = hass.states.get(PAUSED_ENTITY_ID) assert state is not None assert state.state == expected_state -async def test_binary_sensor_ignores_none_paused_state( +async def test_paused_ignores_none_state( hass: HomeAssistant, mock_casper_glow: MagicMock, mock_config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that a callback with is_paused=None does not overwrite the state.""" with patch( @@ -69,16 +71,64 @@ async def test_binary_sensor_ignores_none_paused_state( ): await setup_integration(hass, mock_config_entry) - cb = mock_casper_glow.register_callback.call_args[0][0] - # Set a known value first - cb(GlowState(is_paused=True)) - state = hass.states.get(ENTITY_ID) + fire_callbacks(GlowState(is_paused=True)) + state = hass.states.get(PAUSED_ENTITY_ID) assert state is not None assert state.state == STATE_ON # Callback with no is_paused data — state should remain unchanged - cb(GlowState(is_on=True)) - state = hass.states.get(ENTITY_ID) + fire_callbacks(GlowState(is_on=True)) + state = hass.states.get(PAUSED_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.parametrize( + ("is_charging", "expected_state"), + [(True, STATE_ON), (False, STATE_OFF)], + ids=["charging", "not-charging"], +) +async def test_charging_state_update( + hass: HomeAssistant, + mock_casper_glow: MagicMock, + mock_config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], + is_charging: bool, + expected_state: str, +) -> None: + """Test that the charging binary sensor reflects is_charging state changes.""" + with patch( + "homeassistant.components.casper_glow.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + fire_callbacks(GlowState(is_charging=is_charging)) + state = hass.states.get(CHARGING_ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +async def test_charging_ignores_none_state( + hass: HomeAssistant, + mock_casper_glow: MagicMock, + mock_config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test that a callback with is_charging=None does not overwrite the state.""" + with patch( + "homeassistant.components.casper_glow.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + # Set a known value first + fire_callbacks(GlowState(is_charging=True)) + state = hass.states.get(CHARGING_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + # Callback with no is_charging data — state should remain unchanged + fire_callbacks(GlowState(is_on=True)) + state = hass.states.get(CHARGING_ENTITY_ID) assert state is not None assert state.state == STATE_ON diff --git a/tests/components/casper_glow/test_select.py b/tests/components/casper_glow/test_select.py index ef72c092bb3029..5ca1071a49c101 100644 --- a/tests/components/casper_glow/test_select.py +++ b/tests/components/casper_glow/test_select.py @@ -153,7 +153,7 @@ async def test_select_ignores_remaining_time_updates( fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that callbacks with only remaining time do not change the select state.""" - fire_callbacks(GlowState(dimming_time_remaining_ms=44)) + fire_callbacks(GlowState(dimming_time_remaining_ms=2_640_000)) state = hass.states.get(ENTITY_ID) assert state is not None diff --git a/tests/components/casper_glow/test_sensor.py b/tests/components/casper_glow/test_sensor.py new file mode 100644 index 00000000000000..329e2440e368f6 --- /dev/null +++ b/tests/components/casper_glow/test_sensor.py @@ -0,0 +1,72 @@ +"""Test the Casper Glow sensor platform.""" + +from collections.abc import Callable +from unittest.mock import MagicMock, patch + +from pycasperglow import BatteryLevel, GlowState +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +BATTERY_ENTITY_ID = "sensor.jar_battery" + + +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all sensor entities match the snapshot.""" + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("battery_level", "expected_state"), + [ + (BatteryLevel.PCT_75, "75"), + (BatteryLevel.PCT_50, "50"), + ], +) +async def test_battery_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + battery_level: BatteryLevel, + expected_state: str, +) -> None: + """Test that the battery sensor reflects device state at setup.""" + mock_casper_glow.state = GlowState(battery_level=battery_level) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(BATTERY_ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +async def test_battery_state_updated_via_callback( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test battery sensor updates when a device callback fires.""" + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + fire_callbacks(GlowState(battery_level=BatteryLevel.PCT_50)) + + state = hass.states.get(BATTERY_ENTITY_ID) + assert state is not None + assert state.state == "50" From 6cf264dc18bc0a09fa6d3e7fc8ac7c4d840575ac Mon Sep 17 00:00:00 2001 From: Niracler Date: Thu, 2 Apr 2026 00:17:24 +0800 Subject: [PATCH 0334/1707] Mark entity-disabled-by-default as exempt in sunricher_dali (#166861) --- homeassistant/components/sunricher_dali/quality_scale.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sunricher_dali/quality_scale.yaml b/homeassistant/components/sunricher_dali/quality_scale.yaml index 27b40e9335d2f2..2ee5dd92a14cc0 100644 --- a/homeassistant/components/sunricher_dali/quality_scale.yaml +++ b/homeassistant/components/sunricher_dali/quality_scale.yaml @@ -61,7 +61,9 @@ rules: dynamic-devices: todo entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: + status: exempt + comment: No noisy or non-essential entities to disable. entity-translations: done exception-translations: todo icon-translations: todo From 33bcd710fc8f31410d863cbe500d09c05f4dd4dd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 18:39:46 +0200 Subject: [PATCH 0335/1707] =?UTF-8?q?Fix=20spelling=20of=20"Cannot=20rehea?= =?UTF-8?q?t=20=E2=80=A6"=20in=20`kitchen=5Fsink`=20(#167082)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/kitchen_sink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index 15305d711b26a1..e369e0942bdc77 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -72,7 +72,7 @@ "cold_tea": { "fix_flow": { "abort": { - "not_tea_time": "Can not re-heat the tea at this time" + "not_tea_time": "Cannot reheat the tea at this time" }, "step": {} }, From c077538015c9da0e6f527e7acd38c8b8078e22dd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 18:46:34 +0200 Subject: [PATCH 0336/1707] Fix spelling of "cannot" in `pooldose` exception string (#167079) --- homeassistant/components/pooldose/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/pooldose/strings.json b/homeassistant/components/pooldose/strings.json index 67656c9d6e1718..e8e8a5ea416b81 100644 --- a/homeassistant/components/pooldose/strings.json +++ b/homeassistant/components/pooldose/strings.json @@ -346,7 +346,7 @@ }, "exceptions": { "cannot_connect": { - "message": "Value can not be set because the device is not connected" + "message": "Value cannot be set because the device is not connected" }, "write_rejected": { "message": "The device rejected the value for {entity}: {value}" From 73da736ebb4c9e575221192947afe82f84a7e6e5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 1 Apr 2026 18:55:53 +0200 Subject: [PATCH 0337/1707] Patch the correct socket method in SNMP (#167081) --- tests/components/snmp/conftest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/snmp/conftest.py b/tests/components/snmp/conftest.py index 1ed2f456c8ac7a..8626e2497b432a 100644 --- a/tests/components/snmp/conftest.py +++ b/tests/components/snmp/conftest.py @@ -7,7 +7,7 @@ @pytest.fixture(autouse=True) -def patch_gethostbyname(): - """Patch gethostbyname to avoid DNS lookups in SNMP tests.""" - with patch.object(socket, "gethostbyname"): +def patch_getaddrinfo(): + """Patch getaddrinfo to avoid DNS lookups in SNMP tests.""" + with patch.object(socket, "getaddrinfo"): yield From a3badd0a83cbe1a35c95cfa1cd1a9dfaf873bf89 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 19:24:01 +0200 Subject: [PATCH 0338/1707] Spelling fixes in user-facing strings of `wiz` (#167091) --- homeassistant/components/wiz/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wiz/strings.json b/homeassistant/components/wiz/strings.json index 5569cb422d409d..b3bd6120fe0107 100644 --- a/homeassistant/components/wiz/strings.json +++ b/homeassistant/components/wiz/strings.json @@ -6,10 +6,10 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, "error": { - "bulb_time_out": "Can not connect to the bulb. Maybe the bulb is offline or a wrong IP was entered. Please turn on the light and try again!", + "bulb_time_out": "Cannot connect to the bulb. Maybe the bulb is offline or a wrong IP was entered. Please turn on the light and try again!", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_ip": "Not a valid IP address.", - "no_wiz_light": "The bulb cannot be connected via WiZ Platform integration.", + "no_wiz_light": "The bulb cannot be connected via WiZ integration.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name} ({host})", @@ -26,7 +26,7 @@ "data": { "host": "[%key:common::config_flow::data::ip%]" }, - "description": "If you leave the IP Address empty, discovery will be used to find devices." + "description": "If you leave the IP address empty, discovery will be used to find devices." } } }, From 879d9176bd637369d92bf07c5ed4d8ec4be45774 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 19:26:59 +0200 Subject: [PATCH 0339/1707] Fix spelling of "cannot" in `azure_storage` exception string (#167088) --- homeassistant/components/azure_storage/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 13d16dd596ef66..68853ecd6055c1 100644 --- a/homeassistant/components/azure_storage/strings.json +++ b/homeassistant/components/azure_storage/strings.json @@ -54,7 +54,7 @@ "message": "Storage account {account_name} not found" }, "cannot_connect": { - "message": "Can not connect to storage account {account_name}" + "message": "Cannot connect to storage account {account_name}" }, "container_not_found": { "message": "Storage container {container_name} not found" From 6355adc6de45b1c4cce6700aac8cffc4872815d0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 19:29:49 +0200 Subject: [PATCH 0340/1707] Fix spelling of "cannot" in `rehlko` exception string (#167092) --- homeassistant/components/rehlko/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rehlko/strings.json b/homeassistant/components/rehlko/strings.json index e802d234c93ae9..3950a2eb7d9618 100644 --- a/homeassistant/components/rehlko/strings.json +++ b/homeassistant/components/rehlko/strings.json @@ -122,7 +122,7 @@ }, "exceptions": { "cannot_connect": { - "message": "Can not connect to Rehlko servers." + "message": "Cannot connect to Rehlko servers." }, "invalid_auth": { "message": "Authentication failed for email {email}." From bff97254d71b39a5a9e32897775ed8854112d3d2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Apr 2026 19:41:46 +0200 Subject: [PATCH 0341/1707] Fix select condition state selector (#167064) --- homeassistant/components/select/conditions.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/select/conditions.yaml b/homeassistant/components/select/conditions.yaml index bc1feaccbf4ca3..18ff8c47c0c70f 100644 --- a/homeassistant/components/select/conditions.yaml +++ b/homeassistant/components/select/conditions.yaml @@ -19,7 +19,6 @@ is_option_selected: required: true selector: state: - attribute: options hide_states: - unavailable - unknown From cc1114de631dd95b86ef705f9790ad39406be13c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 19:48:54 +0200 Subject: [PATCH 0342/1707] Fix spelling of "cannot" in `local_file` error string (#167089) --- homeassistant/components/local_file/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/local_file/strings.json b/homeassistant/components/local_file/strings.json index 14866fa63006eb..d35b4e653c1b32 100644 --- a/homeassistant/components/local_file/strings.json +++ b/homeassistant/components/local_file/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "error": { - "not_readable_path": "The provided path to the file can not be read" + "not_readable_path": "The provided path to the file cannot be read" }, "step": { "user": { From d9acf6490470f274b740339d86e0d37293784ac3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 19:49:38 +0200 Subject: [PATCH 0343/1707] Fix one misspelled occurrence of "cannot" in `shelly` (#167093) --- homeassistant/components/shelly/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index b61ce0af7724f9..8778cd753bee9f 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -341,7 +341,7 @@ "charger_end": "Charge completed", "charger_fault": "Error while charging", "charger_free": "[%key:component::binary_sensor::entity_component::plug::state::off%]", - "charger_free_fault": "Can not release plug", + "charger_free_fault": "Cannot release plug", "charger_insert": "[%key:component::binary_sensor::entity_component::plug::state::on%]", "charger_pause": "Charging paused by charger", "charger_wait": "Charging paused by vehicle" From 983bade8c58dbe28e1fc4ab9618ab362f5957a4c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 Apr 2026 19:52:05 +0200 Subject: [PATCH 0344/1707] Bump pySmartThings to 3.7.3 (#167075) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 8ec347a5edfc7b..5cc4530e97a978 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -38,5 +38,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.7.2"] + "requirements": ["pysmartthings==3.7.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1e980dce827724..99b39b7d30ddee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2500,7 +2500,7 @@ pysmappee==0.2.29 pysmarlaapi==1.0.2 # homeassistant.components.smartthings -pysmartthings==3.7.2 +pysmartthings==3.7.3 # homeassistant.components.smarty pysmarty2==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf9fb7b6a9e963..acf1bbfd81bd61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2135,7 +2135,7 @@ pysmappee==0.2.29 pysmarlaapi==1.0.2 # homeassistant.components.smartthings -pysmartthings==3.7.2 +pysmartthings==3.7.3 # homeassistant.components.smarty pysmarty2==0.10.3 From 6470cbeadaecfff688da859356da3206dbd07083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 1 Apr 2026 18:53:33 +0100 Subject: [PATCH 0345/1707] Add --draft flag to raise-pull-request agent PR creation command (#167068) Co-authored-by: Claude Sonnet 4.6 --- .claude/agents/raise-pull-request.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/agents/raise-pull-request.md b/.claude/agents/raise-pull-request.md index 102fe669fb89ed..3d2d53406b1388 100644 --- a/.claude/agents/raise-pull-request.md +++ b/.claude/agents/raise-pull-request.md @@ -195,6 +195,7 @@ GITHUB_USER=$(gh api user --jq .login 2>/dev/null || git remote get-url "$PUSH_R # Create PR (gh pr create pushes the branch automatically) gh pr create --repo home-assistant/core --base dev \ --head "$GITHUB_USER:$BRANCH" \ + --draft \ --title "TITLE_HERE" \ --body "$(cat <<'EOF' BODY_HERE From 7daaf3de6aa0d923996174a9c75ae9617b55a43f Mon Sep 17 00:00:00 2001 From: johanzander Date: Wed, 1 Apr 2026 19:56:53 +0200 Subject: [PATCH 0346/1707] growatt_server: implement reconfiguration flow (Gold) (#165961) Co-authored-by: Claude Sonnet 4.6 --- .../components/growatt_server/config_flow.py | 64 +++- .../growatt_server/quality_scale.yaml | 2 +- .../components/growatt_server/strings.json | 19 +- .../snapshots/test_config_flow.ambr | 170 ---------- .../growatt_server/test_config_flow.py | 321 +++++++++++++++--- 5 files changed, 346 insertions(+), 230 deletions(-) delete mode 100644 tests/components/growatt_server/snapshots/test_config_flow.ambr diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index bec7e583c26c2d..8476c16dcfb846 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -8,7 +8,7 @@ import requests import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -64,6 +64,16 @@ async def async_step_user( menu_options=["password_auth", "token_auth"], ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self._async_step_credentials( + step_id="reconfigure", + entry=self._get_reconfigure_entry(), + user_input=user_input, + ) + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: """Handle reauth.""" return await self.async_step_reauth_confirm() @@ -72,11 +82,23 @@ async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reauth confirmation.""" + return await self._async_step_credentials( + step_id="reauth_confirm", + entry=self._get_reauth_entry(), + user_input=user_input, + ) + + async def _async_step_credentials( + self, + step_id: str, + entry: ConfigEntry, + user_input: dict[str, Any] | None, + ) -> ConfigFlowResult: + """Handle credential update for both reauth and reconfigure.""" errors: dict[str, str] = {} - reauth_entry = self._get_reauth_entry() if user_input is not None: - auth_type = reauth_entry.data.get(CONF_AUTH_TYPE) + auth_type = entry.data.get(CONF_AUTH_TYPE) if auth_type == AUTH_PASSWORD: server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]] @@ -91,17 +113,19 @@ async def async_step_reauth_confirm( api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) except requests.exceptions.RequestException as ex: - _LOGGER.debug("Network error during reauth login: %s", ex) + _LOGGER.debug("Network error during credential update: %s", ex) errors["base"] = ERROR_CANNOT_CONNECT except (ValueError, KeyError, TypeError, AttributeError) as ex: - _LOGGER.debug("Invalid response format during reauth login: %s", ex) + _LOGGER.debug( + "Invalid response format during credential update: %s", ex + ) errors["base"] = ERROR_CANNOT_CONNECT else: if not isinstance(login_response, dict): errors["base"] = ERROR_CANNOT_CONNECT elif login_response.get("success"): return self.async_update_reload_and_abort( - reauth_entry, + entry, data_updates={ CONF_USERNAME: user_input[CONF_USERNAME], CONF_PASSWORD: user_input[CONF_PASSWORD], @@ -121,28 +145,26 @@ async def async_step_reauth_confirm( try: await self.hass.async_add_executor_job(api.plant_list) except requests.exceptions.RequestException as ex: - _LOGGER.debug( - "Network error during reauth token validation: %s", ex - ) + _LOGGER.debug("Network error during credential update: %s", ex) errors["base"] = ERROR_CANNOT_CONNECT except growattServer.GrowattV1ApiError as err: if err.error_code == V1_API_ERROR_NO_PRIVILEGE: errors["base"] = ERROR_INVALID_AUTH else: _LOGGER.debug( - "Growatt V1 API error during reauth: %s (Code: %s)", + "Growatt V1 API error during credential update: %s (Code: %s)", err.error_msg or str(err), err.error_code, ) errors["base"] = ERROR_CANNOT_CONNECT except (ValueError, KeyError, TypeError, AttributeError) as ex: _LOGGER.debug( - "Invalid response format during reauth token validation: %s", ex + "Invalid response format during credential update: %s", ex ) errors["base"] = ERROR_CANNOT_CONNECT else: return self.async_update_reload_and_abort( - reauth_entry, + entry, data_updates={ CONF_TOKEN: user_input[CONF_TOKEN], CONF_URL: server_url, @@ -151,19 +173,19 @@ async def async_step_reauth_confirm( # Determine the current region key from the stored config value. # Legacy entries may store the region key directly; newer entries store the URL. - stored_url = reauth_entry.data.get(CONF_URL, "") + stored_url = entry.data.get(CONF_URL, "") if stored_url in SERVER_URLS_NAMES: current_region = stored_url else: current_region = _URL_TO_REGION.get(stored_url, DEFAULT_URL) - auth_type = reauth_entry.data.get(CONF_AUTH_TYPE) + auth_type = entry.data.get(CONF_AUTH_TYPE) if auth_type == AUTH_PASSWORD: data_schema = vol.Schema( { vol.Required( CONF_USERNAME, - default=reauth_entry.data.get(CONF_USERNAME), + default=entry.data.get(CONF_USERNAME), ): str, vol.Required(CONF_PASSWORD): str, vol.Required(CONF_REGION, default=current_region): SelectSelector( @@ -189,8 +211,18 @@ async def async_step_reauth_confirm( else: return self.async_abort(reason=ERROR_CANNOT_CONNECT) + if user_input is not None: + data_schema = self.add_suggested_values_to_schema( + data_schema, + { + key: value + for key, value in user_input.items() + if key not in (CONF_PASSWORD, CONF_TOKEN) + }, + ) + return self.async_show_form( - step_id="reauth_confirm", + step_id=step_id, data_schema=data_schema, errors=errors, ) diff --git a/homeassistant/components/growatt_server/quality_scale.yaml b/homeassistant/components/growatt_server/quality_scale.yaml index 48f5168eb4bc3b..5d29f1aa4942dc 100644 --- a/homeassistant/components/growatt_server/quality_scale.yaml +++ b/homeassistant/components/growatt_server/quality_scale.yaml @@ -50,7 +50,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: Integration does not raise repairable issues. diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index ee65115f4933fc..4160c5bac84ee4 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -4,7 +4,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_plants": "No plants have been found on this account", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.", @@ -49,6 +50,22 @@ "description": "Re-enter your credentials to continue using this integration.", "title": "Re-authenticate with Growatt" }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "region": "[%key:component::growatt_server::config::step::password_auth::data::region%]", + "token": "[%key:component::growatt_server::config::step::token_auth::data::token%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::growatt_server::config::step::password_auth::data_description::password%]", + "region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]", + "token": "[%key:component::growatt_server::config::step::token_auth::data_description::token%]", + "username": "[%key:component::growatt_server::config::step::password_auth::data_description::username%]" + }, + "description": "Update your credentials to continue using this integration.", + "title": "Reconfigure Growatt" + }, "token_auth": { "data": { "region": "[%key:component::growatt_server::config::step::password_auth::data::region%]", diff --git a/tests/components/growatt_server/snapshots/test_config_flow.ambr b/tests/components/growatt_server/snapshots/test_config_flow.ambr deleted file mode 100644 index 50c84d170efe62..00000000000000 --- a/tests/components/growatt_server/snapshots/test_config_flow.ambr +++ /dev/null @@ -1,170 +0,0 @@ -# serializer version: 1 -# name: test_reauth_password_error_then_recovery[None-login_return_value0] - FlowResultSnapshot({ - 'description_placeholders': dict({ - 'name': 'Mock Title', - }), - 'errors': dict({ - 'base': 'invalid_auth', - }), - 'flow_id': , - 'handler': 'growatt_server', - 'last_step': None, - 'preview': None, - 'step_id': 'reauth_confirm', - 'type': , - }) -# --- -# name: test_reauth_password_error_then_recovery[login_side_effect1-None] - FlowResultSnapshot({ - 'description_placeholders': dict({ - 'name': 'Mock Title', - }), - 'errors': dict({ - 'base': 'cannot_connect', - }), - 'flow_id': , - 'handler': 'growatt_server', - 'last_step': None, - 'preview': None, - 'step_id': 'reauth_confirm', - 'type': , - }) -# --- -# name: test_reauth_password_exception - dict({ - 'auth_type': 'password', - 'name': 'Test Plant', - 'password': 'password', - 'plant_id': '123456', - 'url': 'https://openapi.growatt.com/', - 'username': 'username', - }) -# --- -# name: test_reauth_password_non_auth_login_failure - dict({ - 'auth_type': 'password', - 'name': 'Test Plant', - 'password': 'password', - 'plant_id': '123456', - 'url': 'https://openapi.growatt.com/', - 'username': 'username', - }) -# --- -# name: test_reauth_password_success[https://openapi-us.growatt.com/-user_input1-north_america] - FlowResultSnapshot({ - 'description_placeholders': dict({ - 'name': 'Mock Title', - }), - 'errors': dict({ - }), - 'flow_id': , - 'handler': 'growatt_server', - 'last_step': None, - 'preview': None, - 'step_id': 'reauth_confirm', - 'type': , - }) -# --- -# name: test_reauth_password_success[https://openapi-us.growatt.com/-user_input1-north_america].1 - dict({ - 'auth_type': 'password', - 'name': 'Test Plant', - 'password': 'password', - 'plant_id': '123456', - 'url': 'https://openapi-us.growatt.com/', - 'username': 'username', - }) -# --- -# name: test_reauth_password_success[https://openapi.growatt.com/-user_input0-other_regions] - FlowResultSnapshot({ - 'description_placeholders': dict({ - 'name': 'Mock Title', - }), - 'errors': dict({ - }), - 'flow_id': , - 'handler': 'growatt_server', - 'last_step': None, - 'preview': None, - 'step_id': 'reauth_confirm', - 'type': , - }) -# --- -# name: test_reauth_password_success[https://openapi.growatt.com/-user_input0-other_regions].1 - dict({ - 'auth_type': 'password', - 'name': 'Test Plant', - 'password': 'password', - 'plant_id': '123456', - 'url': 'https://openapi.growatt.com/', - 'username': 'username', - }) -# --- -# name: test_reauth_token_error_then_recovery[plant_list_side_effect0] - FlowResultSnapshot({ - 'description_placeholders': dict({ - 'name': 'Mock Title', - }), - 'errors': dict({ - 'base': 'invalid_auth', - }), - 'flow_id': , - 'handler': 'growatt_server', - 'last_step': None, - 'preview': None, - 'step_id': 'reauth_confirm', - 'type': , - }) -# --- -# name: test_reauth_token_error_then_recovery[plant_list_side_effect1] - FlowResultSnapshot({ - 'description_placeholders': dict({ - 'name': 'Mock Title', - }), - 'errors': dict({ - 'base': 'cannot_connect', - }), - 'flow_id': , - 'handler': 'growatt_server', - 'last_step': None, - 'preview': None, - 'step_id': 'reauth_confirm', - 'type': , - }) -# --- -# name: test_reauth_token_exception - dict({ - 'auth_type': 'api_token', - 'name': 'Test Plant', - 'plant_id': '123456', - 'token': 'test_api_token_12345', - 'url': 'https://openapi.growatt.com/', - 'user_id': '12345', - }) -# --- -# name: test_reauth_token_success - FlowResultSnapshot({ - 'description_placeholders': dict({ - 'name': 'Mock Title', - }), - 'errors': dict({ - }), - 'flow_id': , - 'handler': 'growatt_server', - 'last_step': None, - 'preview': None, - 'step_id': 'reauth_confirm', - 'type': , - }) -# --- -# name: test_reauth_token_success.1 - dict({ - 'auth_type': 'api_token', - 'name': 'Test Plant', - 'plant_id': '123456', - 'token': 'test_api_token_12345', - 'url': 'https://openapi.growatt.com/', - 'user_id': '12345', - }) -# --- diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index 1abadb5ce84769..ed0c855ecfb5f1 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -1,12 +1,13 @@ """Tests for the Growatt server config flow.""" +from collections.abc import Callable from copy import deepcopy +from typing import Any +from unittest.mock import MagicMock import growattServer import pytest import requests -from syrupy.assertion import SnapshotAssertion -from syrupy.filters import props import voluptuous as vol from homeassistant import config_entries @@ -718,10 +719,9 @@ async def test_password_auth_plant_list_invalid_format( ) async def test_reauth_password_success( hass: HomeAssistant, - mock_growatt_classic_api, - snapshot: SnapshotAssertion, + mock_growatt_classic_api: MagicMock, stored_url: str, - user_input: dict, + user_input: dict[str, str], expected_region: str, ) -> None: """Test successful reauthentication with password auth for default and non-default regions.""" @@ -733,7 +733,7 @@ async def test_reauth_password_success( CONF_PASSWORD: "test_password", CONF_URL: stored_url, CONF_PLANT_ID: "123456", - "name": "Test Plant", + CONF_NAME: "Test Plant", }, unique_id="123456", ) @@ -743,7 +743,6 @@ async def test_reauth_password_success( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result == snapshot(exclude=props("data_schema")) region_key = next( k for k in result["data_schema"].schema @@ -758,29 +757,35 @@ async def test_reauth_password_success( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert entry.data == snapshot + assert entry.data == { + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_NAME: "Test Plant", + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_PLANT_ID: "123456", + CONF_URL: SERVER_URLS_NAMES[user_input[CONF_REGION]], + CONF_USERNAME: user_input[CONF_USERNAME], + } @pytest.mark.parametrize( - ("login_side_effect", "login_return_value"), + ("login_side_effect", "expected_error"), [ ( - None, - {"msg": LOGIN_INVALID_AUTH_CODE, "success": False}, + lambda *args, **kwargs: {"msg": LOGIN_INVALID_AUTH_CODE, "success": False}, + ERROR_INVALID_AUTH, ), ( requests.exceptions.ConnectionError("Connection failed"), - None, + ERROR_CANNOT_CONNECT, ), ], ) async def test_reauth_password_error_then_recovery( hass: HomeAssistant, - mock_growatt_classic_api, + mock_growatt_classic_api: MagicMock, mock_config_entry_classic: MockConfigEntry, - snapshot: SnapshotAssertion, - login_side_effect: Exception | None, - login_return_value: dict | None, + login_side_effect: Callable[..., Any] | Exception, + expected_error: str, ) -> None: """Test password reauth shows error then allows recovery.""" mock_config_entry_classic.add_to_hass(hass) @@ -791,15 +796,13 @@ async def test_reauth_password_error_then_recovery( assert result["step_id"] == "reauth_confirm" mock_growatt_classic_api.login.side_effect = login_side_effect - if login_return_value is not None: - mock_growatt_classic_api.login.return_value = login_return_value result = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT_PASSWORD ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result == snapshot(exclude=props("data_schema")) + assert result["errors"] == {"base": expected_error} # Recover with correct credentials mock_growatt_classic_api.login.side_effect = None @@ -814,9 +817,8 @@ async def test_reauth_password_error_then_recovery( async def test_reauth_token_success( hass: HomeAssistant, - mock_growatt_v1_api, + mock_growatt_v1_api: MagicMock, mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, ) -> None: """Test successful reauthentication with token auth.""" mock_config_entry.add_to_hass(hass) @@ -825,7 +827,6 @@ async def test_reauth_token_success( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result == snapshot(exclude=props("data_schema")) mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE result = await hass.config_entries.flow.async_configure( @@ -834,7 +835,14 @@ async def test_reauth_token_success( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert mock_config_entry.data == snapshot + assert mock_config_entry.data == { + CONF_AUTH_TYPE: AUTH_API_TOKEN, + CONF_NAME: "Test Plant", + CONF_PLANT_ID: "123456", + CONF_TOKEN: FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN], + CONF_URL: SERVER_URLS_NAMES[FIXTURE_USER_INPUT_TOKEN[CONF_REGION]], + "user_id": "12345", + } def _make_no_privilege_error() -> growattServer.GrowattV1ApiError: @@ -844,18 +852,18 @@ def _make_no_privilege_error() -> growattServer.GrowattV1ApiError: @pytest.mark.parametrize( - "plant_list_side_effect", + ("plant_list_side_effect", "expected_error"), [ - _make_no_privilege_error(), - requests.exceptions.ConnectionError("Network error"), + (_make_no_privilege_error(), ERROR_INVALID_AUTH), + (requests.exceptions.ConnectionError("Network error"), ERROR_CANNOT_CONNECT), ], ) async def test_reauth_token_error_then_recovery( hass: HomeAssistant, - mock_growatt_v1_api, + mock_growatt_v1_api: MagicMock, mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, plant_list_side_effect: Exception, + expected_error: str, ) -> None: """Test token reauth shows error then allows recovery.""" mock_config_entry.add_to_hass(hass) @@ -872,7 +880,7 @@ async def test_reauth_token_error_then_recovery( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result == snapshot(exclude=props("data_schema")) + assert result["errors"] == {"base": expected_error} # Recover with a valid token mock_growatt_v1_api.plant_list.side_effect = None @@ -887,7 +895,7 @@ async def test_reauth_token_error_then_recovery( async def test_reauth_token_non_auth_api_error( hass: HomeAssistant, - mock_growatt_v1_api, + mock_growatt_v1_api: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test reauth token with non-auth V1 API error (e.g. rate limit) shows cannot_connect.""" @@ -912,7 +920,7 @@ async def test_reauth_token_non_auth_api_error( async def test_reauth_password_invalid_response( hass: HomeAssistant, - mock_growatt_classic_api, + mock_growatt_classic_api: MagicMock, mock_config_entry_classic: MockConfigEntry, ) -> None: """Test reauth password flow with non-dict login response, then recovery.""" @@ -940,9 +948,8 @@ async def test_reauth_password_invalid_response( async def test_reauth_password_non_auth_login_failure( hass: HomeAssistant, - mock_growatt_classic_api, + mock_growatt_classic_api: MagicMock, mock_config_entry_classic: MockConfigEntry, - snapshot: SnapshotAssertion, ) -> None: """Test reauth password flow when login fails with a non-auth error.""" mock_config_entry_classic.add_to_hass(hass) @@ -968,14 +975,20 @@ async def test_reauth_password_non_auth_login_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert mock_config_entry_classic.data == snapshot + assert mock_config_entry_classic.data == { + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_NAME: "Test Plant", + CONF_PASSWORD: FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD], + CONF_PLANT_ID: "123456", + CONF_URL: SERVER_URLS_NAMES[FIXTURE_USER_INPUT_PASSWORD[CONF_REGION]], + CONF_USERNAME: FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME], + } async def test_reauth_password_exception( hass: HomeAssistant, - mock_growatt_classic_api, + mock_growatt_classic_api: MagicMock, mock_config_entry_classic: MockConfigEntry, - snapshot: SnapshotAssertion, ) -> None: """Test reauth password flow with unexpected exception from login, then recovery.""" mock_config_entry_classic.add_to_hass(hass) @@ -999,14 +1012,20 @@ async def test_reauth_password_exception( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert mock_config_entry_classic.data == snapshot + assert mock_config_entry_classic.data == { + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_NAME: "Test Plant", + CONF_PASSWORD: FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD], + CONF_PLANT_ID: "123456", + CONF_URL: SERVER_URLS_NAMES[FIXTURE_USER_INPUT_PASSWORD[CONF_REGION]], + CONF_USERNAME: FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME], + } async def test_reauth_token_exception( hass: HomeAssistant, - mock_growatt_v1_api, + mock_growatt_v1_api: MagicMock, mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, ) -> None: """Test reauth token flow with unexpected exception from plant_list, then recovery.""" mock_config_entry.add_to_hass(hass) @@ -1030,7 +1049,14 @@ async def test_reauth_token_exception( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert mock_config_entry.data == snapshot + assert mock_config_entry.data == { + CONF_AUTH_TYPE: AUTH_API_TOKEN, + CONF_NAME: "Test Plant", + CONF_PLANT_ID: "123456", + CONF_TOKEN: FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN], + CONF_URL: SERVER_URLS_NAMES[FIXTURE_USER_INPUT_TOKEN[CONF_REGION]], + "user_id": "12345", + } async def test_reauth_unknown_auth_type(hass: HomeAssistant) -> None: @@ -1039,8 +1065,8 @@ async def test_reauth_unknown_auth_type(hass: HomeAssistant) -> None: domain=DOMAIN, data={ CONF_AUTH_TYPE: "unknown_type", - "plant_id": "123456", - "name": "Test Plant", + CONF_PLANT_ID: "123456", + CONF_NAME: "Test Plant", }, unique_id="123456", ) @@ -1051,3 +1077,214 @@ async def test_reauth_unknown_auth_type(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == ERROR_CANNOT_CONNECT + + +# Reconfiguration flow tests + + +@pytest.mark.parametrize( + ("stored_url", "user_input", "expected_region"), + [ + ( + SERVER_URLS_NAMES["other_regions"], + FIXTURE_USER_INPUT_PASSWORD, + "other_regions", + ), + ( + SERVER_URLS_NAMES["north_america"], + { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_REGION: "north_america", + }, + "north_america", + ), + ], +) +async def test_reconfigure_password_success( + hass: HomeAssistant, + mock_growatt_classic_api: MagicMock, + stored_url: str, + user_input: dict[str, str], + expected_region: str, +) -> None: + """Test successful reconfiguration with password auth for default and non-default regions.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_password", + CONF_URL: stored_url, + CONF_PLANT_ID: "123456", + CONF_NAME: "Test Plant", + }, + unique_id="123456", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + region_key = next( + k + for k in result["data_schema"].schema + if isinstance(k, vol.Required) and k.schema == CONF_REGION + ) + assert region_key.default() == expected_region + + mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_NAME: "Test Plant", + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_PLANT_ID: "123456", + CONF_URL: SERVER_URLS_NAMES[user_input[CONF_REGION]], + CONF_USERNAME: user_input[CONF_USERNAME], + } + + +@pytest.mark.parametrize( + ("login_side_effect", "expected_error"), + [ + ( + lambda *args, **kwargs: {"msg": LOGIN_INVALID_AUTH_CODE, "success": False}, + ERROR_INVALID_AUTH, + ), + ( + requests.exceptions.ConnectionError("Connection failed"), + ERROR_CANNOT_CONNECT, + ), + ], +) +async def test_reconfigure_password_error_then_recovery( + hass: HomeAssistant, + mock_growatt_classic_api: MagicMock, + mock_config_entry_classic: MockConfigEntry, + login_side_effect: Callable[..., Any] | Exception, + expected_error: str, +) -> None: + """Test password reconfigure shows error then allows recovery.""" + mock_config_entry_classic.add_to_hass(hass) + + result = await mock_config_entry_classic.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_growatt_classic_api.login.side_effect = login_side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": expected_error} + + # Recover with correct credentials + mock_growatt_classic_api.login.side_effect = None + mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_token_success( + hass: HomeAssistant, + mock_growatt_v1_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reconfiguration with token auth.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_AUTH_TYPE: AUTH_API_TOKEN, + CONF_NAME: "Test Plant", + CONF_PLANT_ID: "123456", + CONF_TOKEN: FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN], + CONF_URL: SERVER_URLS_NAMES[FIXTURE_USER_INPUT_TOKEN[CONF_REGION]], + "user_id": "12345", + } + + +@pytest.mark.parametrize( + ("plant_list_side_effect", "expected_error"), + [ + (_make_no_privilege_error(), ERROR_INVALID_AUTH), + (requests.exceptions.ConnectionError("Network error"), ERROR_CANNOT_CONNECT), + ], +) +async def test_reconfigure_token_error_then_recovery( + hass: HomeAssistant, + mock_growatt_v1_api: MagicMock, + mock_config_entry: MockConfigEntry, + plant_list_side_effect: Exception, + expected_error: str, +) -> None: + """Test token reconfigure shows error then allows recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_growatt_v1_api.plant_list.side_effect = plant_list_side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": expected_error} + + # Recover with a valid token + mock_growatt_v1_api.plant_list.side_effect = None + mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_unknown_auth_type(hass: HomeAssistant) -> None: + """Test reconfigure aborts immediately when the config entry has an unknown auth type.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: "unknown_type", + CONF_PLANT_ID: "123456", + CONF_NAME: "Test Plant", + }, + unique_id="123456", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == ERROR_CANNOT_CONNECT From 0fc62c315066f3197fc9a7ec5935008da3cf0068 Mon Sep 17 00:00:00 2001 From: Andres Ruiz Date: Wed, 1 Apr 2026 14:03:15 -0400 Subject: [PATCH 0347/1707] Add support for energy statistics in waterfurnace integration (#166707) Co-authored-by: Joostlek --- .../components/waterfurnace/__init__.py | 27 +- .../components/waterfurnace/const.py | 1 + .../components/waterfurnace/coordinator.py | 199 +++++++++++++- .../components/waterfurnace/manifest.json | 1 + .../components/waterfurnace/sensor.py | 4 +- tests/components/waterfurnace/conftest.py | 6 +- tests/components/waterfurnace/test_init.py | 66 ++++- .../waterfurnace/test_statistics.py | 251 ++++++++++++++++++ 8 files changed, 541 insertions(+), 14 deletions(-) create mode 100644 tests/components/waterfurnace/test_statistics.py diff --git a/homeassistant/components/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py index 066fbc530af1ae..bdb370084b4220 100644 --- a/homeassistant/components/waterfurnace/__init__.py +++ b/homeassistant/components/waterfurnace/__init__.py @@ -17,7 +17,11 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, INTEGRATION_TITLE -from .coordinator import WaterFurnaceCoordinator +from .coordinator import ( + WaterFurnaceCoordinator, + WaterFurnaceDeviceData, + WaterFurnaceEnergyCoordinator, +) _LOGGER = logging.getLogger(__name__) @@ -34,7 +38,7 @@ }, extra=vol.ALLOW_EXTRA, ) -type WaterFurnaceConfigEntry = ConfigEntry[dict[str, WaterFurnaceCoordinator]] +type WaterFurnaceConfigEntry = ConfigEntry[dict[str, WaterFurnaceDeviceData]] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -95,7 +99,7 @@ async def _async_setup_coordinator( password: str, device_index: int, entry: WaterFurnaceConfigEntry, -) -> tuple[str, WaterFurnaceCoordinator]: +) -> tuple[str, WaterFurnaceDeviceData]: """Set up a coordinator for a device.""" device_client = WaterFurnace(username, password, device=device_index) @@ -107,7 +111,18 @@ async def _async_setup_coordinator( raise ConfigEntryNotReady( f"Invalid GWID for device at index {device_index}: {device_client.gwid}" ) - return device_client.gwid, coordinator + + energy_coordinator = WaterFurnaceEnergyCoordinator( + hass, device_client, entry, device_client.gwid + ) + # Use async_refresh() instead of async_config_entry_first_refresh() so that + # energy data failures (e.g. WFNoDataError for new accounts) don't block + # the integration from loading. Realtime sensor data is the primary concern. + await energy_coordinator.async_refresh() + + return device_client.gwid, WaterFurnaceDeviceData( + realtime=coordinator, energy=energy_coordinator + ) async def async_setup_entry( @@ -126,10 +141,12 @@ async def async_setup_entry( "Authentication failed. Please update your credentials." ) from err + device_count = len(client.devices) if client.devices else 0 + results = await asyncio.gather( *[ _async_setup_coordinator(hass, username, password, index, entry) - for index in range(len(client.devices) if client.devices else 0) + for index in range(device_count) ] ) entry.runtime_data = dict(results) diff --git a/homeassistant/components/waterfurnace/const.py b/homeassistant/components/waterfurnace/const.py index 5f12739eb05d2a..f2382045df8e98 100644 --- a/homeassistant/components/waterfurnace/const.py +++ b/homeassistant/components/waterfurnace/const.py @@ -6,3 +6,4 @@ DOMAIN: Final = "waterfurnace" INTEGRATION_TITLE: Final = "WaterFurnace" UPDATE_INTERVAL: Final = timedelta(seconds=10) +ENERGY_UPDATE_INTERVAL: Final = timedelta(hours=2) diff --git a/homeassistant/components/waterfurnace/coordinator.py b/homeassistant/components/waterfurnace/coordinator.py index 66816763232a63..483bc9e54c80ef 100644 --- a/homeassistant/components/waterfurnace/coordinator.py +++ b/homeassistant/components/waterfurnace/coordinator.py @@ -1,14 +1,38 @@ """Data update coordinator for WaterFurnace.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING -from waterfurnace.waterfurnace import WaterFurnace, WFException, WFGateway, WFReading +from waterfurnace.waterfurnace import ( + WaterFurnace, + WFCredentialError, + WFException, + WFGateway, + WFNoDataError, + WFReading, +) -from homeassistant.core import HomeAssistant +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticMeanType +from homeassistant.components.recorder.models.statistics import ( + StatisticData, + StatisticMetaData, +) +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, +) +from homeassistant.const import UnitOfEnergy +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import EnergyConverter -from .const import UPDATE_INTERVAL +from .const import DOMAIN, ENERGY_UPDATE_INTERVAL, UPDATE_INTERVAL if TYPE_CHECKING: from . import WaterFurnaceConfigEntry @@ -16,6 +40,14 @@ _LOGGER = logging.getLogger(__name__) +@dataclass +class WaterFurnaceDeviceData: + """Container for per-device coordinators.""" + + realtime: WaterFurnaceCoordinator + energy: WaterFurnaceEnergyCoordinator + + class WaterFurnaceCoordinator(DataUpdateCoordinator[WFReading]): """WaterFurnace data update coordinator. @@ -54,3 +86,164 @@ async def _async_update_data(self): return await self.hass.async_add_executor_job(self.client.read_with_retry) except WFException as err: raise UpdateFailed(str(err)) from err + + +class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]): + """WaterFurnace energy data coordinator. + + Periodically fetches energy data and inserts external statistics + for the Energy Dashboard. + """ + + config_entry: WaterFurnaceConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: WaterFurnace, + config_entry: WaterFurnaceConfigEntry, + gwid: str, + ) -> None: + """Initialize the energy coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"WaterFurnace Energy {gwid}", + update_interval=ENERGY_UPDATE_INTERVAL, + config_entry=config_entry, + ) + self.client = client + self.gwid = gwid + self.statistic_id = f"{DOMAIN}:{gwid.lower()}_energy" + self._statistic_metadata = StatisticMetaData( + has_sum=True, + mean_type=StatisticMeanType.NONE, + name=f"WaterFurnace Energy {gwid}", + source=DOMAIN, + statistic_id=self.statistic_id, + unit_class=EnergyConverter.UNIT_CLASS, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ) + + @callback + def _dummy_listener() -> None: + pass + + # Ensure periodic polling even without entity listeners, + # since this coordinator only inserts external statistics. + self.async_add_listener(_dummy_listener) + + async def _async_get_last_stat(self) -> tuple[float, float] | None: + """Get the last recorded statistic timestamp and sum. + + Returns (timestamp, sum) or None if no statistics exist. + """ + last_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, self.statistic_id, True, {"sum"} + ) + if not last_stat: + return None + entry = last_stat[self.statistic_id][0] + if entry["sum"] is None: + return None + return (entry["start"], entry["sum"]) + + def _fetch_energy_data( + self, start_date: str, end_date: str + ) -> list[tuple[datetime, float]]: + """Fetch energy data and return list of (timestamp, kWh) tuples.""" + # Re-login to refresh the HTTP session token, which expires between + # the 2-hour polling intervals. + try: + self.client.login() + except WFCredentialError as err: + raise UpdateFailed( + "Authentication failed during energy data fetch" + ) from err + data = self.client.get_energy_data( + start_date, + end_date, + frequency="1H", + timezone_str=self.hass.config.time_zone, + ) + return [ + (reading.timestamp, reading.total_power) + for reading in data + if reading.total_power is not None + ] + + @staticmethod + def _build_statistics( + readings: list[tuple[datetime, float]], + last_ts: float, + last_sum: float, + now: datetime, + ) -> list[StatisticData]: + """Build hourly statistics from readings, skipping already-recorded ones.""" + current_hour_ts = now.replace(minute=0, second=0, microsecond=0).timestamp() + statistics: list[StatisticData] = [] + seen_hours: set[float] = set() + running_sum = last_sum + for timestamp, kwh in sorted(readings, key=lambda x: x[0]): + ts = timestamp.timestamp() + if ts <= last_ts: + continue + if ts >= current_hour_ts: + continue + hour_ts = timestamp.replace(minute=0, second=0, microsecond=0).timestamp() + if hour_ts in seen_hours: + continue + seen_hours.add(hour_ts) + running_sum += kwh + statistics.append( + StatisticData( + start=timestamp.replace(minute=0, second=0, microsecond=0), + state=kwh, + sum=running_sum, + ) + ) + return statistics + + async def _async_update_data(self) -> None: + """Fetch energy data and insert statistics.""" + last = await self._async_get_last_stat() + now = dt_util.utcnow() + + if last is None: + _LOGGER.info("No prior statistics found, fetching recent energy data") + last_ts = 0.0 + last_sum = 0.0 + start_dt = now - timedelta(days=1) + else: + last_ts, last_sum = last + start_dt = dt_util.utc_from_timestamp(last_ts) + _LOGGER.debug("Last stat: ts=%s, sum=%s", start_dt.isoformat(), last_sum) + + local_tz = dt_util.DEFAULT_TIME_ZONE + start_date = start_dt.astimezone(local_tz).strftime("%Y-%m-%d") + end_date = (now.astimezone(local_tz) + timedelta(days=1)).strftime("%Y-%m-%d") + + try: + readings = await self.hass.async_add_executor_job( + self._fetch_energy_data, start_date, end_date + ) + except WFNoDataError: + _LOGGER.debug("No energy data available for %s to %s", start_date, end_date) + return + except WFException as err: + raise UpdateFailed(str(err)) from err + + if not readings: + _LOGGER.debug("No readings returned for %s to %s", start_date, end_date) + return + + _LOGGER.debug("Fetched %s readings", len(readings)) + + statistics = self._build_statistics(readings, last_ts, last_sum, now) + + _LOGGER.debug("Built %s statistics to insert", len(statistics)) + + if statistics: + async_add_external_statistics( + self.hass, self._statistic_metadata, statistics + ) diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json index bcdfff1ca993c1..31934f71ae574c 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -1,6 +1,7 @@ { "domain": "waterfurnace", "name": "WaterFurnace", + "after_dependencies": ["recorder"], "codeowners": ["@sdague", "@masterkoppa"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waterfurnace", diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 519ea0acea1008..9634baabb51a8e 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -156,8 +156,8 @@ async def async_setup_entry( ) -> None: """Set up Waterfurnace sensors from a config entry.""" async_add_entities( - WaterFurnaceSensor(coordinator, description) - for coordinator in config_entry.runtime_data.values() + WaterFurnaceSensor(device_data.realtime, description) + for device_data in config_entry.runtime_data.values() for description in SENSORS ) diff --git a/tests/components/waterfurnace/conftest.py b/tests/components/waterfurnace/conftest.py index f1472946e73bc0..0abc5d3c20b7ce 100644 --- a/tests/components/waterfurnace/conftest.py +++ b/tests/components/waterfurnace/conftest.py @@ -4,8 +4,9 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from waterfurnace.waterfurnace import WaterFurnace, WFGateway, WFReading +from waterfurnace.waterfurnace import WaterFurnace, WFGateway, WFNoDataError, WFReading +from homeassistant.components.recorder import Recorder from homeassistant.components.waterfurnace.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -49,6 +50,7 @@ def mock_waterfurnace_client() -> Generator[Mock]: device_data = WFReading(load_json_object_fixture("device_data.json", DOMAIN)) client.read.return_value = device_data client.read_with_retry.return_value = device_data + client.get_energy_data.side_effect = WFNoDataError("No data") yield client @@ -87,6 +89,7 @@ def mock_waterfurnace_client_multi_device() -> Generator[Mock]: client.devices = [WFGateway(gateway_data_1), WFGateway(gateway_data_2)] client.read.return_value = device_data client.read_with_retry.return_value = device_data + client.get_energy_data.side_effect = WFNoDataError("No data") instances.append(client) mock_client.side_effect = lambda username, password, device=0: instances[device] @@ -111,6 +114,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def init_integration( + recorder_mock: Recorder, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_waterfurnace_client: Mock, diff --git a/tests/components/waterfurnace/test_init.py b/tests/components/waterfurnace/test_init.py index ba686caf16ee16..9679c4f980e9a6 100644 --- a/tests/components/waterfurnace/test_init.py +++ b/tests/components/waterfurnace/test_init.py @@ -5,6 +5,7 @@ import pytest from waterfurnace.waterfurnace import WFCredentialError +from homeassistant.components.recorder import Recorder from homeassistant.components.waterfurnace.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -124,11 +125,70 @@ async def test_reload_entry( ) -> None: """Test reloading a config entry.""" assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_waterfurnace_client.login.call_count == 2 + assert mock_waterfurnace_client.login.call_count == 3 await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_waterfurnace_client.login.call_count == 4 - assert "TEST_GWID_12345" in mock_config_entry.runtime_data + assert mock_waterfurnace_client.login.call_count == 6 + + +async def test_setup_creates_energy_coordinator( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client: Mock, +) -> None: + """Test that setup creates both realtime and energy coordinators.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_waterfurnace_client.login.call_count == 3 + assert mock_waterfurnace_client.read_with_retry.call_count == 1 + assert mock_waterfurnace_client.get_energy_data.call_count == 1 + + +async def test_setup_multi_device_energy_coordinators( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client_multi_device: Mock, +) -> None: + """Test multi-device setup creates energy coordinators with correct gwids.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + gwids = set() + for gwid, device_data in mock_config_entry.runtime_data.items(): + assert device_data.energy is not None + assert device_data.energy.gwid == gwid + gwids.add(gwid) + + assert gwids == {"TEST_GWID_12345", "TEST_GWID_67890"} + + +async def test_setup_energy_statistic_ids_per_device( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client_multi_device: Mock, +) -> None: + """Test that each device gets a unique statistic_id.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + stat_ids = { + device_data.energy.statistic_id + for device_data in mock_config_entry.runtime_data.values() + } + assert stat_ids == { + f"{DOMAIN}:test_gwid_12345_energy", + f"{DOMAIN}:test_gwid_67890_energy", + } diff --git a/tests/components/waterfurnace/test_statistics.py b/tests/components/waterfurnace/test_statistics.py new file mode 100644 index 00000000000000..c4c593cb23512b --- /dev/null +++ b/tests/components/waterfurnace/test_statistics.py @@ -0,0 +1,251 @@ +"""Tests for WaterFurnace energy statistics.""" + +from datetime import datetime, timedelta +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from waterfurnace.waterfurnace import WFCredentialError, WFEnergyData + +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.statistics import ( + StatisticsRow, + statistics_during_period, +) +from homeassistant.components.waterfurnace.const import DOMAIN, ENERGY_UPDATE_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.recorder.common import async_wait_recording_done + +STATISTIC_ID = f"{DOMAIN}:test_gwid_12345_energy" + +# All time-sensitive tests are pinned to this instant. +NOW = "2025-01-15 12:00:00+00:00" +_NOW_DT = dt_util.as_utc(dt_util.parse_datetime(NOW)) + + +def _make_energy_data(readings: list[tuple[datetime, float]]) -> WFEnergyData: + """Build a WFEnergyData from (timestamp, total_power_kwh) pairs.""" + columns = ["total_power"] + index = [int(ts.timestamp() * 1000) for ts, _ in readings] + data = [[kwh] for _, kwh in readings] + return WFEnergyData({"columns": columns, "index": index, "data": data}) + + +async def _get_stats( + hass: HomeAssistant, + start: datetime, + end: datetime, +) -> list[StatisticsRow]: + """Get statistics for the test statistic_id.""" + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + start, + end, + {STATISTIC_ID}, + "hour", + None, + {"state", "sum"}, + ) + return stats.get(STATISTIC_ID, []) + + +async def _trigger_energy_poll( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Advance time to trigger an energy poll.""" + freezer.tick(ENERGY_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + # The coordinator reads from the recorder on an executor thread, then + # calls async_add_external_statistics which queues a write back to the + # recorder thread. A single flush isn't enough because the write is + # queued after the event loop task completes. Flush twice to ensure the + # full event-loop → recorder → event-loop → recorder chain settles. + await async_wait_recording_done(hass) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + +@pytest.mark.freeze_time(NOW) +async def test_poll_inserts_statistics( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client: Mock, +) -> None: + """Test that energy data is fetched and inserted as statistics.""" + t1 = _NOW_DT - timedelta(hours=2) + t2 = _NOW_DT - timedelta(hours=1) + + mock_waterfurnace_client.get_energy_data.side_effect = None + mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data( + [(t1, 2.0), (t2, 3.0)] + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + entries = await _get_stats(hass, t1, t2 + timedelta(seconds=1)) + assert len(entries) == 2 + assert entries[0]["state"] == pytest.approx(2.0) + assert entries[0]["sum"] == pytest.approx(2.0) + assert entries[1]["state"] == pytest.approx(3.0) + assert entries[1]["sum"] == pytest.approx(5.0) + + +@pytest.mark.freeze_time(NOW) +async def test_poll_skips_current_hour( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client: Mock, +) -> None: + """Test that readings from the current incomplete hour are skipped.""" + t_completed = _NOW_DT - timedelta(hours=1) + t_current = _NOW_DT + + mock_waterfurnace_client.get_energy_data.side_effect = None + mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data( + [(t_completed, 2.0), (t_current, 5.0)] + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + entries = await _get_stats(hass, t_completed, t_current + timedelta(seconds=1)) + assert len(entries) == 1 + assert entries[0]["sum"] == pytest.approx(2.0) + + +async def test_poll_empty_response( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client: Mock, +) -> None: + """Test that empty energy data response is handled gracefully.""" + mock_waterfurnace_client.get_energy_data.side_effect = None + mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data([]) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.freeze_time(NOW) +async def test_subsequent_poll_resumes_sum( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that subsequent polls correctly resume from the last recorded sum.""" + t1 = _NOW_DT - timedelta(hours=1) + + mock_waterfurnace_client.get_energy_data.side_effect = None + mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data( + [(t1, 4.0)] + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # Advance time so t2 becomes a completed hour, then poll again + t2 = _NOW_DT + mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data( + [(t2, 6.0)] + ) + await _trigger_energy_poll(hass, freezer) + + entries = await _get_stats(hass, t2, t2 + timedelta(seconds=1)) + assert len(entries) == 1 + assert entries[0]["state"] == pytest.approx(6.0) + assert entries[0]["sum"] == pytest.approx(10.0) + + +async def test_no_data_error_handled_gracefully( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client: Mock, +) -> None: + """Test that WFNoDataError does not prevent integration setup.""" + # Default conftest sets get_energy_data.side_effect = WFNoDataError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.freeze_time(NOW) +async def test_login_credential_error_raises_update_failed( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that WFCredentialError during energy login raises UpdateFailed.""" + # First setup succeeds with no data + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # On next poll, login fails with credential error + mock_waterfurnace_client.login.side_effect = WFCredentialError("bad creds") + mock_waterfurnace_client.get_energy_data.side_effect = None + + await _trigger_energy_poll(hass, freezer) + + device_data = mock_config_entry.runtime_data["TEST_GWID_12345"] + assert device_data.energy.last_update_success is False + + +@pytest.mark.freeze_time(NOW) +async def test_timezone_conversion( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waterfurnace_client: Mock, +) -> None: + """Test that energy data is correctly handled across time zones.""" + await hass.config.async_set_time_zone("America/New_York") + + t1 = _NOW_DT - timedelta(hours=2) + t2 = _NOW_DT - timedelta(hours=1) + + mock_waterfurnace_client.get_energy_data.side_effect = None + mock_waterfurnace_client.get_energy_data.return_value = _make_energy_data( + [(t1, 1.5), (t2, 2.5)] + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + entries = await _get_stats(hass, t1, t2 + timedelta(seconds=1)) + assert len(entries) == 2 + assert entries[0]["sum"] == pytest.approx(1.5) + assert entries[1]["sum"] == pytest.approx(4.0) + + # Verify the API was called with dates in the configured timezone + call_args = mock_waterfurnace_client.get_energy_data.call_args + assert call_args.kwargs.get("timezone_str") == "America/New_York" From 2beca6b322e44c466e448b9f68464177cb8ff9f4 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:10:39 +0200 Subject: [PATCH 0348/1707] Fix websocket calling `async_release_notes` in update component although unavailable (#167067) --- homeassistant/components/update/__init__.py | 8 +++- tests/components/update/test_init.py | 43 ++++++++++++++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 2d9f13f02ada34..200bb346c4d1dd 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -531,7 +531,13 @@ async def websocket_release_notes( "Entity does not support release notes", ) return - + if entity.available is False: + connection.send_error( + msg["id"], + websocket_api.ERR_HOME_ASSISTANT_ERROR, + "Entity is not available", + ) + return connection.send_result( msg["id"], await entity.async_release_notes(), diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 17ffbbc3f25e6b..a606ef43a4c127 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -48,6 +48,8 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component +from .common import MockUpdateEntity + from tests.common import ( MockConfigEntry, MockEntityPlatform, @@ -64,13 +66,9 @@ TEST_DOMAIN = "test" -class MockUpdateEntity(UpdateEntity): - """Mock UpdateEntity to use in tests.""" - - async def test_update(hass: HomeAssistant) -> None: """Test getting data from the mocked update entity.""" - update = MockUpdateEntity() + update = UpdateEntity() update.hass = hass update.platform = MockEntityPlatform(hass) @@ -797,6 +795,41 @@ async def test_release_notes_entity_does_not_support_release_notes( assert result["error"]["message"] == "Entity does not support release notes" +async def test_release_notes_entity_unavailable( + hass: HomeAssistant, + mock_update_entities: list[MockUpdateEntity], + hass_ws_client: WebSocketGenerator, +) -> None: + """Test getting the release notes for entity that is unavailable.""" + entity = MockUpdateEntity( + name="Update unavailable", + unique_id="unavailable", + installed_version="1.0.0", + latest_version="1.0.1", + available=False, + supported_features=UpdateEntityFeature.RELEASE_NOTES, + ) + + setup_test_component_platform(hass, DOMAIN, [entity]) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.update_unavailable", + } + ) + result = await client.receive_json() + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == "Entity is not available" + + class MockFlow(ConfigFlow): """Test flow.""" From cd0ed42941cbe0644a12b48290ffb444fada6f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 1 Apr 2026 19:16:34 +0100 Subject: [PATCH 0349/1707] Make the Claude's GH reviewer skill a subagent (#167065) --- .../SKILL.md => agents/github-pr-reviewer.md} | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename .claude/{skills/github-pr-reviewer/SKILL.md => agents/github-pr-reviewer.md} (90%) diff --git a/.claude/skills/github-pr-reviewer/SKILL.md b/.claude/agents/github-pr-reviewer.md similarity index 90% rename from .claude/skills/github-pr-reviewer/SKILL.md rename to .claude/agents/github-pr-reviewer.md index 1a7e09945cdca9..8ae8da1af8f0bc 100644 --- a/.claude/skills/github-pr-reviewer/SKILL.md +++ b/.claude/agents/github-pr-reviewer.md @@ -1,6 +1,7 @@ --- name: github-pr-reviewer -description: Review a GitHub pull request and provide feedback comments. Use when the user says "review the current PR" or asks to review a specific PR. +description: Reviews GitHub pull requests and provides feedback comments. +disallowedTools: Write, Edit --- # Review GitHub Pull Request From 83e8c3fc195faad74467cf46e035e748bb20e5f0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 Apr 2026 20:19:16 +0200 Subject: [PATCH 0350/1707] Revert "Pull out Dropbox integration" (#166995) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/dropbox/__init__.py | 64 ++ .../dropbox/application_credentials.py | 38 ++ homeassistant/components/dropbox/auth.py | 44 ++ homeassistant/components/dropbox/backup.py | 230 +++++++ .../components/dropbox/config_flow.py | 60 ++ homeassistant/components/dropbox/const.py | 19 + .../components/dropbox/manifest.json | 13 + .../components/dropbox/quality_scale.yaml | 112 ++++ homeassistant/components/dropbox/strings.json | 35 ++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/dropbox/__init__.py | 1 + tests/components/dropbox/conftest.py | 114 ++++ tests/components/dropbox/test_backup.py | 577 ++++++++++++++++++ tests/components/dropbox/test_config_flow.py | 210 +++++++ tests/components/dropbox/test_init.py | 100 +++ 22 files changed, 1644 insertions(+) create mode 100644 homeassistant/components/dropbox/__init__.py create mode 100644 homeassistant/components/dropbox/application_credentials.py create mode 100644 homeassistant/components/dropbox/auth.py create mode 100644 homeassistant/components/dropbox/backup.py create mode 100644 homeassistant/components/dropbox/config_flow.py create mode 100644 homeassistant/components/dropbox/const.py create mode 100644 homeassistant/components/dropbox/manifest.json create mode 100644 homeassistant/components/dropbox/quality_scale.yaml create mode 100644 homeassistant/components/dropbox/strings.json create mode 100644 tests/components/dropbox/__init__.py create mode 100644 tests/components/dropbox/conftest.py create mode 100644 tests/components/dropbox/test_backup.py create mode 100644 tests/components/dropbox/test_config_flow.py create mode 100644 tests/components/dropbox/test_init.py diff --git a/.strict-typing b/.strict-typing index 14a2a7ed98c739..5e1549256616c9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -174,6 +174,7 @@ homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.downloader.* +homeassistant.components.dropbox.* homeassistant.components.droplet.* homeassistant.components.dsmr.* homeassistant.components.duckdns.* diff --git a/CODEOWNERS b/CODEOWNERS index 32705e6c684e13..a7fac84580c936 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -401,6 +401,8 @@ build.json @home-assistant/supervisor /tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer +/homeassistant/components/dropbox/ @bdr99 +/tests/components/dropbox/ @bdr99 /homeassistant/components/droplet/ @sarahseidman /tests/components/droplet/ @sarahseidman /homeassistant/components/dsmr/ @Robbie1221 diff --git a/homeassistant/components/dropbox/__init__.py b/homeassistant/components/dropbox/__init__.py new file mode 100644 index 00000000000000..4be8074a5cd188 --- /dev/null +++ b/homeassistant/components/dropbox/__init__.py @@ -0,0 +1,64 @@ +"""The Dropbox integration.""" + +from __future__ import annotations + +from python_dropbox_api import ( + DropboxAPIClient, + DropboxAuthException, + DropboxUnknownException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) + +from .auth import DropboxConfigEntryAuth +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +type DropboxConfigEntry = ConfigEntry[DropboxAPIClient] + + +async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: + """Set up Dropbox from a config entry.""" + try: + oauth2_implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + oauth2_session = OAuth2Session(hass, entry, oauth2_implementation) + + auth = DropboxConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), oauth2_session + ) + + client = DropboxAPIClient(auth) + + try: + await client.get_account_info() + except DropboxAuthException as err: + raise ConfigEntryAuthFailed from err + except (DropboxUnknownException, TimeoutError) as err: + raise ConfigEntryNotReady from err + + entry.runtime_data = client + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: + """Unload a config entry.""" + return True diff --git a/homeassistant/components/dropbox/application_credentials.py b/homeassistant/components/dropbox/application_credentials.py new file mode 100644 index 00000000000000..3babe856a28aca --- /dev/null +++ b/homeassistant/components/dropbox/application_credentials.py @@ -0,0 +1,38 @@ +"""Application credentials platform for the Dropbox integration.""" + +from homeassistant.components.application_credentials import ClientCredential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2Implementation, + LocalOAuth2ImplementationWithPkce, +) + +from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> AbstractOAuth2Implementation: + """Return custom auth implementation.""" + return DropboxOAuth2Implementation( + hass, + auth_domain, + credential.client_id, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + credential.client_secret, + ) + + +class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce): + """Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters.""" + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + data: dict = { + "token_access_type": "offline", + "scope": " ".join(OAUTH2_SCOPES), + } + data.update(super().extra_authorize_data) + return data diff --git a/homeassistant/components/dropbox/auth.py b/homeassistant/components/dropbox/auth.py new file mode 100644 index 00000000000000..da6d72f6748f23 --- /dev/null +++ b/homeassistant/components/dropbox/auth.py @@ -0,0 +1,44 @@ +"""Authentication for Dropbox.""" + +from typing import cast + +from aiohttp import ClientSession +from python_dropbox_api import Auth + +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + + +class DropboxConfigEntryAuth(Auth): + """Provide Dropbox authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: OAuth2Session, + ) -> None: + """Initialize DropboxConfigEntryAuth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + + return cast(str, self._oauth_session.token["access_token"]) + + +class DropboxConfigFlowAuth(Auth): + """Provide authentication tied to a fixed token for the config flow.""" + + def __init__( + self, + websession: ClientSession, + token: str, + ) -> None: + """Initialize DropboxConfigFlowAuth.""" + super().__init__(websession) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the fixed access token.""" + return self._token diff --git a/homeassistant/components/dropbox/backup.py b/homeassistant/components/dropbox/backup.py new file mode 100644 index 00000000000000..bc7af3d5cbc859 --- /dev/null +++ b/homeassistant/components/dropbox/backup.py @@ -0,0 +1,230 @@ +"""Backup platform for the Dropbox integration.""" + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import json +import logging +from typing import Any, Concatenate + +from python_dropbox_api import ( + DropboxAPIClient, + DropboxAuthException, + DropboxFileOrFolderNotFoundException, + DropboxUnknownException, +) + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback + +from . import DropboxConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +async def _async_string_iterator(content: str) -> AsyncIterator[bytes]: + """Yield a string as a single bytes chunk.""" + yield content.encode() + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper( + self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except DropboxFileOrFolderNotFoundException as err: + raise BackupNotFound( + f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" + ) from err + except DropboxAuthException as err: + self._entry.async_start_reauth(self._hass) + raise BackupAgentError("Authentication error") from err + except DropboxUnknownException as err: + _LOGGER.error( + "Error during %s: %s", + func.__name__, + err, + ) + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError( + f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" + ) from err + + return wrapper + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + return [DropboxBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +class DropboxBackupAgent(BackupAgent): + """Backup agent for the Dropbox integration.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None: + """Initialize the backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self.name = entry.title + assert entry.unique_id + self.unique_id = entry.unique_id + self._api: DropboxAPIClient = entry.runtime_data + + async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]: + """Get backups and their corresponding file names.""" + files = await self._api.list_folder("") + + tar_files = {f.name for f in files if f.name.endswith(".tar")} + metadata_files = [f for f in files if f.name.endswith(".metadata.json")] + + backups: list[tuple[AgentBackup, str]] = [] + for metadata_file in metadata_files: + tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar" + if tar_name not in tar_files: + _LOGGER.warning( + "Found metadata file '%s' without matching backup file", + metadata_file.name, + ) + continue + + metadata_stream = self._api.download_file(f"/{metadata_file.name}") + raw = b"".join([chunk async for chunk in metadata_stream]) + try: + data = json.loads(raw) + backup = AgentBackup.from_dict(data) + except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err: + _LOGGER.warning( + "Skipping invalid metadata file '%s': %s", + metadata_file.name, + err, + ) + continue + backups.append((backup, tar_name)) + + return backups + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + backup_filename, metadata_filename = _suggested_filenames(backup) + backup_path = f"/{backup_filename}" + metadata_path = f"/{metadata_filename}" + + file_stream = await open_stream() + await self._api.upload_file(backup_path, file_stream) + + metadata_stream = _async_string_iterator(json.dumps(backup.as_dict())) + + try: + await self._api.upload_file(metadata_path, metadata_stream) + except ( + DropboxAuthException, + DropboxUnknownException, + ): + await self._api.delete_file(backup_path) + raise + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + return [backup for backup, _ in await self._async_get_backups()] + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + backups = await self._async_get_backups() + for backup, filename in backups: + if backup.backup_id == backup_id: + return self._api.download_file(f"/{filename}") + + raise BackupNotFound(f"Backup {backup_id} not found") + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup: + """Return a backup.""" + backups = await self._async_get_backups() + + for backup, _ in backups: + if backup.backup_id == backup_id: + return backup + + raise BackupNotFound(f"Backup {backup_id} not found") + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + backups = await self._async_get_backups() + for backup, tar_filename in backups: + if backup.backup_id == backup_id: + metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json" + await self._api.delete_file(f"/{tar_filename}") + await self._api.delete_file(f"/{metadata_filename}") + return + + raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/dropbox/config_flow.py b/homeassistant/components/dropbox/config_flow.py new file mode 100644 index 00000000000000..045f858bd59b89 --- /dev/null +++ b/homeassistant/components/dropbox/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for Dropbox.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from python_dropbox_api import DropboxAPIClient + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler + +from .auth import DropboxConfigFlowAuth +from .const import DOMAIN + + +class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Dropbox OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow, or update existing entry.""" + access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN] + + auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token) + + client = DropboxAPIClient(auth) + account_info = await client.get_account_info() + + await self.async_set_unique_id(account_info.account_id) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=account_info.email, data=data) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/dropbox/const.py b/homeassistant/components/dropbox/const.py new file mode 100644 index 00000000000000..042f5b5c7bfddf --- /dev/null +++ b/homeassistant/components/dropbox/const.py @@ -0,0 +1,19 @@ +"""Constants for the Dropbox integration.""" + +from collections.abc import Callable + +from homeassistant.util.hass_dict import HassKey + +DOMAIN = "dropbox" + +OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize" +OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token" +OAUTH2_SCOPES = [ + "account_info.read", + "files.content.read", + "files.content.write", +] + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/dropbox/manifest.json b/homeassistant/components/dropbox/manifest.json new file mode 100644 index 00000000000000..01254682b79285 --- /dev/null +++ b/homeassistant/components/dropbox/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "dropbox", + "name": "Dropbox", + "after_dependencies": ["backup"], + "codeowners": ["@bdr99"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/dropbox", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["python-dropbox-api==0.1.3"] +} diff --git a/homeassistant/components/dropbox/quality_scale.yaml b/homeassistant/components/dropbox/quality_scale.yaml new file mode 100644 index 00000000000000..3f46b70b7a5e1f --- /dev/null +++ b/homeassistant/components/dropbox/quality_scale.yaml @@ -0,0 +1,112 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register any actions. + appropriate-polling: + status: exempt + comment: Integration does not poll. + brands: done + common-modules: + status: exempt + comment: Integration does not have any entities or coordinators. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not have any entities. + entity-unique-id: + status: exempt + comment: Integration does not have any entities. + has-entity-name: + status: exempt + comment: Integration does not have any entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not have any configuration parameters. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Integration does not have any entities. + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: exempt + comment: Integration does not make any entity updates. + reauthentication-flow: done + test-coverage: done + + # Gold + devices: + status: exempt + comment: Integration does not have any entities. + diagnostics: + status: exempt + comment: Integration does not have any data to diagnose. + discovery-update-info: + status: exempt + comment: Integration is a service. + discovery: + status: exempt + comment: Integration is a service. + docs-data-update: + status: exempt + comment: Integration does not update any data. + docs-examples: + status: exempt + comment: Integration only provides backup functionality. + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: Integration does not support any devices. + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Integration does not use any devices. + entity-category: + status: exempt + comment: Integration does not have any entities. + entity-device-class: + status: exempt + comment: Integration does not have any entities. + entity-disabled-by-default: + status: exempt + comment: Integration does not have any entities. + entity-translations: + status: exempt + comment: Integration does not have any entities. + exception-translations: todo + icon-translations: + status: exempt + comment: Integration does not have any entities. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not have any repairs. + stale-devices: + status: exempt + comment: Integration does not have any devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/dropbox/strings.json b/homeassistant/components/dropbox/strings.json new file mode 100644 index 00000000000000..4904f997e314e7 --- /dev/null +++ b/homeassistant/components/dropbox/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "wrong_account": "Wrong account: Please authenticate with the correct account." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Dropbox integration needs to re-authenticate your account.", + "title": "[%key:common::config_flow::title::reauth%]" + } + } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 51435aac0bb4dd..a520338e91629c 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -6,6 +6,7 @@ APPLICATION_CREDENTIALS = [ "aladdin_connect", "august", + "dropbox", "ekeybionyx", "electric_kiwi", "fitbit", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9d223490e6b360..bb6901c64603d7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -160,6 +160,7 @@ "downloader", "dremel_3d_printer", "drop_connect", + "dropbox", "droplet", "dsmr", "dsmr_reader", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8f2a48cfdee69e..5acc3ec5b4395f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1485,6 +1485,12 @@ "config_flow": true, "iot_class": "local_push" }, + "dropbox": { + "name": "Dropbox", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "droplet": { "name": "Droplet", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 1994a1ace0a36b..0ca25a2f94ba2b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1495,6 +1495,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.dropbox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.droplet.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 99b39b7d30ddee..2c51d9ed45d87a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2568,6 +2568,9 @@ python-clementine-remote==1.0.1 # homeassistant.components.digital_ocean python-digitalocean==1.13.2 +# homeassistant.components.dropbox +python-dropbox-api==0.1.3 + # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index acf1bbfd81bd61..125c6bf77309bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2188,6 +2188,9 @@ python-awair==0.2.5 # homeassistant.components.bsblan python-bsblan==5.1.3 +# homeassistant.components.dropbox +python-dropbox-api==0.1.3 + # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/tests/components/dropbox/__init__.py b/tests/components/dropbox/__init__.py new file mode 100644 index 00000000000000..505d840280e053 --- /dev/null +++ b/tests/components/dropbox/__init__.py @@ -0,0 +1 @@ +"""Tests for the Dropbox integration.""" diff --git a/tests/components/dropbox/conftest.py b/tests/components/dropbox/conftest.py new file mode 100644 index 00000000000000..a5c324c2be5503 --- /dev/null +++ b/tests/components/dropbox/conftest.py @@ -0,0 +1,114 @@ +"""Shared fixtures for Dropbox integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.dropbox.const import DOMAIN, OAUTH2_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +ACCOUNT_ID = "dbid:1234567890abcdef" +ACCOUNT_EMAIL = "user@example.com" +CONFIG_ENTRY_TITLE = "Dropbox test account" +TEST_AGENT_ID = f"{DOMAIN}.{ACCOUNT_ID}" + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Set up application credentials for Dropbox.""" + + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +def account_info() -> SimpleNamespace: + """Return mocked Dropbox account information.""" + + return SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a default Dropbox config entry.""" + + return MockConfigEntry( + domain=DOMAIN, + unique_id=ACCOUNT_ID, + title=CONFIG_ENTRY_TITLE, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": 9_999_999_999, + "scope": " ".join(OAUTH2_SCOPES), + }, + }, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + + with patch( + "homeassistant.components.dropbox.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_dropbox_client(account_info: SimpleNamespace) -> Generator[MagicMock]: + """Patch DropboxAPIClient to exercise auth while mocking API calls.""" + + client = MagicMock() + client.list_folder = AsyncMock(return_value=[]) + client.download_file = MagicMock() + client.upload_file = AsyncMock() + client.delete_file = AsyncMock() + + captured_auth = None + + def capture_auth(auth): + nonlocal captured_auth + captured_auth = auth + return client + + async def get_account_info_with_auth(): + await captured_auth.async_get_access_token() + return client.get_account_info.return_value + + client.get_account_info = AsyncMock( + side_effect=get_account_info_with_auth, + return_value=account_info, + ) + + with ( + patch( + "homeassistant.components.dropbox.config_flow.DropboxAPIClient", + side_effect=capture_auth, + ), + patch( + "homeassistant.components.dropbox.DropboxAPIClient", + side_effect=capture_auth, + ), + ): + yield client diff --git a/tests/components/dropbox/test_backup.py b/tests/components/dropbox/test_backup.py new file mode 100644 index 00000000000000..804a37ef3ee0f2 --- /dev/null +++ b/tests/components/dropbox/test_backup.py @@ -0,0 +1,577 @@ +"""Test the Dropbox backup platform.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from io import StringIO +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from python_dropbox_api import DropboxAuthException + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + suggested_filename, +) +from homeassistant.components.dropbox.backup import ( + DropboxFileOrFolderNotFoundException, + DropboxUnknownException, + async_register_backup_agents_listener, +) +from homeassistant.components.dropbox.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import mock_stream +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +TEST_AGENT_BACKUP = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="dropbox-backup", + database_included=True, + date="2025-01-01T00:00:00.000Z", + extra_metadata={"with_automatic_settings": False}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Dropbox backup", + protected=False, + size=2048, +) + +TEST_AGENT_BACKUP_RESULT = { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "agents": {TEST_AGENT_ID: {"protected": False, "size": 2048}}, + "backup_id": TEST_AGENT_BACKUP.backup_id, + "database_included": True, + "date": TEST_AGENT_BACKUP.date, + "extra_metadata": {"with_automatic_settings": False}, + "failed_addons": [], + "failed_agent_ids": [], + "failed_folders": [], + "folders": [], + "homeassistant_included": True, + "homeassistant_version": TEST_AGENT_BACKUP.homeassistant_version, + "name": TEST_AGENT_BACKUP.name, + "with_automatic_settings": None, +} + + +def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +async def _mock_metadata_stream(backup: AgentBackup) -> AsyncIterator[bytes]: + """Create a mock metadata download stream.""" + yield json.dumps(backup.as_dict()).encode() + + +def _setup_list_folder_with_backup( + mock_dropbox_client: Mock, + backup: AgentBackup, +) -> None: + """Set up mock to return a backup in list_folder and download_file.""" + tar_name, metadata_name = _suggested_filenames(backup) + mock_dropbox_client.list_folder = AsyncMock( + return_value=[ + SimpleNamespace(name=tar_name), + SimpleNamespace(name=metadata_name), + ] + ) + mock_dropbox_client.download_file = Mock(return_value=_mock_metadata_stream(backup)) + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_dropbox_client, +) -> None: + """Set up the Dropbox and Backup integrations for testing.""" + + mock_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_dropbox_client.reset_mock() + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test listing available backup agents.""" + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": TEST_AGENT_ID, "name": CONFIG_ENTRY_TITLE}, + ] + } + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local", "name": "local"}] + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test listing backups via the Dropbox agent.""" + + _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] + mock_dropbox_client.list_folder.assert_awaited() + + +async def test_agents_list_backups_metadata_without_tar( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_dropbox_client: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that orphaned metadata files are skipped with a warning.""" + + mock_dropbox_client.list_folder = AsyncMock( + return_value=[SimpleNamespace(name="orphan.metadata.json")] + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [] + assert "without matching backup file" in caplog.text + + +async def test_agents_list_backups_invalid_metadata( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_dropbox_client: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that invalid metadata files are skipped with a warning.""" + + async def _invalid_stream() -> AsyncIterator[bytes]: + yield b"not valid json" + + mock_dropbox_client.list_folder = AsyncMock( + return_value=[ + SimpleNamespace(name="backup.tar"), + SimpleNamespace(name="backup.metadata.json"), + ] + ) + mock_dropbox_client.download_file = Mock(return_value=_invalid_stream()) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [] + assert "Skipping invalid metadata file" in caplog.text + + +async def test_agents_list_backups_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test handling list backups failures.""" + + mock_dropbox_client.list_folder = AsyncMock( + side_effect=DropboxUnknownException("boom") + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [] + assert response["result"]["agent_errors"] == { + TEST_AGENT_ID: "Failed to list backups" + } + + +async def test_agents_list_backups_reauth( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_dropbox_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthentication is triggered on auth error.""" + + mock_dropbox_client.list_folder = AsyncMock( + side_effect=DropboxAuthException("auth failed") + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [] + assert response["result"]["agent_errors"] == {TEST_AGENT_ID: "Authentication error"} + + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + +@pytest.mark.parametrize( + "backup_id", + [TEST_AGENT_BACKUP.backup_id, "other-backup"], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_dropbox_client: Mock, + backup_id: str, +) -> None: + """Test retrieving a backup's metadata.""" + + _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + if backup_id == TEST_AGENT_BACKUP.backup_id: + assert response["result"]["backup"] == TEST_AGENT_BACKUP_RESULT + else: + assert response["result"]["backup"] is None + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test downloading a backup file.""" + + tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP) + + mock_dropbox_client.list_folder = AsyncMock( + return_value=[ + SimpleNamespace(name=tar_name), + SimpleNamespace(name=metadata_name), + ] + ) + + def download_side_effect(path: str) -> AsyncIterator[bytes]: + if path == f"/{tar_name}": + return mock_stream(b"backup data") + return _mock_metadata_stream(TEST_AGENT_BACKUP) + + mock_dropbox_client.download_file = Mock(side_effect=download_side_effect) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_agents_download_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test handling download failures.""" + + mock_dropbox_client.list_folder = AsyncMock( + side_effect=DropboxUnknownException("boom") + ) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + + assert resp.status == 500 + body = await resp.content.read() + assert b"Failed to get backup" in body + + +async def test_agents_download_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test download when backup disappears between get and download.""" + + tar_name, metadata_name = _suggested_filenames(TEST_AGENT_BACKUP) + files = [ + SimpleNamespace(name=tar_name), + SimpleNamespace(name=metadata_name), + ] + + # First list_folder call (async_get_backup) returns the backup; + # second call (async_download_backup) returns empty, simulating deletion. + mock_dropbox_client.list_folder = AsyncMock(side_effect=[files, []]) + mock_dropbox_client.download_file = Mock( + return_value=_mock_metadata_stream(TEST_AGENT_BACKUP) + ) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + + assert resp.status == 404 + assert await resp.content.read() == b"" + + +async def test_agents_download_file_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test download when Dropbox file is not found returns 404.""" + + mock_dropbox_client.list_folder = AsyncMock( + side_effect=DropboxFileOrFolderNotFoundException("not found") + ) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + + assert resp.status == 404 + + +async def test_agents_download_metadata_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test download when metadata lookup fails.""" + + mock_dropbox_client.list_folder = AsyncMock(return_value=[]) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + + assert resp.status == 404 + assert await resp.content.read() == b"" + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_dropbox_client: Mock, +) -> None: + """Test uploading a backup to Dropbox.""" + + mock_dropbox_client.upload_file = AsyncMock(return_value=None) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {TEST_AGENT_BACKUP.backup_id} to agents" in caplog.text + assert mock_dropbox_client.upload_file.await_count == 2 + + +async def test_agents_upload_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_dropbox_client: Mock, +) -> None: + """Test that backup tar is cleaned up when metadata upload fails.""" + + call_count = 0 + + async def upload_side_effect(path: str, stream: AsyncIterator[bytes]) -> None: + nonlocal call_count + call_count += 1 + async for _ in stream: + pass + if call_count == 2: + raise DropboxUnknownException("metadata upload failed") + + mock_dropbox_client.upload_file = AsyncMock(side_effect=upload_side_effect) + mock_dropbox_client.delete_file = AsyncMock() + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert "Failed to upload backup" in caplog.text + mock_dropbox_client.delete_file.assert_awaited_once() + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test deleting a backup.""" + + _setup_list_folder_with_backup(mock_dropbox_client, TEST_AGENT_BACKUP) + mock_dropbox_client.delete_file = AsyncMock(return_value=None) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert mock_dropbox_client.delete_file.await_count == 2 + + +async def test_agents_delete_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test error handling when delete fails.""" + + mock_dropbox_client.list_folder = AsyncMock( + side_effect=DropboxUnknownException("boom") + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} + } + + +async def test_agents_delete_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_dropbox_client: Mock, +) -> None: + """Test deleting a backup that does not exist.""" + + mock_dropbox_client.list_folder = AsyncMock(return_value=[]) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +async def test_remove_backup_agents_listener( + hass: HomeAssistant, +) -> None: + """Test removing a backup agent listener.""" + listener = Mock() + remove = async_register_backup_agents_listener(hass, listener=listener) + + assert DATA_BACKUP_AGENT_LISTENERS in hass.data + assert listener in hass.data[DATA_BACKUP_AGENT_LISTENERS] + + # Remove all other listeners to test the cleanup path + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] + + remove() + + assert DATA_BACKUP_AGENT_LISTENERS not in hass.data diff --git a/tests/components/dropbox/test_config_flow.py b/tests/components/dropbox/test_config_flow.py new file mode 100644 index 00000000000000..9be36ecf0f4eb3 --- /dev/null +++ b/tests/components/dropbox/test_config_flow.py @@ -0,0 +1,210 @@ +"""Test the Dropbox config flow.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from yarl import URL + +from homeassistant import config_entries +from homeassistant.components.dropbox.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_SCOPES, + OAUTH2_TOKEN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import ACCOUNT_EMAIL, ACCOUNT_ID, CLIENT_ID + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_dropbox_client, + mock_setup_entry: AsyncMock, +) -> None: + """Test creating a new config entry through the OAuth flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + result_url = URL(result["url"]) + assert f"{result_url.origin()}{result_url.path}" == OAUTH2_AUTHORIZE + assert result_url.query["response_type"] == "code" + assert result_url.query["client_id"] == CLIENT_ID + assert ( + result_url.query["redirect_uri"] == "https://example.com/auth/external/callback" + ) + assert result_url.query["state"] == state + assert result_url.query["scope"] == " ".join(OAUTH2_SCOPES) + assert result_url.query["token_access_type"] == "offline" + assert result_url.query["code_challenge"] + assert result_url.query["code_challenge_method"] == "S256" + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_EMAIL + assert result["data"]["token"]["access_token"] == "mock-access-token" + assert result["result"].unique_id == ACCOUNT_ID + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry, + mock_dropbox_client, +) -> None: + """Test aborting when the account is already configured.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ( + "new_account_info", + "expected_reason", + "expected_setup_calls", + "expected_access_token", + ), + [ + ( + SimpleNamespace(account_id=ACCOUNT_ID, email=ACCOUNT_EMAIL), + "reauth_successful", + 1, + "updated-access-token", + ), + ( + SimpleNamespace(account_id="dbid:different", email="other@example.com"), + "wrong_account", + 0, + "mock-access-token", + ), + ], + ids=["success", "wrong_account"], +) +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry, + mock_dropbox_client, + mock_setup_entry: AsyncMock, + new_account_info: SimpleNamespace, + expected_reason: str, + expected_setup_calls: int, + expected_access_token: str, +) -> None: + """Test reauthentication flow outcomes.""" + + mock_config_entry.add_to_hass(hass) + + mock_dropbox_client.get_account_info.return_value = new_account_info + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "token_type": "Bearer", + "expires_in": 120, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + assert mock_setup_entry.await_count == expected_setup_calls + + assert mock_config_entry.data["token"]["access_token"] == expected_access_token diff --git a/tests/components/dropbox/test_init.py b/tests/components/dropbox/test_init.py new file mode 100644 index 00000000000000..8d468f18727b4d --- /dev/null +++ b/tests/components/dropbox/test_init.py @@ -0,0 +1,100 @@ +"""Test the Dropbox integration setup.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from python_dropbox_api import DropboxAuthException, DropboxUnknownException + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_dropbox_client") +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful setup of a config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_setup_entry_auth_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_dropbox_client: AsyncMock, +) -> None: + """Test setup failure when authentication fails.""" + mock_dropbox_client.get_account_info.side_effect = DropboxAuthException( + "Invalid token" + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize( + "side_effect", + [DropboxUnknownException("Unknown error"), TimeoutError("Connection timed out")], + ids=["unknown_exception", "timeout_error"], +) +async def test_setup_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_dropbox_client: AsyncMock, + side_effect: Exception, +) -> None: + """Test setup retry when the service is temporarily unavailable.""" + mock_dropbox_client.get_account_info.side_effect = side_effect + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_implementation_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup retry when OAuth implementation is unavailable.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dropbox.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_dropbox_client") +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From a573ef4b1c1dafd668944cf8d00013df61a833e5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 Apr 2026 20:20:33 +0200 Subject: [PATCH 0351/1707] Use subentry helper in WAQI (#167061) --- homeassistant/components/waqi/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index ae5ed197b0757c..2014f376e9cba9 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -40,10 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool entry.runtime_data = {} - for subentry in entry.subentries.values(): - if subentry.subentry_type != SUBENTRY_TYPE_STATION: - continue - + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STATION): # Create a coordinator for each station subentry coordinator = WAQIDataUpdateCoordinator(hass, entry, subentry, client) await coordinator.async_config_entry_first_refresh() From d104a1126f44db7074d26dc8b6486ef894d5a12a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 2 Apr 2026 04:26:18 +1000 Subject: [PATCH 0352/1707] Fix Tesla Fleet charge current scope handling (#166919) --- homeassistant/components/tesla_fleet/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py index 9d3787775a49a6..48c28cbb0228bf 100644 --- a/homeassistant/components/tesla_fleet/number.py +++ b/homeassistant/components/tesla_fleet/number.py @@ -52,7 +52,7 @@ class TeslaFleetNumberVehicleEntityDescription(NumberEntityDescription): mode=NumberMode.AUTO, max_key="charge_state_charge_current_request_max", func=lambda api, value: api.set_charging_amps(value), - scopes=[Scope.VEHICLE_CHARGING_CMDS], + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], ), TeslaFleetNumberVehicleEntityDescription( key="charge_state_charge_limit_soc", From efc8053027cc49a086ca19741cc9a00777e85d36 Mon Sep 17 00:00:00 2001 From: Kevin O'Brien Date: Wed, 1 Apr 2026 11:32:37 -0700 Subject: [PATCH 0353/1707] Fix Proxmox VE backup status sensor false positive due to case mismatch (#167069) Co-authored-by: Claude Opus 4.6 --- homeassistant/components/proxmoxve/const.py | 2 +- tests/components/proxmoxve/snapshots/test_binary_sensor.ambr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index 4cf821446c1c0a..babedc499a794d 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -21,7 +21,7 @@ STORAGE_ACTIVE = 1 STORAGE_SHARED = 1 STORAGE_ENABLED = 1 -STATUS_OK = "ok" +STATUS_OK = "OK" AUTH_PAM = "pam" AUTH_PVE = "pve" diff --git a/tests/components/proxmoxve/snapshots/test_binary_sensor.ambr b/tests/components/proxmoxve/snapshots/test_binary_sensor.ambr index 7c4a51cbdf5032..3e527c83745632 100644 --- a/tests/components/proxmoxve/snapshots/test_binary_sensor.ambr +++ b/tests/components/proxmoxve/snapshots/test_binary_sensor.ambr @@ -149,7 +149,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_all_entities[binary_sensor.pve1_status-entry] From e50b7f41aa36c16ae77bd95451e832e795c97e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 1 Apr 2026 19:41:35 +0100 Subject: [PATCH 0354/1707] Simplify claude's integrations skill (#166903) --- .claude/skills/integrations/SKILL.md | 766 +----------------- .../integrations/platform-diagnostics.md | 13 - .../skills/integrations/platform-repairs.md | 34 - 3 files changed, 10 insertions(+), 803 deletions(-) diff --git a/.claude/skills/integrations/SKILL.md b/.claude/skills/integrations/SKILL.md index 2bf861a9c8b963..f2fb755ae8dd5a 100644 --- a/.claude/skills/integrations/SKILL.md +++ b/.claude/skills/integrations/SKILL.md @@ -3,54 +3,27 @@ name: Home Assistant Integration knowledge description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference. --- -### File Locations +## File Locations - **Integration code**: `./homeassistant/components//` - **Integration tests**: `./tests/components//` -## Integration Templates +## General guidelines -### Standard Integration Structure -``` -homeassistant/components/my_integration/ -├── __init__.py # Entry point with async_setup_entry -├── manifest.json # Integration metadata and dependencies -├── const.py # Domain and constants -├── config_flow.py # UI configuration flow -├── coordinator.py # Data update coordinator (if needed) -├── entity.py # Base entity class (if shared patterns) -├── sensor.py # Sensor platform -├── strings.json # User-facing text and translations -├── services.yaml # Service definitions (if applicable) -└── quality_scale.yaml # Quality scale rule status -``` +- When looking for examples, prefer integrations with the platinum or gold quality scale level first. +- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries. +- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names. -An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). The following platforms have extra guidelines: +The following platforms have extra guidelines: - **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection - **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues -### Minimal Integration Checklist -- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.) -- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry` -- [ ] `config_flow.py` with UI configuration support -- [ ] `const.py` with `DOMAIN` constant -- [ ] `strings.json` with at least config flow text -- [ ] Platform files (`sensor.py`, etc.) as needed -- [ ] `quality_scale.yaml` with rule status tracking ## Integration Quality Scale -Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply: +- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules +- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices. -### Quality Scale Levels -- **Bronze**: Basic requirements (ALL Bronze rules are mandatory) -- **Silver**: Enhanced functionality -- **Gold**: Advanced features -- **Platinum**: Highest quality standards - -### Quality Scale Progression -- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows -- **Silver → Gold**: Add device management, diagnostics, translations -- **Gold → Platinum**: Add strict typing, async dependencies, websession injection +Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml` ### How Rules Apply 1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level @@ -61,726 +34,7 @@ Home Assistant uses an Integration Quality Scale to ensure code quality and cons - `exempt`: Rule doesn't apply (with reason in comment) - `todo`: Rule needs implementation -### Example `quality_scale.yaml` Structure -```yaml -rules: - # Bronze (mandatory) - config-flow: done - entity-unique-id: done - action-setup: - status: exempt - comment: Integration does not register custom actions. - - # Silver (if targeting Silver+) - entity-unavailable: done - parallel-updates: done - - # Gold (if targeting Gold+) - devices: done - diagnostics: done - - # Platinum (if targeting Platinum) - strict-typing: done -``` - -**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules. - -## Code Organization - -### Core Locations -- Shared constants: `homeassistant/const.py` (use these instead of hardcoding) -- Integration structure: - - `homeassistant/components/{domain}/const.py` - Constants - - `homeassistant/components/{domain}/models.py` - Data models - - `homeassistant/components/{domain}/coordinator.py` - Update coordinator - - `homeassistant/components/{domain}/config_flow.py` - Configuration flow - - `homeassistant/components/{domain}/{platform}.py` - Platform implementations - -### Common Modules -- **coordinator.py**: Centralize data fetching logic - ```python - class MyCoordinator(DataUpdateCoordinator[MyData]): - def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: - super().__init__( - hass, - logger=LOGGER, - name=DOMAIN, - update_interval=timedelta(minutes=1), - config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended - ) - ``` -- **entity.py**: Base entity definitions to reduce duplication - ```python - class MyEntity(CoordinatorEntity[MyCoordinator]): - _attr_has_entity_name = True - ``` - -### Runtime Data Storage -- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data - ```python - type MyIntegrationConfigEntry = ConfigEntry[MyClient] - - async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool: - client = MyClient(entry.data[CONF_HOST]) - entry.runtime_data = client - ``` - -### Manifest Requirements -- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements` -- **Integration Types**: `device`, `hub`, `service`, `system`, `helper` -- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`) -- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb` -- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`) - -### Config Flow Patterns -- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1` -- **Unique ID Management**: - ```python - await self.async_set_unique_id(device_unique_id) - self._abort_if_unique_id_configured() - ``` -- **Error Handling**: Define errors in `strings.json` under `config.error` -- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.) - -### Integration Ownership -- **manifest.json**: Add GitHub usernames to `codeowners`: - ```json - { - "domain": "my_integration", - "name": "My Integration", - "codeowners": ["@me"] - } - ``` - -### Async Dependencies (Platinum) -- **Requirement**: All dependencies must use asyncio -- Ensures efficient task handling without thread context switching - -### WebSession Injection (Platinum) -- **Pass WebSession**: Support passing web sessions to dependencies - ```python - async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool: - """Set up integration from config entry.""" - client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass)) - ``` -- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx) - -### Data Update Coordinator -- **Standard Pattern**: Use for efficient data management - ```python - class MyCoordinator(DataUpdateCoordinator): - def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: - super().__init__( - hass, - logger=LOGGER, - name=DOMAIN, - update_interval=timedelta(minutes=5), - config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended - ) - self.client = client - - async def _async_update_data(self): - try: - return await self.client.fetch_data() - except ApiError as err: - raise UpdateFailed(f"API communication error: {err}") - ``` -- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues -- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended - -## Integration Guidelines - -### Configuration Flow -- **UI Setup Required**: All integrations must support configuration via UI -- **Manifest**: Set `"config_flow": true` in `manifest.json` -- **Data Storage**: - - Connection-critical config: Store in `ConfigEntry.data` - - Non-critical settings: Store in `ConfigEntry.options` -- **Validation**: Always validate user input before creating entries -- **Config Entry Naming**: - - ❌ Do NOT allow users to set config entry names in config flows - - Names are automatically generated or can be customized later in UI - - ✅ Exception: Helper integrations MAY allow custom names in config flow -- **Connection Testing**: Test device/service connection during config flow: - ```python - try: - await client.get_data() - except MyException: - errors["base"] = "cannot_connect" - ``` -- **Duplicate Prevention**: Prevent duplicate configurations: - ```python - # Using unique ID - await self.async_set_unique_id(identifier) - self._abort_if_unique_id_configured() - - # Using unique data - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - ``` - -### Reauthentication Support -- **Required Method**: Implement `async_step_reauth` in config flow -- **Credential Updates**: Allow users to update credentials without re-adding -- **Validation**: Verify account matches existing unique ID: - ```python - await self.async_set_unique_id(user_id) - self._abort_if_unique_id_mismatch(reason="wrong_account") - return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]} - ) - ``` - -### Reconfiguration Flow -- **Purpose**: Allow configuration updates without removing device -- **Implementation**: Add `async_step_reconfigure` method -- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch` - -### Device Discovery -- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.) - ```json - { - "zeroconf": ["_mydevice._tcp.local."] - } - ``` -- **Discovery Handler**: Implement appropriate `async_step_*` method: - ```python - async def async_step_zeroconf(self, discovery_info): - """Handle zeroconf discovery.""" - await self.async_set_unique_id(discovery_info.properties["serialno"]) - self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) - ``` -- **Network Updates**: Use discovery to update dynamic IP addresses - -### Network Discovery Implementation -- **Zeroconf/mDNS**: Use async instances - ```python - aiozc = await zeroconf.async_get_async_instance(hass) - ``` -- **SSDP Discovery**: Register callbacks with cleanup - ```python - entry.async_on_unload( - ssdp.async_register_callback( - hass, _async_discovered_device, - {"st": "urn:schemas-upnp-org:device:ZonePlayer:1"} - ) - ) - ``` - -### Bluetooth Integration -- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies -- **Connectable**: Set `"connectable": true` for connection-required devices -- **Scanner Usage**: Always use shared scanner instance - ```python - scanner = bluetooth.async_get_scanner() - entry.async_on_unload( - bluetooth.async_register_callback( - hass, _async_discovered_device, - {"service_uuid": "example_uuid"}, - bluetooth.BluetoothScanningMode.ACTIVE - ) - ) - ``` -- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts - -### Setup Validation -- **Test Before Setup**: Verify integration can be set up in `async_setup_entry` -- **Exception Handling**: - - `ConfigEntryNotReady`: Device offline or temporary failure - - `ConfigEntryAuthFailed`: Authentication issues - - `ConfigEntryError`: Unresolvable setup problems - -### Config Entry Unloading -- **Required**: Implement `async_unload_entry` for runtime removal/reload -- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms` -- **Cleanup**: Register callbacks with `entry.async_on_unload`: - ```python - async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool: - """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - entry.runtime_data.listener() # Clean up resources - return unload_ok - ``` - -### Service Actions -- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry` -- **Validation**: Check config entry existence and loaded state: - ```python - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - async def service_action(call: ServiceCall) -> ServiceResponse: - if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])): - raise ServiceValidationError("Entry not found") - if entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError("Entry not loaded") - ``` -- **Exception Handling**: Raise appropriate exceptions: - ```python - # For invalid input - if end_date < start_date: - raise ServiceValidationError("End date must be after start date") - - # For service errors - try: - await client.set_schedule(start_date, end_date) - except MyConnectionError as err: - raise HomeAssistantError("Could not connect to the schedule") from err - ``` - -### Service Registration Patterns -- **Entity Services**: Register on platform setup - ```python - platform.async_register_entity_service( - "my_entity_service", - {vol.Required("parameter"): cv.string}, - "handle_service_method" - ) - ``` -- **Service Schema**: Always validate input - ```python - SERVICE_SCHEMA = vol.Schema({ - vol.Required("entity_id"): cv.entity_ids, - vol.Required("parameter"): cv.string, - vol.Optional("timeout", default=30): cv.positive_int, - }) - ``` -- **Services File**: Create `services.yaml` with descriptions and field definitions - -### Polling -- Use update coordinator pattern when possible -- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries -- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input -- **Minimum Intervals**: - - Local network: 5 seconds - - Cloud services: 60 seconds -- **Parallel Updates**: Specify number of concurrent updates: - ```python - PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device - # OR - PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only) - ``` - -## Entity Development - -### Unique IDs -- **Required**: Every entity must have a unique ID for registry tracking -- Must be unique per platform (not per integration) -- Don't include integration domain or platform in ID -- **Implementation**: - ```python - class MySensor(SensorEntity): - def __init__(self, device_id: str) -> None: - self._attr_unique_id = f"{device_id}_temperature" - ``` - -**Acceptable ID Sources**: -- Device serial numbers -- MAC addresses (formatted using `format_mac` from device registry) -- Physical identifiers (printed/EEPROM) -- Config entry ID as last resort: `f"{entry.entry_id}-battery"` - -**Never Use**: -- IP addresses, hostnames, URLs -- Device names -- Email addresses, usernames - -### Entity Descriptions -- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation -- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability -- **Bad pattern**: - ```python - SensorEntityDescription( - key="temperature", - name="Temperature", - value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long - ) - ``` -- **Good pattern**: - ```python - SensorEntityDescription( - key="temperature", - name="Temperature", - value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda - round(data["temp_value"] * 1.8 + 32, 1) - if data.get("temp_value") is not None - else None - ), - ) - ``` - -### Entity Naming -- **Use has_entity_name**: Set `_attr_has_entity_name = True` -- **For specific fields**: - ```python - class MySensor(SensorEntity): - _attr_has_entity_name = True - def __init__(self, device: Device, field: str) -> None: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.id)}, - name=device.name, - ) - self._attr_name = field # e.g., "temperature", "humidity" - ``` -- **For device itself**: Set `_attr_name = None` - -### Event Lifecycle Management -- **Subscribe in `async_added_to_hass`**: - ```python - async def async_added_to_hass(self) -> None: - """Subscribe to events.""" - self.async_on_remove( - self.client.events.subscribe("my_event", self._handle_event) - ) - ``` -- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove` -- Never subscribe in `__init__` or other methods - -### State Handling -- Unknown values: Use `None` (not "unknown" or "unavailable") -- Availability: Implement `available()` property instead of using "unavailable" state - -### Entity Availability -- **Mark Unavailable**: When data cannot be fetched from device/service -- **Coordinator Pattern**: - ```python - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and self.identifier in self.coordinator.data - ``` -- **Direct Update Pattern**: - ```python - async def async_update(self) -> None: - """Update entity.""" - try: - data = await self.client.get_data() - except MyException: - self._attr_available = False - else: - self._attr_available = True - self._attr_native_value = data.value - ``` - -### Extra State Attributes -- All attribute keys must always be present -- Unknown values: Use `None` -- Provide descriptive attributes - -## Device Management - -### Device Registry -- **Create Devices**: Group related entities under devices -- **Device Info**: Provide comprehensive metadata: - ```python - _attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, device.mac)}, - identifiers={(DOMAIN, device.id)}, - name=device.name, - manufacturer="My Company", - model="My Sensor", - sw_version=device.version, - ) - ``` -- For services: Add `entry_type=DeviceEntryType.SERVICE` - -### Dynamic Device Addition -- **Auto-detect New Devices**: After initial setup -- **Implementation Pattern**: - ```python - def _check_device() -> None: - current_devices = set(coordinator.data) - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices]) - - entry.async_on_unload(coordinator.async_add_listener(_check_device)) - ``` - -### Stale Device Removal -- **Auto-remove**: When devices disappear from hub/account -- **Device Registry Update**: - ```python - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - ``` -- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed - -### Entity Categories -- **Required**: Assign appropriate category to entities -- **Implementation**: Set `_attr_entity_category` - ```python - class MySensor(SensorEntity): - _attr_entity_category = EntityCategory.DIAGNOSTIC - ``` -- Categories include: `DIAGNOSTIC` for system/technical information - -### Device Classes -- **Use When Available**: Set appropriate device class for entity type - ```python - class MyTemperatureSensor(SensorEntity): - _attr_device_class = SensorDeviceClass.TEMPERATURE - ``` -- Provides context for: unit conversion, voice control, UI representation - -### Disabled by Default -- **Disable Noisy/Less Popular Entities**: Reduce resource usage - ```python - class MySignalStrengthSensor(SensorEntity): - _attr_entity_registry_enabled_default = False - ``` -- Target: frequently changing states, technical diagnostics - -### Entity Translations -- **Required with has_entity_name**: Support international users -- **Implementation**: - ```python - class MySensor(SensorEntity): - _attr_has_entity_name = True - _attr_translation_key = "phase_voltage" - ``` -- Create `strings.json` with translations: - ```json - { - "entity": { - "sensor": { - "phase_voltage": { - "name": "Phase voltage" - } - } - } - } - ``` - -### Exception Translations (Gold) -- **Translatable Errors**: Use translation keys for user-facing exceptions -- **Implementation**: - ```python - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="end_date_before_start_date", - ) - ``` -- Add to `strings.json`: - ```json - { - "exceptions": { - "end_date_before_start_date": { - "message": "The end date cannot be before the start date." - } - } - } - ``` - -### Icon Translations (Gold) -- **Dynamic Icons**: Support state and range-based icon selection -- **State-based Icons**: - ```json - { - "entity": { - "sensor": { - "tree_pollen": { - "default": "mdi:tree", - "state": { - "high": "mdi:tree-outline" - } - } - } - } - } - ``` -- **Range-based Icons** (for numeric values): - ```json - { - "entity": { - "sensor": { - "battery_level": { - "default": "mdi:battery-unknown", - "range": { - "0": "mdi:battery-outline", - "90": "mdi:battery-90", - "100": "mdi:battery" - } - } - } - } - } - ``` ## Testing Requirements -- **Location**: `tests/components/{domain}/` -- **Coverage Requirement**: Above 95% test coverage for all modules -- **Best Practices**: - - Use pytest fixtures from `tests.common` - - Mock all external dependencies - - Use snapshots for complex data structures - - Follow existing test patterns - -### Config Flow Testing -- **100% Coverage Required**: All config flow paths must be tested -- **Patch Boundaries**: Only patch library or client methods when testing config flows. Do not patch methods defined in `config_flow.py`; exercise the flow logic end-to-end. -- **Test Scenarios**: - - All flow initiation methods (user, discovery, import) - - Successful configuration paths - - Error recovery scenarios - - Prevention of duplicate entries - - Flow completion after errors - - Reauthentication/reconfigure flows - -### Testing -- **Integration-specific tests** (recommended): - ```bash - pytest ./tests/components/ \ - --cov=homeassistant.components. \ - --cov-report term-missing \ - --durations-min=1 \ - --durations=0 \ - --numprocesses=auto - ``` - -### Testing Best Practices -- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead -- **Use snapshot testing** - For verifying entity states and attributes -- **Test through integration setup** - Don't test entities in isolation -- **Mock external APIs** - Use fixtures with realistic JSON data -- **Verify registries** - Ensure entities are properly registered with devices - -### Config Flow Testing Template -```python -async def test_user_flow_success(hass, mock_api): - """Test successful user flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" - - # Test form submission - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TEST_USER_INPUT - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "My Device" - assert result["data"] == TEST_USER_INPUT - -async def test_flow_connection_error(hass, mock_api_error): - """Test connection error handling.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TEST_USER_INPUT - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} -``` - -### Entity Testing Patterns -```python -@pytest.fixture -def platforms() -> list[Platform]: - """Overridden fixture to specify platforms to test.""" - return [Platform.SENSOR] # Or another specific platform as needed. - -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") -async def test_entities( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the sensor entities.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - # Ensure entities are correctly assigned to device - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, "device_unique_id")} - ) - assert device_entry - entity_entries = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - for entity_entry in entity_entries: - assert entity_entry.device_id == device_entry.id -``` - -### Mock Patterns -```python -# Modern integration fixture setup -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return the default mocked config entry.""" - return MockConfigEntry( - title="My Integration", - domain=DOMAIN, - data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"}, - unique_id="device_unique_id", - ) - -@pytest.fixture -def mock_device_api() -> Generator[MagicMock]: - """Return a mocked device API.""" - with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock: - api = api_mock.return_value - api.get_data.return_value = MyDeviceData.from_json( - load_fixture("device_data.json", DOMAIN) - ) - yield api - -@pytest.fixture -def platforms() -> list[Platform]: - """Fixture to specify platforms to test.""" - return PLATFORMS - -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_device_api: MagicMock, - platforms: list[Platform], -) -> MockConfigEntry: - """Set up the integration for testing.""" - mock_config_entry.add_to_hass(hass) - - with patch("homeassistant.components.my_integration.PLATFORMS", platforms): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - return mock_config_entry -``` - -## Debugging & Troubleshooting - -### Common Issues & Solutions -- **Integration won't load**: Check `manifest.json` syntax and required fields -- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation -- **Config flow errors**: Check `strings.json` entries and error handling -- **Discovery not working**: Verify manifest discovery configuration and callbacks -- **Tests failing**: Check mock setup and async context - -### Debug Logging Setup -```python -# Enable debug logging in tests -caplog.set_level(logging.DEBUG, logger="my_integration") - -# In integration code - use proper logging -_LOGGER = logging.getLogger(__name__) -_LOGGER.debug("Processing data: %s", data) # Use lazy logging -``` - -### Validation Commands -```bash -# Check specific integration -python -m script.hassfest --integration-path homeassistant/components/my_integration - -# Validate quality scale -# Check quality_scale.yaml against current rules - -# Run integration tests with coverage -pytest ./tests/components/my_integration \ - --cov=homeassistant.components.my_integration \ - --cov-report term-missing -``` +- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations diff --git a/.claude/skills/integrations/platform-diagnostics.md b/.claude/skills/integrations/platform-diagnostics.md index 2d01cd08a62289..8d3fa73cd974a7 100644 --- a/.claude/skills/integrations/platform-diagnostics.md +++ b/.claude/skills/integrations/platform-diagnostics.md @@ -3,17 +3,4 @@ Platform exists as `homeassistant/components//diagnostics.py`. - **Required**: Implement diagnostic data collection -- **Implementation**: - ```python - TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE] - - async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: MyConfigEntry - ) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - return { - "entry_data": async_redact_data(entry.data, TO_REDACT), - "data": entry.runtime_data.data, - } - ``` - **Security**: Never expose passwords, tokens, or sensitive coordinates diff --git a/.claude/skills/integrations/platform-repairs.md b/.claude/skills/integrations/platform-repairs.md index 08d631dd5f0a5d..269db92239bc32 100644 --- a/.claude/skills/integrations/platform-repairs.md +++ b/.claude/skills/integrations/platform-repairs.md @@ -8,29 +8,6 @@ Platform exists as `homeassistant/components//repairs.py`. - Provide specific steps users need to take to resolve the issue - Use friendly, helpful language - Include relevant context (device names, error details, etc.) -- **Implementation**: - ```python - ir.async_create_issue( - hass, - DOMAIN, - "outdated_version", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.ERROR, - translation_key="outdated_version", - ) - ``` -- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`: - ```json - { - "issues": { - "outdated_version": { - "title": "Device firmware is outdated", - "description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant." - } - } - } - ``` - **String Content Must Include**: - What the problem is - Why it matters @@ -41,15 +18,4 @@ Platform exists as `homeassistant/components//repairs.py`. - `CRITICAL`: Reserved for extreme scenarios only - `ERROR`: Requires immediate user attention - `WARNING`: Indicates future potential breakage -- **Additional Attributes**: - ```python - ir.async_create_issue( - hass, DOMAIN, "issue_id", - breaks_in_ha_version="2024.1.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.ERROR, - translation_key="issue_description", - ) - ``` - Only create issues for problems users can potentially resolve From 7c549870b5ea934bf439bf772d0db2b7c910df6a Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 1 Apr 2026 20:48:06 +0200 Subject: [PATCH 0355/1707] Add firmware update to Ubiquiti airOS (#166913) --- homeassistant/components/airos/__init__.py | 25 +- .../components/airos/binary_sensor.py | 2 +- homeassistant/components/airos/button.py | 4 +- homeassistant/components/airos/const.py | 1 + homeassistant/components/airos/coordinator.py | 111 +- homeassistant/components/airos/diagnostics.py | 12 +- homeassistant/components/airos/sensor.py | 2 +- homeassistant/components/airos/strings.json | 6 + homeassistant/components/airos/update.py | 101 ++ tests/components/airos/conftest.py | 28 +- .../fixtures/firmware_update_available.json | 9 + .../fixtures/firmware_update_latest.json | 3 + .../airos/snapshots/test_diagnostics.ambr | 1215 +++++++++-------- tests/components/airos/test_binary_sensor.py | 2 +- tests/components/airos/test_config_flow.py | 38 +- tests/components/airos/test_diagnostics.py | 5 +- tests/components/airos/test_init.py | 15 +- tests/components/airos/test_update.py | 146 ++ 18 files changed, 1051 insertions(+), 674 deletions(-) create mode 100644 homeassistant/components/airos/update.py create mode 100644 tests/components/airos/fixtures/firmware_update_available.json create mode 100644 tests/components/airos/fixtures/firmware_update_latest.json create mode 100644 tests/components/airos/test_update.py diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index a0e573f2f50a22..1942059b1e5289 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -33,14 +33,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS -from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator +from .coordinator import ( + AirOSConfigEntry, + AirOSDataUpdateCoordinator, + AirOSFirmwareUpdateCoordinator, + AirOSRuntimeData, +) _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, + Platform.UPDATE, ] + _LOGGER = logging.getLogger(__name__) @@ -86,10 +93,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo airos_device = airos_class(**conn_data) - coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device) - await coordinator.async_config_entry_first_refresh() + data_coordinator = AirOSDataUpdateCoordinator( + hass, entry, device_data, airos_device + ) + await data_coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + firmware_coordinator: AirOSFirmwareUpdateCoordinator | None = None + if device_data["fw_major"] >= 8: + firmware_coordinator = AirOSFirmwareUpdateCoordinator(hass, entry, airos_device) + await firmware_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = AirOSRuntimeData( + status=data_coordinator, + firmware=firmware_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/airos/binary_sensor.py b/homeassistant/components/airos/binary_sensor.py index 0154db8dcb511c..ced58410e9d63b 100644 --- a/homeassistant/components/airos/binary_sensor.py +++ b/homeassistant/components/airos/binary_sensor.py @@ -87,7 +87,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AirOS binary sensors from a config entry.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.status entities = [ AirOSBinarySensor(coordinator, description) diff --git a/homeassistant/components/airos/button.py b/homeassistant/components/airos/button.py index 44eca04b9b6473..1f60352947abd6 100644 --- a/homeassistant/components/airos/button.py +++ b/homeassistant/components/airos/button.py @@ -31,7 +31,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AirOS button from a config entry.""" - async_add_entities([AirOSRebootButton(config_entry.runtime_data, REBOOT_BUTTON)]) + async_add_entities( + [AirOSRebootButton(config_entry.runtime_data.status, REBOOT_BUTTON)] + ) class AirOSRebootButton(AirOSEntity, ButtonEntity): diff --git a/homeassistant/components/airos/const.py b/homeassistant/components/airos/const.py index 548c4eff805de3..8e268a28d54831 100644 --- a/homeassistant/components/airos/const.py +++ b/homeassistant/components/airos/const.py @@ -5,6 +5,7 @@ DOMAIN = "airos" SCAN_INTERVAL = timedelta(minutes=1) +UPDATE_SCAN_INTERVAL = timedelta(days=1) MANUFACTURER = "Ubiquiti" diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py index 52ca88faebeb5d..8748300b329d87 100644 --- a/homeassistant/components/airos/coordinator.py +++ b/homeassistant/components/airos/coordinator.py @@ -2,7 +2,10 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import logging +from typing import Any, TypeVar from airos.airos6 import AirOS6, AirOS6Data from airos.airos8 import AirOS8, AirOS8Data @@ -19,20 +22,61 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL, UPDATE_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -AirOSDeviceDetect = AirOS8 | AirOS6 -AirOSDataDetect = AirOS8Data | AirOS6Data +type AirOSDeviceDetect = AirOS8 | AirOS6 +type AirOSDataDetect = AirOS8Data | AirOS6Data +type AirOSUpdateData = dict[str, Any] -type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator] +type AirOSConfigEntry = ConfigEntry[AirOSRuntimeData] + +T = TypeVar("T", bound=AirOSDataDetect | AirOSUpdateData) + + +@dataclass +class AirOSRuntimeData: + """Data for AirOS config entry.""" + + status: AirOSDataUpdateCoordinator + firmware: AirOSFirmwareUpdateCoordinator | None + + +async def async_fetch_airos_data( + airos_device: AirOSDeviceDetect, + update_method: Callable[[], Awaitable[T]], +) -> T: + """Fetch data from AirOS device.""" + try: + await airos_device.login() + return await update_method() + except AirOSConnectionAuthenticationError as err: + _LOGGER.exception("Error authenticating with airOS device") + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + TimeoutError, + ) as err: + _LOGGER.error("Error connecting to airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except AirOSDataMissingError as err: + _LOGGER.error("Expected data not returned by airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="error_data_missing", + ) from err class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]): - """Class to manage fetching AirOS data from single endpoint.""" + """Class to manage fetching AirOS status data from single endpoint.""" - airos_device: AirOSDeviceDetect config_entry: AirOSConfigEntry def __init__( @@ -54,28 +98,33 @@ def __init__( ) async def _async_update_data(self) -> AirOSDataDetect: - """Fetch data from AirOS.""" - try: - await self.airos_device.login() - return await self.airos_device.status() - except AirOSConnectionAuthenticationError as err: - _LOGGER.exception("Error authenticating with airOS device") - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="invalid_auth" - ) from err - except ( - AirOSConnectionSetupError, - AirOSDeviceConnectionError, - TimeoutError, - ) as err: - _LOGGER.error("Error connecting to airOS device: %s", err) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except AirOSDataMissingError as err: - _LOGGER.error("Expected data not returned by airOS device: %s", err) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="error_data_missing", - ) from err + """Fetch status data from AirOS.""" + return await async_fetch_airos_data(self.airos_device, self.airos_device.status) + + +class AirOSFirmwareUpdateCoordinator(DataUpdateCoordinator[AirOSUpdateData]): + """Class to manage fetching AirOS firmware.""" + + config_entry: AirOSConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + airos_device: AirOSDeviceDetect, + ) -> None: + """Initialize the coordinator.""" + self.airos_device = airos_device + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> AirOSUpdateData: + """Fetch firmware data from AirOS.""" + return await async_fetch_airos_data( + self.airos_device, self.airos_device.update_check + ) diff --git a/homeassistant/components/airos/diagnostics.py b/homeassistant/components/airos/diagnostics.py index 70fef685c86895..4e006fedffd963 100644 --- a/homeassistant/components/airos/diagnostics.py +++ b/homeassistant/components/airos/diagnostics.py @@ -29,5 +29,15 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" return { "entry_data": async_redact_data(entry.data, TO_REDACT_HA), - "data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS), + "data": { + "status_data": async_redact_data( + entry.runtime_data.status.data.to_dict(), TO_REDACT_AIROS + ), + "firmware_data": async_redact_data( + entry.runtime_data.firmware.data + if entry.runtime_data.firmware is not None + else {}, + TO_REDACT_AIROS, + ), + }, } diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py index 8b0673e241c74a..7b1b7a20b06bc7 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -180,7 +180,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AirOS sensors from a config entry.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.status entities = [AirOSSensor(coordinator, description) for description in COMMON_SENSORS] diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 56026eac5529aa..fad6af5d58c3c6 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -206,6 +206,12 @@ }, "reboot_failed": { "message": "The device did not accept the reboot request. Try again, or check your device web interface for errors." + }, + "update_connection_authentication_error": { + "message": "Authentication or connection failed during firmware update" + }, + "update_error": { + "message": "Connection failed during firmware update" } } } diff --git a/homeassistant/components/airos/update.py b/homeassistant/components/airos/update.py new file mode 100644 index 00000000000000..fa79c9b01a1d7b --- /dev/null +++ b/homeassistant/components/airos/update.py @@ -0,0 +1,101 @@ +"""AirOS update component for Home Assistant.""" + +from __future__ import annotations + +import logging +from typing import Any + +from airos.exceptions import AirOSConnectionAuthenticationError, AirOSException + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + AirOSConfigEntry, + AirOSDataUpdateCoordinator, + AirOSFirmwareUpdateCoordinator, +) +from .entity import AirOSEntity + +PARALLEL_UPDATES = 0 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the AirOS update entity from a config entry.""" + runtime_data = config_entry.runtime_data + + if runtime_data.firmware is None: # Unsupported device + return + async_add_entities([AirOSUpdateEntity(runtime_data.status, runtime_data.firmware)]) + + +class AirOSUpdateEntity(AirOSEntity, UpdateEntity): + """Update entity for AirOS firmware updates.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + + def __init__( + self, + status: AirOSDataUpdateCoordinator, + firmware: AirOSFirmwareUpdateCoordinator, + ) -> None: + """Initialize the AirOS update entity.""" + super().__init__(status) + self.status = status + self.firmware = firmware + + self._attr_unique_id = f"{status.data.derived.mac}_firmware_update" + + @property + def installed_version(self) -> str | None: + """Return the installed firmware version.""" + return self.status.data.host.fwversion + + @property + def latest_version(self) -> str | None: + """Return the latest firmware version.""" + if not self.firmware.data.get("update", False): + return self.status.data.host.fwversion + return self.firmware.data.get("version") + + @property + def release_url(self) -> str | None: + """Return the release url of the latest firmware.""" + return self.firmware.data.get("changelog") + + async def async_install( + self, + version: str | None, + backup: bool, + **kwargs: Any, + ) -> None: + """Handle the firmware update installation.""" + _LOGGER.debug("Starting firmware update") + try: + await self.status.airos_device.login() + await self.status.airos_device.download() + await self.status.airos_device.install() + except AirOSConnectionAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_connection_authentication_error", + ) from err + except AirOSException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_error", + ) from err diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py index 1d47f111f08ba5..f30b9b60617f3c 100644 --- a/tests/components/airos/conftest.py +++ b/tests/components/airos/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Ubiquiti airOS tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from airos.airos6 import AirOS6Data @@ -9,6 +10,7 @@ import pytest from homeassistant.components.airos.const import DEFAULT_USERNAME, DOMAIN +from homeassistant.components.airos.coordinator import AirOSUpdateData from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from . import AirOSData @@ -17,7 +19,16 @@ @pytest.fixture -def ap_fixture(request: pytest.FixtureRequest) -> AirOSData: +def ap_firmware_fixture(request: pytest.FixtureRequest) -> AirOSUpdateData: + """Return fixture for AP firmware data.""" + available = getattr(request, "param", False) + if available: + return load_json_object_fixture("firmware_update_available.json", DOMAIN) + return load_json_object_fixture("firmware_update_latest.json", DOMAIN) + + +@pytest.fixture +def ap_status_fixture(request: pytest.FixtureRequest) -> AirOSData: """Load fixture data for airOS device.""" json_data = load_json_object_fixture("airos_loco5ac_ap-ptp.json", DOMAIN) if hasattr(request, "param"): @@ -61,11 +72,14 @@ def mock_airos_class() -> Generator[MagicMock]: @pytest.fixture def mock_airos_client( - mock_airos_class: MagicMock, ap_fixture: AirOSData + mock_airos_class: MagicMock, + ap_status_fixture: AirOSData, + ap_firmware_fixture: dict[str, Any], ) -> Generator[AsyncMock]: """Fixture to mock the AirOS API client.""" client = mock_airos_class.return_value - client.status.return_value = ap_fixture + client.status.return_value = ap_status_fixture + client.update_check.return_value = ap_firmware_fixture client.login.return_value = True client.reboot.return_value = True return client @@ -97,13 +111,13 @@ def mock_discovery_method() -> Generator[AsyncMock]: @pytest.fixture -def mock_async_get_firmware_data(ap_fixture: AirOSData): +def mock_async_get_firmware_data(ap_status_fixture: AirOSData): """Fixture to mock async_get_firmware_data to not do a network call.""" - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) return_value = DetectDeviceData( fw_major=fw_major, - mac=ap_fixture.derived.mac, - hostname=ap_fixture.host.hostname, + mac=ap_status_fixture.derived.mac, + hostname=ap_status_fixture.host.hostname, ) mock = AsyncMock(return_value=return_value) diff --git a/tests/components/airos/fixtures/firmware_update_available.json b/tests/components/airos/fixtures/firmware_update_available.json new file mode 100644 index 00000000000000..d4faedbd2cd2be --- /dev/null +++ b/tests/components/airos/fixtures/firmware_update_available.json @@ -0,0 +1,9 @@ +{ + "checksum": "95915b6f040fedd05033f514427e99a1", + "version": "v8.7.22", + "security": "", + "date": "260227", + "url": "https://dl.ubnt.com/firmwares/XC-fw/V8.7.22/WA.v8.7.22.48486.260227.1959.bin", + "update": true, + "changelog": "https://dl.ubnt.com/firmwares/XC-fw/V8.7.22/changelog.txt" +} diff --git a/tests/components/airos/fixtures/firmware_update_latest.json b/tests/components/airos/fixtures/firmware_update_latest.json new file mode 100644 index 00000000000000..a99ca57496a2a7 --- /dev/null +++ b/tests/components/airos/fixtures/firmware_update_latest.json @@ -0,0 +1,3 @@ +{ + "update": false +} diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index b1bed6741cfbef..c2079c8247ee60 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -2,635 +2,640 @@ # name: test_diagnostics dict({ 'data': dict({ - 'chain_names': list([ - dict({ - 'name': 'Chain 0', - 'number': 1, - }), - dict({ - 'name': 'Chain 1', - 'number': 2, - }), - ]), - 'derived': dict({ - 'access_point': True, - 'fw_major': 8, - 'mac': '**REDACTED**', - 'mac_interface': 'br0', - 'mode': 'point_to_point', - 'ptmp': False, - 'ptp': True, - 'role': 'access_point', - 'sku': 'Loco5AC', - 'station': False, - }), - 'firewall': dict({ - 'eb6tables': False, - 'ebtables': False, - 'ip6tables': False, - 'iptables': False, - }), - 'genuine': '/images/genuine.png', - 'gps': dict({ - 'alt': None, - 'dim': None, - 'dop': None, - 'fix': 0, - 'lat': '**REDACTED**', - 'lon': '**REDACTED**', - 'sats': None, - 'time_synced': None, + 'firmware_data': dict({ + 'update': False, }), - 'host': dict({ - 'cpuload': 10.10101, - 'device_id': '03aa0d0b40fed0a47088293584ef5432', - 'devmodel': 'NanoStation 5AC loco', - 'freeram': 16564224, - 'fwversion': 'v8.7.17', - 'height': 3, - 'hostname': '**REDACTED**', - 'loadavg': 0.412598, - 'netrole': 'bridge', - 'power_time': 268683, - 'temperature': 0, - 'time': '2025-06-23 23:06:42', - 'timestamp': 2668313184, - 'totalram': 63447040, - 'uptime': 264888, - }), - 'interfaces': list([ - dict({ - 'enabled': True, - 'hwaddr': '**REDACTED**', - 'ifname': 'eth0', - 'mtu': 1500, - 'status': dict({ - 'cable_len': 18, - 'duplex': True, - 'ip6addr': None, - 'ipaddr': '**REDACTED**', - 'plugged': True, - 'rx_bytes': 3984971949, - 'rx_dropped': 0, - 'rx_errors': 4, - 'rx_packets': 73564835, - 'snr': list([ - 30, - 30, - 30, - 30, - ]), - 'speed': 1000, - 'tx_bytes': 209900085624, - 'tx_dropped': 10, - 'tx_errors': 0, - 'tx_packets': 185866883, + 'status_data': dict({ + 'chain_names': list([ + dict({ + 'name': 'Chain 0', + 'number': 1, }), - }), - dict({ - 'enabled': True, - 'hwaddr': '**REDACTED**', - 'ifname': 'ath0', - 'mtu': 1500, - 'status': dict({ - 'cable_len': None, - 'duplex': False, - 'ip6addr': None, - 'ipaddr': '**REDACTED**', - 'plugged': False, - 'rx_bytes': 206938324766, - 'rx_dropped': 0, - 'rx_errors': 0, - 'rx_packets': 149767200, - 'snr': None, - 'speed': 0, - 'tx_bytes': 5265602738, - 'tx_dropped': 2005, - 'tx_errors': 0, - 'tx_packets': 52980390, + dict({ + 'name': 'Chain 1', + 'number': 2, }), + ]), + 'derived': dict({ + 'access_point': True, + 'fw_major': 8, + 'mac': '**REDACTED**', + 'mac_interface': 'br0', + 'mode': 'point_to_point', + 'ptmp': False, + 'ptp': True, + 'role': 'access_point', + 'sku': 'Loco5AC', + 'station': False, }), - dict({ - 'enabled': True, - 'hwaddr': '**REDACTED**', - 'ifname': 'br0', - 'mtu': 1500, - 'status': dict({ - 'cable_len': None, - 'duplex': False, - 'ip6addr': '**REDACTED**', - 'ipaddr': '**REDACTED**', - 'plugged': True, - 'rx_bytes': 204802727, - 'rx_dropped': 0, - 'rx_errors': 0, - 'rx_packets': 1791592, - 'snr': None, - 'speed': 0, - 'tx_bytes': 236295176, - 'tx_dropped': 0, - 'tx_errors': 0, - 'tx_packets': 298119, - }), + 'firewall': dict({ + 'eb6tables': False, + 'ebtables': False, + 'ip6tables': False, + 'iptables': False, }), - ]), - 'ntpclient': dict({ - }), - 'portfw': False, - 'provmode': dict({ - }), - 'services': dict({ - 'airview': 2, - 'dhcp6d_stateful': False, - 'dhcpc': False, - 'dhcpd': False, - 'pppoe': False, - }), - 'unms': dict({ - 'status': 0, - 'timestamp': None, - }), - 'wireless': dict({ - 'antenna_gain': 13, - 'apmac': '**REDACTED**', - 'aprepeater': False, - 'band': 2, - 'cac_state': 0, - 'cac_timeout': 0, - 'center1_freq': 5530, - 'chanbw': 80, - 'compat_11n': 0, - 'count': 1, - 'dfs': 1, - 'distance': 0, - 'essid': '**REDACTED**', - 'frequency': 5500, - 'hide_essid': 0, - 'ieeemode': '11ACVHT80', - 'mode': 'ap-ptp', - 'noisef': -89, - 'nol_state': 0, - 'nol_timeout': 0, - 'polling': dict({ - 'atpc_status': 2, - 'cb_capacity': 593970, - 'dl_capacity': 647400, - 'ff_cap_rep': False, - 'fixed_frame': False, - 'flex_mode': None, - 'gps_sync': False, - 'rx_use': 42, - 'tx_use': 6, - 'ul_capacity': 540540, - 'use': 48, + 'genuine': '/images/genuine.png', + 'gps': dict({ + 'alt': None, + 'dim': None, + 'dop': None, + 'fix': 0, + 'lat': '**REDACTED**', + 'lon': '**REDACTED**', + 'sats': None, + 'time_synced': None, }), - 'rstatus': 5, - 'rx_chainmask': 3, - 'rx_idx': 8, - 'rx_nss': 2, - 'security': 'WPA2', - 'service': dict({ - 'link': 266003, - 'time': 267181, + 'host': dict({ + 'cpuload': 10.10101, + 'device_id': '03aa0d0b40fed0a47088293584ef5432', + 'devmodel': 'NanoStation 5AC loco', + 'freeram': 16564224, + 'fwversion': 'v8.7.17', + 'height': 3, + 'hostname': '**REDACTED**', + 'loadavg': 0.412598, + 'netrole': 'bridge', + 'power_time': 268683, + 'temperature': 0, + 'time': '2025-06-23 23:06:42', + 'timestamp': 2668313184, + 'totalram': 63447040, + 'uptime': 264888, }), - 'sta': list([ + 'interfaces': list([ dict({ - 'airmax': dict({ - 'actual_priority': 0, - 'atpc_status': 2, - 'beam': 0, - 'cb_capacity': 593970, - 'desired_priority': 0, - 'dl_capacity': 647400, - 'rx': dict({ - 'cinr': 31, - 'evm': list([ - list([ - 31, - 28, - 33, - 32, - 32, - 32, - 31, - 31, - 31, - 29, - 30, - 32, - 30, - 27, - 34, - 31, - 31, - 30, - 32, - 29, - 31, - 29, - 31, - 33, - 31, - 31, - 32, - 30, - 31, - 34, - 33, - 31, - 30, - 31, - 30, - 31, - 31, - 32, - 31, - 30, - 33, - 31, - 30, - 31, - 27, - 31, - 30, - 30, - 30, - 30, - 30, - 29, - 32, - 34, - 31, - 30, - 28, - 30, - 29, - 35, - 31, - 33, - 32, - 29, - ]), - list([ - 34, - 34, - 35, - 34, - 35, - 35, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 35, - 35, - 34, - 34, - 35, - 34, - 33, - 33, - 35, - 34, - 34, - 35, - 34, - 35, - 34, - 34, - 35, - 34, - 34, - 33, - 34, - 34, - 34, - 34, - 34, - 35, - 35, - 35, - 34, - 35, - 33, - 34, - 34, - 34, - 34, - 35, - 35, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 34, - 35, - 35, - ]), - ]), - 'usage': 42, - }), - 'tx': dict({ - 'cinr': 31, - 'evm': list([ - list([ - 32, - 34, - 28, - 33, - 35, - 30, - 31, - 33, - 30, - 30, - 32, - 30, - 29, - 33, - 31, - 29, - 33, - 31, - 31, - 30, - 33, - 34, - 33, - 31, - 33, - 32, - 32, - 31, - 29, - 31, - 30, - 32, - 31, - 30, - 29, - 32, - 31, - 32, - 31, - 31, - 32, - 29, - 31, - 29, - 30, - 32, - 32, - 31, - 32, - 32, - 33, - 31, - 28, - 29, - 31, - 31, - 33, - 32, - 33, - 32, - 32, - 32, - 31, - 33, + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'eth0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': 18, + 'duplex': True, + 'ip6addr': None, + 'ipaddr': '**REDACTED**', + 'plugged': True, + 'rx_bytes': 3984971949, + 'rx_dropped': 0, + 'rx_errors': 4, + 'rx_packets': 73564835, + 'snr': list([ + 30, + 30, + 30, + 30, + ]), + 'speed': 1000, + 'tx_bytes': 209900085624, + 'tx_dropped': 10, + 'tx_errors': 0, + 'tx_packets': 185866883, + }), + }), + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'ath0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': None, + 'duplex': False, + 'ip6addr': None, + 'ipaddr': '**REDACTED**', + 'plugged': False, + 'rx_bytes': 206938324766, + 'rx_dropped': 0, + 'rx_errors': 0, + 'rx_packets': 149767200, + 'snr': None, + 'speed': 0, + 'tx_bytes': 5265602738, + 'tx_dropped': 2005, + 'tx_errors': 0, + 'tx_packets': 52980390, + }), + }), + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'br0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': None, + 'duplex': False, + 'ip6addr': '**REDACTED**', + 'ipaddr': '**REDACTED**', + 'plugged': True, + 'rx_bytes': 204802727, + 'rx_dropped': 0, + 'rx_errors': 0, + 'rx_packets': 1791592, + 'snr': None, + 'speed': 0, + 'tx_bytes': 236295176, + 'tx_dropped': 0, + 'tx_errors': 0, + 'tx_packets': 298119, + }), + }), + ]), + 'ntpclient': dict({ + }), + 'portfw': False, + 'provmode': dict({ + }), + 'services': dict({ + 'airview': 2, + 'dhcp6d_stateful': False, + 'dhcpc': False, + 'dhcpd': False, + 'pppoe': False, + }), + 'unms': dict({ + 'status': 0, + 'timestamp': None, + }), + 'wireless': dict({ + 'antenna_gain': 13, + 'apmac': '**REDACTED**', + 'aprepeater': False, + 'band': 2, + 'cac_state': 0, + 'cac_timeout': 0, + 'center1_freq': 5530, + 'chanbw': 80, + 'compat_11n': 0, + 'count': 1, + 'dfs': 1, + 'distance': 0, + 'essid': '**REDACTED**', + 'frequency': 5500, + 'hide_essid': 0, + 'ieeemode': '11ACVHT80', + 'mode': 'ap-ptp', + 'noisef': -89, + 'nol_state': 0, + 'nol_timeout': 0, + 'polling': dict({ + 'atpc_status': 2, + 'cb_capacity': 593970, + 'dl_capacity': 647400, + 'ff_cap_rep': False, + 'fixed_frame': False, + 'flex_mode': None, + 'gps_sync': False, + 'rx_use': 42, + 'tx_use': 6, + 'ul_capacity': 540540, + 'use': 48, + }), + 'rstatus': 5, + 'rx_chainmask': 3, + 'rx_idx': 8, + 'rx_nss': 2, + 'security': 'WPA2', + 'service': dict({ + 'link': 266003, + 'time': 267181, + }), + 'sta': list([ + dict({ + 'airmax': dict({ + 'actual_priority': 0, + 'atpc_status': 2, + 'beam': 0, + 'cb_capacity': 593970, + 'desired_priority': 0, + 'dl_capacity': 647400, + 'rx': dict({ + 'cinr': 31, + 'evm': list([ + list([ + 31, + 28, + 33, + 32, + 32, + 32, + 31, + 31, + 31, + 29, + 30, + 32, + 30, + 27, + 34, + 31, + 31, + 30, + 32, + 29, + 31, + 29, + 31, + 33, + 31, + 31, + 32, + 30, + 31, + 34, + 33, + 31, + 30, + 31, + 30, + 31, + 31, + 32, + 31, + 30, + 33, + 31, + 30, + 31, + 27, + 31, + 30, + 30, + 30, + 30, + 30, + 29, + 32, + 34, + 31, + 30, + 28, + 30, + 29, + 35, + 31, + 33, + 32, + 29, + ]), + list([ + 34, + 34, + 35, + 34, + 35, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + 34, + 34, + 35, + 34, + 33, + 33, + 35, + 34, + 34, + 35, + 34, + 35, + 34, + 34, + 35, + 34, + 34, + 33, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + 35, + 34, + 35, + 33, + 34, + 34, + 34, + 34, + 35, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + ]), ]), - list([ - 37, - 37, - 37, - 38, - 38, - 37, - 36, - 38, - 38, - 37, - 37, - 37, - 37, - 37, - 39, - 37, - 37, - 37, - 37, - 37, - 37, - 36, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 38, - 37, - 37, - 38, - 37, - 37, - 37, - 38, - 37, - 38, - 37, - 37, - 37, - 37, - 37, - 36, - 37, - 37, - 37, - 37, - 37, - 37, - 38, - 37, - 37, - 38, - 37, - 36, - 37, - 37, - 37, - 37, - 37, - 37, - 37, + 'usage': 42, + }), + 'tx': dict({ + 'cinr': 31, + 'evm': list([ + list([ + 32, + 34, + 28, + 33, + 35, + 30, + 31, + 33, + 30, + 30, + 32, + 30, + 29, + 33, + 31, + 29, + 33, + 31, + 31, + 30, + 33, + 34, + 33, + 31, + 33, + 32, + 32, + 31, + 29, + 31, + 30, + 32, + 31, + 30, + 29, + 32, + 31, + 32, + 31, + 31, + 32, + 29, + 31, + 29, + 30, + 32, + 32, + 31, + 32, + 32, + 33, + 31, + 28, + 29, + 31, + 31, + 33, + 32, + 33, + 32, + 32, + 32, + 31, + 33, + ]), + list([ + 37, + 37, + 37, + 38, + 38, + 37, + 36, + 38, + 38, + 37, + 37, + 37, + 37, + 37, + 39, + 37, + 37, + 37, + 37, + 37, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 37, + 38, + 37, + 37, + 38, + 37, + 37, + 37, + 38, + 37, + 38, + 37, + 37, + 37, + 37, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 38, + 37, + 37, + 38, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 37, + ]), ]), - ]), - 'usage': 6, + 'usage': 6, + }), + 'ul_capacity': 540540, }), - 'ul_capacity': 540540, - }), - 'airos_connected': True, - 'cb_capacity_expect': 416000, - 'chainrssi': list([ - 35, - 32, - 0, - ]), - 'distance': 1, - 'dl_avg_linkscore': 100, - 'dl_capacity_expect': 208000, - 'dl_linkscore': 100, - 'dl_rate_expect': 3, - 'dl_signal_expect': -80, - 'last_disc': 1, - 'lastip': '**REDACTED**', - 'mac': '**REDACTED**', - 'noisefloor': -89, - 'remote': dict({ - 'age': 1, - 'airview': 2, - 'antenna_gain': 13, - 'cable_loss': 0, + 'airos_connected': True, + 'cb_capacity_expect': 416000, 'chainrssi': list([ - 33, - 37, + 35, + 32, 0, ]), - 'compat_11n': 0, - 'cpuload': 43.564301, - 'device_id': 'd4f4cdf82961e619328a8f72f8d7653b', 'distance': 1, - 'ethlist': list([ - dict({ - 'cable_len': 14, - 'duplex': True, - 'enabled': True, - 'ifname': 'eth0', - 'plugged': True, - 'snr': list([ - 30, - 30, - 29, - 30, - ]), - 'speed': 1000, + 'dl_avg_linkscore': 100, + 'dl_capacity_expect': 208000, + 'dl_linkscore': 100, + 'dl_rate_expect': 3, + 'dl_signal_expect': -80, + 'last_disc': 1, + 'lastip': '**REDACTED**', + 'mac': '**REDACTED**', + 'noisefloor': -89, + 'remote': dict({ + 'age': 1, + 'airview': 2, + 'antenna_gain': 13, + 'cable_loss': 0, + 'chainrssi': list([ + 33, + 37, + 0, + ]), + 'compat_11n': 0, + 'cpuload': 43.564301, + 'device_id': 'd4f4cdf82961e619328a8f72f8d7653b', + 'distance': 1, + 'ethlist': list([ + dict({ + 'cable_len': 14, + 'duplex': True, + 'enabled': True, + 'ifname': 'eth0', + 'plugged': True, + 'snr': list([ + 30, + 30, + 29, + 30, + ]), + 'speed': 1000, + }), + ]), + 'freeram': 14290944, + 'gps': dict({ + 'alt': None, + 'dim': None, + 'dop': None, + 'fix': 0, + 'lat': '**REDACTED**', + 'lon': '**REDACTED**', + 'sats': None, + 'time_synced': None, }), - ]), - 'freeram': 14290944, - 'gps': dict({ - 'alt': None, - 'dim': None, - 'dop': None, - 'fix': 0, - 'lat': '**REDACTED**', - 'lon': '**REDACTED**', - 'sats': None, - 'time_synced': None, + 'height': 2, + 'hostname': '**REDACTED**', + 'ip6addr': '**REDACTED**', + 'ipaddr': '**REDACTED**', + 'mode': 'sta-ptp', + 'netrole': 'bridge', + 'noisefloor': -90, + 'oob': False, + 'platform': 'NanoStation 5AC loco', + 'power_time': 268512, + 'rssi': 38, + 'rx_bytes': 3624206478, + 'rx_chainmask': 3, + 'rx_throughput': 251, + 'service': dict({ + 'link': 265996, + 'time': 267195, + }), + 'signal': -58, + 'sys_id': '0xe7fa', + 'temperature': 0, + 'time': '2025-06-23 23:13:54', + 'totalram': 63447040, + 'tx_bytes': 212308148210, + 'tx_power': -4, + 'tx_ratedata': list([ + 14, + 4, + 372, + 2223, + 4708, + 4037, + 8142, + 485763, + 29420892, + 24748154, + ]), + 'tx_throughput': 16023, + 'unms': dict({ + 'status': 0, + 'timestamp': None, + }), + 'uptime': 265320, + 'version': 'WA.ar934x.v8.7.17.48152.250620.2132', }), - 'height': 2, - 'hostname': '**REDACTED**', - 'ip6addr': '**REDACTED**', - 'ipaddr': '**REDACTED**', - 'mode': 'sta-ptp', - 'netrole': 'bridge', - 'noisefloor': -90, - 'oob': False, - 'platform': 'NanoStation 5AC loco', - 'power_time': 268512, - 'rssi': 38, - 'rx_bytes': 3624206478, - 'rx_chainmask': 3, - 'rx_throughput': 251, - 'service': dict({ - 'link': 265996, - 'time': 267195, + 'rssi': 37, + 'rx_idx': 8, + 'rx_nss': 2, + 'signal': -59, + 'stats': dict({ + 'rx_bytes': 206938324814, + 'rx_packets': 149767200, + 'rx_pps': 846, + 'tx_bytes': 5265602739, + 'tx_packets': 52980390, + 'tx_pps': 0, }), - 'signal': -58, - 'sys_id': '0xe7fa', - 'temperature': 0, - 'time': '2025-06-23 23:13:54', - 'totalram': 63447040, - 'tx_bytes': 212308148210, - 'tx_power': -4, + 'tx_idx': 9, + 'tx_latency': 0, + 'tx_lretries': 0, + 'tx_nss': 2, + 'tx_packets': 0, 'tx_ratedata': list([ - 14, + 175, 4, - 372, - 2223, - 4708, - 4037, - 8142, - 485763, - 29420892, - 24748154, + 47, + 200, + 673, + 158, + 163, + 138, + 68895, + 19577430, ]), - 'tx_throughput': 16023, - 'unms': dict({ - 'status': 0, - 'timestamp': None, - }), - 'uptime': 265320, - 'version': 'WA.ar934x.v8.7.17.48152.250620.2132', + 'tx_sretries': 0, + 'ul_avg_linkscore': 88, + 'ul_capacity_expect': 624000, + 'ul_linkscore': 86, + 'ul_rate_expect': 8, + 'ul_signal_expect': -55, + 'uptime': 170281, }), - 'rssi': 37, - 'rx_idx': 8, - 'rx_nss': 2, - 'signal': -59, - 'stats': dict({ - 'rx_bytes': 206938324814, - 'rx_packets': 149767200, - 'rx_pps': 846, - 'tx_bytes': 5265602739, - 'tx_packets': 52980390, - 'tx_pps': 0, - }), - 'tx_idx': 9, - 'tx_latency': 0, - 'tx_lretries': 0, - 'tx_nss': 2, - 'tx_packets': 0, - 'tx_ratedata': list([ - 175, - 4, - 47, - 200, - 673, - 158, - 163, - 138, - 68895, - 19577430, - ]), - 'tx_sretries': 0, - 'ul_avg_linkscore': 88, - 'ul_capacity_expect': 624000, - 'ul_linkscore': 86, - 'ul_rate_expect': 8, - 'ul_signal_expect': -55, - 'uptime': 170281, + ]), + 'sta_disconnected': list([ + ]), + 'throughput': dict({ + 'rx': 9907, + 'tx': 222, }), - ]), - 'sta_disconnected': list([ - ]), - 'throughput': dict({ - 'rx': 9907, - 'tx': 222, + 'tx_chainmask': 3, + 'tx_idx': 9, + 'tx_nss': 2, + 'txpower': -3, }), - 'tx_chainmask': 3, - 'tx_idx': 9, - 'tx_nss': 2, - 'txpower': -3, }), }), 'entry_data': dict({ diff --git a/tests/components/airos/test_binary_sensor.py b/tests/components/airos/test_binary_sensor.py index 85aa771a53a9cf..6c1d2800830ac4 100644 --- a/tests/components/airos/test_binary_sensor.py +++ b/tests/components/airos/test_binary_sensor.py @@ -15,7 +15,7 @@ @pytest.mark.parametrize( - ("ap_fixture"), + ("ap_status_fixture"), [ "airos_loco5ac_ap-ptp.json", # v8 ptp "airos_liteapgps_ap_ptmp_40mhz.json", # v8 ptmp diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 8ed8ca3ac35220..d3d4af7142a78f 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -84,7 +84,7 @@ async def test_manual_flow_creates_entry( hass: HomeAssistant, - ap_fixture: dict[str, Any], + ap_status_fixture: dict[str, Any], mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, mock_setup_entry: AsyncMock, @@ -159,7 +159,7 @@ async def test_form_duplicate_entry( async def test_form_exception_handling( hass: HomeAssistant, mock_setup_entry: AsyncMock, - ap_fixture: dict[str, Any], + ap_status_fixture: dict[str, Any], mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, exception: Exception, @@ -186,11 +186,11 @@ async def test_form_exception_handling( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) valid_data = DetectDeviceData( fw_major=fw_major, - mac=ap_fixture.derived.mac, - hostname=ap_fixture.host.hostname, + mac=ap_status_fixture.derived.mac, + hostname=ap_status_fixture.host.hostname, ) with patch( @@ -210,7 +210,7 @@ async def test_form_exception_handling( async def test_reauth_flow_scenario( hass: HomeAssistant, - ap_fixture: AirOSData, + ap_status_fixture: AirOSData, mock_airos_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_setup_entry: AsyncMock, @@ -234,11 +234,11 @@ async def test_reauth_flow_scenario( assert flow["type"] == FlowResultType.FORM assert flow["step_id"] == REAUTH_STEP - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) valid_data = DetectDeviceData( fw_major=fw_major, - mac=ap_fixture.derived.mac, - hostname=ap_fixture.host.hostname, + mac=ap_status_fixture.derived.mac, + hostname=ap_status_fixture.host.hostname, ) mock_firmware = AsyncMock(return_value=valid_data) @@ -283,7 +283,7 @@ async def test_reauth_flow_scenario( ) async def test_reauth_flow_scenarios( hass: HomeAssistant, - ap_fixture: AirOSData, + ap_status_fixture: AirOSData, expected_error: str, mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, @@ -321,11 +321,11 @@ async def test_reauth_flow_scenarios( assert result["step_id"] == REAUTH_STEP assert result["errors"] == {"base": expected_error} - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) valid_data = DetectDeviceData( fw_major=fw_major, - mac=ap_fixture.derived.mac, - hostname=ap_fixture.host.hostname, + mac=ap_status_fixture.derived.mac, + hostname=ap_status_fixture.host.hostname, ) with patch( @@ -346,7 +346,7 @@ async def test_reauth_flow_scenarios( async def test_reauth_unique_id_mismatch( hass: HomeAssistant, - ap_fixture: AirOSData, + ap_status_fixture: AirOSData, mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, mock_config_entry: MockConfigEntry, @@ -366,11 +366,11 @@ async def test_reauth_unique_id_mismatch( data=mock_config_entry.data, ) - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) valid_data = DetectDeviceData( fw_major=fw_major, mac="FF:23:45:67:89:AB", - hostname=ap_fixture.host.hostname, + hostname=ap_status_fixture.host.hostname, ) with patch( @@ -501,7 +501,7 @@ async def test_reconfigure_flow_failure( async def test_reconfigure_unique_id_mismatch( hass: HomeAssistant, - ap_fixture: AirOSData, + ap_status_fixture: AirOSData, mock_airos_client: AsyncMock, mock_async_get_firmware_data: AsyncMock, mock_config_entry: MockConfigEntry, @@ -516,11 +516,11 @@ async def test_reconfigure_unique_id_mismatch( ) flow_id = result["flow_id"] - fw_major = int(ap_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) + fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0]) mismatched_data = DetectDeviceData( fw_major=fw_major, mac="FF:23:45:67:89:AB", - hostname=ap_fixture.host.hostname, + hostname=ap_status_fixture.host.hostname, ) user_input = { diff --git a/tests/components/airos/test_diagnostics.py b/tests/components/airos/test_diagnostics.py index f6b8733f880673..99f5297c0229ab 100644 --- a/tests/components/airos/test_diagnostics.py +++ b/tests/components/airos/test_diagnostics.py @@ -11,7 +11,7 @@ from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.typing import ClientSessionGenerator +from tests.typing import Any, ClientSessionGenerator async def test_diagnostics( @@ -19,7 +19,8 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, mock_airos_client: MagicMock, mock_config_entry: MockConfigEntry, - ap_fixture: AirOS8Data, + ap_status_fixture: AirOS8Data, + ap_firmware_fixture: dict[str, Any], snapshot: SnapshotAssertion, mock_async_get_firmware_data: AsyncMock, ) -> None: diff --git a/tests/components/airos/test_init.py b/tests/components/airos/test_init.py index 2b06fb3dc80c61..77da2ddffc61b7 100644 --- a/tests/components/airos/test_init.py +++ b/tests/components/airos/test_init.py @@ -18,9 +18,14 @@ DOMAIN, SECTION_ADVANCED_SETTINGS, ) +from homeassistant.components.airos.coordinator import async_fetch_airos_data from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntryAuthFailed, + ConfigEntryState, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -282,3 +287,11 @@ async def test_setup_entry_failure( result = await hass.config_entries.async_setup(mock_config_entry.entry_id) assert result is False assert mock_config_entry.state == state + + +async def test_fetch_airos_data_auth_error(mock_airos_client: MagicMock) -> None: + """Test login auth error triggers ConfigEntryAuthFailed.""" + mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError + + with pytest.raises(ConfigEntryAuthFailed): + await async_fetch_airos_data(mock_airos_client, mock_airos_client.status) diff --git a/tests/components/airos/test_update.py b/tests/components/airos/test_update.py new file mode 100644 index 00000000000000..b162258e68fa4f --- /dev/null +++ b/tests/components/airos/test_update.py @@ -0,0 +1,146 @@ +"""Test the Ubiquiti airOS firmware update.""" + +from unittest.mock import AsyncMock + +from airos.exceptions import ( + AirOSConnectionAuthenticationError, + AirOSDeviceConnectionError, + AirOSException, +) +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("ap_status_fixture", "ap_firmware_fixture", "entity_id"), + [ + ("airos_loco5ac_ap-ptp.json", True, "update.nanostation_5ac_ap_name_firmware"), + ("airos_liteapgps_ap_ptmp_40mhz.json", True, "update.house_bridge_firmware"), + ], + indirect=["ap_status_fixture", "ap_firmware_fixture"], +) +async def test_update_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_airos_client: AsyncMock, + mock_async_get_firmware_data: AsyncMock, + entity_id: str, +) -> None: + """Test the firmware update entity behavior.""" + await setup_integration(hass, mock_config_entry, [Platform.UPDATE]) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + update_entities = [e for e in entries if e.domain == "update"] + + assert len(update_entities) == 1 + assert update_entities[0].entity_id == entity_id + + state = hass.states.get(entity_id) + assert state is not None + + await hass.services.async_call( + "update", + "install", + {"entity_id": entity_id}, + blocking=True, + ) + + mock_airos_client.update_check.assert_awaited() + mock_airos_client.download.assert_awaited() + mock_airos_client.install.assert_awaited() + + await hass.async_block_till_done() + new_state = hass.states.get(entity_id) + assert new_state is not None + + +@pytest.mark.parametrize( + "ap_status_fixture", + [ + "airos_NanoStation_loco_M5_v6.3.16_XM_sta.json", + "airos_NanoStation_M5_sta_v6.3.16.json", + ], + indirect=True, +) +async def test_no_update_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_airos_client: AsyncMock, + mock_async_get_firmware_data: AsyncMock, + ap_firmware_fixture: dict[str, bool], +) -> None: + """Test the firmware update entity behavior is not implemented.""" + await setup_integration(hass, mock_config_entry, [Platform.UPDATE]) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + update_entities = [e for e in entries if e.domain == "update"] + + assert not update_entities + + +@pytest.mark.parametrize( + ("ap_status_fixture", "ap_firmware_fixture", "exception", "translation_key"), + [ + ( + "airos_loco5ac_ap-ptp.json", + True, + AirOSConnectionAuthenticationError, + "update_connection_authentication_error", + ), + ( + "airos_liteapgps_ap_ptmp_40mhz.json", + True, + AirOSDeviceConnectionError, + "update_error", + ), + ], + indirect=["ap_status_fixture", "ap_firmware_fixture"], +) +async def test_update_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_airos_client: AsyncMock, + mock_async_get_firmware_data: AsyncMock, + exception: AirOSException, + translation_key: str, +) -> None: + """Test the firmware update entity behavior.""" + await setup_integration(hass, mock_config_entry, [Platform.UPDATE]) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + update_entities = [e for e in entries if e.domain == "update"] + + assert len(update_entities) == 1 + entity_id = update_entities[0].entity_id + + state = hass.states.get(entity_id) + assert state is not None + + mock_airos_client.download.side_effect = exception + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + "update", + "install", + {"entity_id": entity_id}, + blocking=True, + ) + + assert exc.value.translation_key == translation_key From 79b37bff0ba6bd047b775c58456c96d89e2ab299 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 21:15:19 +0200 Subject: [PATCH 0356/1707] Improve `shelly` action naming consistency (#167102) --- homeassistant/components/shelly/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 8778cd753bee9f..1a2fc3513f6f52 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -795,7 +795,7 @@ }, "services": { "get_kvs_value": { - "description": "Get a value from the device's Key-Value Storage.", + "description": "Gets a value from a Shelly device's Key-Value Storage.", "fields": { "device_id": { "description": "The ID of the Shelly device to get the KVS value from.", @@ -806,10 +806,10 @@ "name": "Key" } }, - "name": "Get KVS value" + "name": "Get Shelly KVS value" }, "set_kvs_value": { - "description": "Set a value in the device's Key-Value Storage.", + "description": "Sets a value in a Shelly device's Key-Value Storage.", "fields": { "device_id": { "description": "The ID of the Shelly device to set the KVS value.", @@ -824,7 +824,7 @@ "name": "Value" } }, - "name": "Set KVS value" + "name": "Set Shelly KVS value" } } } From f09602363c56943f6ab0f789789c92b54eda501e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 21:42:59 +0200 Subject: [PATCH 0357/1707] Improve `system_log` action naming consistency (#167104) --- homeassistant/components/system_log/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/system_log/strings.json b/homeassistant/components/system_log/strings.json index e8bccb214381e4..27f512c41acff2 100644 --- a/homeassistant/components/system_log/strings.json +++ b/homeassistant/components/system_log/strings.json @@ -12,11 +12,11 @@ }, "services": { "clear": { - "description": "Deletes all log entries.", - "name": "Clear" + "description": "Deletes all system log entries.", + "name": "Clear system log" }, "write": { - "description": "Write log entry.", + "description": "Writes a system log entry.", "fields": { "level": { "description": "Log level.", @@ -31,7 +31,7 @@ "name": "Message" } }, - "name": "Write" + "name": "Write to system log" } } } From 2881916c919ce816067f9c6e041b2f0eb6f759c2 Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:53:28 +0000 Subject: [PATCH 0358/1707] Replace NINA attributes with sensors (#161882) --- homeassistant/components/nina/__init__.py | 2 +- .../components/nina/binary_sensor.py | 26 +- homeassistant/components/nina/config_flow.py | 60 +- homeassistant/components/nina/const.py | 13 + homeassistant/components/nina/coordinator.py | 44 +- homeassistant/components/nina/entity.py | 12 +- .../components/nina/quality_scale.yaml | 18 +- homeassistant/components/nina/sensor.py | 159 ++ homeassistant/components/nina/strings.json | 34 +- tests/components/nina/__init__.py | 27 +- tests/components/nina/conftest.py | 38 +- tests/components/nina/const.py | 18 + tests/components/nina/fixtures/warnings.json | 4 +- .../nina/snapshots/test_binary_sensor.ambr | 72 +- .../nina/snapshots/test_diagnostics.ambr | 12 +- .../nina/snapshots/test_sensor.ambr | 2096 +++++++++++++++++ tests/components/nina/test_binary_sensor.py | 112 +- tests/components/nina/test_config_flow.py | 16 +- tests/components/nina/test_sensor.py | 107 + 19 files changed, 2682 insertions(+), 188 deletions(-) create mode 100644 homeassistant/components/nina/sensor.py create mode 100644 tests/components/nina/snapshots/test_sensor.ambr create mode 100644 tests/components/nina/test_sensor.py diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 4bb435ea1cecd1..544402b0b3dda2 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -18,7 +18,7 @@ ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator -PLATFORMS: list[str] = [Platform.BINARY_SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 621627833407df..3f351c0b6f4500 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -1,4 +1,4 @@ -"""NINA sensor platform.""" +"""NINA binary sensor platform.""" from __future__ import annotations @@ -88,15 +88,19 @@ def extra_state_attributes(self) -> dict[str, Any]: data = self._get_warning_data() return { - ATTR_HEADLINE: data.headline, - ATTR_DESCRIPTION: data.description, - ATTR_SENDER: data.sender, - ATTR_SEVERITY: data.severity, - ATTR_RECOMMENDED_ACTIONS: data.recommended_actions, - ATTR_AFFECTED_AREAS: data.affected_areas, - ATTR_WEB: data.web, + ATTR_HEADLINE: data.headline, # Deprecated, remove in 2026.11 + ATTR_DESCRIPTION: data.description, # Deprecated, remove in 2026.11 + ATTR_SENDER: data.sender, # Deprecated, remove in 2026.11 + ATTR_SEVERITY: data.severity or "Unknown", # Deprecated, remove in 2026.11 + ATTR_RECOMMENDED_ACTIONS: data.recommended_actions, # Deprecated, remove in 2026.11 + ATTR_AFFECTED_AREAS: data.affected_areas, # Deprecated, remove in 2026.11 + ATTR_WEB: data.more_info_url, # Deprecated, remove in 2026.11 ATTR_ID: data.id, - ATTR_SENT: data.sent, - ATTR_START: data.start, - ATTR_EXPIRES: data.expires, + ATTR_SENT: data.sent.isoformat(), # Deprecated, remove in 2026.11 + ATTR_START: data.start.isoformat() + if data.start + else "", # Deprecated, remove in 2026.11 + ATTR_EXPIRES: data.expires.isoformat() + if data.expires + else "", # Deprecated, remove in 2026.11 } diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 2eeec4de19d4f0..f00f8918298eab 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -31,6 +31,7 @@ CONST_REGIONS, DOMAIN, NO_MATCH_REGEX, + SENSOR_SUFFIXES, ) @@ -243,32 +244,7 @@ async def async_step_init( user_input, self._all_region_codes_sorted ) - entity_registry = er.async_get(self.hass) - - entries = er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ) - - removed_entities_slots = [ - f"{region}-{slot_id}" - for region in self.data[CONF_REGIONS] - for slot_id in range(self.data[CONF_MESSAGE_SLOTS] + 1) - if slot_id > user_input[CONF_MESSAGE_SLOTS] - ] - - removed_entities_area = [ - f"{cfg_region}-{slot_id}" - for slot_id in range(1, self.data[CONF_MESSAGE_SLOTS] + 1) - for cfg_region in self.data[CONF_REGIONS] - if cfg_region not in user_input[CONF_REGIONS] - ] - - for entry in entries: - for entity_uid in list( - set(removed_entities_slots + removed_entities_area) - ): - if entry.unique_id == entity_uid: - entity_registry.async_remove(entry.entity_id) + await self.remove_unused_entities(user_input) self.hass.config_entries.async_update_entry( self.config_entry, data=user_input @@ -287,3 +263,35 @@ async def async_step_init( data_schema=schema_with_suggested, errors=errors, ) + + async def remove_unused_entities(self, user_input: dict[str, Any]) -> None: + """Remove entities which are not used anymore.""" + entity_registry = er.async_get(self.hass) + + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) + + id_type_suffix = [f"-{sensor_id}" for sensor_id in SENSOR_SUFFIXES] + [""] + + removed_entities_slots = [ + f"{region}-{slot_id}{suffix}" + for region in self.data[CONF_REGIONS] + for slot_id in range(self.data[CONF_MESSAGE_SLOTS] + 1) + for suffix in id_type_suffix + if slot_id > user_input[CONF_MESSAGE_SLOTS] + ] + + removed_entities_area = [ + f"{cfg_region}-{slot_id}{suffix}" + for slot_id in range(1, self.data[CONF_MESSAGE_SLOTS] + 1) + for cfg_region in self.data[CONF_REGIONS] + for suffix in id_type_suffix + if cfg_region not in user_input[CONF_REGIONS] + ] + + removed_uids = set(removed_entities_slots + removed_entities_area) + + for entry in entries: + if entry.unique_id in removed_uids: + entity_registry.async_remove(entry.entity_id) diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 409658e4131574..d034303a243dcd 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -15,6 +15,8 @@ NO_MATCH_REGEX: str = "/(?!)/" ALL_MATCH_REGEX: str = ".*" +SEVERITY_VALUES: list[str] = ["extreme", "severe", "moderate", "minor", "unknown"] + CONF_REGIONS: str = "regions" CONF_MESSAGE_SLOTS: str = "slots" CONF_FILTERS: str = "filters" @@ -34,6 +36,17 @@ ATTR_START: str = "start" ATTR_EXPIRES: str = "expires" +SENSOR_SUFFIXES: list[str] = [ + "headline", + "sender", + "severity", + "affected_areas", + "more_info_url", + "sent", + "start", + "expires", +] + CONST_LIST_A_TO_D: list[str] = ["A", "Ä", "B", "C", "D"] CONST_LIST_E_TO_H: list[str] = ["E", "F", "G", "H"] CONST_LIST_I_TO_L: list[str] = ["I", "J", "K", "L"] diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index b41bcea55ae83e..12e4e831dc6cf9 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -4,6 +4,7 @@ import asyncio from dataclasses import dataclass +from datetime import datetime import re from typing import Any @@ -12,7 +13,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -36,13 +36,14 @@ class NinaWarningData: headline: str description: str sender: str - severity: str + severity: str | None recommended_actions: str + affected_areas_short: str affected_areas: str - web: str - sent: str - start: str - expires: str + more_info_url: str + sent: datetime + start: datetime | None + expires: datetime | None is_valid: bool @@ -65,12 +66,6 @@ def __init__( ] self.area_filter: str = config_entry.data[CONF_FILTERS][CONF_AREA_FILTER] - self.device_info = DeviceInfo( - identifiers={(DOMAIN, config_entry.entry_id)}, - manufacturer="NINA", - entry_type=DeviceEntryType.SERVICE, - ) - regions: dict[str, str] = config_entry.data[CONF_REGIONS] for region in regions: self._nina.add_region(region) @@ -146,18 +141,33 @@ def _parse_data(self) -> dict[str, list[NinaWarningData]]: ) continue + shortened_affected_areas: str = ( + affected_areas_string[0:250] + "..." + if len(affected_areas_string) > 250 + else affected_areas_string + ) + + severity = ( + None + if raw_warn.severity.lower() == "unknown" + else raw_warn.severity + ) + warning_data: NinaWarningData = NinaWarningData( raw_warn.id, raw_warn.headline, raw_warn.description, - raw_warn.sender, - raw_warn.severity, + raw_warn.sender or "", + severity, " ".join([str(action) for action in raw_warn.recommended_actions]), + shortened_affected_areas, affected_areas_string, raw_warn.web or "", - raw_warn.sent or "", - raw_warn.start or "", - raw_warn.expires or "", + datetime.fromisoformat(raw_warn.sent), + datetime.fromisoformat(raw_warn.start) if raw_warn.start else None, + datetime.fromisoformat(raw_warn.expires) + if raw_warn.expires + else None, raw_warn.is_valid, ) warnings_for_regions.append(warning_data) diff --git a/homeassistant/components/nina/entity.py b/homeassistant/components/nina/entity.py index 97db7c90064ef0..c5b462fcd7ac46 100644 --- a/homeassistant/components/nina/entity.py +++ b/homeassistant/components/nina/entity.py @@ -1,7 +1,9 @@ """NINA common entity.""" +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import NINADataUpdateCoordinator, NinaWarningData @@ -20,12 +22,18 @@ def __init__( self._region = region self._warning_index = slot_id - 1 + self._region_name = region_name self._attr_translation_placeholders = { - "region_name": region_name, "slot_id": str(slot_id), } - self._attr_device_info = coordinator.device_info + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._region)}, + manufacturer="NINA", + name=self._region_name, + entry_type=DeviceEntryType.SERVICE, + ) def _get_active_warnings_count(self) -> int: """Return the number of active warnings for the region.""" diff --git a/homeassistant/components/nina/quality_scale.yaml b/homeassistant/components/nina/quality_scale.yaml index 1d405b9e8cbbef..45d3e909d5e6fa 100644 --- a/homeassistant/components/nina/quality_scale.yaml +++ b/homeassistant/components/nina/quality_scale.yaml @@ -62,23 +62,17 @@ rules: docs-supported-devices: status: exempt comment: | - This integration does not use devices. - docs-supported-functions: todo + This integration exposes Home Assistant devices only for logical grouping and does not integrate specific physical devices that need to be documented as supported hardware. + docs-supported-functions: done docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: done - entity-category: todo - entity-device-class: - status: todo - comment: | - Extract attributes into own entities. + entity-category: done + entity-device-class: done entity-disabled-by-default: done - entity-translations: todo + entity-translations: done exception-translations: todo - icon-translations: - status: exempt - comment: | - This integration does not custom icons. + icon-translations: todo reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/nina/sensor.py b/homeassistant/components/nina/sensor.py new file mode 100644 index 00000000000000..d1491d6365b381 --- /dev/null +++ b/homeassistant/components/nina/sensor.py @@ -0,0 +1,159 @@ +"""NINA sensor platform.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_MESSAGE_SLOTS, CONF_REGIONS, SENSOR_SUFFIXES, SEVERITY_VALUES +from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator, NinaWarningData +from .entity import NinaEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class NinaSensorEntityDescription(SensorEntityDescription): + """Describes NINA sensor entity.""" + + value_fn: Callable[[NinaWarningData], str | datetime | None] + + +SENSOR_TYPES: tuple[NinaSensorEntityDescription, ...] = ( + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[0], + translation_key="headline", + value_fn=lambda data: data.headline, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[1], + translation_key="sender", + value_fn=lambda data: data.sender, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[2], + options=SEVERITY_VALUES, + device_class=SensorDeviceClass.ENUM, + translation_key="severity", + value_fn=lambda data: ( + data.severity.lower() if data.severity is not None else None + ), + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[3], + translation_key="affected_areas", + value_fn=lambda data: data.affected_areas_short, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[4], + translation_key="more_info_url", + value_fn=lambda data: data.more_info_url, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[5], + translation_key="sent", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.sent, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[6], + translation_key="start", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.start, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[7], + translation_key="expires", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.expires, + ), +) + + +def create_sensors_for_warning( + coordinator: NINADataUpdateCoordinator, region: str, region_name: str, slot_id: int +) -> Sequence[NinaSensor]: + """Create sensors for a warning.""" + return [ + NinaSensor( + coordinator, + region, + region_name, + slot_id, + description, + ) + for description in SENSOR_TYPES + ] + + +async def async_setup_entry( + _: HomeAssistant, + config_entry: NinaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NINA sensor platform.""" + + coordinator = config_entry.runtime_data + + regions: dict[str, str] = config_entry.data[CONF_REGIONS] + message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS] + + entities = [ + create_sensors_for_warning(coordinator, ent, regions[ent], i + 1) + for ent in coordinator.data + for i in range(message_slots) + ] + + async_add_entities( + [entity for slot_entities in entities for entity in slot_entities] + ) + + +class NinaSensor(NinaEntity, SensorEntity): + """Representation of a NINA sensor.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + entity_description: NinaSensorEntityDescription + + def __init__( + self, + coordinator: NINADataUpdateCoordinator, + region: str, + region_name: str, + slot_id: int, + description: NinaSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator, region, region_name, slot_id) + + self.entity_description = description + + self._attr_unique_id = f"{region}-{slot_id}-{self.entity_description.key}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + if self._get_active_warnings_count() <= self._warning_index: + return False + + return self._get_warning_data().is_valid and super().available + + @property + def native_value(self) -> str | datetime | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._get_warning_data()) diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 711ca9d3715f7a..2e36e2fd61e0b8 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -48,7 +48,39 @@ "entity": { "binary_sensor": { "warning": { - "name": "Warning: {region_name} {slot_id}" + "name": "Warning {slot_id}" + } + }, + "sensor": { + "affected_areas": { + "name": "Affected areas {slot_id}" + }, + "expires": { + "name": "Expires {slot_id}" + }, + "headline": { + "name": "Headline {slot_id}" + }, + "more_info_url": { + "name": "More information URL {slot_id}" + }, + "sender": { + "name": "Sender {slot_id}" + }, + "sent": { + "name": "Sent {slot_id}" + }, + "severity": { + "name": "Severity {slot_id}", + "state": { + "extreme": "Extreme", + "minor": "Minor", + "moderate": "Moderate", + "severe": "Severe" + } + }, + "start": { + "name": "Start {slot_id}" } } }, diff --git a/tests/components/nina/__init__.py b/tests/components/nina/__init__.py index 1ea7d130b6534b..07858d38a70ed0 100644 --- a/tests/components/nina/__init__.py +++ b/tests/components/nina/__init__.py @@ -1,12 +1,13 @@ """Tests for the Nina integration.""" from copy import deepcopy -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from pynina import Warning from homeassistant.components.nina.const import CONF_REGIONS from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -18,7 +19,7 @@ async def setup_platform( mock_nina_class: AsyncMock, nina_warnings: list[Warning], ) -> None: - """Set up the NINA platform.""" + """Set up the NINA platforms.""" mock_nina_class.warnings = { region: deepcopy(nina_warnings) for region in config_entry.data.get(CONF_REGIONS, {}) @@ -28,3 +29,25 @@ async def setup_platform( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED + + +async def setup_single_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platform: Platform | None, + mock_nina_class: AsyncMock, + nina_warnings: list[Warning], +) -> None: + """Set up a single NINA platform.""" + mock_nina_class.warnings = { + region: deepcopy(nina_warnings) + for region in config_entry.data.get(CONF_REGIONS, {}) + } + + platforms = [platform] if platform else [] + + with patch("homeassistant.components.nina.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/nina/conftest.py b/tests/components/nina/conftest.py index 53384452ddab6b..8113eca50baa33 100644 --- a/tests/components/nina/conftest.py +++ b/tests/components/nina/conftest.py @@ -10,7 +10,11 @@ from homeassistant.components.nina.const import DOMAIN from homeassistant.core import HomeAssistant -from .const import DUMMY_CONFIG_ENTRY +from .const import ( + DUMMY_CONFIG_ENTRY, + DUMMY_CONFIG_ENTRY_AREA_FILTERS, + DUMMY_CONFIG_ENTRY_DEFAULT_FILTERS, +) from tests.common import ( MockConfigEntry, @@ -44,6 +48,38 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry +@pytest.fixture +def mock_config_entry_default_filter(hass: HomeAssistant) -> MockConfigEntry: + """Provide a common mock config entry with no filters.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=deepcopy(DUMMY_CONFIG_ENTRY_DEFAULT_FILTERS), + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +@pytest.fixture +def mock_config_entry_area_filter(hass: HomeAssistant) -> MockConfigEntry: + """Provide a common mock config entry with an area filter.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=deepcopy(DUMMY_CONFIG_ENTRY_AREA_FILTERS), + version=1, + minor_version=3, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + @pytest.fixture def mock_nina_class(nina_region_codes: dict[str, str]) -> Generator[AsyncMock]: """Fixture to mock the NINA class.""" diff --git a/tests/components/nina/const.py b/tests/components/nina/const.py index 0179d20339e07a..bdf0f601e1ed93 100644 --- a/tests/components/nina/const.py +++ b/tests/components/nina/const.py @@ -37,3 +37,21 @@ CONST_REGION_A_TO_D: deepcopy(DUMMY_USER_INPUT[CONST_REGION_A_TO_D]), CONF_REGIONS: {"095760000000": "Aach"}, } + +DUMMY_CONFIG_ENTRY_DEFAULT_FILTERS: dict[str, Any] = { + CONF_MESSAGE_SLOTS: 5, + CONF_REGIONS: {"083350000000": "Aach, Stadt"}, + CONF_FILTERS: { + CONF_HEADLINE_FILTER: "/(?!)/", + CONF_AREA_FILTER: ".*", + }, +} + +DUMMY_CONFIG_ENTRY_AREA_FILTERS: dict[str, Any] = { + CONF_MESSAGE_SLOTS: 5, + CONF_REGIONS: {"083350000000": "Aach, Stadt"}, + CONF_FILTERS: { + CONF_HEADLINE_FILTER: "/(?!)/", + CONF_AREA_FILTER: ".*nagold.*", + }, +} diff --git a/tests/components/nina/fixtures/warnings.json b/tests/components/nina/fixtures/warnings.json index 2e3a9f4ecea40a..4c603c0eeb2da7 100644 --- a/tests/components/nina/fixtures/warnings.json +++ b/tests/components/nina/fixtures/warnings.json @@ -23,7 +23,9 @@ "affected_areas": [ "Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere." ], - "recommended_actions": [], + "recommended_actions": [ + "ACHTUNG! Hinweis auf mögliche Gefahren: Es können zum Beispiel einzelne Äste herabstürzen. Achte besonders auf herabfallende Gegenstände." + ], "web": "https://www.wettergefahren.de", "sent": "2021-10-11T05:20:00+01:00", "start": "2021-11-01T05:20:00+01:00", diff --git a/tests/components/nina/snapshots/test_binary_sensor.ambr b/tests/components/nina/snapshots/test_binary_sensor.ambr index 63adc9ac5f0773..7dd2415ba5e62a 100644 --- a/tests/components/nina/snapshots/test_binary_sensor.ambr +++ b/tests/components/nina/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[binary_sensor.nina_warning_aach_1-entry] +# name: test_binary_sensors[binary_sensor.aach_warning_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -13,7 +13,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.nina_warning_aach_1', + 'entity_id': 'binary_sensor.aach_warning_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -21,12 +21,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Warning: Aach 1', + 'object_id_base': 'Warning 1', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Warning: Aach 1', + 'original_name': 'Warning 1', 'platform': 'nina', 'previous_unique_id': None, 'suggested_object_id': None, @@ -36,17 +36,17 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[binary_sensor.nina_warning_aach_1-state] +# name: test_binary_sensors[binary_sensor.aach_warning_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'affected_areas': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere.', 'description': 'Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden.', 'device_class': 'safety', 'expires': '3021-11-22T05:19:00+01:00', - 'friendly_name': 'NINA Warning: Aach 1', + 'friendly_name': 'Aach Warning 1', 'headline': 'Ausfall Notruf 112', 'id': 'mow.DE-NW-BN-SE030-20201014-30-000', - 'recommended_actions': '', + 'recommended_actions': 'ACHTUNG! Hinweis auf mögliche Gefahren: Es können zum Beispiel einzelne Äste herabstürzen. Achte besonders auf herabfallende Gegenstände.', 'sender': 'Deutscher Wetterdienst', 'sent': '2021-10-11T05:20:00+01:00', 'severity': 'Minor', @@ -54,14 +54,14 @@ 'web': 'https://www.wettergefahren.de', }), 'context': , - 'entity_id': 'binary_sensor.nina_warning_aach_1', + 'entity_id': 'binary_sensor.aach_warning_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_sensors[binary_sensor.nina_warning_aach_2-entry] +# name: test_binary_sensors[binary_sensor.aach_warning_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -75,7 +75,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.nina_warning_aach_2', + 'entity_id': 'binary_sensor.aach_warning_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -83,12 +83,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Warning: Aach 2', + 'object_id_base': 'Warning 2', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Warning: Aach 2', + 'original_name': 'Warning 2', 'platform': 'nina', 'previous_unique_id': None, 'suggested_object_id': None, @@ -98,21 +98,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[binary_sensor.nina_warning_aach_2-state] +# name: test_binary_sensors[binary_sensor.aach_warning_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'safety', - 'friendly_name': 'NINA Warning: Aach 2', + 'friendly_name': 'Aach Warning 2', }), 'context': , - 'entity_id': 'binary_sensor.nina_warning_aach_2', + 'entity_id': 'binary_sensor.aach_warning_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_sensors[binary_sensor.nina_warning_aach_3-entry] +# name: test_binary_sensors[binary_sensor.aach_warning_3-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -126,7 +126,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.nina_warning_aach_3', + 'entity_id': 'binary_sensor.aach_warning_3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -134,12 +134,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Warning: Aach 3', + 'object_id_base': 'Warning 3', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Warning: Aach 3', + 'original_name': 'Warning 3', 'platform': 'nina', 'previous_unique_id': None, 'suggested_object_id': None, @@ -149,21 +149,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[binary_sensor.nina_warning_aach_3-state] +# name: test_binary_sensors[binary_sensor.aach_warning_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'safety', - 'friendly_name': 'NINA Warning: Aach 3', + 'friendly_name': 'Aach Warning 3', }), 'context': , - 'entity_id': 'binary_sensor.nina_warning_aach_3', + 'entity_id': 'binary_sensor.aach_warning_3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_sensors[binary_sensor.nina_warning_aach_4-entry] +# name: test_binary_sensors[binary_sensor.aach_warning_4-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -177,7 +177,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.nina_warning_aach_4', + 'entity_id': 'binary_sensor.aach_warning_4', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -185,12 +185,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Warning: Aach 4', + 'object_id_base': 'Warning 4', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Warning: Aach 4', + 'original_name': 'Warning 4', 'platform': 'nina', 'previous_unique_id': None, 'suggested_object_id': None, @@ -200,21 +200,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[binary_sensor.nina_warning_aach_4-state] +# name: test_binary_sensors[binary_sensor.aach_warning_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'safety', - 'friendly_name': 'NINA Warning: Aach 4', + 'friendly_name': 'Aach Warning 4', }), 'context': , - 'entity_id': 'binary_sensor.nina_warning_aach_4', + 'entity_id': 'binary_sensor.aach_warning_4', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_sensors[binary_sensor.nina_warning_aach_5-entry] +# name: test_binary_sensors[binary_sensor.aach_warning_5-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -228,7 +228,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.nina_warning_aach_5', + 'entity_id': 'binary_sensor.aach_warning_5', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -236,12 +236,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Warning: Aach 5', + 'object_id_base': 'Warning 5', 'options': dict({ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Warning: Aach 5', + 'original_name': 'Warning 5', 'platform': 'nina', 'previous_unique_id': None, 'suggested_object_id': None, @@ -251,14 +251,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[binary_sensor.nina_warning_aach_5-state] +# name: test_binary_sensors[binary_sensor.aach_warning_5-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'safety', - 'friendly_name': 'NINA Warning: Aach 5', + 'friendly_name': 'Aach Warning 5', }), 'context': , - 'entity_id': 'binary_sensor.nina_warning_aach_5', + 'entity_id': 'binary_sensor.aach_warning_5', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/nina/snapshots/test_diagnostics.ambr b/tests/components/nina/snapshots/test_diagnostics.ambr index beeba164e02b85..c89ae1c71d285e 100644 --- a/tests/components/nina/snapshots/test_diagnostics.ambr +++ b/tests/components/nina/snapshots/test_diagnostics.ambr @@ -5,31 +5,33 @@ '095760000000': list([ dict({ 'affected_areas': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere.', + 'affected_areas_short': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Lieb...', 'description': 'Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden.', 'expires': '3021-11-22T05:19:00+01:00', 'headline': 'Ausfall Notruf 112', 'id': 'mow.DE-NW-BN-SE030-20201014-30-000', 'is_valid': True, - 'recommended_actions': '', + 'more_info_url': 'https://www.wettergefahren.de', + 'recommended_actions': 'ACHTUNG! Hinweis auf mögliche Gefahren: Es können zum Beispiel einzelne Äste herabstürzen. Achte besonders auf herabfallende Gegenstände.', 'sender': 'Deutscher Wetterdienst', 'sent': '2021-10-11T05:20:00+01:00', 'severity': 'Minor', 'start': '2021-11-01T05:20:00+01:00', - 'web': 'https://www.wettergefahren.de', }), dict({ 'affected_areas': 'Axstedt, Gnarrenburg, Grasberg, Hagen im Bremischen, Hambergen, Hepstedt, Holste, Lilienthal, Lübberstedt, Osterholz-Scharmbeck, Ritterhude, Schwanewede, Vollersode, Worpswede', + 'affected_areas_short': 'Axstedt, Gnarrenburg, Grasberg, Hagen im Bremischen, Hambergen, Hepstedt, Holste, Lilienthal, Lübberstedt, Osterholz-Scharmbeck, Ritterhude, Schwanewede, Vollersode, Worpswede', 'description': 'In Beverstedt im Landkreis Cuxhaven ist am 20. Juli 2022 in einer Geflügelhaltung der Ausbruch der Geflügelpest (Vogelgrippe, Aviäre Influenza) amtlich festgestellt worden. Durch die geografische Nähe des Ausbruchsbetriebes zum Gebiet des Landkreises Osterholz musste das Veterinäramt des Landkreises zum Schutz vor einer Ausbreitung der Geflügelpest auch für sein Gebiet ein Restriktionsgebiet festlegen. Rund um den Ausbruchsort wurde eine Überwachungszone ausgewiesen. Eine entsprechende Tierseuchenbehördliche Allgemeinverfügung wurde vom Landkreis Osterholz erlassen und tritt am 23.07.2022 in Kraft.
\xa0
Die Überwachungszone mit einem Radius von mindestens zehn Kilometern um den Ausbruchsbetrieb erstreckt sich im Landkreis Osterholz innerhalb der Samtgemeinde Hambergen auf die Mitgliedsgemeinden Axstedt, Holste und Lübberstedt. Die vorgenannten Gemeinden sind vollständig zur Überwachungszone erklärt worden. Der genaue Grenzverlauf des Gebietes kann auch der interaktiven Karte im Internet entnommen werden.
\xa0
In der Überwachungszone liegen im Landkreis Osterholz rund 70 Geflügelhaltungen mit einem Gesamtbestand von rund 1.800 Tieren. Sie alle unterliegen mit der Allgemeinverfügung der sogenannten amtlichen Beobachtung. Für die Betriebe sind die Biosicherheitsmaßnahmen einzuhalten. Dazu zählen insbesondere Hygienemaßnahmen im laufenden Betrieb und eine ordnungsgemäße Schadnagerbekämpfung.
\xa0
Das Verbringen von Vögeln, Fleisch von Geflügel, Eiern und sonstige Nebenprodukte von Geflügel in und aus Betrieben in der Überwachungszone ist verboten. Auch Geflügeltransporte sind in der Überwachungszone verboten. Jeder Verdacht der Erkrankung auf Geflügelpest ist zudem dem Veterinäramt des Landkreises Osterholz unter der E-Mail-Adresse veterinaeramt@landkreis-osterholz.de sofort zu melden. Alle Hinweise, die innerhalb der Überwachungszone zu beachten sind, sind unter www.landkreis-osterholz.de/gefluegelpest zusammengefasst dargestellt.
\xa0
Die Veterinärbehörde weist zudem darauf hin, dass sämtliche Geflügelhaltungen – Hühner, Enten, Gänse, Fasane, Perlhühner, Rebhühner, Truthühner, Wachteln oder Laufvögel – der zuständigen Behörde angezeigt werden müssen. Wer dies bisher noch nicht gemacht hat und über keine Registriernummer für seinen Geflügelbestand verfügt, sollte die Meldung über das Veterinäramt umgehend nachholen.
\xa0
Das Beobachtungsgebiet kann frühestens 30 Tage nach der Grobreinigung des Ausbruchsbetriebes wieder aufgehoben werden. Hierüber wird der Landkreis Osterholz informieren.
\xa0
Die Allgemeinverfügung, eine Übersicht zur Überwachungszone und weitere Hinweise sind auf der Internetseite unter www.landkreis-osterholz.de/gefluegelpest zu finden.', 'expires': '2002-08-07T10:59:00+02:00', 'headline': 'Geflügelpest im Landkreis Cuxhaven - Teile des Landkreises Osterholz zur Überwachungszone erklärt', 'id': 'biw.BIWAPP-69634', 'is_valid': False, + 'more_info_url': '', 'recommended_actions': '', - 'sender': None, + 'sender': '', 'sent': '1999-08-07T10:59:00+02:00', 'severity': 'Minor', - 'start': '', - 'web': '', + 'start': None, }), ]), }), diff --git a/tests/components/nina/snapshots/test_sensor.ambr b/tests/components/nina/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..b0c97ed9e04110 --- /dev/null +++ b/tests/components/nina/snapshots/test_sensor.ambr @@ -0,0 +1,2096 @@ +# serializer version: 1 +# name: test_sensors[sensor.aach_affected_areas_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_affected_areas_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Affected areas 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Affected areas 1', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'affected_areas', + 'unique_id': '095760000000-1-affected_areas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_affected_areas_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Affected areas 1', + }), + 'context': , + 'entity_id': 'sensor.aach_affected_areas_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Lieb...', + }) +# --- +# name: test_sensors[sensor.aach_affected_areas_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_affected_areas_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Affected areas 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Affected areas 2', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'affected_areas', + 'unique_id': '095760000000-2-affected_areas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_affected_areas_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Affected areas 2', + }), + 'context': , + 'entity_id': 'sensor.aach_affected_areas_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_affected_areas_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_affected_areas_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Affected areas 3', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Affected areas 3', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'affected_areas', + 'unique_id': '095760000000-3-affected_areas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_affected_areas_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Affected areas 3', + }), + 'context': , + 'entity_id': 'sensor.aach_affected_areas_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_affected_areas_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_affected_areas_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Affected areas 4', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Affected areas 4', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'affected_areas', + 'unique_id': '095760000000-4-affected_areas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_affected_areas_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Affected areas 4', + }), + 'context': , + 'entity_id': 'sensor.aach_affected_areas_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_affected_areas_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_affected_areas_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Affected areas 5', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Affected areas 5', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'affected_areas', + 'unique_id': '095760000000-5-affected_areas', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_affected_areas_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Affected areas 5', + }), + 'context': , + 'entity_id': 'sensor.aach_affected_areas_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_expires_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_expires_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Expires 1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Expires 1', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'expires', + 'unique_id': '095760000000-1-expires', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_expires_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Expires 1', + }), + 'context': , + 'entity_id': 'sensor.aach_expires_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3021-11-22T04:19:00+00:00', + }) +# --- +# name: test_sensors[sensor.aach_expires_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_expires_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Expires 2', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Expires 2', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'expires', + 'unique_id': '095760000000-2-expires', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_expires_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Expires 2', + }), + 'context': , + 'entity_id': 'sensor.aach_expires_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_expires_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_expires_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Expires 3', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Expires 3', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'expires', + 'unique_id': '095760000000-3-expires', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_expires_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Expires 3', + }), + 'context': , + 'entity_id': 'sensor.aach_expires_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_expires_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_expires_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Expires 4', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Expires 4', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'expires', + 'unique_id': '095760000000-4-expires', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_expires_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Expires 4', + }), + 'context': , + 'entity_id': 'sensor.aach_expires_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_expires_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_expires_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Expires 5', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Expires 5', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'expires', + 'unique_id': '095760000000-5-expires', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_expires_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Expires 5', + }), + 'context': , + 'entity_id': 'sensor.aach_expires_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_headline_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_headline_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Headline 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Headline 1', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'headline', + 'unique_id': '095760000000-1-headline', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_headline_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Headline 1', + }), + 'context': , + 'entity_id': 'sensor.aach_headline_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ausfall Notruf 112', + }) +# --- +# name: test_sensors[sensor.aach_headline_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_headline_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Headline 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Headline 2', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'headline', + 'unique_id': '095760000000-2-headline', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_headline_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Headline 2', + }), + 'context': , + 'entity_id': 'sensor.aach_headline_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_headline_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_headline_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Headline 3', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Headline 3', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'headline', + 'unique_id': '095760000000-3-headline', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_headline_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Headline 3', + }), + 'context': , + 'entity_id': 'sensor.aach_headline_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_headline_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_headline_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Headline 4', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Headline 4', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'headline', + 'unique_id': '095760000000-4-headline', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_headline_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Headline 4', + }), + 'context': , + 'entity_id': 'sensor.aach_headline_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_headline_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_headline_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Headline 5', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Headline 5', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'headline', + 'unique_id': '095760000000-5-headline', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_headline_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Headline 5', + }), + 'context': , + 'entity_id': 'sensor.aach_headline_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_more_information_url_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'More information URL 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'More information URL 1', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'more_info_url', + 'unique_id': '095760000000-1-more_info_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach More information URL 1', + }), + 'context': , + 'entity_id': 'sensor.aach_more_information_url_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'https://www.wettergefahren.de', + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_more_information_url_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'More information URL 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'More information URL 2', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'more_info_url', + 'unique_id': '095760000000-2-more_info_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach More information URL 2', + }), + 'context': , + 'entity_id': 'sensor.aach_more_information_url_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_more_information_url_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'More information URL 3', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'More information URL 3', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'more_info_url', + 'unique_id': '095760000000-3-more_info_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach More information URL 3', + }), + 'context': , + 'entity_id': 'sensor.aach_more_information_url_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_more_information_url_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'More information URL 4', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'More information URL 4', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'more_info_url', + 'unique_id': '095760000000-4-more_info_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach More information URL 4', + }), + 'context': , + 'entity_id': 'sensor.aach_more_information_url_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_more_information_url_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'More information URL 5', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'More information URL 5', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'more_info_url', + 'unique_id': '095760000000-5-more_info_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_more_information_url_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach More information URL 5', + }), + 'context': , + 'entity_id': 'sensor.aach_more_information_url_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_sender_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sender_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sender 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sender 1', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sender', + 'unique_id': '095760000000-1-sender', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sender_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Sender 1', + }), + 'context': , + 'entity_id': 'sensor.aach_sender_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Deutscher Wetterdienst', + }) +# --- +# name: test_sensors[sensor.aach_sender_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sender_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sender 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sender 2', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sender', + 'unique_id': '095760000000-2-sender', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sender_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Sender 2', + }), + 'context': , + 'entity_id': 'sensor.aach_sender_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_sender_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sender_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sender 3', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sender 3', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sender', + 'unique_id': '095760000000-3-sender', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sender_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Sender 3', + }), + 'context': , + 'entity_id': 'sensor.aach_sender_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_sender_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sender_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sender 4', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sender 4', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sender', + 'unique_id': '095760000000-4-sender', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sender_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Sender 4', + }), + 'context': , + 'entity_id': 'sensor.aach_sender_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_sender_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sender_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sender 5', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sender 5', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sender', + 'unique_id': '095760000000-5-sender', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sender_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aach Sender 5', + }), + 'context': , + 'entity_id': 'sensor.aach_sender_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_sent_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sent_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sent 1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sent 1', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sent', + 'unique_id': '095760000000-1-sent', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sent_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Sent 1', + }), + 'context': , + 'entity_id': 'sensor.aach_sent_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-10-11T04:20:00+00:00', + }) +# --- +# name: test_sensors[sensor.aach_sent_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sent_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sent 2', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sent 2', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sent', + 'unique_id': '095760000000-2-sent', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sent_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Sent 2', + }), + 'context': , + 'entity_id': 'sensor.aach_sent_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_sent_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sent_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sent 3', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sent 3', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sent', + 'unique_id': '095760000000-3-sent', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sent_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Sent 3', + }), + 'context': , + 'entity_id': 'sensor.aach_sent_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_sent_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sent_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sent 4', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sent 4', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sent', + 'unique_id': '095760000000-4-sent', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sent_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Sent 4', + }), + 'context': , + 'entity_id': 'sensor.aach_sent_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_sent_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_sent_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sent 5', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sent 5', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sent', + 'unique_id': '095760000000-5-sent', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_sent_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Sent 5', + }), + 'context': , + 'entity_id': 'sensor.aach_sent_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_severity_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_severity_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Severity 1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Severity 1', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'severity', + 'unique_id': '095760000000-1-severity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_severity_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Aach Severity 1', + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.aach_severity_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'minor', + }) +# --- +# name: test_sensors[sensor.aach_severity_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_severity_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Severity 2', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Severity 2', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'severity', + 'unique_id': '095760000000-2-severity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_severity_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Aach Severity 2', + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.aach_severity_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_severity_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_severity_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Severity 3', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Severity 3', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'severity', + 'unique_id': '095760000000-3-severity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_severity_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Aach Severity 3', + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.aach_severity_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_severity_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_severity_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Severity 4', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Severity 4', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'severity', + 'unique_id': '095760000000-4-severity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_severity_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Aach Severity 4', + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.aach_severity_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_severity_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_severity_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Severity 5', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Severity 5', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'severity', + 'unique_id': '095760000000-5-severity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_severity_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Aach Severity 5', + 'options': list([ + 'extreme', + 'severe', + 'moderate', + 'minor', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.aach_severity_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_start_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_start_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Start 1', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start 1', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': '095760000000-1-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_start_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Start 1', + }), + 'context': , + 'entity_id': 'sensor.aach_start_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-11-01T04:20:00+00:00', + }) +# --- +# name: test_sensors[sensor.aach_start_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_start_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Start 2', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start 2', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': '095760000000-2-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_start_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Start 2', + }), + 'context': , + 'entity_id': 'sensor.aach_start_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_start_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_start_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Start 3', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start 3', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': '095760000000-3-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_start_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Start 3', + }), + 'context': , + 'entity_id': 'sensor.aach_start_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_start_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_start_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Start 4', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start 4', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': '095760000000-4-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_start_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Start 4', + }), + 'context': , + 'entity_id': 'sensor.aach_start_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensors[sensor.aach_start_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.aach_start_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Start 5', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start 5', + 'platform': 'nina', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': '095760000000-5-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aach_start_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Aach Start 5', + }), + 'context': , + 'entity_id': 'sensor.aach_start_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index ce66142666a12b..b417c846bfc891 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -2,48 +2,22 @@ from __future__ import annotations -from typing import Any from unittest.mock import AsyncMock +from pynina import Warning from syrupy.assertion import SnapshotAssertion -from homeassistant.components.nina.const import ( - ATTR_HEADLINE, - CONF_AREA_FILTER, - CONF_FILTERS, - CONF_HEADLINE_FILTER, - CONF_MESSAGE_SLOTS, - CONF_REGIONS, - DOMAIN, -) -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.nina.const import ATTR_HEADLINE +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_platform +from . import setup_single_platform from tests.common import MockConfigEntry, snapshot_platform -ENTRY_DATA_NO_CORONA: dict[str, Any] = { - CONF_MESSAGE_SLOTS: 5, - CONF_REGIONS: {"083350000000": "Aach, Stadt"}, - CONF_FILTERS: { - CONF_HEADLINE_FILTER: "/(?!)/", - CONF_AREA_FILTER: ".*", - }, -} - -ENTRY_DATA_SPECIFIC_AREA: dict[str, Any] = { - CONF_MESSAGE_SLOTS: 5, - CONF_REGIONS: {"083350000000": "Aach, Stadt"}, - CONF_FILTERS: { - CONF_HEADLINE_FILTER: "/(?!)/", - CONF_AREA_FILTER: ".*nagold.*", - }, -} - - -async def test_sensors( + +async def test_binary_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, @@ -51,32 +25,31 @@ async def test_sensors( mock_nina_class: AsyncMock, nina_warnings: list[Warning], ) -> None: - """Test the creation and values of the NINA sensors.""" - await setup_platform(hass, mock_config_entry, mock_nina_class, nina_warnings) + """Test the creation and values of the NINA binary sensors.""" + await setup_single_platform( + hass, mock_config_entry, Platform.BINARY_SENSOR, mock_nina_class, nina_warnings + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_sensors_without_corona_filter( +async def test_binary_sensors_without_corona_filter( hass: HomeAssistant, entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, + mock_config_entry_default_filter: MockConfigEntry, mock_nina_class: AsyncMock, nina_warnings: list[Warning], ) -> None: - """Test the creation and values of the NINA sensors without the corona filter.""" - - conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, - title="NINA", - data=ENTRY_DATA_NO_CORONA, - version=1, - minor_version=3, + """Test the creation and values of the NINA binary sensors without the corona filter.""" + + await setup_single_platform( + hass, + mock_config_entry_default_filter, + Platform.BINARY_SENSOR, + mock_nina_class, + nina_warnings, ) - conf_entry.add_to_hass(hass) - - await setup_platform(hass, conf_entry, mock_nina_class, nina_warnings) - state_w1 = hass.states.get("binary_sensor.nina_warning_aach_stadt_1") + state_w1 = hass.states.get("binary_sensor.aach_stadt_warning_1") assert state_w1.state == STATE_ON assert ( @@ -84,61 +57,58 @@ async def test_sensors_without_corona_filter( == "Corona-Verordnung des Landes: Warnstufe durch Landesgesundheitsamt ausgerufen" ) - state_w2 = hass.states.get("binary_sensor.nina_warning_aach_stadt_2") + state_w2 = hass.states.get("binary_sensor.aach_stadt_warning_2") assert state_w2.state == STATE_ON assert state_w2.attributes.get(ATTR_HEADLINE) == "Ausfall Notruf 112" - state_w3 = hass.states.get("binary_sensor.nina_warning_aach_stadt_3") + state_w3 = hass.states.get("binary_sensor.aach_stadt_warning_3") - assert state_w3.state == STATE_OFF + assert state_w3.state == STATE_OFF # Warning expired - state_w4 = hass.states.get("binary_sensor.nina_warning_aach_stadt_4") + state_w4 = hass.states.get("binary_sensor.aach_stadt_warning_4") assert state_w4.state == STATE_OFF - state_w5 = hass.states.get("binary_sensor.nina_warning_aach_stadt_5") + state_w5 = hass.states.get("binary_sensor.aach_stadt_warning_5") assert state_w5.state == STATE_OFF -async def test_sensors_with_area_filter( +async def test_binary_sensors_with_area_filter( hass: HomeAssistant, entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, + mock_config_entry_area_filter: MockConfigEntry, mock_nina_class: AsyncMock, nina_warnings: list[Warning], ) -> None: - """Test the creation and values of the NINA sensors with a restrictive area filter.""" - - conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, - title="NINA", - data=ENTRY_DATA_SPECIFIC_AREA, - version=1, - minor_version=3, + """Test the creation and values of the NINA binary sensors with a restrictive area filter.""" + + await setup_single_platform( + hass, + mock_config_entry_area_filter, + Platform.BINARY_SENSOR, + mock_nina_class, + nina_warnings, ) - conf_entry.add_to_hass(hass) - - await setup_platform(hass, conf_entry, mock_nina_class, nina_warnings) - state_w1 = hass.states.get("binary_sensor.nina_warning_aach_stadt_1") + state_w1 = hass.states.get("binary_sensor.aach_stadt_warning_1") assert state_w1.state == STATE_ON assert state_w1.attributes.get(ATTR_HEADLINE) == "Ausfall Notruf 112" - state_w2 = hass.states.get("binary_sensor.nina_warning_aach_stadt_2") + state_w2 = hass.states.get("binary_sensor.aach_stadt_warning_2") assert state_w2.state == STATE_OFF - state_w3 = hass.states.get("binary_sensor.nina_warning_aach_stadt_3") + state_w3 = hass.states.get("binary_sensor.aach_stadt_warning_3") assert state_w3.state == STATE_OFF - state_w4 = hass.states.get("binary_sensor.nina_warning_aach_stadt_4") + state_w4 = hass.states.get("binary_sensor.aach_stadt_warning_4") assert state_w4.state == STATE_OFF - state_w5 = hass.states.get("binary_sensor.nina_warning_aach_stadt_5") + state_w5 = hass.states.get("binary_sensor.aach_stadt_warning_5") assert state_w5.state == STATE_OFF diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 4d67ec1da80949..8e3f8e9dae9964 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -6,7 +6,7 @@ from typing import Any from unittest.mock import AsyncMock -from pynina import ApiError +from pynina import ApiError, Warning from homeassistant.components.nina.const import ( CONF_AREA_FILTER, @@ -21,6 +21,7 @@ CONST_REGION_R_TO_U, CONST_REGION_V_TO_Z, DOMAIN, + SENSOR_SUFFIXES, ) from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -285,6 +286,17 @@ async def test_options_flow_entity_removal( """Test if old entities are removed.""" await setup_platform(hass, mock_config_entry, mock_nina_class, nina_warnings) + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + entities_per_slot = len(SENSOR_SUFFIXES) + 1 + + assert ( + len(entries) + == mock_config_entry.data.get(CONF_MESSAGE_SLOTS) * entities_per_slot + ) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) new_slot_count = 2 @@ -309,4 +321,4 @@ async def test_options_flow_entity_removal( entity_registry, mock_config_entry.entry_id ) - assert len(entries) == new_slot_count + assert len(entries) == new_slot_count * entities_per_slot diff --git a/tests/components/nina/test_sensor.py b/tests/components/nina/test_sensor.py new file mode 100644 index 00000000000000..32d4031ebadb8e --- /dev/null +++ b/tests/components/nina/test_sensor.py @@ -0,0 +1,107 @@ +"""Test the Nina sensor.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from pynina import Warning +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_single_platform + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_nina_class: AsyncMock, + nina_warnings: list[Warning], +) -> None: + """Test the creation and values of the NINA sensors.""" + await setup_single_platform( + hass, mock_config_entry, Platform.SENSOR, mock_nina_class, nina_warnings + ) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_without_corona_filter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry_default_filter: MockConfigEntry, + mock_nina_class: AsyncMock, + nina_warnings: list[Warning], +) -> None: + """Test the creation and values of the NINA sensors without the corona filter.""" + await setup_single_platform( + hass, + mock_config_entry_default_filter, + Platform.SENSOR, + mock_nina_class, + nina_warnings, + ) + + state_w1 = hass.states.get("sensor.aach_stadt_severity_1") + + assert state_w1.state == "minor" + + state_w2 = hass.states.get("sensor.aach_stadt_severity_2") + + assert state_w2.state == "minor" + + state_w3 = hass.states.get("sensor.aach_stadt_severity_3") + + assert state_w3.state == STATE_UNAVAILABLE # Warning expired + + state_w4 = hass.states.get("sensor.aach_stadt_severity_4") + + assert state_w4.state == STATE_UNAVAILABLE + + state_w5 = hass.states.get("sensor.aach_stadt_severity_5") + + assert state_w5.state == STATE_UNAVAILABLE + + +async def test_sensors_with_area_filter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry_area_filter: MockConfigEntry, + mock_nina_class: AsyncMock, + nina_warnings: list[Warning], +) -> None: + """Test the creation and values of the NINA sensors with a restrictive area filter.""" + await setup_single_platform( + hass, + mock_config_entry_area_filter, + Platform.SENSOR, + mock_nina_class, + nina_warnings, + ) + + state_w1 = hass.states.get("sensor.aach_stadt_severity_1") + + assert state_w1.state == "minor" + + state_w2 = hass.states.get("sensor.aach_stadt_severity_2") + + assert state_w2.state == STATE_UNAVAILABLE + + state_w3 = hass.states.get("sensor.aach_stadt_severity_3") + + assert state_w3.state == STATE_UNAVAILABLE + + state_w4 = hass.states.get("sensor.aach_stadt_severity_4") + + assert state_w4.state == STATE_UNAVAILABLE + + state_w5 = hass.states.get("sensor.aach_stadt_severity_5") + + assert state_w5.state == STATE_UNAVAILABLE From 279c9e71dfcc5f4f19c66af4621bc045b1754a54 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 21:57:55 +0200 Subject: [PATCH 0359/1707] Improve `google_sheets` action naming consistency (#167107) --- homeassistant/components/google_sheets/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index 7dfe6bc36129c0..ae5f81c6fc0857 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -68,7 +68,7 @@ "name": "Worksheet" } }, - "name": "Append to sheet" + "name": "Append data to Google sheet" }, "get_sheet": { "description": "Gets data from a worksheet in Google Sheets.", @@ -86,7 +86,7 @@ "name": "[%key:component::google_sheets::services::append_sheet::fields::worksheet::name%]" } }, - "name": "Get data from sheet" + "name": "Get data from Google sheet" } } } From 7a77b071a275f58f8306bdc481c8360827aaeab4 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 1 Apr 2026 23:20:56 +0300 Subject: [PATCH 0360/1707] Add coordinator to Anthropic for availability check (#164615) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .../components/anthropic/__init__.py | 35 +-- .../components/anthropic/coordinator.py | 78 ++++++ homeassistant/components/anthropic/entity.py | 24 +- .../components/anthropic/quality_scale.yaml | 4 +- homeassistant/components/anthropic/repairs.py | 2 +- .../components/anthropic/test_conversation.py | 36 +-- .../components/anthropic/test_coordinator.py | 264 ++++++++++++++++++ 7 files changed, 370 insertions(+), 73 deletions(-) create mode 100644 homeassistant/components/anthropic/coordinator.py create mode 100644 tests/components/anthropic/test_coordinator.py diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 9011ad21e42e92..5e43b2f1c75c98 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -2,19 +2,15 @@ from __future__ import annotations -import anthropic - -from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, issue_registry as ir, ) -from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType from .const import ( @@ -24,12 +20,11 @@ DOMAIN, LOGGER, ) +from .coordinator import AnthropicConfigEntry, AnthropicCoordinator PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Anthropic.""" @@ -39,29 +34,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: """Set up Anthropic from a config entry.""" - client = anthropic.AsyncAnthropic( - api_key=entry.data[CONF_API_KEY], http_client=get_async_client(hass) - ) - try: - await client.models.list(timeout=10.0) - except anthropic.AuthenticationError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="api_authentication_error", - translation_placeholders={"message": err.message}, - ) from err - except anthropic.AnthropicError as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="api_error", - translation_placeholders={ - "message": err.message - if isinstance(err, anthropic.APIError) - else str(err) - }, - ) from err - - entry.runtime_data = client + coordinator = AnthropicCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/anthropic/coordinator.py b/homeassistant/components/anthropic/coordinator.py new file mode 100644 index 00000000000000..3a7a209d7c6de3 --- /dev/null +++ b/homeassistant/components/anthropic/coordinator.py @@ -0,0 +1,78 @@ +"""Coordinator for the Anthropic integration.""" + +from __future__ import annotations + +from datetime import timedelta + +import anthropic + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +UPDATE_INTERVAL_CONNECTED = timedelta(hours=12) +UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1) + +type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator] + + +class AnthropicCoordinator(DataUpdateCoordinator[None]): + """DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates.""" + + client: anthropic.AsyncAnthropic + + def __init__(self, hass: HomeAssistant, config_entry: AnthropicConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=UPDATE_INTERVAL_CONNECTED, + update_method=self.async_update_data, + always_update=False, + ) + self.client = anthropic.AsyncAnthropic( + api_key=config_entry.data[CONF_API_KEY], http_client=get_async_client(hass) + ) + + @callback + def async_set_updated_data(self, data: None) -> None: + """Manually update data, notify listeners and update refresh interval.""" + self.update_interval = UPDATE_INTERVAL_CONNECTED + super().async_set_updated_data(data) + + async def async_update_data(self) -> None: + """Fetch data from the API.""" + try: + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + await self.client.models.list(timeout=10.0) + self.update_interval = UPDATE_INTERVAL_CONNECTED + except anthropic.APITimeoutError as err: + raise TimeoutError(err.message or str(err)) from err + except anthropic.AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_authentication_error", + translation_placeholders={"message": err.message}, + ) from err + except anthropic.APIError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": err.message}, + ) from err + + def mark_connection_error(self) -> None: + """Mark the connection as having an error and reschedule background check.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + if self.last_update_success: + self.last_update_success = False + self.async_update_listeners() + if self._listeners and not self.hass.is_stopping: + self._schedule_refresh() diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 021fc727a7558c..400cbe1626d988 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -82,12 +82,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm -from homeassistant.helpers.entity import Entity from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from homeassistant.util.json import JsonObjectType -from . import AnthropicConfigEntry from .const import ( CONF_CHAT_MODEL, CONF_CODE_EXECUTION, @@ -111,6 +110,7 @@ PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS, UNSUPPORTED_STRUCTURED_OUTPUT_MODELS, ) +from .coordinator import AnthropicConfigEntry, AnthropicCoordinator # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 @@ -658,7 +658,7 @@ def _create_token_stats( } -class AnthropicBaseLLMEntity(Entity): +class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): """Anthropic base LLM entity.""" _attr_has_entity_name = True @@ -666,6 +666,7 @@ class AnthropicBaseLLMEntity(Entity): def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" + super().__init__(entry.runtime_data) self.entry = entry self.subentry = subentry self._attr_unique_id = subentry.subentry_id @@ -877,7 +878,8 @@ async def _async_handle_chat_log( if tools: model_args["tools"] = tools - client = self.entry.runtime_data + coordinator = self.entry.runtime_data + client = coordinator.client # To prevent infinite loops, we limit the number of iterations for _iteration in range(max_iterations): @@ -899,13 +901,24 @@ async def _async_handle_chat_log( ) messages.extend(new_messages) except anthropic.AuthenticationError as err: - self.entry.async_start_reauth(self.hass) + # Trigger coordinator to confirm the auth failure and trigger the reauth flow. + await coordinator.async_request_refresh() raise HomeAssistantError( translation_domain=DOMAIN, translation_key="api_authentication_error", translation_placeholders={"message": err.message}, ) from err + except anthropic.APIConnectionError as err: + LOGGER.info("Connection error while talking to Anthropic: %s", err) + coordinator.mark_connection_error() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": err.message}, + ) from err except anthropic.AnthropicError as err: + # Non-connection error, mark connection as healthy + coordinator.async_set_updated_data(None) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="api_error", @@ -917,6 +930,7 @@ async def _async_handle_chat_log( ) from err if not chat_log.unresponded_tool_results: + coordinator.async_set_updated_data(None) break diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml index 1cf9008fc418d0..6279e5eb3d62f7 100644 --- a/homeassistant/components/anthropic/quality_scale.yaml +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -35,9 +35,9 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: status: exempt comment: | diff --git a/homeassistant/components/anthropic/repairs.py b/homeassistant/components/anthropic/repairs.py index ac78e690eba012..a00a7f977e8f23 100644 --- a/homeassistant/components/anthropic/repairs.py +++ b/homeassistant/components/anthropic/repairs.py @@ -58,7 +58,7 @@ async def async_step_init( if entry.entry_id in self._model_list_cache: model_list = self._model_list_cache[entry.entry_id] else: - client = entry.runtime_data + client = entry.runtime_data.client model_list = [ model_option for model_option in await get_model_list(client) diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index eb696b3c953e9c..285b894309b0be 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch -from anthropic import AuthenticationError, RateLimitError +from anthropic import RateLimitError from anthropic.types import ( CitationsWebSearchResultLocation, CitationWebSearchResultLocationParam, @@ -42,10 +42,8 @@ CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, - DOMAIN, ) from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails -from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -129,38 +127,6 @@ async def test_error_handling( assert result.response.error_code == "unknown", result -async def test_auth_error_handling( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, - mock_create_stream: AsyncMock, -) -> None: - """Test reauth after authentication error during conversation.""" - mock_create_stream.side_effect = AuthenticationError( - message="Invalid API key", - response=Response(status_code=403, request=Request(method="POST", url=URL())), - body=None, - ) - - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude_conversation" - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == "unknown", result - - await hass.async_block_till_done() - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - - flow = flows[0] - assert flow["step_id"] == "reauth_confirm" - assert flow["handler"] == DOMAIN - assert "context" in flow - assert flow["context"]["source"] == SOURCE_REAUTH - assert flow["context"]["entry_id"] == mock_config_entry.entry_id - - async def test_template_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/anthropic/test_coordinator.py b/tests/components/anthropic/test_coordinator.py new file mode 100644 index 00000000000000..d48bf0a69afdb6 --- /dev/null +++ b/tests/components/anthropic/test_coordinator.py @@ -0,0 +1,264 @@ +"""Tests for the Anthropic integration.""" + +import datetime +from unittest.mock import AsyncMock, patch + +from anthropic import APITimeoutError, AuthenticationError, RateLimitError +from freezegun import freeze_time +from httpx import URL, Request, Response + +from homeassistant.components import conversation +from homeassistant.components.anthropic.const import DOMAIN +from homeassistant.components.anthropic.coordinator import ( + UPDATE_INTERVAL_CONNECTED, + UPDATE_INTERVAL_DISCONNECTED, +) +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock) +async def test_auth_error_handling( + mock_model_list: AsyncMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, +) -> None: + """Test reauth after authentication error during conversation.""" + # This is an assumption of the tests, not the main code: + assert UPDATE_INTERVAL_DISCONNECTED < UPDATE_INTERVAL_CONNECTED + + mock_create_stream.side_effect = mock_model_list.side_effect = AuthenticationError( + message="Invalid API key", + response=Response(status_code=403, request=Request(method="POST", url=URL())), + body=None, + ) + + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == "unknown", result + + await hass.async_block_till_done() + + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unavailable" + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + +@freeze_time("2026-02-27 12:00:00") +@patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock) +async def test_connection_error_handling( + mock_model_list: AsyncMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, +) -> None: + """Test making entity unavailable on connection error.""" + mock_create_stream.side_effect = APITimeoutError( + request=Request(method="POST", url=URL()), + ) + + # Check initial state + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unknown" + + # Get timeout + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == "unknown", result + + # Check new state + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unavailable" + + # Try again + await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" + ) + + # Check state is still unavailable + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unavailable" + + mock_create_stream.side_effect = RateLimitError( + message=None, + response=Response(status_code=429, request=Request(method="POST", url=URL())), + body=None, + ) + + # Get a different error meaning the connection is restored + await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" + ) + + # Check state is back to normal + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "2026-02-27T12:00:00+00:00" + + # Verify the background check period + test_time = datetime.datetime.now(datetime.UTC) + UPDATE_INTERVAL_DISCONNECTED + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + mock_model_list.assert_not_awaited() + + test_time += UPDATE_INTERVAL_CONNECTED - UPDATE_INTERVAL_DISCONNECTED + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + mock_model_list.assert_awaited_once() + + +@patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock) +async def test_connection_check_reauth( + mock_model_list: AsyncMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test authentication error during background availability check.""" + mock_model_list.side_effect = APITimeoutError( + request=Request(method="POST", url=URL()), + ) + + # Check initial state + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unknown" + + # Get timeout + assert mock_model_list.await_count == 0 + test_time = datetime.datetime.now(datetime.UTC) + UPDATE_INTERVAL_CONNECTED + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert mock_model_list.await_count == 1 + + # Check new state + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unavailable" + + mock_model_list.side_effect = AuthenticationError( + message="Invalid API key", + response=Response(status_code=403, request=Request(method="POST", url=URL())), + body=None, + ) + + # Wait for background check to run and fail + test_time += UPDATE_INTERVAL_DISCONNECTED + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert mock_model_list.await_count == 2 + + # Check state is still unavailable + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unavailable" + + # Verify that the background check is not running anymore + test_time += UPDATE_INTERVAL_DISCONNECTED + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert mock_model_list.await_count == 2 + + # Check that a reauth flow has been created + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + +@patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock) +async def test_connection_restore( + mock_model_list: AsyncMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, +) -> None: + """Test background availability check restore on non-connectivity error.""" + mock_create_stream.side_effect = APITimeoutError( + request=Request(method="POST", url=URL()), + ) + + # Check initial state + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unknown" + + # Get timeout + await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" + ) + + # Check new state + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unavailable" + + mock_model_list.side_effect = APITimeoutError( + request=Request(method="POST", url=URL()), + ) + + # Wait for background check to run and fail + assert mock_model_list.await_count == 0 + test_time = datetime.datetime.now(datetime.UTC) + UPDATE_INTERVAL_DISCONNECTED + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert mock_model_list.await_count == 1 + + # Check state is still unavailable + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state == "unavailable" + + # Now make the background check succeed + mock_model_list.side_effect = None + test_time += UPDATE_INTERVAL_DISCONNECTED + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert mock_model_list.await_count == 2 + + # Check that state is back to normal since the error is not connectivity related + state = hass.states.get("conversation.claude_conversation") + assert state + assert state.state != "unavailable" + + # Verify the background check period + test_time += UPDATE_INTERVAL_DISCONNECTED + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert mock_model_list.await_count == 2 + + test_time += UPDATE_INTERVAL_CONNECTED - UPDATE_INTERVAL_DISCONNECTED + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert mock_model_list.await_count == 3 From 4d6a278137a8576dffeaf4d872831344487a6a80 Mon Sep 17 00:00:00 2001 From: Jon Culver Date: Wed, 1 Apr 2026 13:29:13 -0700 Subject: [PATCH 0361/1707] Add Off mode support for water_heater entities in HomeKit (#166836) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/homekit/type_thermostats.py | 75 +++++++++- .../homekit/test_type_thermostats.py | 131 +++++++++++++++++- 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index ebf1bd97c5be60..783a66ea261537 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -49,14 +49,21 @@ HVACMode, ) from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_WATER_HEATER, + WaterHeaterEntityFeature, ) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature, @@ -745,6 +752,7 @@ def __init__(self, *args: Any) -> None: ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_OPERATION_LIST, ) ) self._unit = self.hass.config.units.temperature_unit @@ -752,6 +760,20 @@ def __init__(self, *args: Any) -> None: assert state min_temp, max_temp = self.get_temperature_range(state) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + operation_list = state.attributes.get(ATTR_OPERATION_LIST) or [] + self._supports_on_off = bool(features & WaterHeaterEntityFeature.ON_OFF) + self._supports_operation_mode = bool( + features & WaterHeaterEntityFeature.OPERATION_MODE + ) + self._off_mode_available = self._supports_on_off or ( + self._supports_operation_mode and STATE_OFF in operation_list + ) + + valid_modes = dict(HC_HOMEKIT_VALID_MODES_WATER_HEATER) + if self._off_mode_available: + valid_modes["Off"] = HC_HEAT_COOL_OFF + serv_thermostat = self.add_preload_service(SERV_THERMOSTAT) self.char_current_heat_cool = serv_thermostat.configure_char( @@ -761,7 +783,7 @@ def __init__(self, *args: Any) -> None: CHAR_TARGET_HEATING_COOLING, value=1, setter_callback=self.set_heat_cool, - valid_values=HC_HOMEKIT_VALID_MODES_WATER_HEATER, + valid_values=valid_modes, ) self.char_current_temp = serv_thermostat.configure_char( @@ -795,8 +817,48 @@ def get_temperature_range(self, state: State) -> tuple[float, float]: def set_heat_cool(self, value: int) -> None: """Change operation mode to value if call came from HomeKit.""" _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) - if HC_HOMEKIT_TO_HASS[value] != HVACMode.HEAT: - self.char_target_heat_cool.set_value(1) # Heat + params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_id} + if value == HC_HEAT_COOL_OFF: + if self._supports_on_off: + self.async_call_service( + WATER_HEATER_DOMAIN, SERVICE_TURN_OFF, params, "off" + ) + elif self._off_mode_available and self._supports_operation_mode: + params[ATTR_OPERATION_MODE] = STATE_OFF + self.async_call_service( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + params, + STATE_OFF, + ) + else: + self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) + elif value == HC_HEAT_COOL_HEAT: + if self._supports_on_off: + self.async_call_service( + WATER_HEATER_DOMAIN, SERVICE_TURN_ON, params, "on" + ) + elif self._off_mode_available and self._supports_operation_mode: + state = self.hass.states.get(self.entity_id) + if not state: + return + current_operation_mode = state.attributes.get(ATTR_OPERATION_MODE) + if current_operation_mode and current_operation_mode != STATE_OFF: + # Already in a non-off operation mode; do not change it. + return + operation_list = state.attributes.get(ATTR_OPERATION_LIST) or [] + for mode in operation_list: + if mode != STATE_OFF: + params[ATTR_OPERATION_MODE] = mode + self.async_call_service( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + params, + mode, + ) + break + else: + self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) def set_target_temperature(self, value: float) -> None: """Set target temperature to value if call came from HomeKit.""" @@ -829,7 +891,12 @@ def async_update_state(self, new_state: State) -> None: # Update target operation mode if new_state.state: - self.char_target_heat_cool.set_value(1) # Heat + if new_state.state == STATE_OFF and self._off_mode_available: + self.char_target_heat_cool.set_value(HC_HEAT_COOL_OFF) + self.char_current_heat_cool.set_value(HC_HEAT_COOL_OFF) + else: + self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) + self.char_current_heat_cool.set_value(HC_HEAT_COOL_HEAT) def _get_temperature_range_from_state( diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index d8a24bcbb3be2f..35dc04072974c7 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -43,6 +43,7 @@ HVACAction, HVACMode, ) +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_VALUE, CHAR_CURRENT_FAN_STATE, @@ -66,7 +67,11 @@ Thermostat, WaterHeater, ) -from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN +from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterEntityFeature, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -1782,6 +1787,130 @@ async def test_water_heater( assert acc.char_target_heat_cool.value == 1 +async def test_water_heater_off_mode_on_off( + hass: HomeAssistant, hk_driver: HomeDriver, events: list[Event] +) -> None: + """Test water heater Off mode via ON_OFF feature.""" + entity_id = "water_heater.test" + + hass.states.async_set( + entity_id, + HVACMode.HEAT, + {ATTR_SUPPORTED_FEATURES: WaterHeaterEntityFeature.ON_OFF}, + ) + await hass.async_block_till_done() + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + + # Verify Off is exposed as a valid mode + valid_values = acc.char_target_heat_cool.properties.get("ValidValues", {}) + assert valid_values == {"Off": 0, "Heat": 1} + + # Set to Off from HomeKit + call_turn_off = async_mock_service(hass, WATER_HEATER_DOMAIN, "turn_off") + + acc.char_target_heat_cool.client_update_value(0) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "off" + + # Set to Heat from HomeKit + call_turn_on = async_mock_service(hass, WATER_HEATER_DOMAIN, "turn_on") + + acc.char_target_heat_cool.client_update_value(1) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == "on" + + # Update HA state to off and verify HomeKit reflects it + hass.states.async_set( + entity_id, + "off", + {ATTR_SUPPORTED_FEATURES: WaterHeaterEntityFeature.ON_OFF}, + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_OFF + assert acc.char_current_heat_cool.value == HC_HEAT_COOL_OFF + + # Update HA state back to heat and verify HomeKit reflects it + hass.states.async_set( + entity_id, + HVACMode.HEAT, + {ATTR_SUPPORTED_FEATURES: WaterHeaterEntityFeature.ON_OFF}, + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT + assert acc.char_current_heat_cool.value == HC_HEAT_COOL_HEAT + + +async def test_water_heater_off_mode_operation_mode( + hass: HomeAssistant, hk_driver: HomeDriver, events: list[Event] +) -> None: + """Test water heater Off mode via OPERATION_MODE feature.""" + entity_id = "water_heater.test" + + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + ATTR_SUPPORTED_FEATURES: WaterHeaterEntityFeature.OPERATION_MODE, + ATTR_OPERATION_LIST: ["off", "electric", "gas"], + }, + ) + await hass.async_block_till_done() + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + + # Verify Off is exposed as a valid mode + valid_values = acc.char_target_heat_cool.properties.get("ValidValues", {}) + assert valid_values == {"Off": 0, "Heat": 1} + + # Set to Off from HomeKit — should call set_operation_mode + call_set_op = async_mock_service(hass, WATER_HEATER_DOMAIN, "set_operation_mode") + + acc.char_target_heat_cool.client_update_value(0) + await hass.async_block_till_done() + assert call_set_op + assert call_set_op[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_op[0].data["operation_mode"] == "off" + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "off" + + # Set to Heat from HomeKit — should pick first non-off operation mode + call_set_op.clear() + + acc.char_target_heat_cool.client_update_value(1) + await hass.async_block_till_done() + assert call_set_op + assert call_set_op[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_op[0].data["operation_mode"] == "electric" + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == "electric" + + +async def test_water_heater_no_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver, events: list[Event] +) -> None: + """Test water heater without ON_OFF or OPERATION_MODE does not expose Off.""" + entity_id = "water_heater.test" + + hass.states.async_set(entity_id, HVACMode.HEAT) + await hass.async_block_till_done() + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + + valid_values = acc.char_target_heat_cool.properties.get("ValidValues", {}) + assert valid_values == {"Heat": 1} + assert "Off" not in valid_values + + async def test_water_heater_fahrenheit( hass: HomeAssistant, hk_driver, events: list[Event] ) -> None: From 25b66be84d9a8c147097298f602e36cc821ebce9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 22:37:21 +0200 Subject: [PATCH 0362/1707] Fix spelling of "cannot" in two user-facing strings of `reolink` (#167085) --- homeassistant/components/reolink/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 6a707d6ff72e51..979154776a7ca7 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -1042,7 +1042,7 @@ "title": "Reolink firmware update required" }, "https_webhook": { - "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `{example_url}` where `{example_ip}` is the IP of the Home Assistant device", + "description": "Reolink products cannot push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `{example_url}` where `{example_ip}` is the IP of the Home Assistant device", "title": "Reolink webhook URL uses HTTPS (SSL)" }, "password_too_long": { @@ -1054,7 +1054,7 @@ "title": "Reolink incompatible with global SSL certificate" }, "webhook_url": { - "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `{example_url}` where `{example_ip}` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL. Using fast motion/AI state polling until the first ONVIF push is received.", + "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera cannot reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `{example_url}` where `{example_ip}` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL. Using fast motion/AI state polling until the first ONVIF push is received.", "title": "Reolink webhook URL unreachable" } }, From e1c1e9a8b2ba67e228c36fa8a8204d41e2e1ab4f Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 2 Apr 2026 00:11:13 +0200 Subject: [PATCH 0363/1707] Bump unifi-discovery to version 1.3.0 (#167106) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b94b2250797b22..b74946294ffc71 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.2.3", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==10.2.3", "unifi-discovery==1.3.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 2c51d9ed45d87a..cb69f0e49005d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3187,7 +3187,7 @@ uiprotect==10.2.3 ultraheat-api==0.5.7 # homeassistant.components.unifiprotect -unifi-discovery==1.2.0 +unifi-discovery==1.3.0 # homeassistant.components.unifi_direct unifi_ap==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 125c6bf77309bf..248022d9ffe3a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2699,7 +2699,7 @@ uiprotect==10.2.3 ultraheat-api==0.5.7 # homeassistant.components.unifiprotect -unifi-discovery==1.2.0 +unifi-discovery==1.3.0 # homeassistant.components.homeassistant_hardware universal-silabs-flasher==1.0.3 From 6e567ced92a826b3c2c27197f409b4bd804bf259 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 2 Apr 2026 03:05:09 -0400 Subject: [PATCH 0364/1707] Wrap hassio import in is_hassio check in get_system_info helper (#167111) --- homeassistant/helpers/system_info.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 725b303c79b04b..20da2ec6d65492 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -6,7 +6,7 @@ from getpass import getuser import logging import platform -from typing import TYPE_CHECKING, Any +from typing import Any from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant @@ -15,7 +15,6 @@ from homeassistant.util.system_info import is_official_image from .hassio import is_hassio -from .importlib import async_import_module from .singleton import singleton _LOGGER = logging.getLogger(__name__) @@ -54,15 +53,6 @@ def _read_arch_file() -> str: @bind_hass async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: """Return info about the system.""" - # Local import to avoid circular dependencies - # We use the import helper because hassio - # may not be loaded yet and we don't want to - # do blocking I/O in the event loop to import it. - if TYPE_CHECKING: - from homeassistant.components import hassio # noqa: PLC0415 - else: - hassio = await async_import_module(hass, "homeassistant.components.hassio") - is_hassio_ = is_hassio(hass) info_object = { @@ -105,6 +95,9 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: # Enrich with Supervisor information if is_hassio_: + # Local import to avoid circular dependencies + from homeassistant.components import hassio # noqa: PLC0415 + if not (info := hassio.get_info(hass)): _LOGGER.warning("No Home Assistant Supervisor info available") info = {} From b60e3962411613758019c3adcc811e96c0cae7c0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:03:16 +0200 Subject: [PATCH 0365/1707] Migrate pvoutput to use runtime_data (#167167) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/pvoutput/__init__.py | 15 ++++++--------- homeassistant/components/pvoutput/coordinator.py | 6 ++++-- homeassistant/components/pvoutput/diagnostics.py | 9 +++------ homeassistant/components/pvoutput/sensor.py | 7 +++---- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/pvoutput/__init__.py b/homeassistant/components/pvoutput/__init__.py index 7dc02a07d1cf3e..9932ff24d148f5 100644 --- a/homeassistant/components/pvoutput/__init__.py +++ b/homeassistant/components/pvoutput/__init__.py @@ -2,26 +2,23 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS -from .coordinator import PVOutputDataUpdateCoordinator +from .const import PLATFORMS +from .coordinator import PvOutputConfigEntry, PVOutputDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PvOutputConfigEntry) -> bool: """Set up PVOutput from a config entry.""" coordinator = PVOutputDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PvOutputConfigEntry) -> bool: """Unload PVOutput config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/pvoutput/coordinator.py b/homeassistant/components/pvoutput/coordinator.py index ce3642421bf080..8b90144e6a8901 100644 --- a/homeassistant/components/pvoutput/coordinator.py +++ b/homeassistant/components/pvoutput/coordinator.py @@ -13,13 +13,15 @@ from .const import CONF_SYSTEM_ID, DOMAIN, LOGGER, SCAN_INTERVAL +type PvOutputConfigEntry = ConfigEntry[PVOutputDataUpdateCoordinator] + class PVOutputDataUpdateCoordinator(DataUpdateCoordinator[Status]): """The PVOutput Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: PvOutputConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: PvOutputConfigEntry) -> None: """Initialize the PVOutput coordinator.""" self.pvoutput = PVOutput( api_key=entry.data[CONF_API_KEY], diff --git a/homeassistant/components/pvoutput/diagnostics.py b/homeassistant/components/pvoutput/diagnostics.py index 3b9007b77b4406..e75a0b59f20153 100644 --- a/homeassistant/components/pvoutput/diagnostics.py +++ b/homeassistant/components/pvoutput/diagnostics.py @@ -4,16 +4,13 @@ from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import PVOutputDataUpdateCoordinator +from .coordinator import PvOutputConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: PvOutputConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PVOutputDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return coordinator.data.to_dict() + return entry.runtime_data.data.to_dict() diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index b4ed3f93945501..b92d4ea2ec52b4 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfElectricPotential, UnitOfEnergy, @@ -26,7 +25,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_SYSTEM_ID, DOMAIN -from .coordinator import PVOutputDataUpdateCoordinator +from .coordinator import PvOutputConfigEntry, PVOutputDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -97,11 +96,11 @@ class PVOutputSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PvOutputConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a PVOutput sensors based on a config entry.""" - coordinator: PVOutputDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data system = await coordinator.pvoutput.system() async_add_entities( From 9ff5c9863f7fc8353021247bf267e5f1fa2bdbff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:40:10 +0200 Subject: [PATCH 0366/1707] Migrate openexchangerates to use runtime_data (#167182) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/openexchangerates/__init__.py | 28 ++++++++----------- .../openexchangerates/coordinator.py | 6 ++-- .../components/openexchangerates/sensor.py | 9 +++--- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/openexchangerates/__init__.py b/homeassistant/components/openexchangerates/__init__.py index ed704a61fed9eb..4559c098acbf52 100644 --- a/homeassistant/components/openexchangerates/__init__.py +++ b/homeassistant/components/openexchangerates/__init__.py @@ -2,31 +2,28 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_BASE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import BASE_UPDATE_INTERVAL, DOMAIN, LOGGER -from .coordinator import OpenexchangeratesCoordinator +from .coordinator import OpenexchangeratesConfigEntry, OpenexchangeratesCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: OpenexchangeratesConfigEntry +) -> bool: """Set up Open Exchange Rates from a config entry.""" api_key: str = entry.data[CONF_API_KEY] base: str = entry.data[CONF_BASE] # Create one coordinator per base currency per API key. - existing_coordinators: dict[str, OpenexchangeratesCoordinator] = hass.data.get( - DOMAIN, {} - ) existing_coordinator_for_api_key = { - existing_coordinator - for config_entry_id, existing_coordinator in existing_coordinators.items() - if (config_entry := hass.config_entries.async_get_entry(config_entry_id)) - and config_entry.data[CONF_API_KEY] == api_key + existing_entry.runtime_data + for existing_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if existing_entry.data[CONF_API_KEY] == api_key } # Adjust update interval by coordinators per API key. @@ -48,16 +45,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: OpenexchangeratesConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/openexchangerates/coordinator.py b/homeassistant/components/openexchangerates/coordinator.py index 6245877ddbdde8..295e6f33d72905 100644 --- a/homeassistant/components/openexchangerates/coordinator.py +++ b/homeassistant/components/openexchangerates/coordinator.py @@ -20,16 +20,18 @@ from .const import CLIENT_TIMEOUT, DOMAIN, LOGGER +type OpenexchangeratesConfigEntry = ConfigEntry[OpenexchangeratesCoordinator] + class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): """Represent a coordinator for Open Exchange Rates API.""" - config_entry: ConfigEntry + config_entry: OpenexchangeratesConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenexchangeratesConfigEntry, session: ClientSession, api_key: str, base: str, diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 756823ff0ece5e..cb493ab5e849de 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_QUOTE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -11,19 +10,19 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import OpenexchangeratesCoordinator +from .coordinator import OpenexchangeratesConfigEntry, OpenexchangeratesCoordinator ATTRIBUTION = "Data provided by openexchangerates.org" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenexchangeratesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Open Exchange Rates sensor.""" quote: str = config_entry.data.get(CONF_QUOTE, "EUR") - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( OpenexchangeratesSensor( @@ -43,7 +42,7 @@ class OpenexchangeratesSensor( def __init__( self, - config_entry: ConfigEntry, + config_entry: OpenexchangeratesConfigEntry, coordinator: OpenexchangeratesCoordinator, quote: str, enabled: bool, From 962cac902beed603ebac80bd01fc5439307db6ee Mon Sep 17 00:00:00 2001 From: rrooggiieerr Date: Thu, 2 Apr 2026 11:41:47 +0200 Subject: [PATCH 0367/1707] Add Config Flow to Pico TTS (#163114) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com> Co-authored-by: Ariel Ebersberger --- CODEOWNERS | 2 + homeassistant/components/picotts/__init__.py | 32 ++- .../components/picotts/config_flow.py | 49 +++++ homeassistant/components/picotts/const.py | 6 + homeassistant/components/picotts/issue.py | 25 +++ .../components/picotts/manifest.json | 7 +- homeassistant/components/picotts/strings.json | 38 ++++ homeassistant/components/picotts/tts.py | 105 ++++++++- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- tests/components/picotts/__init__.py | 1 + tests/components/picotts/conftest.py | 15 ++ tests/components/picotts/test_config_flow.py | 117 ++++++++++ tests/components/picotts/test_tts.py | 201 ++++++++++++++++++ 14 files changed, 586 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/picotts/config_flow.py create mode 100644 homeassistant/components/picotts/const.py create mode 100644 homeassistant/components/picotts/issue.py create mode 100644 homeassistant/components/picotts/strings.json create mode 100644 tests/components/picotts/__init__.py create mode 100644 tests/components/picotts/conftest.py create mode 100644 tests/components/picotts/test_config_flow.py create mode 100644 tests/components/picotts/test_tts.py diff --git a/CODEOWNERS b/CODEOWNERS index a7fac84580c936..ccd0f605f58106 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1301,6 +1301,8 @@ build.json @home-assistant/supervisor /tests/components/pi_hole/ @shenxn /homeassistant/components/picnic/ @corneyl @codesalatdev /tests/components/picnic/ @corneyl @codesalatdev +/homeassistant/components/picotts/ @rooggiieerr +/tests/components/picotts/ @rooggiieerr /homeassistant/components/ping/ @jpbede /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan diff --git a/homeassistant/components/picotts/__init__.py b/homeassistant/components/picotts/__init__.py index 7ffc80db2f95f2..c8e47e7f22a2c5 100644 --- a/homeassistant/components/picotts/__init__.py +++ b/homeassistant/components/picotts/__init__.py @@ -1 +1,31 @@ -"""Support for pico integration.""" +"""The Pico TTS integration.""" + +from __future__ import annotations + +import shutil + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.TTS] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Pico TTS from a config entry.""" + if await hass.async_add_executor_job(shutil.which, "pico2wave") is None: + raise ConfigEntryError( + translation_domain=DOMAIN, translation_key="binary_not_found" + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/picotts/config_flow.py b/homeassistant/components/picotts/config_flow.py new file mode 100644 index 00000000000000..eb9684bdcb871e --- /dev/null +++ b/homeassistant/components/picotts/config_flow.py @@ -0,0 +1,49 @@ +"""Config flow for Pico TTS integration.""" + +from __future__ import annotations + +import shutil +from typing import Any + +import voluptuous as vol + +from homeassistant.components.tts import CONF_LANG +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import DEFAULT_LANG, DOMAIN, SUPPORT_LANGUAGES + +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} +) + + +class PicoTTSConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Pico TTS.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if await self.hass.async_add_executor_job(shutil.which, "pico2wave") is None: + return self.async_abort(reason="binary_not_found") + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + language = user_input[CONF_LANG] + + self._async_abort_entries_match({CONF_LANG: language}) + + title = f"Pico TTS {language}" + data = { + CONF_LANG: language, + } + + return self.async_create_entry(title=title, data=data) + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import Pico TTS config from yaml.""" + + return await self.async_step_user(import_info) diff --git a/homeassistant/components/picotts/const.py b/homeassistant/components/picotts/const.py new file mode 100644 index 00000000000000..055577ee7eff04 --- /dev/null +++ b/homeassistant/components/picotts/const.py @@ -0,0 +1,6 @@ +"""Constants for the Pico TTS integration.""" + +DEFAULT_LANG = "en-US" +DOMAIN = "picotts" + +SUPPORT_LANGUAGES = ["en-US", "en-GB", "de-DE", "es-ES", "fr-FR", "it-IT"] diff --git a/homeassistant/components/picotts/issue.py b/homeassistant/components/picotts/issue.py new file mode 100644 index 00000000000000..c932a5b8ff4c90 --- /dev/null +++ b/homeassistant/components/picotts/issue.py @@ -0,0 +1,25 @@ +"""Issues for Pico TTS integration.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN + + +@callback +def deprecate_yaml_issue(hass: HomeAssistant) -> None: + """Deprecate yaml issue.""" + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2026.10.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Pico TTS", + }, + ) diff --git a/homeassistant/components/picotts/manifest.json b/homeassistant/components/picotts/manifest.json index 6e8c346a3c930c..20ca5487e9457f 100644 --- a/homeassistant/components/picotts/manifest.json +++ b/homeassistant/components/picotts/manifest.json @@ -1,8 +1,9 @@ { "domain": "picotts", "name": "Pico TTS", - "codeowners": [], + "codeowners": ["@rooggiieerr"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/picotts", - "iot_class": "local_push", - "quality_scale": "legacy" + "integration_type": "service", + "iot_class": "local_push" } diff --git a/homeassistant/components/picotts/strings.json b/homeassistant/components/picotts/strings.json new file mode 100644 index 00000000000000..fbe6183180944d --- /dev/null +++ b/homeassistant/components/picotts/strings.json @@ -0,0 +1,38 @@ +{ + "common": { + "binary_not_found": "pico2wave binary could not be found" + }, + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "binary_not_found": "[%key:component::picotts::common::binary_not_found%]" + }, + "step": { + "user": { + "data": { + "language": "[%key:common::config_flow::data::language%]" + } + } + } + }, + "exceptions": { + "binary_not_found": { + "message": "[%key:component::picotts::common::binary_not_found%]" + }, + "file_read_error": { + "message": "Error trying to read {filename}" + }, + "returncode_error": { + "message": "Error running pico2wave, return code: {returncode}" + }, + "timeout_error": { + "message": "Timeout running pico2wave" + } + }, + "issues": { + "deprecated_yaml": { + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nThe actions `tts.{domain}_*_say` will be removed and automations should be updated to use the `tts.speak` action with the new tts entities. Then remove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]" + } + } +} diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py index 11cb2d7f557eb3..90ae5847b7ce54 100644 --- a/homeassistant/components/picotts/tts.py +++ b/homeassistant/components/picotts/tts.py @@ -1,5 +1,6 @@ """Support for the Pico TTS speech service.""" +import contextlib import logging import os import shutil @@ -13,32 +14,114 @@ CONF_LANG, PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, + TextToSpeechEntity, TtsAudioType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) - -SUPPORT_LANGUAGES = ["en-US", "en-GB", "de-DE", "es-ES", "fr-FR", "it-IT"] +from .const import DEFAULT_LANG, DOMAIN, SUPPORT_LANGUAGES +from .issue import deprecate_yaml_issue -DEFAULT_LANG = "en-US" +_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = TTS_PLATFORM_SCHEMA.extend( {vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} ) -def get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> Provider | None: """Set up Pico speech component.""" - if shutil.which("pico2wave") is None: + if await hass.async_add_executor_job(shutil.which, "pico2wave") is None: _LOGGER.error("'pico2wave' was not found") - return False + return None + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + deprecate_yaml_issue(hass) + return PicoProvider(config[CONF_LANG]) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Pico TTS speech component via config entry.""" + async_add_entities([PicoTTSEntity(config_entry, config_entry.data[CONF_LANG])]) + + +class PicoTTSEntity(TextToSpeechEntity): + """The Pico TTS API entity.""" + + _attr_supported_languages = SUPPORT_LANGUAGES + + def __init__(self, config_entry: ConfigEntry, lang: str) -> None: + """Initialize Pico TTS service.""" + self._attr_default_language = lang + self._attr_name = f"Pico TTS {lang}" + self._attr_unique_id = config_entry.entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry.entry_id)}, + model="Pico TTS", + name=f"Pico TTS {lang}", + ) + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load TTS using pico2wave.""" + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf: + fname = tmpf.name + + cmd = ["pico2wave", "--wave", fname, "-l", language] + try: + subprocess.run(cmd, text=True, input=message, check=True, timeout=30) + with open(fname, "rb") as voice: + data = voice.read() + except subprocess.CalledProcessError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="returncode_error", + translation_placeholders={"returncode": str(exc.returncode)}, + ) from exc + except subprocess.TimeoutExpired as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from exc + except OSError as exc: + _LOGGER.debug("Full exception %s", exc) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_read_error", + translation_placeholders={"filename": fname}, + ) from exc + finally: + with contextlib.suppress(OSError): + os.remove(fname) + + return "wav", data + + class PicoProvider(Provider): """The Pico TTS API provider.""" - def __init__(self, lang): + def __init__(self, lang: str) -> None: """Initialize Pico TTS provider.""" self._lang = lang self.name = "PicoTTS" @@ -68,15 +151,15 @@ def get_tts_audio( _LOGGER.error( "Error running pico2wave, return code: %s", result.returncode ) - return (None, None) + return None, None with open(fname, "rb") as voice: data = voice.read() except OSError: _LOGGER.error("Error trying to read %s", fname) - return (None, None) + return None, None finally: os.remove(fname) if data: return ("wav", data) - return (None, None) + return None, None diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bb6901c64603d7..b28eb0a3c74e36 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -545,6 +545,7 @@ "philips_js", "pi_hole", "picnic", + "picotts", "ping", "pjlink", "plaato", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5acc3ec5b4395f..a13d5b35294a18 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5244,8 +5244,8 @@ }, "picotts": { "name": "Pico TTS", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "local_push" }, "pilight": { diff --git a/tests/components/picotts/__init__.py b/tests/components/picotts/__init__.py new file mode 100644 index 00000000000000..190f88adfbcf25 --- /dev/null +++ b/tests/components/picotts/__init__.py @@ -0,0 +1 @@ +"""Tests for the Pico TTS integration.""" diff --git a/tests/components/picotts/conftest.py b/tests/components/picotts/conftest.py new file mode 100644 index 00000000000000..fbc521f9911e6c --- /dev/null +++ b/tests/components/picotts/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the Pico TTS tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.picotts.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/picotts/test_config_flow.py b/tests/components/picotts/test_config_flow.py new file mode 100644 index 00000000000000..3cfd01127418e4 --- /dev/null +++ b/tests/components/picotts/test_config_flow.py @@ -0,0 +1,117 @@ +"""Test the Pico TTS config flow.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components import tts +from homeassistant.components.picotts.const import DOMAIN +from homeassistant.components.tts import CONF_LANG +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_step(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test user step create entry result.""" + with patch( + "homeassistant.components.picotts.shutil.which", + return_value="/usr/local/bin/pico2wave", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LANG: "es-ES", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Pico TTS es-ES" + assert result["data"] == { + CONF_LANG: "es-ES", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_binary_not_found( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test user step aborts when binary not found.""" + with patch( + "homeassistant.components.picotts.shutil.which", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "binary_not_found" + + +async def test_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test user step already configured entry.""" + with patch( + "homeassistant.components.picotts.shutil.which", + return_value="/usr/local/bin/pico2wave", + ): + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_LANG: "es-ES"}) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LANG: "es-ES", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_import_flow( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the import flow.""" + with patch( + "homeassistant.components.picotts.shutil.which", + return_value="/usr/local/bin/pico2wave", + ): + assert not hass.config_entries.async_entries(DOMAIN) + assert await async_setup_component( + hass, + tts.DOMAIN, + {tts.DOMAIN: {CONF_PLATFORM: DOMAIN}}, + ) + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.state is config_entries.ConfigEntryState.LOADED + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_yaml_{DOMAIN}", + ) diff --git a/tests/components/picotts/test_tts.py b/tests/components/picotts/test_tts.py new file mode 100644 index 00000000000000..0ebce107b8017c --- /dev/null +++ b/tests/components/picotts/test_tts.py @@ -0,0 +1,201 @@ +"""The tests for the Pico TTS speech platform.""" + +from __future__ import annotations + +from http import HTTPStatus +import io +from pathlib import Path +import subprocess +from typing import Any +from unittest.mock import MagicMock, mock_open, patch +import wave + +import pytest + +from homeassistant.components import tts +from homeassistant.components.media_player import ATTR_MEDIA_CONTENT_ID +from homeassistant.components.picotts.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core_config import async_process_ha_core_config +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator + + +def get_empty_wav() -> bytes: + """Get bytes for empty WAV file.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(22050 * 2)) + return wav_io.getvalue() + + +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None: + """Mock the TTS cache dir with empty dir.""" + + +@pytest.fixture(autouse=True) +async def setup_internal_url(hass: HomeAssistant) -> None: + """Set up internal url.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"} + ) + + +@pytest.fixture +async def setup_picotts( + hass: HomeAssistant, + config: dict[str, Any], +) -> None: + """Set up picotts integration via config entry.""" + default_config = {tts.CONF_LANG: "en-US"} + config_entry = MockConfigEntry(domain=DOMAIN, data=default_config | config) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.picotts.shutil.which", + return_value="/usr/local/bin/pico2wave", + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + +@pytest.fixture(name="config") +def config_fixture() -> dict[str, Any]: + """Return config.""" + return {} + + +@pytest.mark.parametrize( + ("config", "entity_id", "extra_service_data"), + [ + ({}, "tts.pico_tts_en_us", {}), + ({tts.CONF_LANG: "de-DE"}, "tts.pico_tts_de_de", {}), + ({}, "tts.pico_tts_en_us", {tts.ATTR_LANGUAGE: "de-DE"}), + ({tts.CONF_LANG: "en-GB"}, "tts.pico_tts_en_gb", {}), + ({}, "tts.pico_tts_en_us", {tts.ATTR_LANGUAGE: "en-GB"}), + ], +) +async def test_tts_service( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + service_calls: list[ServiceCall], + setup_picotts: None, + entity_id: str, + extra_service_data: dict[str, Any], +) -> None: + """Test tts speak service with various language configurations.""" + mock_result = MagicMock() + mock_result.returncode = 0 + with ( + patch( + "homeassistant.components.picotts.tts.subprocess.run", + return_value=mock_result, + ), + patch( + "homeassistant.components.picotts.tts.open", + mock_open(read_data=get_empty_wav()), + ), + ): + await hass.services.async_call( + tts.DOMAIN, + "speak", + { + ATTR_ENTITY_ID: entity_id, + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + **extra_service_data, + }, + blocking=True, + ) + + assert len(service_calls) == 2 + assert ( + await retrieve_media( + hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] + ) + == HTTPStatus.OK + ) + + +async def test_get_tts_audio_subprocess_error( + hass: HomeAssistant, + setup_picotts: None, +) -> None: + """Test get_tts_audio when subprocess returns non-zero exit code.""" + with ( + patch( + "homeassistant.components.picotts.tts.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "pico2wave"), + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, "Hello world", "tts.pico_tts_en_us", "en-US" + ), + ) + + assert exc_info.value.translation_key == "returncode_error" + assert exc_info.value.translation_placeholders == {"returncode": "1"} + + +async def test_get_tts_audio_timeout( + hass: HomeAssistant, + setup_picotts: None, +) -> None: + """Test get_tts_audio when pico2wave times out.""" + with ( + patch( + "homeassistant.components.picotts.tts.subprocess.run", + side_effect=subprocess.TimeoutExpired("pico2wave", 30), + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, "Hello world", "tts.pico_tts_en_us", "en-US" + ), + ) + + assert exc_info.value.translation_key == "timeout_error" + + +async def test_get_tts_audio_file_read_error( + hass: HomeAssistant, + setup_picotts: None, +) -> None: + """Test get_tts_audio when reading the wav file fails.""" + with ( + patch( + "homeassistant.components.picotts.tts.subprocess.run", + ), + patch( + "homeassistant.components.picotts.tts.open", + side_effect=FileNotFoundError("No such file"), + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, "Hello world", "tts.pico_tts_en_us", "en-US" + ), + ) + + assert exc_info.value.translation_key == "file_read_error" From 07c33233ee9abca4f5eab54b2879868351465aa4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:47:30 +0200 Subject: [PATCH 0368/1707] Migrate nibe_heatpump to use runtime_data (#167181) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/nibe_heatpump/__init__.py | 19 +++++++++---------- .../components/nibe_heatpump/binary_sensor.py | 8 +++----- .../components/nibe_heatpump/button.py | 9 ++++----- .../components/nibe_heatpump/climate.py | 8 +++----- .../components/nibe_heatpump/coordinator.py | 6 ++++-- .../components/nibe_heatpump/number.py | 8 +++----- .../components/nibe_heatpump/select.py | 8 +++----- .../components/nibe_heatpump/sensor.py | 8 +++----- .../components/nibe_heatpump/switch.py | 8 +++----- .../components/nibe_heatpump/water_heater.py | 8 +++----- 10 files changed, 38 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index ac201ed2322695..6fc5ea49d97ca4 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -7,7 +7,6 @@ from nibe.connection.nibegw import NibeGW, ProductInfo from nibe.heatpump import HeatPump, Model -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_IP_ADDRESS, CONF_MODEL, @@ -30,7 +29,7 @@ CONF_WORD_SWAP, DOMAIN, ) -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -45,7 +44,9 @@ COIL_READ_RETRIES = 5 -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: NibeHeatpumpConfigEntry +) -> bool: """Set up Nibe Heat Pump from a config entry.""" heatpump = HeatPump(Model[entry.data[CONF_MODEL]]) @@ -83,8 +84,7 @@ async def _async_stop(_): coordinator = CoilCoordinator(hass, entry, heatpump, connection) - data = hass.data.setdefault(DOMAIN, {}) - data[entry.entry_id] = coordinator + entry.runtime_data = coordinator reg = dr.async_get(hass) device_entry = reg.async_get_or_create( @@ -113,9 +113,8 @@ def _on_product_info(product_info: ProductInfo): return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: NibeHeatpumpConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index d49862180bdcd5..4245a1c7652613 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -5,24 +5,22 @@ from nibe.coil import Coil, CoilData from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( BinarySensor(coordinator, coil) diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index 8b6c8abf3598d8..3d63da77f1637b 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -6,24 +6,23 @@ from nibe.exceptions import CoilNotFoundException from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, LOGGER -from .coordinator import CoilCoordinator +from .const import LOGGER +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data def reset_buttons(): if unit := UNIT_COILGROUPS.get(coordinator.series, {}).get("main"): diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 1b8a0ecc0df3ef..19dcca2362a479 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -24,31 +24,29 @@ HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - DOMAIN, LOGGER, VALUES_COOL_WITH_ROOM_SENSOR_OFF, VALUES_MIXING_VALVE_CLOSED_STATE, VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, ) -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data main_unit = UNIT_COILGROUPS[coordinator.series]["main"] diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index 05e652d7f42f91..edd0439de54221 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -28,6 +28,8 @@ from .const import DOMAIN, LOGGER +type NibeHeatpumpConfigEntry = ConfigEntry[CoilCoordinator] + class ContextCoordinator[_DataTypeT, _ContextTypeT](DataUpdateCoordinator[_DataTypeT]): """Update coordinator with context adjustments.""" @@ -73,12 +75,12 @@ def release_update(): class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]): """Update coordinator for nibe heat pumps.""" - config_entry: ConfigEntry + config_entry: NibeHeatpumpConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, heatpump: HeatPump, connection: Connection, ) -> None: diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 59f365f52bf470..b1857067df846f 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -5,24 +5,22 @@ from nibe.coil import Coil, CoilData from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity, NumberMode -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Number(coordinator, coil) diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index c92c12a882a356..fa0c936ec5c4aa 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -5,24 +5,22 @@ from nibe.coil import Coil, CoilData from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Select(coordinator, coil) diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index 54cd0f7ea34c54..92afbbf4bcdbe7 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -11,7 +11,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -28,8 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity UNIT_DESCRIPTIONS = { @@ -185,12 +183,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Sensor(coordinator, coil, UNIT_DESCRIPTIONS.get(coil.unit)) diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 452244f05b58a4..42a104e1f30bbd 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -7,24 +7,22 @@ from nibe.coil import Coil, CoilData from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Switch(coordinator, coil) diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index a72851e7eab61b..72be4503fe8700 100644 --- a/homeassistant/components/nibe_heatpump/water_heater.py +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -14,29 +14,27 @@ WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - DOMAIN, LOGGER, VALUES_TEMPORARY_LUX_INACTIVE, VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE, ) -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data def water_heaters(): for key, group in WATER_HEATER_COILGROUPS.get(coordinator.series, ()).items(): From a0e118d41144616fc2d0b8b291f6f7087525aa2f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:47:51 +0200 Subject: [PATCH 0369/1707] Migrate mutesync to use runtime_data (#167180) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/mutesync/__init__.py | 20 +++++++------------ .../components/mutesync/binary_sensor.py | 9 ++++----- .../components/mutesync/coordinator.py | 6 ++++-- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/mutesync/__init__.py b/homeassistant/components/mutesync/__init__.py index 8c1347b2b04e65..4921ec1f821976 100644 --- a/homeassistant/components/mutesync/__init__.py +++ b/homeassistant/components/mutesync/__init__.py @@ -2,32 +2,26 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import MutesyncUpdateCoordinator +from .coordinator import MutesyncConfigEntry, MutesyncUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MutesyncConfigEntry) -> bool: """Set up mütesync from a config entry.""" - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - MutesyncUpdateCoordinator(hass, entry) - ) + coordinator = MutesyncUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MutesyncConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 66fe78e931cb93..34ba8100443cf1 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -1,14 +1,13 @@ """mütesync binary sensor entities.""" from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import MutesyncUpdateCoordinator +from .coordinator import MutesyncConfigEntry, MutesyncUpdateCoordinator SENSORS = ( "in_meeting", @@ -18,11 +17,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MutesyncConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the mütesync button.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + """Set up the mütesync binary sensors.""" + coordinator = config_entry.runtime_data async_add_entities( [MuteStatus(coordinator, sensor_type) for sensor_type in SENSORS], True ) diff --git a/homeassistant/components/mutesync/coordinator.py b/homeassistant/components/mutesync/coordinator.py index 03c545c7e24b17..2e4925edd5624a 100644 --- a/homeassistant/components/mutesync/coordinator.py +++ b/homeassistant/components/mutesync/coordinator.py @@ -15,18 +15,20 @@ from .const import DOMAIN, UPDATE_INTERVAL_IN_MEETING, UPDATE_INTERVAL_NOT_IN_MEETING +type MutesyncConfigEntry = ConfigEntry[MutesyncUpdateCoordinator] + _LOGGER = logging.getLogger(__name__) class MutesyncUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator for the mütesync integration.""" - config_entry: ConfigEntry + config_entry: MutesyncConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: MutesyncConfigEntry, ) -> None: """Initialize the coordinator.""" super().__init__( From 54b2e0285ce8d00208b55d36f7e289c671cafc74 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:48:36 +0200 Subject: [PATCH 0370/1707] Migrate panasonic_viera to use runtime_data (#167171) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/panasonic_viera/__init__.py | 23 ++++++++----------- .../panasonic_viera/media_player.py | 7 +++--- .../components/panasonic_viera/remote.py | 8 +++---- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 2d0a2b9d26ba81..1478b02095ed54 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -19,7 +19,6 @@ from .const import ( ATTR_DEVICE_INFO, - ATTR_REMOTE, ATTR_UDN, CONF_APP_ID, CONF_ENCRYPTION_KEY, @@ -29,6 +28,8 @@ DOMAIN, ) +type PanasonicVieraConfigEntry = ConfigEntry[Remote] + _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( @@ -68,10 +69,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: PanasonicVieraConfigEntry +) -> bool: """Set up Panasonic Viera from a config entry.""" - panasonic_viera_data = hass.data.setdefault(DOMAIN, {}) - config = config_entry.data host = config[CONF_HOST] @@ -88,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b remote = Remote(hass, host, port, on_action, **params) await remote.async_create_remote_control(during_setup=True) - panasonic_viera_data[config_entry.entry_id] = {ATTR_REMOTE: remote} + config_entry.runtime_data = remote # Add device_info to older config entries if ATTR_DEVICE_INFO not in config or config[ATTR_DEVICE_INFO] is None: @@ -112,15 +113,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: PanasonicVieraConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) class Remote: diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index a78920f33a5182..b2c5bdd1a5db94 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -17,17 +17,16 @@ MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import PanasonicVieraConfigEntry from .const import ( ATTR_DEVICE_INFO, ATTR_MANUFACTURER, ATTR_MODEL_NUMBER, - ATTR_REMOTE, ATTR_UDN, DEFAULT_MANUFACTURER, DEFAULT_MODEL_NUMBER, @@ -39,14 +38,14 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PanasonicVieraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Panasonic Viera TV from a config entry.""" config = config_entry.data - remote = hass.data[DOMAIN][config_entry.entry_id][ATTR_REMOTE] + remote = config_entry.runtime_data name = config[CONF_NAME] device_info = config[ATTR_DEVICE_INFO] diff --git a/homeassistant/components/panasonic_viera/remote.py b/homeassistant/components/panasonic_viera/remote.py index 5fa4be9ca2b906..59090e46ef72f9 100644 --- a/homeassistant/components/panasonic_viera/remote.py +++ b/homeassistant/components/panasonic_viera/remote.py @@ -6,18 +6,16 @@ from typing import Any from homeassistant.components.remote import RemoteEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import Remote +from . import PanasonicVieraConfigEntry, Remote from .const import ( ATTR_DEVICE_INFO, ATTR_MANUFACTURER, ATTR_MODEL_NUMBER, - ATTR_REMOTE, ATTR_UDN, DEFAULT_MANUFACTURER, DEFAULT_MODEL_NUMBER, @@ -27,14 +25,14 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PanasonicVieraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Panasonic Viera TV Remote from a config entry.""" config = config_entry.data - remote = hass.data[DOMAIN][config_entry.entry_id][ATTR_REMOTE] + remote = config_entry.runtime_data name = config[CONF_NAME] device_info = config[ATTR_DEVICE_INFO] From de5a2d47a51febafcd4e5eb3de0fbd66363839f1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:48:39 +0200 Subject: [PATCH 0371/1707] Migrate pushbullet to use runtime_data (#167166) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/pushbullet/__init__.py | 13 ++++++------- homeassistant/components/pushbullet/notify.py | 11 +++++------ homeassistant/components/pushbullet/sensor.py | 6 +++--- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py index e5892afc9260fa..4adfbcad4f9d4e 100644 --- a/homeassistant/components/pushbullet/__init__.py +++ b/homeassistant/components/pushbullet/__init__.py @@ -21,6 +21,8 @@ from .api import PushBulletNotificationProvider from .const import DATA_HASS_CONFIG, DOMAIN +type PushbulletConfigEntry = ConfigEntry[PushBulletNotificationProvider] + PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -35,7 +37,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PushbulletConfigEntry) -> bool: """Set up pushbullet from a config entry.""" try: @@ -49,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err pb_provider = PushBulletNotificationProvider(hass, pushbullet) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = pb_provider + entry.runtime_data = pb_provider def start_listener(event: Event) -> None: """Start the listener thread.""" @@ -72,11 +74,8 @@ def start_listener(event: Event) -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PushbulletConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN].pop( - entry.entry_id - ) - await hass.async_add_executor_job(pb_provider.close) + await hass.async_add_executor_job(entry.runtime_data.close) return unload_ok diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index f2e70695b27e45..26ecc859ad269d 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -22,8 +22,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .api import PushBulletNotificationProvider -from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN +from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL _LOGGER = logging.getLogger(__name__) @@ -36,10 +35,10 @@ async def async_get_service( """Get the Pushbullet notification service.""" if TYPE_CHECKING: assert discovery_info is not None - pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][ - discovery_info["entry_id"] - ] - return PushBulletNotificationService(hass, pb_provider.pushbullet) + entry = hass.config_entries.async_get_entry(discovery_info["entry_id"]) + if TYPE_CHECKING: + assert entry is not None + return PushBulletNotificationService(hass, entry.runtime_data.pushbullet) class PushBulletNotificationService(BaseNotificationService): diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 3ab55ecf072eaf..ade6f9362ed938 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -3,13 +3,13 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import PushbulletConfigEntry from .api import PushBulletNotificationProvider from .const import DATA_UPDATED, DOMAIN @@ -69,12 +69,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PushbulletConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pushbullet sensors from config entry.""" - pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][entry.entry_id] + pb_provider = entry.runtime_data entities = [ PushBulletNotificationSensor(entry.data[CONF_NAME], pb_provider, description) From ee8bd9f016b36ca0b9e9ef605f4497a20957a25a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:48:49 +0200 Subject: [PATCH 0372/1707] Migrate picnic to use runtime_data (#167151) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/picnic/__init__.py | 23 ++++++------------- homeassistant/components/picnic/const.py | 3 --- .../components/picnic/coordinator.py | 8 +++++-- homeassistant/components/picnic/sensor.py | 10 ++++---- homeassistant/components/picnic/services.py | 13 +++++++---- homeassistant/components/picnic/todo.py | 11 ++++----- tests/components/picnic/test_sensor.py | 4 +--- 7 files changed, 32 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index bf9bb61b539a14..9bd76865c7137f 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -2,14 +2,13 @@ from python_picnic_api2 import PicnicAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_API, CONF_COORDINATOR, DOMAIN -from .coordinator import PicnicUpdateCoordinator +from .const import DOMAIN +from .coordinator import PicnicConfigEntry, PicnicUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -24,7 +23,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def create_picnic_client(entry: ConfigEntry): +def create_picnic_client(entry: PicnicConfigEntry): """Create an instance of the PicnicAPI client.""" return PicnicAPI( auth_token=entry.data.get(CONF_ACCESS_TOKEN), @@ -32,7 +31,7 @@ def create_picnic_client(entry: ConfigEntry): ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PicnicConfigEntry) -> bool: """Set up Picnic from a config entry.""" picnic_client = await hass.async_add_executor_job(create_picnic_client, entry) picnic_coordinator = PicnicUpdateCoordinator(hass, picnic_client, entry) @@ -40,21 +39,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data so we have data when entities subscribe await picnic_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - CONF_API: picnic_client, - CONF_COORDINATOR: picnic_coordinator, - } + entry.runtime_data = picnic_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PicnicConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index f8737806746651..9cde3dea03dc34 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -4,9 +4,6 @@ DOMAIN = "picnic" -CONF_API = "api" -CONF_COORDINATOR = "coordinator" - SERVICE_ADD_PRODUCT_TO_CART = "add_product" ATTR_PRODUCT_ID = "product_id" diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index a63be7614c2550..17c6e446a17f4a 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -1,5 +1,7 @@ """Coordinator to fetch data from the Picnic API.""" +from __future__ import annotations + import asyncio from contextlib import suppress import copy @@ -17,17 +19,19 @@ from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT_DATA +type PicnicConfigEntry = ConfigEntry[PicnicUpdateCoordinator] + class PicnicUpdateCoordinator(DataUpdateCoordinator): """The coordinator to fetch data from the Picnic API at a set interval.""" - config_entry: ConfigEntry + config_entry: PicnicConfigEntry def __init__( self, hass: HomeAssistant, picnic_api_client: PicnicAPI, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, ) -> None: """Initialize the coordinator with the given Picnic API client.""" self.picnic_api_client = picnic_api_client diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index dcfd908649193c..a6b1f3ae8c4ef1 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -12,7 +12,6 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -23,7 +22,6 @@ from .const import ( ATTRIBUTION, - CONF_COORDINATOR, DOMAIN, SENSOR_CART_ITEMS_COUNT, SENSOR_CART_TOTAL_PRICE, @@ -42,7 +40,7 @@ SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, SENSOR_SELECTED_SLOT_START, ) -from .coordinator import PicnicUpdateCoordinator +from .coordinator import PicnicConfigEntry, PicnicUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -202,11 +200,11 @@ class PicnicSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Picnic sensor entries.""" - picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] + picnic_coordinator = config_entry.runtime_data # Add an entity for each sensor type async_add_entities( @@ -225,7 +223,7 @@ class PicnicSensor(SensorEntity, CoordinatorEntity[PicnicUpdateCoordinator]): def __init__( self, coordinator: PicnicUpdateCoordinator, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, description: PicnicSensorEntityDescription, ) -> None: """Init a Picnic sensor.""" diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index d0465fcc13c436..bdc3395020450f 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -7,6 +7,7 @@ from python_picnic_api2 import PicnicAPI import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv @@ -16,10 +17,10 @@ ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS, ATTR_PRODUCT_NAME, - CONF_API, DOMAIN, SERVICE_ADD_PRODUCT_TO_CART, ) +from .coordinator import PicnicConfigEntry class PicnicServiceException(Exception): @@ -50,10 +51,14 @@ async def async_add_product_service(call: ServiceCall): async def get_api_client(hass: HomeAssistant, config_entry_id: str) -> PicnicAPI: - """Get the right Picnic API client based on the device id, else get the default one.""" - if config_entry_id not in hass.data[DOMAIN]: + """Get the right Picnic API client based on the config entry id.""" + + entry: PicnicConfigEntry | None = hass.config_entries.async_get_entry( + config_entry_id + ) + if entry is None or entry.state != ConfigEntryState.LOADED: raise ValueError(f"Config entry with id {config_entry_id} not found!") - return hass.data[DOMAIN][config_entry_id][CONF_API] + return entry.runtime_data.picnic_api_client async def handle_add_product( diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py index 383c236de3c17d..aee818a8fe615c 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -11,15 +11,14 @@ TodoListEntity, TodoListEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_COORDINATOR, DOMAIN -from .coordinator import PicnicUpdateCoordinator +from .const import DOMAIN +from .coordinator import PicnicConfigEntry, PicnicUpdateCoordinator from .services import product_search _LOGGER = logging.getLogger(__name__) @@ -27,11 +26,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Picnic shopping cart todo platform config entry.""" - picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] + picnic_coordinator = config_entry.runtime_data async_add_entities([PicnicCart(picnic_coordinator, config_entry)]) @@ -46,7 +45,7 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): def __init__( self, coordinator: PicnicUpdateCoordinator, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, ) -> None: """Initialize PicnicCart.""" super().__init__(coordinator) diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 37191642c07273..c1778f794a6eca 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -129,9 +129,7 @@ async def asyncTearDown(self): @property def _coordinator(self): - return self.hass.data[const.DOMAIN][self.config_entry.entry_id][ - const.CONF_COORDINATOR - ] + return self.config_entry.runtime_data def _assert_sensor(self, name, state=None, cls=None, unit=None, disabled=False): sensor = self.hass.states.get(name) From 69fd6532cca82152c364f50e9933bf9531611bed Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:51:31 +0200 Subject: [PATCH 0373/1707] Migrate openhome to use runtime_data (#167183) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/openhome/__init__.py | 17 ++++++++--------- .../components/openhome/media_player.py | 6 +++--- homeassistant/components/openhome/update.py | 6 +++--- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/openhome/__init__.py b/homeassistant/components/openhome/__init__.py index 393f0f4065b884..887b7cc71c1621 100644 --- a/homeassistant/components/openhome/__init__.py +++ b/homeassistant/components/openhome/__init__.py @@ -18,6 +18,8 @@ _LOGGER = logging.getLogger(__name__) +type OpenhomeConfigEntry = ConfigEntry[Device] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) PLATFORMS = [Platform.MEDIA_PLAYER, Platform.UPDATE] @@ -30,7 +32,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenhomeConfigEntry, ) -> bool: """Set up the configuration config entry.""" _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) @@ -44,18 +46,15 @@ async def async_setup_entry( _LOGGER.debug("Initialised device: %s", device.uuid()) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = device + config_entry.runtime_data = device await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: OpenhomeConfigEntry +) -> bool: """Cleanup before removing config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 746468730ef76c..21730c401c4ab5 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -19,11 +19,11 @@ MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import OpenhomeConfigEntry from .const import DOMAIN SUPPORT_OPENHOME = ( @@ -37,14 +37,14 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenhomeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Openhome config entry.""" _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) - device = hass.data[DOMAIN][config_entry.entry_id] + device = config_entry.runtime_data entity = OpenhomeDevice(device) diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index cc210866e64648..fc5f4bb2f7a6d7 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -13,12 +13,12 @@ UpdateEntity, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import OpenhomeConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -26,14 +26,14 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenhomeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Reolink component.""" _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) - device = hass.data[DOMAIN][config_entry.entry_id] + device = config_entry.runtime_data entity = OpenhomeUpdateEntity(device) From 5ba0764a87d3488e5bac89b1c59e1fd19742c1d2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Apr 2026 11:58:48 +0200 Subject: [PATCH 0374/1707] Fix propagation of GPS accuracy in person entity (#167174) --- homeassistant/components/person/__init__.py | 2 +- tests/components/person/test_init.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index d67f45d1540baf..610d77aefd2b22 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -566,7 +566,7 @@ def _parse_source_state(self, state: State, coordinates: State) -> None: self._source = state.entity_id self._latitude = coordinates.attributes.get(ATTR_LATITUDE) self._longitude = coordinates.attributes.get(ATTR_LONGITUDE) - self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY) + self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY) @callback def _update_extra_state_attributes(self) -> None: diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 81b38f59a3d62d..a4ddf45d101bf1 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -216,7 +216,18 @@ async def test_setup_two_trackers( hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - hass.states.async_set(DEVICE_TRACKER, "home", {ATTR_SOURCE_TYPE: SourceType.ROUTER}) + # Router tracker at home with gps_accuracy — the person entity should get + # coordinates from the home zone (which has no gps_accuracy),not from the + # router tracker's attributes. + # Note: This is not a realistic test case, a router tracker would not have + # gps_accuracy, but we want to assert that the person entity uses latitude + # longitude and accuracy from the home zone, not from the state attributes + # of the device tracker. + hass.states.async_set( + DEVICE_TRACKER, + "home", + {ATTR_SOURCE_TYPE: SourceType.ROUTER, ATTR_GPS_ACCURACY: 99}, + ) await hass.async_block_till_done() state = hass.states.get("person.tracked_person") @@ -224,6 +235,8 @@ async def test_setup_two_trackers( assert state.attributes.get(ATTR_ID) == "1234" assert state.attributes.get(ATTR_LATITUDE) == 32.87336 assert state.attributes.get(ATTR_LONGITUDE) == -117.22743 + # GPS accuracy comes from the coordinates source (home zone), not from + # the state source (router tracker which reported gps_accuracy=99). assert state.attributes.get(ATTR_GPS_ACCURACY) is None assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER assert state.attributes.get(ATTR_USER_ID) == user_id From f437d65d3c5a945e8739d72fbfe27de70e589d79 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:06:47 +0200 Subject: [PATCH 0375/1707] Remove deprecated LANnouncer integration (#166838) --- homeassistant/components/lannouncer/notify.py | 110 +++--------------- .../components/lannouncer/strings.json | 4 +- 2 files changed, 16 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py index 4b5f249a2f17ad..c24148ab6993bd 100644 --- a/homeassistant/components/lannouncer/notify.py +++ b/homeassistant/components/lannouncer/notify.py @@ -2,111 +2,29 @@ from __future__ import annotations -import logging -import socket -from typing import Any -from urllib.parse import urlencode - import voluptuous as vol -from homeassistant.components.notify import ( - ATTR_DATA, - PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, - BaseNotificationService, -) -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.components.notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType DOMAIN = "lannouncer" -ATTR_METHOD = "method" -ATTR_METHOD_DEFAULT = "speak" -ATTR_METHOD_ALLOWED = ["speak", "alarm"] - -DEFAULT_PORT = 1035 - -PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } -) +PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -_LOGGER = logging.getLogger(__name__) - -def get_service( +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, -) -> LannouncerNotificationService: +) -> None: """Get the Lannouncer notification service.""" - - @callback - def _async_create_issue() -> None: - """Create issue for removed integration.""" - ir.async_create_issue( - hass, - DOMAIN, - "integration_removed", - is_fixable=False, - breaks_in_ha_version="2026.3.0", - severity=ir.IssueSeverity.WARNING, - translation_key="integration_removed", - ) - - hass.add_job(_async_create_issue) - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - - return LannouncerNotificationService(hass, host, port) - - -class LannouncerNotificationService(BaseNotificationService): - """Implementation of a notification service for Lannouncer.""" - - def __init__(self, hass, host, port): - """Initialize the service.""" - self._hass = hass - self._host = host - self._port = port - - def send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to Lannouncer.""" - data = kwargs.get(ATTR_DATA) - if data is not None and ATTR_METHOD in data: - method = data.get(ATTR_METHOD) - else: - method = ATTR_METHOD_DEFAULT - - if method not in ATTR_METHOD_ALLOWED: - _LOGGER.error("Unknown method %s", method) - return - - cmd = urlencode({method: message}) - - try: - # Open socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(10) - sock.connect((self._host, self._port)) - - # Send message - _LOGGER.debug("Sending message: %s", cmd) - sock.sendall(cmd.encode()) - sock.sendall(b"&@DONE@\n") - - # Check response - buffer = sock.recv(1024) - if buffer != b"LANnouncer: OK": - _LOGGER.error("Error sending data to Lannnouncer: %s", buffer.decode()) - - # Close socket - sock.close() - except socket.gaierror: - _LOGGER.error("Unable to connect to host %s", self._host) - except OSError: - _LOGGER.exception("Failed to send data to Lannnouncer") + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + ) diff --git a/homeassistant/components/lannouncer/strings.json b/homeassistant/components/lannouncer/strings.json index 63b2e86aa829b4..1152be3fde9eab 100644 --- a/homeassistant/components/lannouncer/strings.json +++ b/homeassistant/components/lannouncer/strings.json @@ -1,8 +1,8 @@ { "issues": { "integration_removed": { - "description": "The LANnouncer Android app is no longer available, so this integration has been deprecated and will be removed in a future release.\n\nTo resolve this issue:\n1. Remove the LANnouncer integration from your `configuration.yaml`.\n2. Restart the Home Assistant instance.\n\nAfter removal, this issue will disappear.", - "title": "LANnouncer integration is deprecated" + "description": "The LANnouncer integration has been removed from Home Assistant because the LANnouncer Android app is no longer available.\n\nTo resolve this issue:\n1. Remove the LANnouncer integration from your `configuration.yaml`.\n2. Restart the Home Assistant instance.\n\nAfter removal, this issue will disappear.", + "title": "LANnouncer integration has been removed" } } } From 38b27d624a6c3e29916366805766a1b57d8e7773 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Apr 2026 12:24:35 +0200 Subject: [PATCH 0376/1707] Add new state attribute in_zones to device_tracker (#166573) --- .../components/device_tracker/__init__.py | 1 + .../components/device_tracker/config_entry.py | 29 +++++++--- .../components/device_tracker/const.py | 1 + homeassistant/components/zone/__init__.py | 41 +++++++++++--- .../snapshots/test_device_tracker.ambr | 2 + .../device_tracker/test_config_entry.py | 21 ++++++- .../snapshots/test_device_tracker.ambr | 2 + .../snapshots/test_device_tracker.ambr | 2 + .../ituran/snapshots/test_device_tracker.ambr | 2 + .../lojack/snapshots/test_device_tracker.ambr | 2 + .../mobile_app/test_device_tracker.py | 55 +++++++++++++++---- tests/components/mqtt/test_diagnostics.py | 1 + .../snapshots/test_device_tracker.ambr | 2 + .../snapshots/test_device_tracker.ambr | 12 ++++ .../snapshots/test_device_tracker.ambr | 4 ++ .../snapshots/test_device_tracker.ambr | 8 +++ .../tessie/snapshots/test_device_tracker.ambr | 4 ++ .../tile/snapshots/test_device_tracker.ambr | 2 + .../snapshots/test_diagnostics.ambr | 4 ++ .../snapshots/test_device_tracker.ambr | 2 + .../volvo/snapshots/test_device_tracker.ambr | 8 +++ tests/components/zone/test_init.py | 45 +++++++++++---- 22 files changed, 209 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 313373e3181b88..3ffb48596c7ec6 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -21,6 +21,7 @@ ATTR_DEV_ID, ATTR_GPS, ATTR_HOST_NAME, + ATTR_IN_ZONES, ATTR_IP, ATTR_LOCATION_NAME, ATTR_MAC, diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index b82cf0352a72ee..3802e31e2833d9 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import final +from typing import Any, final from propcache.api import cached_property @@ -18,7 +18,7 @@ STATE_NOT_HOME, EntityCategory, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import ( DeviceInfo, @@ -33,6 +33,7 @@ from .const import ( ATTR_HOST_NAME, + ATTR_IN_ZONES, ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, @@ -223,6 +224,9 @@ class TrackerEntity( _attr_longitude: float | None = None _attr_source_type: SourceType = SourceType.GPS + __active_zone: State | None = None + __in_zones: list[str] | None = None + @cached_property def should_poll(self) -> bool: """No polling for entities that have location pushed.""" @@ -256,6 +260,18 @@ def longitude(self) -> float | None: """Return longitude value of the device.""" return self._attr_longitude + @callback + def _async_write_ha_state(self) -> None: + """Calculate active zones.""" + if self.latitude is not None and self.longitude is not None: + self.__active_zone, self.__in_zones = zone.async_in_zones( + self.hass, self.latitude, self.longitude, self.location_accuracy + ) + else: + self.__active_zone = None + self.__in_zones = None + super()._async_write_ha_state() + @property def state(self) -> str | None: """Return the state of the device.""" @@ -263,9 +279,7 @@ def state(self) -> str | None: return self.location_name if self.latitude is not None and self.longitude is not None: - zone_state = zone.async_active_zone( - self.hass, self.latitude, self.longitude, self.location_accuracy - ) + zone_state = self.__active_zone if zone_state is None: state = STATE_NOT_HOME elif zone_state.entity_id == zone.ENTITY_ID_HOME: @@ -278,12 +292,13 @@ def state(self) -> str | None: @final @property - def state_attributes(self) -> dict[str, StateType]: + def state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" - attr: dict[str, StateType] = {} + attr: dict[str, Any] = {ATTR_IN_ZONES: []} 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/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index c9e4d4e910a58b..87b8f7d2cbf57d 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -43,6 +43,7 @@ class SourceType(StrEnum): ATTR_DEV_ID: Final = "dev_id" ATTR_GPS: Final = "gps" ATTR_HOST_NAME: Final = "host_name" +ATTR_IN_ZONES: Final = "in_zones" ATTR_LOCATION_NAME: Final = "location_name" ATTR_MAC: Final = "mac" ATTR_SOURCE_TYPE: Final = "source_type" diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index b0d7a6ba8d1819..ca707c02f9a47c 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -113,17 +113,21 @@ def empty_value(value: Any) -> Any: DATA_ZONE_ENTITY_IDS: HassKey[list[str]] = HassKey(ZONE_ENTITY_IDS) -@bind_hass -def async_active_zone( +def async_in_zones( hass: HomeAssistant, latitude: float, longitude: float, radius: float = 0 -) -> State | None: - """Find the active zone for given latitude, longitude. +) -> tuple[State | None, list[str]]: + """Find zones which contain the given latitude and longitude. + + Returns a tuple of the closest active zone and a list of all zones which + contain the given latitude and longitude. The list of zones is sorted by + distance and then by radius so that the closest and smallest zone is first. This method must be run in the event loop. """ # Sort entity IDs so that we are deterministic if equal distance to 2 zones min_dist: float = sys.maxsize closest: State | None = None + zones: list[tuple[str, float, float]] = [] # This can be called before async_setup by device tracker zone_entity_ids = hass.data.get(DATA_ZONE_ENTITY_IDS, ()) @@ -133,10 +137,12 @@ def async_active_zone( not (zone := hass.states.get(entity_id)) # Skip unavailable zones or zone.state == STATE_UNAVAILABLE - # Skip passive zones - or (zone_attrs := zone.attributes).get(ATTR_PASSIVE) + ): + continue + zone_attrs = zone.attributes + if ( # Skip zones where we cannot calculate distance - or ( + ( zone_dist := distance( latitude, longitude, @@ -151,6 +157,12 @@ def async_active_zone( ): continue + zones.append((zone.entity_id, zone_dist, zone_radius)) + + # Skip passive zones + if zone_attrs.get(ATTR_PASSIVE): + continue + # If have a closest and its not closer than the closest skip it if closest and not ( zone_dist < min_dist @@ -166,7 +178,20 @@ def async_active_zone( min_dist = zone_dist closest = zone - return closest + # Sort by distance and then by radius so the closest and smallest zone is first. + zones.sort(key=lambda x: (x[1], x[2])) + return (closest, [itm[0] for itm in zones]) + + +@bind_hass +def async_active_zone( + hass: HomeAssistant, latitude: float, longitude: float, radius: float = 0 +) -> State | None: + """Find the active zone for given latitude, longitude. + + This method must be run in the event loop. + """ + return async_in_zones(hass, latitude, longitude, radius)[0] @callback diff --git a/tests/components/autoskope/snapshots/test_device_tracker.ambr b/tests/components/autoskope/snapshots/test_device_tracker.ambr index 663305de7354d3..5d600717c6d4fe 100644 --- a/tests/components/autoskope/snapshots/test_device_tracker.ambr +++ b/tests/components/autoskope/snapshots/test_device_tracker.ambr @@ -42,6 +42,8 @@ 'friendly_name': 'Test Vehicle', 'gps_accuracy': 6.0, 'icon': 'mdi:car', + 'in_zones': list([ + ]), 'latitude': 50.1109221, 'longitude': 8.6821267, 'source_type': , diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index c83e67d2fd18c8..0a467a6ad8c5fc 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -7,6 +7,7 @@ from homeassistant.components.device_tracker import ( ATTR_HOST_NAME, + ATTR_IN_ZONES, ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, @@ -384,6 +385,7 @@ async def test_load_unload_entry( { ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_GPS_ACCURACY: 0, + ATTR_IN_ZONES: [], ATTR_LATITUDE: 1.0, ATTR_LONGITUDE: 2.0, }, @@ -397,6 +399,7 @@ async def test_load_unload_entry( { ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_GPS_ACCURACY: 0, + ATTR_IN_ZONES: ["zone.home"], ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, }, @@ -410,6 +413,7 @@ async def test_load_unload_entry( { ATTR_SOURCE_TYPE: SourceType.GPS, ATTR_GPS_ACCURACY: 0, + ATTR_IN_ZONES: ["zone.other_zone", "zone.other_zone_larger"], ATTR_LATITUDE: -50.0, ATTR_LONGITUDE: -60.0, }, @@ -422,6 +426,7 @@ async def test_load_unload_entry( "zen_zone", { ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: [], }, ), ( @@ -430,7 +435,10 @@ async def test_load_unload_entry( None, None, STATE_UNKNOWN, - {ATTR_SOURCE_TYPE: SourceType.GPS}, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: [], + }, ), ( 100, @@ -438,7 +446,11 @@ async def test_load_unload_entry( None, None, STATE_UNKNOWN, - {ATTR_BATTERY_LEVEL: 100, ATTR_SOURCE_TYPE: SourceType.GPS}, + { + ATTR_BATTERY_LEVEL: 100, + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_IN_ZONES: [], + }, ), ], ) @@ -463,6 +475,11 @@ async def test_tracker_entity_state( "0", {ATTR_LATITUDE: -50.0, ATTR_LONGITUDE: -60.0, ATTR_RADIUS: 300}, ) + hass.states.async_set( + "zone.other_zone_larger", + "0", + {ATTR_LATITUDE: -50.0, ATTR_LONGITUDE: -60.0, ATTR_RADIUS: 500}, + ) await hass.async_block_till_done() # Write state again to ensure the zone state is taken into account. tracker_entity.async_write_ha_state() diff --git a/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr b/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr index 38473d3bcfa65b..dfd98e35b77d07 100644 --- a/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr +++ b/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr @@ -42,6 +42,8 @@ 'entity_picture': 'http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg', 'friendly_name': 'Fluffy', 'gps_accuracy': 10.0, + 'in_zones': list([ + ]), 'latitude': 52.520008, 'longitude': 13.404954, 'source_type': , diff --git a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr index 639772b085ed20..65e426b949833b 100644 --- a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr @@ -41,6 +41,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 35.5402913, 'longitude': -82.5527055, 'source_type': , diff --git a/tests/components/ituran/snapshots/test_device_tracker.ambr b/tests/components/ituran/snapshots/test_device_tracker.ambr index 74d012b72910fa..8912c205468574 100644 --- a/tests/components/ituran/snapshots/test_device_tracker.ambr +++ b/tests/components/ituran/snapshots/test_device_tracker.ambr @@ -41,6 +41,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'mock model', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 25.0, 'longitude': -71.0, 'source_type': , diff --git a/tests/components/lojack/snapshots/test_device_tracker.ambr b/tests/components/lojack/snapshots/test_device_tracker.ambr index 4e68e26af2ebd5..dbb2bab555f975 100644 --- a/tests/components/lojack/snapshots/test_device_tracker.ambr +++ b/tests/components/lojack/snapshots/test_device_tracker.ambr @@ -41,6 +41,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': '2021 Honda Accord', 'gps_accuracy': 10, + 'in_zones': list([ + ]), 'latitude': 37.7749, 'longitude': -122.4194, 'source_type': , diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py index 46e3b6e5e6a1bf..8f56710f1872d9 100644 --- a/tests/components/mobile_app/test_device_tracker.py +++ b/tests/components/mobile_app/test_device_tracker.py @@ -51,53 +51,83 @@ async def setup_zone(hass: HomeAssistant) -> None: # Send coordinates + location_name: Location name has precedence ( {"gps": [10, 20], "location_name": "home"}, - {"latitude": 10, "longitude": 20, "gps_accuracy": 30}, + { + "latitude": 10, + "longitude": 20, + "gps_accuracy": 30, + "in_zones": ["zone.home"], + }, "home", ), ( {"gps": [20, 30], "location_name": "office"}, - {"latitude": 20, "longitude": 30, "gps_accuracy": 30}, + { + "latitude": 20, + "longitude": 30, + "gps_accuracy": 30, + "in_zones": ["zone.office"], + }, "Office", ), ( {"gps": [30, 40], "location_name": "school"}, - {"latitude": 30, "longitude": 40, "gps_accuracy": 30}, + { + "latitude": 30, + "longitude": 40, + "gps_accuracy": 30, + "in_zones": ["zone.school"], + }, "School", ), # Send wrong coordinates + location_name: Location name has precedence ( {"gps": [10, 10], "location_name": "home"}, - {"latitude": 10, "longitude": 10, "gps_accuracy": 30}, + {"latitude": 10, "longitude": 10, "gps_accuracy": 30, "in_zones": []}, "home", ), ( {"gps": [10, 10], "location_name": "office"}, - {"latitude": 10, "longitude": 10, "gps_accuracy": 30}, + {"latitude": 10, "longitude": 10, "gps_accuracy": 30, "in_zones": []}, "Office", ), ( {"gps": [10, 10], "location_name": "school"}, - {"latitude": 10, "longitude": 10, "gps_accuracy": 30}, + {"latitude": 10, "longitude": 10, "gps_accuracy": 30, "in_zones": []}, "School", ), # Send location_name only - ({"location_name": "home"}, {}, "home"), - ({"location_name": "office"}, {}, "Office"), - ({"location_name": "school"}, {}, "School"), + ({"location_name": "home"}, {"in_zones": []}, "home"), + ({"location_name": "office"}, {"in_zones": []}, "Office"), + ({"location_name": "school"}, {"in_zones": []}, "School"), # Send coordinates only - location is determined by coordinates ( {"gps": [10, 20]}, - {"latitude": 10, "longitude": 20, "gps_accuracy": 30}, + { + "latitude": 10, + "longitude": 20, + "gps_accuracy": 30, + "in_zones": ["zone.home"], + }, "home", ), ( {"gps": [20, 30]}, - {"latitude": 20, "longitude": 30, "gps_accuracy": 30}, + { + "latitude": 20, + "longitude": 30, + "gps_accuracy": 30, + "in_zones": ["zone.office"], + }, "Office", ), ( {"gps": [30, 40]}, - {"latitude": 30, "longitude": 40, "gps_accuracy": 30}, + { + "latitude": 30, + "longitude": 40, + "gps_accuracy": 30, + "in_zones": ["zone.school"], + }, "School", ), ], @@ -180,6 +210,7 @@ async def test_sending_location( "course": 6, "speed": 7, "vertical_accuracy": 8, + "in_zones": [], } diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index 5db0340eb2c5c8..c669a86b11262d 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -239,6 +239,7 @@ async def test_redact_diagnostics( "state": { "attributes": { "gps_accuracy": 1.5, + "in_zones": ["zone.home"], "latitude": "**REDACTED**", "longitude": "**REDACTED**", "source_type": "gps", diff --git a/tests/components/nrgkick/snapshots/test_device_tracker.ambr b/tests/components/nrgkick/snapshots/test_device_tracker.ambr index 6e6b2f97e7cd9d..36f19af9448ce1 100644 --- a/tests/components/nrgkick/snapshots/test_device_tracker.ambr +++ b/tests/components/nrgkick/snapshots/test_device_tracker.ambr @@ -41,6 +41,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'NRGkick Test GPS tracker', 'gps_accuracy': 1.5, + 'in_zones': list([ + ]), 'latitude': 47.0748, 'longitude': 15.4376, 'source_type': , diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index be641b4c49c8ed..1ee9a115af25af 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -40,6 +40,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'REG-ZOE-50 Location', + 'in_zones': list([ + ]), 'source_type': , }), 'context': , @@ -142,6 +144,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'REG-CAPTUR-FUEL Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , @@ -196,6 +200,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'REG-CAPTUR_PHEV Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , @@ -250,6 +256,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'REG-MEG-0 Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , @@ -304,6 +312,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'REG-TWINGO-III Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , @@ -358,6 +368,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'REG-ZOE-50 Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 48.1234567, 'longitude': 11.1234567, 'source_type': , diff --git a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr index e1e69bcdc1a978..36b107c3517b06 100644 --- a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr +++ b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr @@ -41,6 +41,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': -30.222626, 'longitude': -97.6236871, 'source_type': , @@ -95,6 +97,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Route', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 30.2226265, 'longitude': -97.6236871, 'source_type': , diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index 797de0fc0b60cb..e223dffc51dcc1 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -41,6 +41,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': -30.222626, 'longitude': -97.6236871, 'source_type': , @@ -95,6 +97,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Route', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 30.2226265, 'longitude': -97.6236871, 'source_type': , @@ -112,6 +116,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': -30.222626, 'longitude': -97.6236871, 'source_type': , @@ -129,6 +135,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Route', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 30.2226265, 'longitude': -97.6236871, 'source_type': , diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr index 72760b3737d649..1bab66b19b87e7 100644 --- a/tests/components/tessie/snapshots/test_device_tracker.ambr +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -42,6 +42,8 @@ 'friendly_name': 'Test Location', 'gps_accuracy': 0, 'heading': 185, + 'in_zones': list([ + ]), 'latitude': -30.222626, 'longitude': -97.6236871, 'source_type': , @@ -97,6 +99,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Route', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 30.2226265, 'longitude': -97.6236871, 'source_type': , diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr index 05bb47a8c36e24..f5dc0cab21a34c 100644 --- a/tests/components/tile/snapshots/test_device_tracker.ambr +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -42,6 +42,8 @@ 'altitude': 0, 'friendly_name': 'Wallet', 'gps_accuracy': 13.496111, + 'in_zones': list([ + ]), 'is_lost': False, 'last_lost_timestamp': datetime.datetime(1970, 1, 1, 3, 0, tzinfo=datetime.timezone.utc), 'last_timestamp': datetime.datetime(2020, 8, 13, 0, 55, 26, tzinfo=datetime.timezone.utc), diff --git a/tests/components/traccar_server/snapshots/test_diagnostics.ambr b/tests/components/traccar_server/snapshots/test_diagnostics.ambr index 39e67db8df7b89..40b4bb4bb0865e 100644 --- a/tests/components/traccar_server/snapshots/test_diagnostics.ambr +++ b/tests/components/traccar_server/snapshots/test_diagnostics.ambr @@ -103,6 +103,8 @@ 'custom_attr_1': 'custom_attr_1_value', 'friendly_name': 'X-Wing', 'gps_accuracy': 3.5, + 'in_zones': list([ + ]), 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', 'source_type': 'gps', @@ -397,6 +399,8 @@ 'custom_attr_1': 'custom_attr_1_value', 'friendly_name': 'X-Wing', 'gps_accuracy': 3.5, + 'in_zones': list([ + ]), 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', 'source_type': 'gps', diff --git a/tests/components/tractive/snapshots/test_device_tracker.ambr b/tests/components/tractive/snapshots/test_device_tracker.ambr index afb0c16fce3e30..96f616182a296f 100644 --- a/tests/components/tractive/snapshots/test_device_tracker.ambr +++ b/tests/components/tractive/snapshots/test_device_tracker.ambr @@ -42,6 +42,8 @@ 'battery_level': 88, 'friendly_name': 'Test Pet Tracker', 'gps_accuracy': 99, + 'in_zones': list([ + ]), 'latitude': 22.333, 'longitude': 44.555, 'source_type': , diff --git a/tests/components/volvo/snapshots/test_device_tracker.ambr b/tests/components/volvo/snapshots/test_device_tracker.ambr index 91bb849b0e55bf..63da19b122fdf9 100644 --- a/tests/components/volvo/snapshots/test_device_tracker.ambr +++ b/tests/components/volvo/snapshots/test_device_tracker.ambr @@ -41,6 +41,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Volvo EX30 Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 57.72537482589284, 'longitude': 11.849843629550225, 'source_type': , @@ -95,6 +97,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Volvo S90 Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 57.72537482589284, 'longitude': 11.849843629550225, 'source_type': , @@ -149,6 +153,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Volvo XC40 Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 57.72537482589284, 'longitude': 11.849843629550225, 'source_type': , @@ -203,6 +209,8 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Volvo XC90 Location', 'gps_accuracy': 0, + 'in_zones': list([ + ]), 'latitude': 57.72537482589284, 'longitude': 11.849843629550225, 'source_type': , diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py index 434ec9ccd2ff3a..edd77d9e3c0b28 100644 --- a/tests/components/zone/test_init.py +++ b/tests/components/zone/test_init.py @@ -126,8 +126,11 @@ async def test_active_zone_skips_passive_zones(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - active = zone.async_active_zone(hass, 32.880600, -117.237561) - assert active is None + active_zone = zone.async_active_zone(hass, 32.880600, -117.237561) + assert active_zone is None + active_zone, in_zones = zone.async_in_zones(hass, 32.880600, -117.237561) + assert active_zone is None + assert in_zones == ["zone.passive_zone"] async def test_active_zone_skips_passive_zones_2(hass: HomeAssistant) -> None: @@ -147,8 +150,11 @@ async def test_active_zone_skips_passive_zones_2(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - active = zone.async_active_zone(hass, 32.880700, -117.237561) - assert active.entity_id == "zone.active_zone" + active_zone = zone.async_active_zone(hass, 32.880700, -117.237561) + assert active_zone.entity_id == "zone.active_zone" + active_zone, in_zones = zone.async_in_zones(hass, 32.880600, -117.237561) + assert active_zone.entity_id == "zone.active_zone" + assert in_zones == ["zone.active_zone"] async def test_active_zone_prefers_smaller_zone_if_same_distance( @@ -178,8 +184,11 @@ async def test_active_zone_prefers_smaller_zone_if_same_distance( }, ) - active = zone.async_active_zone(hass, latitude, longitude) - assert active.entity_id == "zone.small_zone" + active_zone = zone.async_active_zone(hass, latitude, longitude) + assert active_zone.entity_id == "zone.small_zone" + active_zone, in_zones = zone.async_in_zones(hass, latitude, longitude) + assert active_zone.entity_id == "zone.small_zone" + assert in_zones == ["zone.small_zone", "zone.big_zone"] async def test_active_zone_prefers_smaller_zone_if_same_distance_2( @@ -203,8 +212,11 @@ async def test_active_zone_prefers_smaller_zone_if_same_distance_2( }, ) - active = zone.async_active_zone(hass, latitude, longitude) - assert active.entity_id == "zone.smallest_zone" + active_zone = zone.async_active_zone(hass, latitude, longitude) + assert active_zone.entity_id == "zone.smallest_zone" + active_zone, in_zones = zone.async_in_zones(hass, latitude, longitude) + assert active_zone.entity_id == "zone.smallest_zone" + assert in_zones == ["zone.smallest_zone"] async def test_in_zone_works_for_passive_zones(hass: HomeAssistant) -> None: @@ -263,11 +275,17 @@ async def test_async_active_zone_with_non_zero_radius( assert home_state.attributes["latitude"] == 32.87336 assert home_state.attributes["longitude"] == -117.22743 - active = zone.async_active_zone(hass, latitude, longitude, 5000) - assert active.entity_id == "zone.home" + active_zone = zone.async_active_zone(hass, latitude, longitude, 5000) + assert active_zone.entity_id == "zone.home" + active_zone, in_zones = zone.async_in_zones(hass, latitude, longitude, 5000) + assert active_zone.entity_id == "zone.home" + assert in_zones == ["zone.home", "zone.small_zone", "zone.big_zone"] - active = zone.async_active_zone(hass, latitude, longitude, 0) - assert active.entity_id == "zone.small_zone" + active_zone = zone.async_active_zone(hass, latitude, longitude, 0) + assert active_zone.entity_id == "zone.small_zone" + active_zone, in_zones = zone.async_in_zones(hass, latitude, longitude, 0) + assert active_zone.entity_id == "zone.small_zone" + assert in_zones == ["zone.small_zone", "zone.big_zone"] async def test_core_config_update(hass: HomeAssistant) -> None: @@ -567,6 +585,9 @@ async def test_unavailable_zone(hass: HomeAssistant) -> None: hass.states.async_set("zone.bla", "unavailable", {"restored": True}) assert zone.async_active_zone(hass, 0.0, 0.01) is None + active_zone, in_zones = zone.async_in_zones(hass, 0.0, 0.01) + assert active_zone is None + assert in_zones == [] assert zone.in_zone(hass.states.get("zone.bla"), 0, 0) is False From a485c3d410b7aed44a0714653780a3b816a66514 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:00:06 +0200 Subject: [PATCH 0377/1707] Migrate prosegur to use runtime_data (#167161) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/prosegur/__init__.py | 21 ++++++++----------- .../prosegur/alarm_control_panel.py | 8 +++---- homeassistant/components/prosegur/camera.py | 11 +++++----- .../components/prosegur/diagnostics.py | 10 ++++----- tests/components/prosegur/conftest.py | 2 +- 5 files changed, 24 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py index bf2aad451df06f..1fb89f3b370308 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -10,25 +10,24 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import DOMAIN - PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] +type ProsegurConfigEntry = ConfigEntry[Auth] + _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ProsegurConfigEntry) -> bool: """Set up Prosegur Alarm from a config entry.""" try: session = aiohttp_client.async_get_clientsession(hass) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = Auth( + auth = Auth( session, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], entry.data[CONF_COUNTRY], ) - await hass.data[DOMAIN][entry.entry_id].login() + await auth.login() except ConnectionRefusedError as error: _LOGGER.error("Configured credential are invalid, %s", error) @@ -39,15 +38,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Could not connect with Prosegur backend: %s", error) raise ConfigEntryNotReady from error + entry.runtime_data = auth + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ProsegurConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 1f0f89c5f04eb0..335737b40edd47 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -12,12 +12,12 @@ AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN +from . import ProsegurConfigEntry +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,12 +31,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ProsegurConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Prosegur alarm control panel platform.""" async_add_entities( - [ProsegurAlarm(entry.data["contract"], hass.data[DOMAIN][entry.entry_id])], + [ProsegurAlarm(entry.data["contract"], entry.runtime_data)], update_before_add=True, ) diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 3e1c91713e1b10..59bae6f71f01ab 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -9,7 +9,6 @@ from pyprosegur.installation import Camera as InstallationCamera, Installation from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( @@ -17,15 +16,15 @@ async_get_current_platform, ) -from . import DOMAIN -from .const import SERVICE_REQUEST_IMAGE +from . import ProsegurConfigEntry +from .const import DOMAIN, SERVICE_REQUEST_IMAGE _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ProsegurConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Prosegur camera platform.""" @@ -38,12 +37,12 @@ async def async_setup_entry( ) _installation = await Installation.retrieve( - hass.data[DOMAIN][entry.entry_id], entry.data["contract"] + entry.runtime_data, entry.data["contract"] ) async_add_entities( [ - ProsegurCamera(_installation, camera, hass.data[DOMAIN][entry.entry_id]) + ProsegurCamera(_installation, camera, entry.runtime_data) for camera in _installation.cameras ], update_before_add=True, diff --git a/homeassistant/components/prosegur/diagnostics.py b/homeassistant/components/prosegur/diagnostics.py index ec13f5511a461e..944a84c3acbb3f 100644 --- a/homeassistant/components/prosegur/diagnostics.py +++ b/homeassistant/components/prosegur/diagnostics.py @@ -7,24 +7,24 @@ from pyprosegur.installation import Installation from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import CONF_CONTRACT, DOMAIN +from . import ProsegurConfigEntry +from .const import CONF_CONTRACT TO_REDACT = {"description", "latitude", "longitude", "contractId", "address"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ProsegurConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" installation = await Installation.retrieve( - hass.data[DOMAIN][entry.entry_id], entry.data[CONF_CONTRACT] + entry.runtime_data, entry.data[CONF_CONTRACT] ) - activity = await installation.activity(hass.data[DOMAIN][entry.entry_id]) + activity = await installation.activity(entry.runtime_data) return { "installation": async_redact_data(installation.data, TO_REDACT), diff --git a/tests/components/prosegur/conftest.py b/tests/components/prosegur/conftest.py index 65ef8e5d9c325a..f6d4f58d7749e6 100644 --- a/tests/components/prosegur/conftest.py +++ b/tests/components/prosegur/conftest.py @@ -5,7 +5,7 @@ from pyprosegur.installation import Camera import pytest -from homeassistant.components.prosegur import DOMAIN +from homeassistant.components.prosegur.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant From 8ab3d482b9fbfc339b8242709927394a23742220 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:48:06 +0200 Subject: [PATCH 0378/1707] Migrate prusalink to use runtime_data (#167164) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/prusalink/__init__.py | 12 +++++------- .../components/prusalink/binary_sensor.py | 10 +++------- homeassistant/components/prusalink/button.py | 14 ++++---------- homeassistant/components/prusalink/camera.py | 10 ++++------ homeassistant/components/prusalink/coordinator.py | 7 +++++-- homeassistant/components/prusalink/sensor.py | 10 +++------- 6 files changed, 24 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 4bb7dee411d355..d181502acccd05 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -24,6 +24,7 @@ InfoUpdateCoordinator, JobUpdateCoordinator, LegacyStatusCoordinator, + PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator, StatusCoordinator, ) @@ -36,7 +37,7 @@ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PrusaLinkConfigEntry) -> bool: """Set up PrusaLink from a config entry.""" if entry.version == 1 and entry.minor_version < 2: raise ConfigEntryError("Please upgrade your printer's firmware.") @@ -57,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -120,9 +121,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PrusaLinkConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/prusalink/binary_sensor.py b/homeassistant/components/prusalink/binary_sensor.py index 56be36c3e9d78a..fff24eef19502e 100644 --- a/homeassistant/components/prusalink/binary_sensor.py +++ b/homeassistant/components/prusalink/binary_sensor.py @@ -13,12 +13,10 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import PrusaLinkUpdateCoordinator +from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator from .entity import PrusaLinkEntity T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) @@ -56,13 +54,11 @@ class PrusaLinkBinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PrusaLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink sensor based on a config entry.""" - coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinators = entry.runtime_data entities: list[PrusaLinkEntity] = [] for coordinator_type, binary_sensors in BINARY_SENSORS.items(): diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index 59a63d874ee89d..a619204eb86e33 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -10,13 +10,11 @@ from pyprusalink.types import Conflict, PrinterState from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import PrusaLinkUpdateCoordinator +from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator from .entity import PrusaLinkEntity T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) @@ -71,13 +69,11 @@ class PrusaLinkButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PrusaLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink buttons based on a config entry.""" - coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinators = entry.runtime_data entities: list[PrusaLinkEntity] = [] @@ -124,9 +120,7 @@ async def async_press(self) -> None: "Action conflicts with current printer state" ) from err - coordinators: dict[str, PrusaLinkUpdateCoordinator] = self.hass.data[DOMAIN][ - self.coordinator.config_entry.entry_id - ] + coordinators = self.coordinator.config_entry.runtime_data for coordinator in coordinators.values(): coordinator.expect_change() diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index 6aac03ca1798ed..0ab5d517d57443 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -5,22 +5,20 @@ from pyprusalink.types import PrinterState from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import JobUpdateCoordinator +from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator from .entity import PrusaLinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PrusaLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink camera.""" - coordinator: JobUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["job"] + coordinator = entry.runtime_data["job"] async_add_entities([PrusaLinkJobPreviewEntity(coordinator)]) @@ -31,7 +29,7 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): last_image: bytes _attr_translation_key = "job_preview" - def __init__(self, coordinator: JobUpdateCoordinator) -> None: + def __init__(self, coordinator: PrusaLinkUpdateCoordinator) -> None: """Initialize a PrusaLink camera entity.""" super().__init__(coordinator) Camera.__init__(self) diff --git a/homeassistant/components/prusalink/coordinator.py b/homeassistant/components/prusalink/coordinator.py index 8d994fa728ae37..e50ef66815be1c 100644 --- a/homeassistant/components/prusalink/coordinator.py +++ b/homeassistant/components/prusalink/coordinator.py @@ -35,14 +35,17 @@ T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) +type PrusaLinkConfigEntry = ConfigEntry[dict[str, PrusaLinkUpdateCoordinator]] + + class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): """Update coordinator for the printer.""" - config_entry: ConfigEntry + config_entry: PrusaLinkConfigEntry expect_change_until = 0.0 def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: PrusaLink + self, hass: HomeAssistant, config_entry: PrusaLinkConfigEntry, api: PrusaLink ) -> None: """Initialize the update coordinator.""" self.api = api diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index cf4818e111eca9..dbfcb8886bc5b8 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -16,7 +16,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -29,8 +28,7 @@ from homeassistant.util.dt import utcnow from homeassistant.util.variance import ignore_variance -from .const import DOMAIN -from .coordinator import PrusaLinkUpdateCoordinator +from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator from .entity import PrusaLinkEntity T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) @@ -204,13 +202,11 @@ class PrusaLinkSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PrusaLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink sensor based on a config entry.""" - coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinators = entry.runtime_data entities: list[PrusaLinkEntity] = [] From 9f41e3341fca1851cae283733e435c23c9d6ceb8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:48:58 +0200 Subject: [PATCH 0379/1707] Migrate peco to use runtime_data (#167147) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/peco/__init__.py | 30 ++++++++++--------- .../components/peco/binary_sensor.py | 11 ++----- homeassistant/components/peco/coordinator.py | 21 ++++++++++--- homeassistant/components/peco/sensor.py | 7 ++--- tests/components/peco/test_init.py | 2 -- tests/components/peco/test_sensor.py | 2 -- 6 files changed, 39 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index 9dd32ecf14c093..e36de2d6fa9dd2 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -4,37 +4,39 @@ from typing import Final -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_PHONE_NUMBER, DOMAIN -from .coordinator import PecoOutageCoordinator, PecoSmartMeterCoordinator +from .const import CONF_PHONE_NUMBER +from .coordinator import ( + PecoConfigEntry, + PecoOutageCoordinator, + PecoRuntimeData, + PecoSmartMeterCoordinator, +) PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PecoConfigEntry) -> bool: """Set up PECO Outage Counter from a config entry.""" outage_coordinator = PecoOutageCoordinator(hass, entry) await outage_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "outage_count": outage_coordinator - } - + meter_coordinator: PecoSmartMeterCoordinator | None = None if phone_number := entry.data.get(CONF_PHONE_NUMBER): meter_coordinator = PecoSmartMeterCoordinator(hass, entry, phone_number) await meter_coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id]["smart_meter"] = meter_coordinator + + entry.runtime_data = PecoRuntimeData( + outage_coordinator=outage_coordinator, + meter_coordinator=meter_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PecoConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py index 86ec12a399987e..3b80cc81ab1170 100644 --- a/homeassistant/components/peco/binary_sensor.py +++ b/homeassistant/components/peco/binary_sensor.py @@ -8,28 +8,23 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import PecoSmartMeterCoordinator +from .coordinator import PecoConfigEntry, PecoSmartMeterCoordinator PARALLEL_UPDATES: Final = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PecoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor for PECO.""" - if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]: + if (coordinator := config_entry.runtime_data.meter_coordinator) is None: return - coordinator: PecoSmartMeterCoordinator = hass.data[DOMAIN][config_entry.entry_id][ - "smart_meter" - ] async_add_entities( [PecoBinarySensor(coordinator, phone_number=config_entry.data["phone_number"])] diff --git a/homeassistant/components/peco/coordinator.py b/homeassistant/components/peco/coordinator.py index 0ecc6d23ef22b6..9c42cddc5dd050 100644 --- a/homeassistant/components/peco/coordinator.py +++ b/homeassistant/components/peco/coordinator.py @@ -1,5 +1,7 @@ """DataUpdateCoordinator for the PECO Outage Counter integration.""" +from __future__ import annotations + from dataclasses import dataclass from datetime import timedelta @@ -28,12 +30,23 @@ class PECOCoordinatorData: alerts: AlertResults +@dataclass +class PecoRuntimeData: + """Runtime data for the PECO integration.""" + + outage_coordinator: PecoOutageCoordinator + meter_coordinator: PecoSmartMeterCoordinator | None = None + + +type PecoConfigEntry = ConfigEntry[PecoRuntimeData] + + class PecoOutageCoordinator(DataUpdateCoordinator[PECOCoordinatorData]): """Coordinator for PECO outage data.""" - config_entry: ConfigEntry + config_entry: PecoConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: PecoConfigEntry) -> None: """Initialize the outage coordinator.""" super().__init__( hass, @@ -65,10 +78,10 @@ async def _async_update_data(self) -> PECOCoordinatorData: class PecoSmartMeterCoordinator(DataUpdateCoordinator[bool]): """Coordinator for PECO smart meter data.""" - config_entry: ConfigEntry + config_entry: PecoConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, phone_number: str + self, hass: HomeAssistant, entry: PecoConfigEntry, phone_number: str ) -> None: """Initialize the smart meter coordinator.""" super().__init__( diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index a376fa8fc5aace..b7e0b5e733a074 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -11,7 +11,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -19,7 +18,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_CONTENT, CONF_COUNTY, DOMAIN -from .coordinator import PECOCoordinatorData, PecoOutageCoordinator +from .coordinator import PecoConfigEntry, PECOCoordinatorData, PecoOutageCoordinator @dataclass(frozen=True, kw_only=True) @@ -72,12 +71,12 @@ class PECOSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PecoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" county: str = config_entry.data[CONF_COUNTY] - coordinator = hass.data[DOMAIN][config_entry.entry_id]["outage_count"] + coordinator = config_entry.runtime_data.outage_coordinator async_add_entities( PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST diff --git a/tests/components/peco/test_init.py b/tests/components/peco/test_init.py index 22d2233093aa76..716d1203cae267 100644 --- a/tests/components/peco/test_init.py +++ b/tests/components/peco/test_init.py @@ -47,8 +47,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] - entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED diff --git a/tests/components/peco/test_sensor.py b/tests/components/peco/test_sensor.py index 4c9a3fca104a53..bd02cd1ca2f78b 100644 --- a/tests/components/peco/test_sensor.py +++ b/tests/components/peco/test_sensor.py @@ -53,8 +53,6 @@ async def test_sensor_available( ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] - entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert config_entry.state is ConfigEntryState.LOADED From d78c05ab62baea8a06f19bc7d06e60ed0e7435cc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Apr 2026 14:09:57 +0200 Subject: [PATCH 0380/1707] Propagate the in_zones attribute from device trackers in person entities (#167192) --- homeassistant/components/person/__init__.py | 5 +++++ tests/components/person/test_init.py | 25 +++++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 610d77aefd2b22..2fc04785812071 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -11,6 +11,7 @@ from homeassistant.auth import EVENT_USER_REMOVED from homeassistant.components import persistent_notification, websocket_api from homeassistant.components.device_tracker import ( + ATTR_IN_ZONES, ATTR_SOURCE_TYPE, DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, @@ -435,6 +436,7 @@ def __init__(self, config: dict[str, Any]) -> None: self._unsub_track_device: Callable[[], None] | None = None self._attr_state: str | None = None self.device_trackers: list[str] = [] + self._in_zones: list[str] = [] self._attr_unique_id = config[CONF_ID] self._set_attrs_from_config() @@ -552,6 +554,7 @@ def _update_state(self) -> None: self._latitude = None self._longitude = None self._gps_accuracy = None + self._in_zones = [] self._update_extra_state_attributes() self.async_write_ha_state() @@ -567,6 +570,7 @@ def _parse_source_state(self, state: State, coordinates: State) -> None: self._latitude = coordinates.attributes.get(ATTR_LATITUDE) self._longitude = coordinates.attributes.get(ATTR_LONGITUDE) self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY) + self._in_zones = coordinates.attributes.get(ATTR_IN_ZONES, []) @callback def _update_extra_state_attributes(self) -> None: @@ -575,6 +579,7 @@ def _update_extra_state_attributes(self) -> None: ATTR_EDITABLE: self.editable, ATTR_ID: self.unique_id, ATTR_DEVICE_TRACKERS: self.device_trackers, + ATTR_IN_ZONES: self._in_zones, } if self._latitude is not None: diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index a4ddf45d101bf1..9e2f26e0862fd2 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -6,7 +6,11 @@ import pytest from homeassistant.components import person -from homeassistant.components.device_tracker import ATTR_SOURCE_TYPE, SourceType +from homeassistant.components.device_tracker import ( + ATTR_IN_ZONES, + ATTR_SOURCE_TYPE, + SourceType, +) from homeassistant.components.person import ( ATTR_DEVICE_TRACKERS, ATTR_SOURCE, @@ -119,6 +123,7 @@ async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> ATTR_EDITABLE: False, ATTR_FRIENDLY_NAME: "tracked person", ATTR_ID: "1234", + ATTR_IN_ZONES: [], ATTR_USER_ID: user_id, } @@ -223,10 +228,17 @@ async def test_setup_two_trackers( # gps_accuracy, but we want to assert that the person entity uses latitude # longitude and accuracy from the home zone, not from the state attributes # of the device tracker. + # Router tracker at home — person gets coordinates from the home zone, + # not from the router tracker. The router tracker has gps_accuracy=99 + # and in_zones=["zone.fake"] to verify these are NOT propagated. hass.states.async_set( DEVICE_TRACKER, "home", - {ATTR_SOURCE_TYPE: SourceType.ROUTER, ATTR_GPS_ACCURACY: 99}, + { + ATTR_SOURCE_TYPE: SourceType.ROUTER, + ATTR_GPS_ACCURACY: 99, + ATTR_IN_ZONES: ["zone.fake"], + }, ) await hass.async_block_till_done() @@ -235,9 +247,10 @@ async def test_setup_two_trackers( assert state.attributes.get(ATTR_ID) == "1234" assert state.attributes.get(ATTR_LATITUDE) == 32.87336 assert state.attributes.get(ATTR_LONGITUDE) == -117.22743 - # GPS accuracy comes from the coordinates source (home zone), not from - # the state source (router tracker which reported gps_accuracy=99). + # GPS accuracy and in_zones come from the coordinates source (home zone), + # not from the state source (router tracker). assert state.attributes.get(ATTR_GPS_ACCURACY) is None + assert state.attributes.get(ATTR_IN_ZONES) == [] assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER assert state.attributes.get(ATTR_USER_ID) == user_id assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ @@ -252,6 +265,7 @@ async def test_setup_two_trackers( ATTR_LATITUDE: 12.123456, ATTR_LONGITUDE: 13.123456, ATTR_GPS_ACCURACY: 12, + ATTR_IN_ZONES: ["zone.work"], ATTR_SOURCE_TYPE: SourceType.GPS, }, ) @@ -267,6 +281,7 @@ async def test_setup_two_trackers( assert state.attributes.get(ATTR_LATITUDE) == 12.123456 assert state.attributes.get(ATTR_LONGITUDE) == 13.123456 assert state.attributes.get(ATTR_GPS_ACCURACY) == 12 + assert state.attributes.get(ATTR_IN_ZONES) == ["zone.work"] assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2 assert state.attributes.get(ATTR_USER_ID) == user_id assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ @@ -346,6 +361,7 @@ async def test_setup_router_ble_trackers( ATTR_LATITUDE: 12.123456, ATTR_LONGITUDE: 13.123456, ATTR_GPS_ACCURACY: 12, + ATTR_IN_ZONES: ["zone.office"], ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, }, ) @@ -358,6 +374,7 @@ async def test_setup_router_ble_trackers( assert state.attributes.get(ATTR_LATITUDE) == 12.123456 assert state.attributes.get(ATTR_LONGITUDE) == 13.123456 assert state.attributes.get(ATTR_GPS_ACCURACY) == 12 + assert state.attributes.get(ATTR_IN_ZONES) == ["zone.office"] assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2 assert state.attributes.get(ATTR_USER_ID) == user_id assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ From f7ee95c4b9035716f7f81cf429345feff04ace0a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:11:33 +0200 Subject: [PATCH 0381/1707] Bump sigstore/cosign-installer from 4.1.0 to 4.1.1 (#167156) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 4a9b7033842296..0400f1a0f899b6 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -342,7 +342,7 @@ jobs: registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] steps: - name: Install Cosign - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 with: cosign-release: "v2.5.3" From 0ebe65c25be1cbdcf18245109fca40dcd89a1a48 Mon Sep 17 00:00:00 2001 From: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:32:57 +0200 Subject: [PATCH 0382/1707] Fix hydrawise crashes when controllers/zones are added (#166708) --- homeassistant/components/hydrawise/entity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 58153d436345e0..465da27a778e31 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -69,6 +69,10 @@ def _update_attrs(self) -> None: @callback def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" + # Guard against updates arriving after the controller has been removed + # but before the entity has been unsubscribed from the coordinator. + if self.controller.id not in self.coordinator.data.controllers: + return self.controller = self.coordinator.data.controllers[self.controller.id] self._update_attrs() super()._handle_coordinator_update() From cca44c675ce4562b0c6fae46b6e0da333cc6a527 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 2 Apr 2026 14:41:26 +0200 Subject: [PATCH 0383/1707] Fix spelling of "cannot" in `climate` exception string (#167139) --- homeassistant/components/climate/strings.json | 2 +- tests/components/climate/test_init.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 2c2947c15ee28c..52e02b11193070 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -239,7 +239,7 @@ "message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}." }, "low_temp_higher_than_high_temp": { - "message": "'Lower target temperature' can not be higher than 'Upper target temperature'." + "message": "'Lower target temperature' cannot be higher than 'Upper target temperature'." }, "missing_target_temperature_entity_feature": { "message": "Set temperature action was used with the 'Target temperature' parameter but the entity does not support it." diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index b1186a4f8143c1..0c2b4e83e0567a 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -705,7 +705,7 @@ def set_temperature(self, **kwargs: Any) -> None: with pytest.raises( ServiceValidationError, - match="'Lower target temperature' can not be higher than 'Upper target temperature'", + match="'Lower target temperature' cannot be higher than 'Upper target temperature'", ) as exc: await hass.services.async_call( DOMAIN, @@ -719,6 +719,6 @@ def set_temperature(self, **kwargs: Any) -> None: ) assert ( str(exc.value) - == "'Lower target temperature' can not be higher than 'Upper target temperature'" + == "'Lower target temperature' cannot be higher than 'Upper target temperature'" ) assert exc.value.translation_key == "low_temp_higher_than_high_temp" From 78f5989cd69b30aa994275d9aae3d7b0e3c19747 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:44:35 +0200 Subject: [PATCH 0384/1707] Migrate permobil to use runtime_data (#167170) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/permobil/__init__.py | 16 ++++++---------- .../components/permobil/binary_sensor.py | 8 +++----- homeassistant/components/permobil/coordinator.py | 6 ++++-- homeassistant/components/permobil/sensor.py | 9 ++++----- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py index 441c6a2646e181..683dd6aa7ea66c 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -6,7 +6,6 @@ from mypermobil import MyPermobil, MyPermobilClientException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CODE, CONF_EMAIL, @@ -19,15 +18,15 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import APPLICATION, DOMAIN -from .coordinator import MyPermobilCoordinator +from .const import APPLICATION +from .coordinator import MyPermobilCoordinator, PermobilConfigEntry PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PermobilConfigEntry) -> bool: """Set up MyPermobil from a config entry.""" # create the API object from the config and save it in hass @@ -51,15 +50,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = MyPermobilCoordinator(hass, entry, p_api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PermobilConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py index c2d51067e19a6b..2d167d2952496b 100644 --- a/homeassistant/components/permobil/binary_sensor.py +++ b/homeassistant/components/permobil/binary_sensor.py @@ -8,7 +8,6 @@ from mypermobil import BATTERY_CHARGING -from homeassistant import config_entries from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, @@ -16,8 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MyPermobilCoordinator +from .coordinator import PermobilConfigEntry from .entity import PermobilEntity @@ -41,12 +39,12 @@ class PermobilBinarySensorEntityDescription(BinarySensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: PermobilConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create and setup the binary sensor.""" - coordinator: MyPermobilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( PermobilbinarySensor(coordinator=coordinator, description=description) diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py index ea7ddadff9fb5f..16a5d93751d641 100644 --- a/homeassistant/components/permobil/coordinator.py +++ b/homeassistant/components/permobil/coordinator.py @@ -13,6 +13,8 @@ _LOGGER = logging.getLogger(__name__) +type PermobilConfigEntry = ConfigEntry[MyPermobilCoordinator] + @dataclass class MyPermobilData: @@ -26,10 +28,10 @@ class MyPermobilData: class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): """MyPermobil coordinator.""" - config_entry: ConfigEntry + config_entry: PermobilConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, p_api: MyPermobil + self, hass: HomeAssistant, config_entry: PermobilConfigEntry, p_api: MyPermobil ) -> None: """Initialize my coordinator.""" super().__init__( diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index 8445bf8b4462ab..fc58407a5f97f9 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -23,7 +23,6 @@ USAGE_DISTANCE, ) -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -34,8 +33,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN, KM, MILES -from .coordinator import MyPermobilCoordinator +from .const import BATTERY_ASSUMED_VOLTAGE, KM, MILES +from .coordinator import PermobilConfigEntry from .entity import PermobilEntity _LOGGER = logging.getLogger(__name__) @@ -176,12 +175,12 @@ class PermobilSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: PermobilConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create sensors from a config entry created in the integrations UI.""" - coordinator: MyPermobilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( PermobilSensor(coordinator=coordinator, description=description) From 2179a5405a7536c808690d06915493d5d8107bca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:45:08 +0200 Subject: [PATCH 0385/1707] Migrate progettihwsw to use runtime_data (#167157) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/progettihwsw/__init__.py | 27 +++++++++---------- .../components/progettihwsw/binary_sensor.py | 11 ++++---- .../components/progettihwsw/switch.py | 11 ++++---- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index 4d090f4d0c1897..32dbb5bd41d66d 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -8,33 +8,32 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] +type ProgettiHWSWConfigEntry = ConfigEntry[ProgettiHWSWAPI] + -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: ProgettiHWSWConfigEntry +) -> bool: """Set up ProgettiHWSW Automation from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = ProgettiHWSWAPI( - f"{entry.data['host']}:{entry.data['port']}" - ) + api = ProgettiHWSWAPI(f"{entry.data['host']}:{entry.data['port']}") # Check board validation again to load new values to API. - await hass.data[DOMAIN][entry.entry_id].check_board() + await api.check_board() + + entry.runtime_data = api await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ProgettiHWSWConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def setup_input(api: ProgettiHWSWAPI, input_number: int) -> Input: diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index aeec792cff1b7b..26643065994fc7 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -7,7 +7,6 @@ from ProgettiHWSW.input import Input from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -15,19 +14,19 @@ DataUpdateCoordinator, ) -from . import setup_input -from .const import DEFAULT_POLLING_INTERVAL_SEC, DOMAIN +from . import ProgettiHWSWConfigEntry, setup_input +from .const import DEFAULT_POLLING_INTERVAL_SEC -_LOGGER = logging.getLogger(DOMAIN) +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ProgettiHWSWConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensors from a config entry.""" - board_api = hass.data[DOMAIN][config_entry.entry_id] + board_api = config_entry.runtime_data input_count = config_entry.data["input_count"] async def async_update_data(): diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index b2f00d52439ca9..06a8f6d0d662f3 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -8,7 +8,6 @@ from ProgettiHWSW.relay import Relay from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -16,19 +15,19 @@ DataUpdateCoordinator, ) -from . import setup_switch -from .const import DEFAULT_POLLING_INTERVAL_SEC, DOMAIN +from . import ProgettiHWSWConfigEntry, setup_switch +from .const import DEFAULT_POLLING_INTERVAL_SEC -_LOGGER = logging.getLogger(DOMAIN) +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ProgettiHWSWConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switches from a config entry.""" - board_api = hass.data[DOMAIN][config_entry.entry_id] + board_api = config_entry.runtime_data relay_count = config_entry.data["relay_count"] async def async_update_data(): From b11292385f417fcbc1bf74ec3fcae7898a3e2084 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:45:19 +0200 Subject: [PATCH 0386/1707] Use runtime_data in ovo_energy (#167141) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/ovo_energy/__init__.py | 26 +++++-------------- homeassistant/components/ovo_energy/const.py | 2 -- .../components/ovo_energy/coordinator.py | 6 +++-- homeassistant/components/ovo_energy/entity.py | 15 ++--------- homeassistant/components/ovo_energy/sensor.py | 24 +++++++---------- 5 files changed, 23 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index ec5d1c7cafa1a3..b496f7ca92f946 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -7,21 +7,20 @@ import aiohttp from ovoenergy import OVOEnergy -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_ACCOUNT, DATA_CLIENT, DATA_COORDINATOR, DOMAIN -from .coordinator import OVOEnergyDataUpdateCoordinator +from .const import CONF_ACCOUNT +from .coordinator import OVOEnergyConfigEntry, OVOEnergyDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OVOEnergyConfigEntry) -> bool: """Set up OVO Energy from a config entry.""" client = OVOEnergy( @@ -45,26 +44,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = OVOEnergyDataUpdateCoordinator(hass, entry, client) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_CLIENT: client, - DATA_COORDINATOR: coordinator, - } - - # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - # Setup components + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OVOEnergyConfigEntry) -> bool: """Unload OVO Energy config entry.""" - # Unload sensors - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - del hass.data[DOMAIN][entry.entry_id] - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ovo_energy/const.py b/homeassistant/components/ovo_energy/const.py index 2d615e7c44a495..e1cf957b992713 100644 --- a/homeassistant/components/ovo_energy/const.py +++ b/homeassistant/components/ovo_energy/const.py @@ -2,6 +2,4 @@ DOMAIN = "ovo_energy" -DATA_CLIENT = "ovo_client" -DATA_COORDINATOR = "coordinator" CONF_ACCOUNT = "account" diff --git a/homeassistant/components/ovo_energy/coordinator.py b/homeassistant/components/ovo_energy/coordinator.py index 6d06fd56092c95..7b41de0b33841a 100644 --- a/homeassistant/components/ovo_energy/coordinator.py +++ b/homeassistant/components/ovo_energy/coordinator.py @@ -21,16 +21,18 @@ _LOGGER = logging.getLogger(__name__) +type OVOEnergyConfigEntry = ConfigEntry[OVOEnergyDataUpdateCoordinator] + class OVOEnergyDataUpdateCoordinator(DataUpdateCoordinator[OVODailyUsage]): """Class to manage fetching OVO Energy data.""" - config_entry: ConfigEntry + config_entry: OVOEnergyConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OVOEnergyConfigEntry, client: OVOEnergy, ) -> None: """Initialize.""" diff --git a/homeassistant/components/ovo_energy/entity.py b/homeassistant/components/ovo_energy/entity.py index 1839f0bae4c341..d3efc151b59644 100644 --- a/homeassistant/components/ovo_energy/entity.py +++ b/homeassistant/components/ovo_energy/entity.py @@ -2,8 +2,6 @@ from __future__ import annotations -from ovoenergy import OVOEnergy - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -16,15 +14,6 @@ class OVOEnergyEntity(CoordinatorEntity[OVOEnergyDataUpdateCoordinator]): _attr_has_entity_name = True - def __init__( - self, - coordinator: OVOEnergyDataUpdateCoordinator, - client: OVOEnergy, - ) -> None: - """Initialize the OVO Energy entity.""" - super().__init__(coordinator) - self._client = client - class OVOEnergyDeviceEntity(OVOEnergyEntity): """Defines a OVO Energy device entity.""" @@ -34,7 +23,7 @@ def device_info(self) -> DeviceInfo: """Return device information about this OVO Energy instance.""" return DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._client.account_id)}, + identifiers={(DOMAIN, self.coordinator.client.account_id)}, manufacturer="OVO Energy", - name=self._client.username, + name=self.coordinator.client.username, ) diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index a42e193e4b5dd0..32e7e5743f0bc2 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -7,7 +7,6 @@ from datetime import datetime, timedelta from typing import Final -from ovoenergy import OVOEnergy from ovoenergy.models import OVODailyUsage from homeassistant.components.sensor import ( @@ -16,15 +15,14 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN -from .coordinator import OVOEnergyDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import OVOEnergyConfigEntry, OVOEnergyDataUpdateCoordinator from .entity import OVOEnergyDeviceEntity SCAN_INTERVAL = timedelta(seconds=300) @@ -114,14 +112,11 @@ class OVOEnergySensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OVOEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OVO Energy sensor based on a config entry.""" - coordinator: OVOEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] - client: OVOEnergy = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + coordinator = entry.runtime_data entities = [] @@ -139,7 +134,7 @@ async def async_setup_entry( coordinator.data.electricity[-1].cost.currency_unit ), ) - entities.append(OVOEnergySensor(coordinator, description, client)) + entities.append(OVOEnergySensor(coordinator, description)) if coordinator.data.gas: for description in SENSOR_TYPES_GAS: if ( @@ -153,7 +148,7 @@ async def async_setup_entry( -1 ].cost.currency_unit, ) - entities.append(OVOEnergySensor(coordinator, description, client)) + entities.append(OVOEnergySensor(coordinator, description)) async_add_entities(entities, True) @@ -167,11 +162,12 @@ def __init__( self, coordinator: OVOEnergyDataUpdateCoordinator, description: OVOEnergySensorEntityDescription, - client: OVOEnergy, ) -> None: """Initialize.""" - super().__init__(coordinator, client) - self._attr_unique_id = f"{DOMAIN}_{client.account_id}_{description.key}" + super().__init__(coordinator) + self._attr_unique_id = ( + f"{DOMAIN}_{coordinator.client.account_id}_{description.key}" + ) self.entity_description = description @property From 5a72dc8eca4177d2dafa9bfccf5cb1397bba6b17 Mon Sep 17 00:00:00 2001 From: Kurt Chrisford <92524101+kclif9@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:45:42 +1000 Subject: [PATCH 0387/1707] Add exception translations to Actron Air (#167159) --- homeassistant/components/actron_air/__init__.py | 5 ++++- homeassistant/components/actron_air/quality_scale.yaml | 2 +- homeassistant/components/actron_air/strings.json | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/actron_air/__init__.py b/homeassistant/components/actron_air/__init__.py index 7048e76512fbe9..456c34ff6fb08f 100644 --- a/homeassistant/components/actron_air/__init__.py +++ b/homeassistant/components/actron_air/__init__.py @@ -36,7 +36,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> translation_key="auth_error", ) from err except ActronAirAPIError as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_connection_error", + ) from err system_coordinators: dict[str, ActronAirSystemCoordinator] = {} for system in systems: diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml index 240b3e4b185175..35107d899df010 100644 --- a/homeassistant/components/actron_air/quality_scale.yaml +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -64,7 +64,7 @@ rules: status: exempt comment: Not required for this integration at this stage. entity-translations: todo - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/actron_air/strings.json b/homeassistant/components/actron_air/strings.json index 9e22a6ffb86f2b..bdde514f2231cb 100644 --- a/homeassistant/components/actron_air/strings.json +++ b/homeassistant/components/actron_air/strings.json @@ -55,6 +55,9 @@ "auth_error": { "message": "Authentication failed, please reauthenticate" }, + "setup_connection_error": { + "message": "Failed to connect to the Actron Air API" + }, "update_error": { "message": "An error occurred while retrieving data from the Actron Air API: {error}" } From 0e521eda2e7a698e2c384e791e84575f95cc96cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:46:28 +0200 Subject: [PATCH 0388/1707] Migrate qbittorrent to use runtime_data (#167196) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/qbittorrent/__init__.py | 39 +++++++++++-------- .../components/qbittorrent/coordinator.py | 6 ++- .../components/qbittorrent/sensor.py | 9 ++--- .../components/qbittorrent/switch.py | 9 ++--- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 513b49d35618ec..62f671fc5c4df3 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -5,7 +5,7 @@ from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, CONF_PASSWORD, @@ -27,7 +27,7 @@ STATE_ATTR_TORRENTS, TORRENT_FILTER, ) -from .coordinator import QBittorrentDataCoordinator +from .coordinator import QBittorrentConfigEntry, QBittorrentDataCoordinator from .helpers import format_torrents, setup_client _LOGGER = logging.getLogger(__name__) @@ -68,7 +68,16 @@ async def handle_get_torrents(service_call: ServiceCall) -> dict[str, Any] | Non translation_placeholders={"device_id": entry_id or ""}, ) - coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][entry_id] + entry: QBittorrentConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if entry is None or entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entry_id", + translation_placeholders={"device_id": entry_id}, + ) + coordinator = entry.runtime_data items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER]) info = format_torrents(items) return { @@ -87,10 +96,10 @@ async def handle_get_all_torrents( ) -> dict[str, Any] | None: torrents = {} - for key, value in hass.data[DOMAIN].items(): - coordinator: QBittorrentDataCoordinator = value + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + coordinator: QBittorrentDataCoordinator = entry.runtime_data items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER]) - torrents[key] = format_torrents(items) + torrents[entry.entry_id] = format_torrents(items) return { STATE_ATTR_ALL_TORRENTS: torrents, @@ -106,7 +115,9 @@ async def handle_get_all_torrents( return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: QBittorrentConfigEntry +) -> bool: """Set up qBittorrent from a config entry.""" try: @@ -127,19 +138,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = QBittorrentDataCoordinator(hass, config_entry, client) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: QBittorrentConfigEntry +) -> bool: """Unload qBittorrent config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - del hass.data[DOMAIN][config_entry.entry_id] - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 8fd23fb3b5b9b5..007945a18e731d 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -24,14 +24,16 @@ _LOGGER = logging.getLogger(__name__) +type QBittorrentConfigEntry = ConfigEntry[QBittorrentDataCoordinator] + class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]): """Coordinator for updating QBittorrent data.""" - config_entry: ConfigEntry + config_entry: QBittorrentConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: Client + self, hass: HomeAssistant, config_entry: QBittorrentConfigEntry, client: Client ) -> None: """Initialize coordinator.""" self.client = client diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index afad29a5b731b0..c942dec6e6cc08 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -22,7 +21,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, STATE_DOWNLOADING, STATE_SEEDING, STATE_UP_DOWN -from .coordinator import QBittorrentDataCoordinator +from .coordinator import QBittorrentConfigEntry, QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -236,12 +235,12 @@ class QBittorrentSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: QBittorrentConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" - coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( QBittorrentSensor(coordinator, config_entry, description) @@ -258,7 +257,7 @@ class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEnt def __init__( self, coordinator: QBittorrentDataCoordinator, - config_entry: ConfigEntry, + config_entry: QBittorrentConfigEntry, entity_description: QBittorrentSensorEntityDescription, ) -> None: """Initialize the qBittorrent sensor.""" diff --git a/homeassistant/components/qbittorrent/switch.py b/homeassistant/components/qbittorrent/switch.py index dd61f130ca1582..176e0942b25956 100644 --- a/homeassistant/components/qbittorrent/switch.py +++ b/homeassistant/components/qbittorrent/switch.py @@ -7,14 +7,13 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import QBittorrentDataCoordinator +from .coordinator import QBittorrentConfigEntry, QBittorrentDataCoordinator @dataclass(frozen=True, kw_only=True) @@ -42,12 +41,12 @@ class QBittorrentSwitchEntityDescription(SwitchEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: QBittorrentConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up qBittorrent switch entries.""" - coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( QBittorrentSwitch(coordinator, config_entry, description) @@ -64,7 +63,7 @@ class QBittorrentSwitch(CoordinatorEntity[QBittorrentDataCoordinator], SwitchEnt def __init__( self, coordinator: QBittorrentDataCoordinator, - config_entry: ConfigEntry, + config_entry: QBittorrentConfigEntry, entity_description: QBittorrentSwitchEntityDescription, ) -> None: """Initialize qBittorrent switch.""" From 7f23a35155661540e2825fc32ce1545c49414e02 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:47:09 +0200 Subject: [PATCH 0389/1707] Migrate qnap to use runtime_data (#167198) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/qnap/__init__.py | 20 +++++++------------- homeassistant/components/qnap/coordinator.py | 6 +++++- homeassistant/components/qnap/sensor.py | 11 +++-------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/qnap/__init__.py b/homeassistant/components/qnap/__init__.py index 82e912a60cd1d9..3315eadac76623 100644 --- a/homeassistant/components/qnap/__init__.py +++ b/homeassistant/components/qnap/__init__.py @@ -2,33 +2,27 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import QnapCoordinator +from .coordinator import QnapConfigEntry, QnapCoordinator PLATFORMS: list[Platform] = [ Platform.SENSOR, ] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: QnapConfigEntry) -> bool: """Set the config entry up.""" - hass.data.setdefault(DOMAIN, {}) coordinator = QnapCoordinator(hass, config_entry) - # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: QnapConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py index 8b6cb930b4ffb0..8351727183cb22 100644 --- a/homeassistant/components/qnap/coordinator.py +++ b/homeassistant/components/qnap/coordinator.py @@ -26,6 +26,8 @@ from .const import DOMAIN +type QnapConfigEntry = ConfigEntry[QnapCoordinator] + UPDATE_INTERVAL = timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) @@ -46,7 +48,9 @@ def suppress_insecure_request_warning(): class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Custom coordinator for the qnap integration.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + config_entry: QnapConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: QnapConfigEntry) -> None: """Initialize the qnap coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 381455cb7e17b6..8f47ebf1428fec 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -5,7 +5,6 @@ from datetime import timedelta from typing import Any -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -20,14 +19,13 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import QnapCoordinator +from .coordinator import QnapConfigEntry, QnapCoordinator ATTR_DRIVE = "Drive" ATTR_IP = "IP Address" @@ -247,14 +245,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: QnapConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - coordinator = QnapCoordinator(hass, config_entry) - await coordinator.async_refresh() - if not coordinator.last_update_success: - raise PlatformNotReady + coordinator = config_entry.runtime_data uid = config_entry.unique_id assert uid is not None sensors: list[QNAPSensor] = [] From 406598dbfad223fb14231a3aeacdcd589073d6b4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 2 Apr 2026 14:47:42 +0200 Subject: [PATCH 0390/1707] Fix agreement mismatch and spelling of "cannot" in `nmbs` (#167137) --- homeassistant/components/nmbs/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json index 918087b8d33017..d7bf9ad6209abd 100644 --- a/homeassistant/components/nmbs/strings.json +++ b/homeassistant/components/nmbs/strings.json @@ -7,7 +7,7 @@ "same_station": "[%key:component::nmbs::config::error::same_station%]" }, "error": { - "same_station": "Departure and arrival station can not be the same." + "same_station": "The departure and arrival station cannot be the same." }, "step": { "user": { From e5f4000ac29b0c5b05a475af5948c1efc1374946 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 2 Apr 2026 14:56:13 +0200 Subject: [PATCH 0391/1707] Add manufacturer to Ecowitt device (#167199) --- homeassistant/components/ecowitt/entity.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py index d6e268c3578146..114270e98a6878 100644 --- a/homeassistant/components/ecowitt/entity.py +++ b/homeassistant/components/ecowitt/entity.py @@ -24,11 +24,10 @@ def __init__(self, sensor: EcoWittSensor) -> None: self._attr_unique_id = f"{sensor.station.key}-{sensor.key}" self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, sensor.station.key), - }, + identifiers={(DOMAIN, sensor.station.key)}, name=sensor.station.model, model=sensor.station.model, + manufacturer="Ecowitt", sw_version=sensor.station.version, ) From 03ed46aa07f943f16756abecc0de601537752240 Mon Sep 17 00:00:00 2001 From: tzagim <2285958+tzagim@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:14:58 +0300 Subject: [PATCH 0392/1707] Bump python-telegram-bot to 22.7 (#167062) --- homeassistant/components/telegram_bot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json index 48bf0c3a270478..3feb66ed4e71fe 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["telegram"], "quality_scale": "gold", - "requirements": ["python-telegram-bot[socks]==22.6"] + "requirements": ["python-telegram-bot[socks]==22.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index cb69f0e49005d6..f485b8c811e5cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2684,7 +2684,7 @@ python-tado==0.18.16 python-technove==2.0.0 # homeassistant.components.telegram_bot -python-telegram-bot[socks]==22.6 +python-telegram-bot[socks]==22.7 # homeassistant.components.vlc python-vlc==3.0.18122 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 248022d9ffe3a4..8f13dd5a4bbfce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2283,7 +2283,7 @@ python-tado==0.18.16 python-technove==2.0.0 # homeassistant.components.telegram_bot -python-telegram-bot[socks]==22.6 +python-telegram-bot[socks]==22.7 # homeassistant.components.xbox python-xbox==0.2.0 From 484d9b0cbe0488167cfce2b90fc8a74b42777a69 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 2 Apr 2026 15:36:24 +0200 Subject: [PATCH 0393/1707] Fix test_receive_backup test error when run in isolation (#167204) --- tests/components/backup/test_manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 4d162f12432691..48c80e397f4c77 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -1975,6 +1975,10 @@ async def test_receive_backup( ) -> None: """Test receive backup and upload to the local and a remote agent.""" mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + # Make sure we wait for Platform.EVENT and Platform.SENSOR to be fully processed, + # to avoid interference with the Path.open patching below which is used to verify + # that the file is written to the expected location. + await hass.async_block_till_done(True) client = await hass_client() upload_data = "test" From 26f677dcd1260d4e3804d9274fea006ade02414b Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 2 Apr 2026 15:55:50 +0200 Subject: [PATCH 0394/1707] Portainer refactor async_setup (#166544) --- homeassistant/components/portainer/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index a9f6a23a822892..179f8a84610b8b 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -26,7 +26,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, ContainerState, EndpointStatus @@ -118,13 +118,13 @@ async def _async_setup(self) -> None: translation_placeholders={"error": repr(err)}, ) from err except PortainerConnectionError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", translation_placeholders={"error": repr(err)}, ) from err except PortainerTimeoutError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", translation_placeholders={"error": repr(err)}, From 23469d89507b8538a5f0793bcb851f6a29bc3efa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 2 Apr 2026 15:58:07 +0200 Subject: [PATCH 0395/1707] Remove not implemented supported feature from Wiim (#167205) --- homeassistant/components/wiim/media_player.py | 1 - tests/components/wiim/test_media_player.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py index dbb8d8edb8bee0..d1be658d0c24a0 100644 --- a/homeassistant/components/wiim/media_player.py +++ b/homeassistant/components/wiim/media_player.py @@ -66,7 +66,6 @@ | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE - | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.SEEK ) diff --git a/tests/components/wiim/test_media_player.py b/tests/components/wiim/test_media_player.py index 973f2ecd9fbae3..3445b78c0c1842 100644 --- a/tests/components/wiim/test_media_player.py +++ b/tests/components/wiim/test_media_player.py @@ -80,7 +80,6 @@ async def test_state_machine_updates_from_device_callbacks( | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE - | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.SEEK ) @@ -130,7 +129,6 @@ async def test_state_machine_updates_from_device_callbacks( | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE - | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.REPEAT_SET From 05bd2d05f59daca141eaf21eb6943a8e2073114d Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Thu, 2 Apr 2026 09:59:01 -0400 Subject: [PATCH 0396/1707] Bump pykaleidescape to v1.1.5 (#167203) --- homeassistant/components/kaleidescape/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kaleidescape/manifest.json b/homeassistant/components/kaleidescape/manifest.json index 6996b70bd2dca9..699cbe8dc0d3bf 100644 --- a/homeassistant/components/kaleidescape/manifest.json +++ b/homeassistant/components/kaleidescape/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/kaleidescape", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pykaleidescape==1.1.4"], + "requirements": ["pykaleidescape==1.1.5"], "ssdp": [ { "deviceType": "schemas-upnp-org:device:Basic:1", diff --git a/requirements_all.txt b/requirements_all.txt index f485b8c811e5cb..6fd469be6bb62c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2209,7 +2209,7 @@ pyituran==0.1.5 pyjvcprojector==2.0.3 # homeassistant.components.kaleidescape -pykaleidescape==1.1.4 +pykaleidescape==1.1.5 # homeassistant.components.kira pykira==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f13dd5a4bbfce..9f988ff2d13cff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1892,7 +1892,7 @@ pyituran==0.1.5 pyjvcprojector==2.0.3 # homeassistant.components.kaleidescape -pykaleidescape==1.1.4 +pykaleidescape==1.1.5 # homeassistant.components.kira pykira==0.1.1 From d3a01d4c804882a049f177788edc16cb55d28753 Mon Sep 17 00:00:00 2001 From: 32u-nd <11225377+32u-nd@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:27:40 +0200 Subject: [PATCH 0397/1707] Add millihertz (mHz) to UnitOfFrequency (#167087) Co-authored-by: Ariel Ebersberger --- homeassistant/const.py | 1 + tests/components/knx/snapshots/test_websocket.ambr | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/const.py b/homeassistant/const.py index 34032df2a92c5a..7ba7fcb9f22c96 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -590,6 +590,7 @@ class UnitOfLength(StrEnum): class UnitOfFrequency(StrEnum): """Frequency units.""" + MILLIHERTZ = "mHz" HERTZ = "Hz" KILOHERTZ = "kHz" MEGAHERTZ = "MHz" diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 2e79ae06e466c6..f5879d0d924b4f 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -1801,6 +1801,7 @@ 'm/min', 'm/s', 'mA', + 'mHz', 'mL', 'mL/s', 'mPa', @@ -2144,6 +2145,7 @@ 'm/min', 'm/s', 'mA', + 'mHz', 'mL', 'mL/s', 'mPa', From b9afb2a86163d0441a88126a7511844f0549cf2b Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:02:14 -0400 Subject: [PATCH 0398/1707] Fix Sonos reporting wrong state when media title is whitespace (#167223) --- homeassistant/components/sonos/media.py | 3 +- tests/components/sonos/test_media_player.py | 40 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/media.py b/homeassistant/components/sonos/media.py index 6e8c629560b9d4..36074988de8650 100644 --- a/homeassistant/components/sonos/media.py +++ b/homeassistant/components/sonos/media.py @@ -132,7 +132,8 @@ def set_basic_track_info(self, update_position: bool = False) -> None: self.artist = track_info.get("artist") self.album_name = track_info.get("album") - self.title = track_info.get("title") + title = track_info.get("title") or "" + self.title = title.strip() or None self.image_url = track_info.get("album_art") playlist_position = int(track_info.get("playlist_position", -1)) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index f1ce2496837d0b..084cf4174e40aa 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1175,6 +1175,46 @@ async def test_media_transport( assert getattr(soco, client_call).call_count == 1 +@pytest.mark.parametrize( + ("transport_state", "title", "expected_state"), + [ + ("PAUSED_PLAYBACK", "Something", "paused"), + ("PAUSED_PLAYBACK", None, "idle"), + ("PAUSED_PLAYBACK", " ", "idle"), + ("STOPPED", "Something", "paused"), + ("STOPPED", None, "idle"), + ("STOPPED", " ", "idle"), + ], +) +async def test_state_paused_idle( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, + no_media_event: SonosMockEvent, + transport_state: str, + title: str | None, + expected_state: str, +) -> None: + """Test that idle is returned when title is None or whitespace, paused otherwise.""" + soco.get_current_track_info.return_value = { + "title": title, + "artist": "", + "album": "", + "album_art": "", + "position": "NOT_IMPLEMENTED", + "playlist_position": "1", + "duration": "NOT_IMPLEMENTED", + "uri": "x-file-cifs://192.168.42.10/music/track.mp3", + "metadata": "NOT_IMPLEMENTED", + } + no_media_event.variables["transport_state"] = transport_state + soco.avTransport.subscribe.return_value.callback(no_media_event) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("media_player.zone_a") + assert state.state == expected_state + + async def test_play_media_announce( hass: HomeAssistant, soco: MockSoCo, From b7d32e0650b242bd6707b5952bb83478af28ca29 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:20:17 +0200 Subject: [PATCH 0399/1707] Adjust git commit guidelines for AI agents (#167184) --- .github/copilot-instructions.md | 5 ++--- AGENTS.md | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3bc651eb2f21a1..bca27d97cd82f8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,10 +11,9 @@ This repository contains the core of Home Assistant, a Python 3 based home automation application. -## Code Review Guidelines +## Git Commit Guidelines -**Git commit practices during review:** -- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review +- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review ## Development Commands diff --git a/AGENTS.md b/AGENTS.md index 888d93ec07eaff..2a8638bc1eef49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,9 @@ This repository contains the core of Home Assistant, a Python 3 based home automation application. -## Code Review Guidelines +## Git Commit Guidelines -**Git commit practices during review:** -- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review +- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review ## Development Commands From 313f97fc47f0eb648acdcfdc3c5123bda4f3678a Mon Sep 17 00:00:00 2001 From: 32u-nd <11225377+32u-nd@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:18:57 +0200 Subject: [PATCH 0400/1707] Add missing mHz docstrings (#167226) --- homeassistant/components/number/const.py | 2 +- homeassistant/components/sensor/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 78ee067bc55edd..30dafa575f4b28 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -224,7 +224,7 @@ class NumberDeviceClass(StrEnum): FREQUENCY = "frequency" """Frequency. - Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz` + Unit of measurement: `mHz`, `Hz`, `kHz`, `MHz`, `GHz` """ GAS = "gas" diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 0a7fac2157657d..381deb0a255c42 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -238,7 +238,7 @@ class SensorDeviceClass(StrEnum): FREQUENCY = "frequency" """Frequency. - Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz` + Unit of measurement: `mHz`, `Hz`, `kHz`, `MHz`, `GHz` """ GAS = "gas" From 0bedcc55ce9ee61228af8b23b83dcf854d79f45e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 2 Apr 2026 18:45:09 +0100 Subject: [PATCH 0401/1707] Set codeowners for agent configurations (#167222) --- CODEOWNERS | 7 +++++++ script/hassfest/codeowners.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index ccd0f605f58106..1662d1b3df0cc9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -37,6 +37,13 @@ build.json @home-assistant/supervisor # Other code /homeassistant/scripts/check_config.py @kellerza +# Agent Configurations +AGENTS.md @home-assistant/core +CLAUDE.md @home-assistant/core +/.agent/ @home-assistant/core +/.claude/ @home-assistant/core +/.gemini/ @home-assistant/core + # Integrations /homeassistant/components/abode/ @shred86 /tests/components/abode/ @shred86 diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index ae5af2f3a99514..72ae8a1a60884d 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -44,6 +44,13 @@ # Other code /homeassistant/scripts/check_config.py @kellerza +# Agent Configurations +AGENTS.md @home-assistant/core +CLAUDE.md @home-assistant/core +/.agent/ @home-assistant/core +/.claude/ @home-assistant/core +/.gemini/ @home-assistant/core + # Integrations """.strip() From bf4773d9bc1716b19dd7627f23c6df5e13a67df9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:25:17 +0200 Subject: [PATCH 0402/1707] Fix asyncio loop scopes for pytest fixtures (#166758) --- tests/components/heos/conftest.py | 3 +-- tests/conftest.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index e72c72c7334a89..271f532a74c008 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -26,7 +26,6 @@ const, ) import pytest -import pytest_asyncio from homeassistant.components.heos import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -81,7 +80,7 @@ def new_heos_mock_fixture(controller: MockHeos) -> Iterator[Mock]: yield new_mock -@pytest_asyncio.fixture(name="controller", autouse=True) +@pytest.fixture(name="controller", autouse=True) async def controller_fixture( players: dict[int, HeosPlayer], favorites: dict[int, MediaItem], diff --git a/tests/conftest.py b/tests/conftest.py index b7c0aba971e690..d852025c406065 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -349,17 +349,24 @@ def long_repr_strings() -> Generator[None]: @pytest.fixture(autouse=True) -def enable_event_loop_debug() -> None: +async def enable_event_loop_debug() -> None: """Enable event loop debug mode.""" - asyncio.get_event_loop().set_debug(True) + asyncio.get_running_loop().set_debug(True) -@pytest.fixture(autouse=True) +@pytest_asyncio.fixture(autouse=True) def verify_cleanup( expected_lingering_tasks: bool, expected_lingering_timers: bool, ) -> Generator[None]: - """Verify that the test has cleaned up resources correctly.""" + """Verify that the test has cleaned up resources correctly. + + This fixture requires the event loop to be stopped. + It therefore cannot be an async fixture. + + Use @pytest_asyncio.fixture to make sure the correct event loop is set + regardless before calling the fixture. + """ event_loop = asyncio.get_event_loop() threads_before = frozenset(threading.enumerate()) tasks_before = asyncio.all_tasks(event_loop) From 940de5ea84522cbad832ffd8bddd6d9d13dde106 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 2 Apr 2026 23:28:44 +0200 Subject: [PATCH 0403/1707] Fix SMHI (#167212) --- homeassistant/components/smhi/manifest.json | 2 +- homeassistant/components/smhi/sensor.py | 29 +- homeassistant/components/smhi/strings.json | 20 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smhi/fixtures/smhi.json | 12569 +++------------- .../components/smhi/fixtures/smhi_night.json | 2795 +++- .../components/smhi/fixtures/smhi_short.json | 173 +- .../smhi/snapshots/test_sensor.ambr | 48 +- .../smhi/snapshots/test_weather.ambr | 1325 +- tests/components/smhi/test_weather.py | 14 +- 11 files changed, 5657 insertions(+), 11322 deletions(-) diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index dbaf57364d6a14..a2ab45a839fca3 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pysmhi"], - "requirements": ["pysmhi==1.1.0"] + "requirements": ["pysmhi==2.0.0"] } diff --git a/homeassistant/components/smhi/sensor.py b/homeassistant/components/smhi/sensor.py index 7531e4e4d6dd51..c1d294167fc8fa 100644 --- a/homeassistant/components/smhi/sensor.py +++ b/homeassistant/components/smhi/sensor.py @@ -56,6 +56,21 @@ "5": "very_dry", "6": "extremely_dry", } +PRECIPITATION_CATEGORY_MAP = { + 0: "no_precipitation", + 1: "rain", + 2: "thunderstorm", + 3: "freezing_rain", + 4: "mixed_ice", + 5: "snow", + 6: "wet_snow", + 7: "rain_snow_mixed", + 8: "ice_pellets", + 9: "graupel", + 10: "hail", + 11: "drizzle", + 12: "freezing_drizzle", +} def get_percentage_values(entity: SMHIWeatherSensor, key: str) -> int | None: @@ -68,6 +83,14 @@ def get_percentage_values(entity: SMHIWeatherSensor, key: str) -> int | None: return None +def get_precipitation_category(entity: SMHIWeatherSensor) -> str | None: + """Return the precipitation category.""" + value: int | None = entity.coordinator.current.get("precipitation_category") + if value in PRECIPITATION_CATEGORY_MAP: + return PRECIPITATION_CATEGORY_MAP[value] + return None + + def get_fire_index_value(entity: SMHIFireSensor, key: str) -> str: """Return index value as string.""" value: int | None = entity.coordinator.fire_current.get(key) # type: ignore[assignment] @@ -128,11 +151,9 @@ class SMHIFireEntityDescription(SensorEntityDescription): SMHIWeatherEntityDescription( key="precipitation_category", translation_key="precipitation_category", - value_fn=lambda entity: str( - get_percentage_values(entity, "precipitation_category") - ), + value_fn=get_precipitation_category, device_class=SensorDeviceClass.ENUM, - options=["0", "1", "2", "3", "4", "5", "6"], + options=[*PRECIPITATION_CATEGORY_MAP.values()], ), SMHIWeatherEntityDescription( key="frozen_precipitation", diff --git a/homeassistant/components/smhi/strings.json b/homeassistant/components/smhi/strings.json index 50fb0c4c2c998f..fc940ca3e5f80b 100644 --- a/homeassistant/components/smhi/strings.json +++ b/homeassistant/components/smhi/strings.json @@ -95,13 +95,19 @@ "precipitation_category": { "name": "Precipitation category", "state": { - "0": "No precipitation", - "1": "Snow", - "2": "Snow and rain", - "3": "Rain", - "4": "Drizzle", - "5": "Freezing rain", - "6": "Freezing drizzle" + "drizzle": "Drizzle", + "freezing_drizzle": "Freezing drizzle", + "freezing_rain": "Freezing rain", + "graupel": "Graupel", + "hail": "Hail", + "ice_pellets": "Ice pellets", + "mixed_ice": "Mixed/ice", + "no_precipitation": "No precipitation", + "rain": "Rain", + "rain_snow_mixed": "Mixture of rain and snow", + "snow": "Snow", + "thunderstorm": "Thunderstorm", + "wet_snow": "Wet snow" } }, "rate_of_spread": { diff --git a/requirements_all.txt b/requirements_all.txt index 6fd469be6bb62c..76e35ae609dbfd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2506,7 +2506,7 @@ pysmartthings==3.7.3 pysmarty2==0.10.3 # homeassistant.components.smhi -pysmhi==1.1.0 +pysmhi==2.0.0 # homeassistant.components.edl21 pysml==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f988ff2d13cff..d842e5af6c24fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2141,7 +2141,7 @@ pysmartthings==3.7.3 pysmarty2==0.10.3 # homeassistant.components.smhi -pysmhi==1.1.0 +pysmhi==2.0.0 # homeassistant.components.edl21 pysml==0.1.5 diff --git a/tests/components/smhi/fixtures/smhi.json b/tests/components/smhi/fixtures/smhi.json index 35770ddd355c28..bf2c1edc602114 100644 --- a/tests/components/smhi/fixtures/smhi.json +++ b/tests/components/smhi/fixtures/smhi.json @@ -1,10084 +1,2497 @@ { - "approvedTime": "2023-08-07T07:07:34Z", - "referenceTime": "2023-08-07T07:00:00Z", - "geometry": { - "type": "Point", - "coordinates": [[15.990068, 57.997072]] - }, + "createdTime": "2026-04-02T11:01:32Z", + "referenceTime": "2026-04-02T10:45:00Z", + "geometry": { "type": "Point", "coordinates": [16.158549, 58.577821] }, "timeSeries": [ { - "validTime": "2023-08-07T08:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [18.4] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [0.4] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [93] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [37] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [7] - } - ] - }, - { - "validTime": "2023-08-07T09:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [18.2] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [0.1] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [103] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [27] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.6] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [7] - } - ] - }, - { - "validTime": "2023-08-07T10:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.5] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [1.6] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [104] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [27] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [7.6] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T11:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [3] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.6] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [3.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [109] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.6] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [97] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T12:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.1] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [991.7] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [3.2] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [114] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [96] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T13:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.7] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [991.7] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [7.5] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [105] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [91] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [8.8] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T14:00:00Z", - "parameters": [ - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.2] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [991.5] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [10.7] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [99] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [86] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [3] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T15:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [991.7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [16.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [9.2] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [108] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.4] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [89] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [8.8] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T16:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [991.4] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [16.5] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [11.5] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [113] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [84] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [10.1] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T17:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [991.4] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [16.1] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [9.5] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [100] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [88] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [7.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T18:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [990.7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [15.6] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [7.7] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [107] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.0] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [91] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T19:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [990.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [15.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [7.3] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [88] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.2] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [94] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T20:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [989.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [15.0] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [4.4] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [39] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [95] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T21:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [989.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.4] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [66] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.3] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [98] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [3] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T22:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [989.0] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.9] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.1] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [81] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.4] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [98] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-07T23:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [988.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [15.0] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [81] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.2] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [97] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-08T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [987.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [357] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [99] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.9] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-08T01:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [986.7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [1.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [5] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.6] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [99] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T02:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [985.8] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.7] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [1.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [359] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.4] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T03:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [985.0] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.7] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [1.3] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [293] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.0] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [2] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T04:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [984.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.7] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [1.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [295] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [0.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [4] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.7] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.3] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T05:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [984.0] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.7] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [0.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [221] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.0] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [5] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.4] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.7] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.3] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T06:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [983.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [1.7] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [230] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.9] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [5] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T07:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [983.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.5] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.2] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [209] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.4] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [98] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [6] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.9] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.5] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T08:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [983.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.1] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [197] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [98] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [6] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [3] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T09:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [983.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [13.9] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.4] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [192] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [98] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [6] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T10:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [983.4] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [13.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.5] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [184] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [97] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [6] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.8] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.5] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T11:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [983.7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [13.1] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [181] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [97] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [4] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [7.0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [984.1] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.7] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [183] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [97] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [3] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [7.6] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.5] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.3] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T13:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [984.4] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.6] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.6] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [190] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [96] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [2] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T14:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [985.0] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [205] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.3] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [96] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [10.6] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.5] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-08T15:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [985.8] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.1] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.9] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [211] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [96] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [11.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.3] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.5] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-08T16:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [986.7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.9] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [3.2] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [213] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [95] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [11.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.2] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-08T17:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [987.7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [209] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [96] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [11.8] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.3] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-08T18:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [989.1] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [3.6] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [208] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.6] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [95] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [13.8] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.9] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.3] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-08T19:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [990.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [10.9] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [4.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [203] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.0] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [95] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [13.8] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.2] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-08T20:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [991.7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [10.6] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [4.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [201] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.0] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [95] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [12.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.1] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-08T21:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [10.6] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [3.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [187] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [95] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [12.4] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.5] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.9] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.7] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.7] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-08T22:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [993.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [10.7] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [3.1] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [185] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [96] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [13.0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.9] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-08T23:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [994.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [10.9] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [8.4] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [192] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [90] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [13.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.3] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-09T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [995.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [11.1] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [193] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [85] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [13.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.3] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-09T01:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [996.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.3] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [12.5] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [188] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [82] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [13.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-09T02:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [996.8] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [13.3] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [189] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [81] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [12.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-09T03:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [997.4] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [14.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [187] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.6] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [78] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [11.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-09T04:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [998.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.1] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [13.9] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [171] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [80] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [11.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-09T05:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [998.9] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.1] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [13.7] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [167] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [80] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [11.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-09T06:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [999.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [14.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [165] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.6] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [80] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [12.1] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-09T07:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [999.8] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.9] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [15.2] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [166] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [77] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [12.0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-09T08:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1000.1] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.3] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [16.2] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [169] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.9] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [75] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [12.6] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-09T09:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1000.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.5] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [24.6] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [167] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [77] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [12.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.5] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-09T10:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1000.7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [18.7] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [164] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.9] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [89] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [12.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.1] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.5] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-09T11:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1001.0] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [8.5] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [159] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.9] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [94] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [12.1] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.3] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.4] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-09T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1001.4] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.1] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [3.1] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [166] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.0] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [95] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [13.4] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.5] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-09T18:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1006.2] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.0] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [1.6] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [199] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.2] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [99] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-10T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1007.8] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [10.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [1.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [200] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.0] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [99] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [7.8] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.5] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.7] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-10T06:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1009.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.0] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.4] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [182] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.3] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [97] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.6] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [19] - } - ] - }, - { - "validTime": "2023-08-10T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1011.1] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [13.9] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [30.9] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [174] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [75] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [8.1] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-10T18:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1011.9] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [43.1] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [143] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [89] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [2] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.6] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [18] - } - ] - }, - { - "validTime": "2023-08-11T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1012.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [11.7] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [169] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [98] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [4] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.6] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-11T06:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1013.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.3] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [214] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.2] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [97] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [4] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [3] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-11T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1015.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.6] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [27.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [197] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [69] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [4] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [3] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [7.6] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-11T18:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1015.8] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [16.1] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [35.3] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [156] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.3] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [82] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [8] - } - ] - }, - { - "validTime": "2023-08-12T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1015.8] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.3] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [2.6] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [191] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.4] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [97] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2023-08-12T06:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1014.8] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [12.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [40.6] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [171] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [92] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-12T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1014.0] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.0] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [50.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [225] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.4] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [82] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [7.8] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-13T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1013.9] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [13.6] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [31.2] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [233] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [92] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.6] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [2] - } - ] - }, - { - "validTime": "2023-08-13T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1013.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [20.0] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [46.8] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [234] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.1] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [59] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.9] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - } - ] - }, - { - "validTime": "2023-08-14T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1015.2] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [13.5] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [37.2] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [227] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.0] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [91] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.6] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - } - ] - }, - { - "validTime": "2023-08-14T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1015.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [20.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [49.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [216] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [56] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.4] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - } - ] - }, - { - "validTime": "2023-08-15T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1014.9] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.3] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [30.3] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [196] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [93] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.8] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [6] - } - ] - }, - { - "validTime": "2023-08-15T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1014.3] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [20.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [39.9] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [226] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [64] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - } - ] - }, - { - "validTime": "2023-08-16T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1014.9] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [13.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [31.6] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [228] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [93] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [3] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.9] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.8] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [2] - } - ] - }, - { - "validTime": "2023-08-16T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1014.0] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [20.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [44.5] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [233] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.9] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [61] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [2] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1.5] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - } - ] + "time": "2026-04-02T11:00:00Z", + "intervalParametersStartTime": "2026-04-02T10:00:00Z", + "data": { + "air_temperature": 10.4, + "wind_from_direction": 255, + "wind_speed": 3.4, + "wind_speed_of_gust": 6.8, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 1011.5, + "visibility_in_air": 17.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 528, + "cloud_top_altitude": 2250, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 10, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T12:00:00Z", + "intervalParametersStartTime": "2026-04-02T11:00:00Z", + "data": { + "air_temperature": 10.4, + "wind_from_direction": 245, + "wind_speed": 3.1, + "wind_speed_of_gust": 6.7, + "relative_humidity": 79, + "air_pressure_at_mean_sea_level": 1011.3, + "visibility_in_air": 18.4, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 4, + "cloud_base_altitude": 735, + "cloud_top_altitude": 2439, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 17, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T13:00:00Z", + "intervalParametersStartTime": "2026-04-02T12:00:00Z", + "data": { + "air_temperature": 10.3, + "wind_from_direction": 229, + "wind_speed": 2.1, + "wind_speed_of_gust": 6.2, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1011.1, + "visibility_in_air": 16.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 1100, + "cloud_top_altitude": 2638, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 17, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T14:00:00Z", + "intervalParametersStartTime": "2026-04-02T13:00:00Z", + "data": { + "air_temperature": 10.1, + "wind_from_direction": 266, + "wind_speed": 1.9, + "wind_speed_of_gust": 4.2, + "relative_humidity": 89, + "air_pressure_at_mean_sea_level": 1010.8, + "visibility_in_air": 11.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 734, + "cloud_top_altitude": 2637, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 20, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T15:00:00Z", + "intervalParametersStartTime": "2026-04-02T14:00:00Z", + "data": { + "air_temperature": 9.6, + "wind_from_direction": 258, + "wind_speed": 1.4, + "wind_speed_of_gust": 3.7, + "relative_humidity": 91, + "air_pressure_at_mean_sea_level": 1010.3, + "visibility_in_air": 9.7, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 2637, + "cloud_top_altitude": 6363, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 23, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T16:00:00Z", + "intervalParametersStartTime": "2026-04-02T15:00:00Z", + "data": { + "air_temperature": 9.1, + "wind_from_direction": 239, + "wind_speed": 1.2, + "wind_speed_of_gust": 2.7, + "relative_humidity": 91, + "air_pressure_at_mean_sea_level": 1009.8, + "visibility_in_air": 9.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 2637, + "cloud_top_altitude": 7996, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.7, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 20, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T17:00:00Z", + "intervalParametersStartTime": "2026-04-02T16:00:00Z", + "data": { + "air_temperature": 8.3, + "wind_from_direction": 223, + "wind_speed": 1.0, + "wind_speed_of_gust": 2.3, + "relative_humidity": 94, + "air_pressure_at_mean_sea_level": 1009.7, + "visibility_in_air": 9.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 660, + "cloud_top_altitude": 7989, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 27, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T18:00:00Z", + "intervalParametersStartTime": "2026-04-02T17:00:00Z", + "data": { + "air_temperature": 7.2, + "wind_from_direction": 180, + "wind_speed": 0.9, + "wind_speed_of_gust": 1.8, + "relative_humidity": 98, + "air_pressure_at_mean_sea_level": 1009.7, + "visibility_in_air": 6.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 996, + "cloud_top_altitude": 6350, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 23, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T19:00:00Z", + "intervalParametersStartTime": "2026-04-02T18:00:00Z", + "data": { + "air_temperature": 6.7, + "wind_from_direction": 211, + "wind_speed": 1.1, + "wind_speed_of_gust": 2.0, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1009.7, + "visibility_in_air": 8.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 1098, + "cloud_top_altitude": 2845, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 20, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T20:00:00Z", + "intervalParametersStartTime": "2026-04-02T19:00:00Z", + "data": { + "air_temperature": 6.4, + "wind_from_direction": 194, + "wind_speed": 1.2, + "wind_speed_of_gust": 2.0, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1009.4, + "visibility_in_air": 9.1, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 1591, + "cloud_top_altitude": 2843, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 37, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-02T21:00:00Z", + "intervalParametersStartTime": "2026-04-02T20:00:00Z", + "data": { + "air_temperature": 6.0, + "wind_from_direction": 199, + "wind_speed": 0.9, + "wind_speed_of_gust": 2.2, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1009.2, + "visibility_in_air": 9.3, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 813, + "cloud_top_altitude": 3063, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.1, + "probability_of_precipitation": 53, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-02T22:00:00Z", + "intervalParametersStartTime": "2026-04-02T21:00:00Z", + "data": { + "air_temperature": 5.9, + "wind_from_direction": 117, + "wind_speed": 0.2, + "wind_speed_of_gust": 1.6, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1008.9, + "visibility_in_air": 8.8, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 5, + "cloud_base_altitude": 589, + "cloud_top_altitude": 3062, + "precipitation_amount_mean_deterministic": 0.3, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.1, + "probability_of_precipitation": 55, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-02T23:00:00Z", + "intervalParametersStartTime": "2026-04-02T22:00:00Z", + "data": { + "air_temperature": 5.7, + "wind_from_direction": 181, + "wind_speed": 0.6, + "wind_speed_of_gust": 1.2, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1008.6, + "visibility_in_air": 5.6, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 5, + "cloud_base_altitude": 525, + "cloud_top_altitude": 3293, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.1, + "probability_of_precipitation": 55, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T00:00:00Z", + "intervalParametersStartTime": "2026-04-02T23:00:00Z", + "data": { + "air_temperature": 5.3, + "wind_from_direction": 138, + "wind_speed": 0.5, + "wind_speed_of_gust": 1.2, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1008.2, + "visibility_in_air": 8.3, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 812, + "cloud_top_altitude": 3292, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 66, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T01:00:00Z", + "intervalParametersStartTime": "2026-04-03T00:00:00Z", + "data": { + "air_temperature": 5.0, + "wind_from_direction": 193, + "wind_speed": 0.6, + "wind_speed_of_gust": 1.9, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.8, + "visibility_in_air": 5.0, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 811, + "cloud_top_altitude": 3290, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.1, + "probability_of_precipitation": 59, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 1 + } + }, + { + "time": "2026-04-03T02:00:00Z", + "intervalParametersStartTime": "2026-04-03T01:00:00Z", + "data": { + "air_temperature": 5.0, + "wind_from_direction": 192, + "wind_speed": 0.6, + "wind_speed_of_gust": 1.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.4, + "visibility_in_air": 7.4, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 730, + "cloud_top_altitude": 3530, + "precipitation_amount_mean_deterministic": 0.3, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 66, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T03:00:00Z", + "intervalParametersStartTime": "2026-04-03T02:00:00Z", + "data": { + "air_temperature": 4.8, + "wind_from_direction": 172, + "wind_speed": 0.7, + "wind_speed_of_gust": 1.3, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.1, + "visibility_in_air": 7.1, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 277, + "cloud_top_altitude": 2237, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.3, + "probability_of_precipitation": 79, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T04:00:00Z", + "intervalParametersStartTime": "2026-04-03T03:00:00Z", + "data": { + "air_temperature": 4.7, + "wind_from_direction": 32, + "wind_speed": 0.4, + "wind_speed_of_gust": 1.3, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1006.9, + "visibility_in_air": 5.0, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 204, + "cloud_top_altitude": 6279, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 69, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T05:00:00Z", + "intervalParametersStartTime": "2026-04-03T04:00:00Z", + "data": { + "air_temperature": 4.4, + "wind_from_direction": 48, + "wind_speed": 2.9, + "wind_speed_of_gust": 5.6, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.0, + "visibility_in_air": 5.2, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 86, + "cloud_top_altitude": 6648, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T06:00:00Z", + "intervalParametersStartTime": "2026-04-03T05:00:00Z", + "data": { + "air_temperature": 4.2, + "wind_from_direction": 45, + "wind_speed": 3.1, + "wind_speed_of_gust": 5.9, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.1, + "visibility_in_air": 5.7, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 61, + "cloud_top_altitude": 6642, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.3, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T07:00:00Z", + "intervalParametersStartTime": "2026-04-03T06:00:00Z", + "data": { + "air_temperature": 4.0, + "wind_from_direction": 79, + "wind_speed": 3.5, + "wind_speed_of_gust": 6.6, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.4, + "visibility_in_air": 5.3, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 61, + "cloud_top_altitude": 3776, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T08:00:00Z", + "intervalParametersStartTime": "2026-04-03T07:00:00Z", + "data": { + "air_temperature": 4.0, + "wind_from_direction": 72, + "wind_speed": 3.2, + "wind_speed_of_gust": 6.6, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.7, + "visibility_in_air": 8.0, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 86, + "cloud_top_altitude": 3775, + "precipitation_amount_mean_deterministic": 0.3, + "precipitation_amount_mean": 0.4, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.3, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T09:00:00Z", + "intervalParametersStartTime": "2026-04-03T08:00:00Z", + "data": { + "air_temperature": 3.9, + "wind_from_direction": 55, + "wind_speed": 3.2, + "wind_speed_of_gust": 6.1, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1007.8, + "visibility_in_air": 6.9, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 112, + "cloud_top_altitude": 4039, + "precipitation_amount_mean_deterministic": 0.3, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 62, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T10:00:00Z", + "intervalParametersStartTime": "2026-04-03T09:00:00Z", + "data": { + "air_temperature": 3.7, + "wind_from_direction": 44, + "wind_speed": 3.0, + "wind_speed_of_gust": 6.2, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1007.9, + "visibility_in_air": 6.8, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 140, + "cloud_top_altitude": 3277, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 55, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T11:00:00Z", + "intervalParametersStartTime": "2026-04-03T10:00:00Z", + "data": { + "air_temperature": 3.3, + "wind_from_direction": 31, + "wind_speed": 2.9, + "wind_speed_of_gust": 5.9, + "relative_humidity": 94, + "air_pressure_at_mean_sea_level": 1008.1, + "visibility_in_air": 6.9, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 140, + "cloud_top_altitude": 2414, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.1, + "probability_of_precipitation": 55, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T12:00:00Z", + "intervalParametersStartTime": "2026-04-03T11:00:00Z", + "data": { + "air_temperature": 3.1, + "wind_from_direction": 27, + "wind_speed": 2.7, + "wind_speed_of_gust": 5.6, + "relative_humidity": 94, + "air_pressure_at_mean_sea_level": 1008.1, + "visibility_in_air": 7.7, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 139, + "cloud_top_altitude": 2613, + "precipitation_amount_mean_deterministic": 0.3, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 62, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T13:00:00Z", + "intervalParametersStartTime": "2026-04-03T12:00:00Z", + "data": { + "air_temperature": 3.2, + "wind_from_direction": 30, + "wind_speed": 2.2, + "wind_speed_of_gust": 5.2, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1008.0, + "visibility_in_air": 8.3, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 6, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 2414, + "precipitation_amount_mean_deterministic": 0.2, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.1, + "probability_of_precipitation": 55, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T14:00:00Z", + "intervalParametersStartTime": "2026-04-03T13:00:00Z", + "data": { + "air_temperature": 3.3, + "wind_from_direction": 19, + "wind_speed": 1.4, + "wind_speed_of_gust": 4.2, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1008.0, + "visibility_in_air": 24.0, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 1307, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 38, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T15:00:00Z", + "intervalParametersStartTime": "2026-04-03T14:00:00Z", + "data": { + "air_temperature": 3.4, + "wind_from_direction": 19, + "wind_speed": 1.4, + "wind_speed_of_gust": 2.9, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1007.6, + "visibility_in_air": 8.3, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 1, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 1189, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 28, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T16:00:00Z", + "intervalParametersStartTime": "2026-04-03T15:00:00Z", + "data": { + "air_temperature": 3.5, + "wind_from_direction": 13, + "wind_speed": 1.3, + "wind_speed_of_gust": 2.9, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1007.5, + "visibility_in_air": 8.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 1189, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 14, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T17:00:00Z", + "intervalParametersStartTime": "2026-04-03T16:00:00Z", + "data": { + "air_temperature": 3.4, + "wind_from_direction": 349, + "wind_speed": 0.7, + "wind_speed_of_gust": 2.6, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1007.6, + "visibility_in_air": 8.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 1433, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 24, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T18:00:00Z", + "intervalParametersStartTime": "2026-04-03T17:00:00Z", + "data": { + "air_temperature": 2.9, + "wind_from_direction": 259, + "wind_speed": 0.8, + "wind_speed_of_gust": 1.7, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1007.7, + "visibility_in_air": 9.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 140, + "cloud_top_altitude": 1717, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T19:00:00Z", + "intervalParametersStartTime": "2026-04-03T18:00:00Z", + "data": { + "air_temperature": 2.3, + "wind_from_direction": 248, + "wind_speed": 1.1, + "wind_speed_of_gust": 2.4, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1007.5, + "visibility_in_air": 8.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 112, + "cloud_top_altitude": 1717, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 7, + "precipitation_frozen_part": 2, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T20:00:00Z", + "intervalParametersStartTime": "2026-04-03T19:00:00Z", + "data": { + "air_temperature": 2.0, + "wind_from_direction": 126, + "wind_speed": 1.0, + "wind_speed_of_gust": 2.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1006.9, + "visibility_in_air": 7.7, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 1, + "cloud_base_altitude": 85, + "cloud_top_altitude": 1082, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T21:00:00Z", + "intervalParametersStartTime": "2026-04-03T20:00:00Z", + "data": { + "air_temperature": 1.9, + "wind_from_direction": 177, + "wind_speed": 1.6, + "wind_speed_of_gust": 3.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1006.7, + "visibility_in_air": 8.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 85, + "cloud_top_altitude": 1434, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T22:00:00Z", + "intervalParametersStartTime": "2026-04-03T21:00:00Z", + "data": { + "air_temperature": 1.8, + "wind_from_direction": 169, + "wind_speed": 1.4, + "wind_speed_of_gust": 3.4, + "relative_humidity": 98, + "air_pressure_at_mean_sea_level": 1006.0, + "visibility_in_air": 6.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 60, + "cloud_top_altitude": 1434, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T23:00:00Z", + "intervalParametersStartTime": "2026-04-03T22:00:00Z", + "data": { + "air_temperature": 1.9, + "wind_from_direction": 193, + "wind_speed": 2.1, + "wind_speed_of_gust": 4.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1005.5, + "visibility_in_air": 7.4, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 85, + "cloud_top_altitude": 6273, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T00:00:00Z", + "intervalParametersStartTime": "2026-04-03T23:00:00Z", + "data": { + "air_temperature": 1.9, + "wind_from_direction": 161, + "wind_speed": 2.1, + "wind_speed_of_gust": 4.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1004.6, + "visibility_in_air": 8.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 5, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 112, + "cloud_top_altitude": 7439, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 3, + "precipitation_frozen_part": 7, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T01:00:00Z", + "intervalParametersStartTime": "2026-04-04T00:00:00Z", + "data": { + "air_temperature": 1.8, + "wind_from_direction": 172, + "wind_speed": 2.2, + "wind_speed_of_gust": 4.6, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1003.9, + "visibility_in_air": 9.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 140, + "cloud_top_altitude": 4905, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T02:00:00Z", + "intervalParametersStartTime": "2026-04-04T01:00:00Z", + "data": { + "air_temperature": 1.7, + "wind_from_direction": 169, + "wind_speed": 2.4, + "wind_speed_of_gust": 4.4, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1003.2, + "visibility_in_air": 6.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 581, + "cloud_top_altitude": 5884, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.7, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 45, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T03:00:00Z", + "intervalParametersStartTime": "2026-04-04T02:00:00Z", + "data": { + "air_temperature": 1.7, + "wind_from_direction": 159, + "wind_speed": 2.4, + "wind_speed_of_gust": 4.5, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1002.3, + "visibility_in_air": 7.1, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 4, + "cloud_base_altitude": 1567, + "cloud_top_altitude": 4883, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.5, + "precipitation_amount_min": 0.4, + "precipitation_amount_max": 0.7, + "precipitation_amount_median": 0.4, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-04T04:00:00Z", + "intervalParametersStartTime": "2026-04-04T03:00:00Z", + "data": { + "air_temperature": 1.5, + "wind_from_direction": 180, + "wind_speed": 3.1, + "wind_speed_of_gust": 6.0, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1001.7, + "visibility_in_air": 5.3, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 1, + "cloud_base_altitude": 647, + "cloud_top_altitude": 4006, + "precipitation_amount_mean_deterministic": 0.6, + "precipitation_amount_mean": 0.6, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.9, + "precipitation_amount_median": 0.4, + "probability_of_precipitation": 93, + "precipitation_frozen_part": 3, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-04T05:00:00Z", + "intervalParametersStartTime": "2026-04-04T04:00:00Z", + "data": { + "air_temperature": 1.7, + "wind_from_direction": 191, + "wind_speed": 3.1, + "wind_speed_of_gust": 6.4, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1001.3, + "visibility_in_air": 9.2, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 6, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 201, + "cloud_top_altitude": 4000, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.5, + "precipitation_amount_min": 0.4, + "precipitation_amount_max": 0.9, + "precipitation_amount_median": 0.4, + "probability_of_precipitation": 69, + "precipitation_frozen_part": 3, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-04T06:00:00Z", + "intervalParametersStartTime": "2026-04-04T05:00:00Z", + "data": { + "air_temperature": 2.8, + "wind_from_direction": 209, + "wind_speed": 3.4, + "wind_speed_of_gust": 6.4, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1001.0, + "visibility_in_air": 8.4, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 887, + "cloud_top_altitude": 1188, + "precipitation_amount_mean_deterministic": 0.2, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 48, + "precipitation_frozen_part": 3, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T07:00:00Z", + "intervalParametersStartTime": "2026-04-04T06:00:00Z", + "data": { + "air_temperature": 3.6, + "wind_from_direction": 233, + "wind_speed": 4.0, + "wind_speed_of_gust": 7.5, + "relative_humidity": 90, + "air_pressure_at_mean_sea_level": 1000.9, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 980, + "cloud_top_altitude": 1080, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 45, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T08:00:00Z", + "intervalParametersStartTime": "2026-04-04T07:00:00Z", + "data": { + "air_temperature": 4.3, + "wind_from_direction": 253, + "wind_speed": 6.8, + "wind_speed_of_gust": 12.5, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1001.0, + "visibility_in_air": 15.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 649, + "cloud_top_altitude": 1188, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 1.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 34, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T09:00:00Z", + "intervalParametersStartTime": "2026-04-04T08:00:00Z", + "data": { + "air_temperature": 5.0, + "wind_from_direction": 257, + "wind_speed": 6.3, + "wind_speed_of_gust": 12.5, + "relative_humidity": 79, + "air_pressure_at_mean_sea_level": 1001.1, + "visibility_in_air": 18.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 723, + "cloud_top_altitude": 1188, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 34, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T10:00:00Z", + "intervalParametersStartTime": "2026-04-04T09:00:00Z", + "data": { + "air_temperature": 5.7, + "wind_from_direction": 261, + "wind_speed": 6.8, + "wind_speed_of_gust": 12.5, + "relative_humidity": 74, + "air_pressure_at_mean_sea_level": 1001.2, + "visibility_in_air": 21.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 890, + "cloud_top_altitude": 4836, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 38, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T11:00:00Z", + "intervalParametersStartTime": "2026-04-04T10:00:00Z", + "data": { + "air_temperature": 6.3, + "wind_from_direction": 269, + "wind_speed": 7.1, + "wind_speed_of_gust": 13.2, + "relative_humidity": 69, + "air_pressure_at_mean_sea_level": 1001.4, + "visibility_in_air": 24.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 7, + "high_type_cloud_area_fraction": 1, + "cloud_base_altitude": 1084, + "cloud_top_altitude": 1870, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 28, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T12:00:00Z", + "intervalParametersStartTime": "2026-04-04T11:00:00Z", + "data": { + "air_temperature": 7.1, + "wind_from_direction": 277, + "wind_speed": 7.0, + "wind_speed_of_gust": 13.3, + "relative_humidity": 64, + "air_pressure_at_mean_sea_level": 1001.9, + "visibility_in_air": 28.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 6, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 1311, + "cloud_top_altitude": 1572, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T13:00:00Z", + "intervalParametersStartTime": "2026-04-04T12:00:00Z", + "data": { + "air_temperature": 7.8, + "wind_from_direction": 294, + "wind_speed": 6.4, + "wind_speed_of_gust": 13.3, + "relative_humidity": 59, + "air_pressure_at_mean_sea_level": 1002.4, + "visibility_in_air": 31.4, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 6, + "medium_type_cloud_area_fraction": 5, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 1575, + "cloud_top_altitude": 5153, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 24, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } + }, + { + "time": "2026-04-04T14:00:00Z", + "intervalParametersStartTime": "2026-04-04T13:00:00Z", + "data": { + "air_temperature": 8.4, + "wind_from_direction": 289, + "wind_speed": 7.6, + "wind_speed_of_gust": 14.2, + "relative_humidity": 54, + "air_pressure_at_mean_sea_level": 1003.1, + "visibility_in_air": 35.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 14, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } + }, + { + "time": "2026-04-04T15:00:00Z", + "intervalParametersStartTime": "2026-04-04T14:00:00Z", + "data": { + "air_temperature": 8.4, + "wind_from_direction": 290, + "wind_speed": 7.5, + "wind_speed_of_gust": 14.8, + "relative_humidity": 51, + "air_pressure_at_mean_sea_level": 1003.8, + "visibility_in_air": 37.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 4, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 14, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-04T16:00:00Z", + "intervalParametersStartTime": "2026-04-04T15:00:00Z", + "data": { + "air_temperature": 8.1, + "wind_from_direction": 288, + "wind_speed": 6.8, + "wind_speed_of_gust": 14.0, + "relative_humidity": 51, + "air_pressure_at_mean_sea_level": 1004.7, + "visibility_in_air": 36.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 4, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 2048, + "cloud_top_altitude": 2223, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 10, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-04T17:00:00Z", + "intervalParametersStartTime": "2026-04-04T16:00:00Z", + "data": { + "air_temperature": 7.6, + "wind_from_direction": 280, + "wind_speed": 5.7, + "wind_speed_of_gust": 12.3, + "relative_humidity": 52, + "air_pressure_at_mean_sea_level": 1005.6, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 3, + "low_type_cloud_area_fraction": 1, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 3, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 2 + } + }, + { + "time": "2026-04-04T18:00:00Z", + "intervalParametersStartTime": "2026-04-04T17:00:00Z", + "data": { + "air_temperature": 6.2, + "wind_from_direction": 271, + "wind_speed": 5.0, + "wind_speed_of_gust": 10.3, + "relative_humidity": 56, + "air_pressure_at_mean_sea_level": 1006.5, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-04T19:00:00Z", + "intervalParametersStartTime": "2026-04-04T18:00:00Z", + "data": { + "air_temperature": 5.1, + "wind_from_direction": 254, + "wind_speed": 3.9, + "wind_speed_of_gust": 9.1, + "relative_humidity": 64, + "air_pressure_at_mean_sea_level": 1007.3, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-04T20:00:00Z", + "intervalParametersStartTime": "2026-04-04T19:00:00Z", + "data": { + "air_temperature": 3.9, + "wind_from_direction": 257, + "wind_speed": 3.9, + "wind_speed_of_gust": 7.1, + "relative_humidity": 75, + "air_pressure_at_mean_sea_level": 1008.1, + "visibility_in_air": 21.1, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-04T21:00:00Z", + "intervalParametersStartTime": "2026-04-04T20:00:00Z", + "data": { + "air_temperature": 2.8, + "wind_from_direction": 265, + "wind_speed": 2.9, + "wind_speed_of_gust": 7.0, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1008.8, + "visibility_in_air": 16.5, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-04T22:00:00Z", + "intervalParametersStartTime": "2026-04-04T21:00:00Z", + "data": { + "air_temperature": 1.7, + "wind_from_direction": 251, + "wind_speed": 2.4, + "wind_speed_of_gust": 5.1, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 1009.5, + "visibility_in_air": 13.7, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-05T00:00:00Z", + "intervalParametersStartTime": "2026-04-04T22:00:00Z", + "data": { + "air_temperature": 2.3, + "wind_from_direction": 254, + "wind_speed": 4.3, + "wind_speed_of_gust": 8.4, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1009.4, + "visibility_in_air": 13.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 8703, + "cloud_top_altitude": 9263, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 3 + } + }, + { + "time": "2026-04-05T06:00:00Z", + "intervalParametersStartTime": "2026-04-05T00:00:00Z", + "data": { + "air_temperature": 2.1, + "wind_from_direction": 161, + "wind_speed": 3.4, + "wind_speed_of_gust": 5.6, + "relative_humidity": 83, + "air_pressure_at_mean_sea_level": 1006.8, + "visibility_in_air": 12.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 5039, + "cloud_top_altitude": 9847, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 2.0, + "precipitation_amount_max": 2.7, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 9, + "precipitation_frozen_part": 3, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-05T12:00:00Z", + "intervalParametersStartTime": "2026-04-05T06:00:00Z", + "data": { + "air_temperature": 8.3, + "wind_from_direction": 182, + "wind_speed": 6.9, + "wind_speed_of_gust": 13.0, + "relative_humidity": 81, + "air_pressure_at_mean_sea_level": 999.0, + "visibility_in_air": 20.6, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 1059, + "cloud_top_altitude": 9831, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 1.8, + "precipitation_amount_min": 1.3, + "precipitation_amount_max": 2.7, + "precipitation_amount_median": 1.9, + "probability_of_precipitation": 83, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-05T18:00:00Z", + "intervalParametersStartTime": "2026-04-05T12:00:00Z", + "data": { + "air_temperature": 6.7, + "wind_from_direction": 227, + "wind_speed": 8.2, + "wind_speed_of_gust": 14.5, + "relative_humidity": 47, + "air_pressure_at_mean_sea_level": 995.8, + "visibility_in_air": 31.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 2, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 1, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.9, + "precipitation_amount_min": 0.5, + "precipitation_amount_max": 1.6, + "precipitation_amount_median": 0.7, + "probability_of_precipitation": 77, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 8 + } + }, + { + "time": "2026-04-06T00:00:00Z", + "intervalParametersStartTime": "2026-04-05T18:00:00Z", + "data": { + "air_temperature": 4.0, + "wind_from_direction": 240, + "wind_speed": 8.8, + "wind_speed_of_gust": 16.9, + "relative_humidity": 69, + "air_pressure_at_mean_sea_level": 995.0, + "visibility_in_air": 50.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 7, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 3159, + "cloud_top_altitude": 4379, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.5, + "precipitation_amount_max": 1.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 56, + "predominant_precipitation_type_at_surface": 6, + "symbol_code": 4 + } + }, + { + "time": "2026-04-06T06:00:00Z", + "intervalParametersStartTime": "2026-04-06T00:00:00Z", + "data": { + "air_temperature": 4.2, + "wind_from_direction": 261, + "wind_speed": 7.9, + "wind_speed_of_gust": 15.7, + "relative_humidity": 67, + "air_pressure_at_mean_sea_level": 996.3, + "visibility_in_air": 20.7, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 7383, + "cloud_top_altitude": 7383, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.5, + "precipitation_amount_max": 1.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-06T12:00:00Z", + "intervalParametersStartTime": "2026-04-06T06:00:00Z", + "data": { + "air_temperature": 10.3, + "wind_from_direction": 290, + "wind_speed": 9.5, + "wind_speed_of_gust": 19.6, + "relative_humidity": 40, + "air_pressure_at_mean_sea_level": 1001.5, + "visibility_in_air": 36.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 1, + "medium_type_cloud_area_fraction": 6, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 6911, + "cloud_top_altitude": 6911, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.4, + "precipitation_amount_max": 0.8, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 28, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } + }, + { + "time": "2026-04-06T18:00:00Z", + "intervalParametersStartTime": "2026-04-06T12:00:00Z", + "data": { + "air_temperature": 8.7, + "wind_from_direction": 297, + "wind_speed": 5.8, + "wind_speed_of_gust": 15.5, + "relative_humidity": 44, + "air_pressure_at_mean_sea_level": 1007.5, + "visibility_in_air": 33.2, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 1, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 1, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 1.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 25, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 1 + } + }, + { + "time": "2026-04-07T00:00:00Z", + "intervalParametersStartTime": "2026-04-06T18:00:00Z", + "data": { + "air_temperature": 5.4, + "wind_from_direction": 315, + "wind_speed": 5.3, + "wind_speed_of_gust": 10.0, + "relative_humidity": 57, + "air_pressure_at_mean_sea_level": 1013.3, + "visibility_in_air": 25.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 4, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 1 + } + }, + { + "time": "2026-04-07T06:00:00Z", + "intervalParametersStartTime": "2026-04-07T00:00:00Z", + "data": { + "air_temperature": 5.2, + "wind_from_direction": 327, + "wind_speed": 4.9, + "wind_speed_of_gust": 9.4, + "relative_humidity": 61, + "air_pressure_at_mean_sea_level": 1017.7, + "visibility_in_air": 24.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 1, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 1, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 2 + } + }, + { + "time": "2026-04-07T12:00:00Z", + "intervalParametersStartTime": "2026-04-07T06:00:00Z", + "data": { + "air_temperature": 10.3, + "wind_from_direction": 352, + "wind_speed": 6.1, + "wind_speed_of_gust": 12.5, + "relative_humidity": 37, + "air_pressure_at_mean_sea_level": 1021.1, + "visibility_in_air": 37.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 1, + "medium_type_cloud_area_fraction": 5, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.5, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 2, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-07T18:00:00Z", + "intervalParametersStartTime": "2026-04-07T12:00:00Z", + "data": { + "air_temperature": 5.6, + "wind_from_direction": 17, + "wind_speed": 3.7, + "wind_speed_of_gust": 10.3, + "relative_humidity": 62, + "air_pressure_at_mean_sea_level": 1024.1, + "visibility_in_air": 23.5, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 3, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 1.3, + "precipitation_amount_max": 1.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 2, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 2 + } + }, + { + "time": "2026-04-08T00:00:00Z", + "intervalParametersStartTime": "2026-04-07T18:00:00Z", + "data": { + "air_temperature": 2.2, + "wind_from_direction": 3, + "wind_speed": 3.5, + "wind_speed_of_gust": 6.5, + "relative_humidity": 83, + "air_pressure_at_mean_sea_level": 1027.0, + "visibility_in_air": 12.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 2, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 2 + } + }, + { + "time": "2026-04-08T06:00:00Z", + "intervalParametersStartTime": "2026-04-08T00:00:00Z", + "data": { + "air_temperature": 3.1, + "wind_from_direction": 19, + "wind_speed": 4.2, + "wind_speed_of_gust": 7.5, + "relative_humidity": 81, + "air_pressure_at_mean_sea_level": 1028.6, + "visibility_in_air": 13.5, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 371, + "cloud_top_altitude": 543, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-08T12:00:00Z", + "intervalParametersStartTime": "2026-04-08T06:00:00Z", + "data": { + "air_temperature": 7.8, + "wind_from_direction": 46, + "wind_speed": 4.8, + "wind_speed_of_gust": 10.6, + "relative_humidity": 57, + "air_pressure_at_mean_sea_level": 1028.8, + "visibility_in_air": 26.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 6, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 1059, + "cloud_top_altitude": 1171, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4 + } + }, + { + "time": "2026-04-09T00:00:00Z", + "intervalParametersStartTime": "2026-04-08T12:00:00Z", + "data": { + "air_temperature": -1.5, + "wind_from_direction": 57, + "wind_speed": 3.0, + "wind_speed_of_gust": 7.2, + "relative_humidity": 85, + "air_pressure_at_mean_sea_level": 1028.4, + "visibility_in_air": 11.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.01, + "cloud_area_fraction": 1, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 1.4, + "precipitation_amount_max": 1.4, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 2, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 1 + } + }, + { + "time": "2026-04-09T12:00:00Z", + "intervalParametersStartTime": "2026-04-09T00:00:00Z", + "data": { + "air_temperature": 6.5, + "wind_from_direction": 104, + "wind_speed": 5.0, + "wind_speed_of_gust": 10.3, + "relative_humidity": 48, + "air_pressure_at_mean_sea_level": 1024.4, + "visibility_in_air": 34.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 6299, + "cloud_top_altitude": 6560, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 1.9, + "precipitation_amount_max": 2.7, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 6, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-10T00:00:00Z", + "intervalParametersStartTime": "2026-04-09T12:00:00Z", + "data": { + "air_temperature": -0.1, + "wind_from_direction": 87, + "wind_speed": 3.5, + "wind_speed_of_gust": 7.9, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1021.1, + "visibility_in_air": 17.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.03, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 5774, + "cloud_top_altitude": 6062, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.6, + "precipitation_amount_min": 2.8, + "precipitation_amount_max": 4.7, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 16, + "precipitation_frozen_part": 88, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 3 + } + }, + { + "time": "2026-04-10T12:00:00Z", + "intervalParametersStartTime": "2026-04-10T00:00:00Z", + "data": { + "air_temperature": 6.7, + "wind_from_direction": 103, + "wind_speed": 4.8, + "wind_speed_of_gust": 10.4, + "relative_humidity": 47, + "air_pressure_at_mean_sea_level": 1020.2, + "visibility_in_air": 36.1, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 5273, + "cloud_top_altitude": 5638, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.5, + "precipitation_amount_min": 0.9, + "precipitation_amount_max": 2.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 25, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-11T00:00:00Z", + "intervalParametersStartTime": "2026-04-10T12:00:00Z", + "data": { + "air_temperature": 0.6, + "wind_from_direction": 111, + "wind_speed": 3.2, + "wind_speed_of_gust": 7.2, + "relative_humidity": 79, + "air_pressure_at_mean_sea_level": 1019.0, + "visibility_in_air": 22.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.01, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 5778, + "cloud_top_altitude": 6023, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.7, + "precipitation_amount_max": 1.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 13, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-11T12:00:00Z", + "intervalParametersStartTime": "2026-04-11T00:00:00Z", + "data": { + "air_temperature": 7.4, + "wind_from_direction": 136, + "wind_speed": 5.5, + "wind_speed_of_gust": 11.5, + "relative_humidity": 50, + "air_pressure_at_mean_sea_level": 1016.6, + "visibility_in_air": 26.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 5, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 4348, + "cloud_top_altitude": 6051, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.7, + "precipitation_amount_min": 0.9, + "precipitation_amount_max": 2.9, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 26, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } + }, + { + "time": "2026-04-12T00:00:00Z", + "intervalParametersStartTime": "2026-04-11T12:00:00Z", + "data": { + "air_temperature": 1.8, + "wind_from_direction": 125, + "wind_speed": 3.6, + "wind_speed_of_gust": 8.1, + "relative_humidity": 79, + "air_pressure_at_mean_sea_level": 1016.4, + "visibility_in_air": 15.5, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 4, + "cloud_base_altitude": 5746, + "cloud_top_altitude": 6725, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.8, + "precipitation_amount_min": 1.3, + "precipitation_amount_max": 5.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 24, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-12T12:00:00Z", + "intervalParametersStartTime": "2026-04-12T00:00:00Z", + "data": { + "air_temperature": 8.3, + "wind_from_direction": 119, + "wind_speed": 5.1, + "wind_speed_of_gust": 11.1, + "relative_humidity": 51, + "air_pressure_at_mean_sea_level": 1016.3, + "visibility_in_air": 20.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 4, + "cloud_base_altitude": 5129, + "cloud_top_altitude": 6507, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.6, + "precipitation_amount_min": 0.8, + "precipitation_amount_max": 2.8, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 31, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } } ] } diff --git a/tests/components/smhi/fixtures/smhi_night.json b/tests/components/smhi/fixtures/smhi_night.json index 121544bd2f19ac..6b09a9a1956c99 100644 --- a/tests/components/smhi/fixtures/smhi_night.json +++ b/tests/components/smhi/fixtures/smhi_night.json @@ -1,700 +1,2107 @@ { - "approvedTime": "2023-08-07T07:07:34Z", - "referenceTime": "2023-08-07T07:00:00Z", - "geometry": { - "type": "Point", - "coordinates": [[15.990068, 57.997072]] - }, + "createdTime": "2026-04-02T11:01:32Z", + "referenceTime": "2026-04-02T10:45:00Z", + "geometry": { "type": "Point", "coordinates": [16.158549, 58.577821] }, "timeSeries": [ { - "validTime": "2023-08-07T23:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [18.4] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [0.4] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [93] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [37] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2023-08-08T00:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [18.2] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [0.1] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [103] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [27] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.6] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2023-08-08T01:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.5] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [1.6] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [104] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [27] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [7.6] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2023-08-08T02:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.1] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [3] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.6] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [3.0] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [109] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [3.6] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [97] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2023-08-08T03:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17.1] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [991.7] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [3.2] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [114] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [96] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [9.1] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] + "time": "2026-04-03T00:00:00Z", + "intervalParametersStartTime": "2026-04-02T23:00:00Z", + "data": { + "air_temperature": 5.3, + "wind_from_direction": 138, + "wind_speed": 0.5, + "wind_speed_of_gust": 1.2, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1008.2, + "visibility_in_air": 8.3, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 812, + "cloud_top_altitude": 3292, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 66, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 1 + } + }, + { + "time": "2026-04-03T01:00:00Z", + "intervalParametersStartTime": "2026-04-03T00:00:00Z", + "data": { + "air_temperature": 5.0, + "wind_from_direction": 193, + "wind_speed": 0.6, + "wind_speed_of_gust": 1.9, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.8, + "visibility_in_air": 5.0, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 811, + "cloud_top_altitude": 3290, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.1, + "probability_of_precipitation": 59, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 1 + } + }, + { + "time": "2026-04-03T02:00:00Z", + "intervalParametersStartTime": "2026-04-03T01:00:00Z", + "data": { + "air_temperature": 5.0, + "wind_from_direction": 192, + "wind_speed": 0.6, + "wind_speed_of_gust": 1.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.4, + "visibility_in_air": 7.4, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 730, + "cloud_top_altitude": 3530, + "precipitation_amount_mean_deterministic": 0.3, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 66, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T03:00:00Z", + "intervalParametersStartTime": "2026-04-03T02:00:00Z", + "data": { + "air_temperature": 4.8, + "wind_from_direction": 172, + "wind_speed": 0.7, + "wind_speed_of_gust": 1.3, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.1, + "visibility_in_air": 7.1, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 277, + "cloud_top_altitude": 2237, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.3, + "probability_of_precipitation": 79, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T04:00:00Z", + "intervalParametersStartTime": "2026-04-03T03:00:00Z", + "data": { + "air_temperature": 4.7, + "wind_from_direction": 32, + "wind_speed": 0.4, + "wind_speed_of_gust": 1.3, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1006.9, + "visibility_in_air": 5.0, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 204, + "cloud_top_altitude": 6279, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 69, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T05:00:00Z", + "intervalParametersStartTime": "2026-04-03T04:00:00Z", + "data": { + "air_temperature": 4.4, + "wind_from_direction": 48, + "wind_speed": 2.9, + "wind_speed_of_gust": 5.6, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.0, + "visibility_in_air": 5.2, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 86, + "cloud_top_altitude": 6648, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T06:00:00Z", + "intervalParametersStartTime": "2026-04-03T05:00:00Z", + "data": { + "air_temperature": 4.2, + "wind_from_direction": 45, + "wind_speed": 3.1, + "wind_speed_of_gust": 5.9, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.1, + "visibility_in_air": 5.7, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 61, + "cloud_top_altitude": 6642, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.3, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T07:00:00Z", + "intervalParametersStartTime": "2026-04-03T06:00:00Z", + "data": { + "air_temperature": 4.0, + "wind_from_direction": 79, + "wind_speed": 3.5, + "wind_speed_of_gust": 6.6, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.4, + "visibility_in_air": 5.3, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 61, + "cloud_top_altitude": 3776, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T08:00:00Z", + "intervalParametersStartTime": "2026-04-03T07:00:00Z", + "data": { + "air_temperature": 4.0, + "wind_from_direction": 72, + "wind_speed": 3.2, + "wind_speed_of_gust": 6.6, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1007.7, + "visibility_in_air": 8.0, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 86, + "cloud_top_altitude": 3775, + "precipitation_amount_mean_deterministic": 0.3, + "precipitation_amount_mean": 0.4, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.3, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T09:00:00Z", + "intervalParametersStartTime": "2026-04-03T08:00:00Z", + "data": { + "air_temperature": 3.9, + "wind_from_direction": 55, + "wind_speed": 3.2, + "wind_speed_of_gust": 6.1, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1007.8, + "visibility_in_air": 6.9, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 112, + "cloud_top_altitude": 4039, + "precipitation_amount_mean_deterministic": 0.3, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 62, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T10:00:00Z", + "intervalParametersStartTime": "2026-04-03T09:00:00Z", + "data": { + "air_temperature": 3.7, + "wind_from_direction": 44, + "wind_speed": 3.0, + "wind_speed_of_gust": 6.2, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1007.9, + "visibility_in_air": 6.8, + "thunderstorm_probability": 2, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 140, + "cloud_top_altitude": 3277, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 55, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T11:00:00Z", + "intervalParametersStartTime": "2026-04-03T10:00:00Z", + "data": { + "air_temperature": 3.3, + "wind_from_direction": 31, + "wind_speed": 2.9, + "wind_speed_of_gust": 5.9, + "relative_humidity": 94, + "air_pressure_at_mean_sea_level": 1008.1, + "visibility_in_air": 6.9, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 140, + "cloud_top_altitude": 2414, + "precipitation_amount_mean_deterministic": 0.4, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.1, + "probability_of_precipitation": 55, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T12:00:00Z", + "intervalParametersStartTime": "2026-04-03T11:00:00Z", + "data": { + "air_temperature": 3.1, + "wind_from_direction": 27, + "wind_speed": 2.7, + "wind_speed_of_gust": 5.6, + "relative_humidity": 94, + "air_pressure_at_mean_sea_level": 1008.1, + "visibility_in_air": 7.7, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 139, + "cloud_top_altitude": 2613, + "precipitation_amount_mean_deterministic": 0.3, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.2, + "probability_of_precipitation": 62, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T13:00:00Z", + "intervalParametersStartTime": "2026-04-03T12:00:00Z", + "data": { + "air_temperature": 3.2, + "wind_from_direction": 30, + "wind_speed": 2.2, + "wind_speed_of_gust": 5.2, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1008.0, + "visibility_in_air": 8.3, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 6, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 2414, + "precipitation_amount_mean_deterministic": 0.2, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.1, + "probability_of_precipitation": 55, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 18 + } + }, + { + "time": "2026-04-03T14:00:00Z", + "intervalParametersStartTime": "2026-04-03T13:00:00Z", + "data": { + "air_temperature": 3.3, + "wind_from_direction": 19, + "wind_speed": 1.4, + "wind_speed_of_gust": 4.2, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1008.0, + "visibility_in_air": 24.0, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 1307, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 38, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T15:00:00Z", + "intervalParametersStartTime": "2026-04-03T14:00:00Z", + "data": { + "air_temperature": 3.4, + "wind_from_direction": 19, + "wind_speed": 1.4, + "wind_speed_of_gust": 2.9, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1007.6, + "visibility_in_air": 8.3, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 1, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 1189, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 28, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T16:00:00Z", + "intervalParametersStartTime": "2026-04-03T15:00:00Z", + "data": { + "air_temperature": 3.5, + "wind_from_direction": 13, + "wind_speed": 1.3, + "wind_speed_of_gust": 2.9, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1007.5, + "visibility_in_air": 8.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 1189, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 14, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 11, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T17:00:00Z", + "intervalParametersStartTime": "2026-04-03T16:00:00Z", + "data": { + "air_temperature": 3.4, + "wind_from_direction": 349, + "wind_speed": 0.7, + "wind_speed_of_gust": 2.6, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1007.6, + "visibility_in_air": 8.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 169, + "cloud_top_altitude": 1433, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 24, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T18:00:00Z", + "intervalParametersStartTime": "2026-04-03T17:00:00Z", + "data": { + "air_temperature": 2.9, + "wind_from_direction": 259, + "wind_speed": 0.8, + "wind_speed_of_gust": 1.7, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1007.7, + "visibility_in_air": 9.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 140, + "cloud_top_altitude": 1717, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T19:00:00Z", + "intervalParametersStartTime": "2026-04-03T18:00:00Z", + "data": { + "air_temperature": 2.3, + "wind_from_direction": 248, + "wind_speed": 1.1, + "wind_speed_of_gust": 2.4, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1007.5, + "visibility_in_air": 8.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 112, + "cloud_top_altitude": 1717, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 7, + "precipitation_frozen_part": 2, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T20:00:00Z", + "intervalParametersStartTime": "2026-04-03T19:00:00Z", + "data": { + "air_temperature": 2.0, + "wind_from_direction": 126, + "wind_speed": 1.0, + "wind_speed_of_gust": 2.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1006.9, + "visibility_in_air": 7.7, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 1, + "cloud_base_altitude": 85, + "cloud_top_altitude": 1082, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T21:00:00Z", + "intervalParametersStartTime": "2026-04-03T20:00:00Z", + "data": { + "air_temperature": 1.9, + "wind_from_direction": 177, + "wind_speed": 1.6, + "wind_speed_of_gust": 3.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1006.7, + "visibility_in_air": 8.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 85, + "cloud_top_altitude": 1434, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T22:00:00Z", + "intervalParametersStartTime": "2026-04-03T21:00:00Z", + "data": { + "air_temperature": 1.8, + "wind_from_direction": 169, + "wind_speed": 1.4, + "wind_speed_of_gust": 3.4, + "relative_humidity": 98, + "air_pressure_at_mean_sea_level": 1006.0, + "visibility_in_air": 6.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 60, + "cloud_top_altitude": 1434, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-03T23:00:00Z", + "intervalParametersStartTime": "2026-04-03T22:00:00Z", + "data": { + "air_temperature": 1.9, + "wind_from_direction": 193, + "wind_speed": 2.1, + "wind_speed_of_gust": 4.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1005.5, + "visibility_in_air": 7.4, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 85, + "cloud_top_altitude": 6273, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T00:00:00Z", + "intervalParametersStartTime": "2026-04-03T23:00:00Z", + "data": { + "air_temperature": 1.9, + "wind_from_direction": 161, + "wind_speed": 2.1, + "wind_speed_of_gust": 4.1, + "relative_humidity": 97, + "air_pressure_at_mean_sea_level": 1004.6, + "visibility_in_air": 8.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 5, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 112, + "cloud_top_altitude": 7439, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 3, + "precipitation_frozen_part": 7, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T01:00:00Z", + "intervalParametersStartTime": "2026-04-04T00:00:00Z", + "data": { + "air_temperature": 1.8, + "wind_from_direction": 172, + "wind_speed": 2.2, + "wind_speed_of_gust": 4.6, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1003.9, + "visibility_in_air": 9.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 140, + "cloud_top_altitude": 4905, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T02:00:00Z", + "intervalParametersStartTime": "2026-04-04T01:00:00Z", + "data": { + "air_temperature": 1.7, + "wind_from_direction": 169, + "wind_speed": 2.4, + "wind_speed_of_gust": 4.4, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1003.2, + "visibility_in_air": 6.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 581, + "cloud_top_altitude": 5884, + "precipitation_amount_mean_deterministic": 0.1, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.7, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 45, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T03:00:00Z", + "intervalParametersStartTime": "2026-04-04T02:00:00Z", + "data": { + "air_temperature": 1.7, + "wind_from_direction": 159, + "wind_speed": 2.4, + "wind_speed_of_gust": 4.5, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1002.3, + "visibility_in_air": 7.1, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 4, + "cloud_base_altitude": 1567, + "cloud_top_altitude": 4883, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.5, + "precipitation_amount_min": 0.4, + "precipitation_amount_max": 0.7, + "precipitation_amount_median": 0.4, + "probability_of_precipitation": 72, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-04T04:00:00Z", + "intervalParametersStartTime": "2026-04-04T03:00:00Z", + "data": { + "air_temperature": 1.5, + "wind_from_direction": 180, + "wind_speed": 3.1, + "wind_speed_of_gust": 6.0, + "relative_humidity": 95, + "air_pressure_at_mean_sea_level": 1001.7, + "visibility_in_air": 5.3, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 1, + "cloud_base_altitude": 647, + "cloud_top_altitude": 4006, + "precipitation_amount_mean_deterministic": 0.6, + "precipitation_amount_mean": 0.6, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.9, + "precipitation_amount_median": 0.4, + "probability_of_precipitation": 93, + "precipitation_frozen_part": 3, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-04T05:00:00Z", + "intervalParametersStartTime": "2026-04-04T04:00:00Z", + "data": { + "air_temperature": 1.7, + "wind_from_direction": 191, + "wind_speed": 3.1, + "wind_speed_of_gust": 6.4, + "relative_humidity": 96, + "air_pressure_at_mean_sea_level": 1001.3, + "visibility_in_air": 9.2, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 6, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 201, + "cloud_top_altitude": 4000, + "precipitation_amount_mean_deterministic": 0.5, + "precipitation_amount_mean": 0.5, + "precipitation_amount_min": 0.4, + "precipitation_amount_max": 0.9, + "precipitation_amount_median": 0.4, + "probability_of_precipitation": 69, + "precipitation_frozen_part": 3, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-04T06:00:00Z", + "intervalParametersStartTime": "2026-04-04T05:00:00Z", + "data": { + "air_temperature": 2.8, + "wind_from_direction": 209, + "wind_speed": 3.4, + "wind_speed_of_gust": 6.4, + "relative_humidity": 93, + "air_pressure_at_mean_sea_level": 1001.0, + "visibility_in_air": 8.4, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 887, + "cloud_top_altitude": 1188, + "precipitation_amount_mean_deterministic": 0.2, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 48, + "precipitation_frozen_part": 3, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T07:00:00Z", + "intervalParametersStartTime": "2026-04-04T06:00:00Z", + "data": { + "air_temperature": 3.6, + "wind_from_direction": 233, + "wind_speed": 4.0, + "wind_speed_of_gust": 7.5, + "relative_humidity": 90, + "air_pressure_at_mean_sea_level": 1000.9, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 980, + "cloud_top_altitude": 1080, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.3, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 45, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T08:00:00Z", + "intervalParametersStartTime": "2026-04-04T07:00:00Z", + "data": { + "air_temperature": 4.3, + "wind_from_direction": 253, + "wind_speed": 6.8, + "wind_speed_of_gust": 12.5, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1001.0, + "visibility_in_air": 15.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 649, + "cloud_top_altitude": 1188, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 1.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 34, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T09:00:00Z", + "intervalParametersStartTime": "2026-04-04T08:00:00Z", + "data": { + "air_temperature": 5.0, + "wind_from_direction": 257, + "wind_speed": 6.3, + "wind_speed_of_gust": 12.5, + "relative_humidity": 79, + "air_pressure_at_mean_sea_level": 1001.1, + "visibility_in_air": 18.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 723, + "cloud_top_altitude": 1188, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 34, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T10:00:00Z", + "intervalParametersStartTime": "2026-04-04T09:00:00Z", + "data": { + "air_temperature": 5.7, + "wind_from_direction": 261, + "wind_speed": 6.8, + "wind_speed_of_gust": 12.5, + "relative_humidity": 74, + "air_pressure_at_mean_sea_level": 1001.2, + "visibility_in_air": 21.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 890, + "cloud_top_altitude": 4836, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 38, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T11:00:00Z", + "intervalParametersStartTime": "2026-04-04T10:00:00Z", + "data": { + "air_temperature": 6.3, + "wind_from_direction": 269, + "wind_speed": 7.1, + "wind_speed_of_gust": 13.2, + "relative_humidity": 69, + "air_pressure_at_mean_sea_level": 1001.4, + "visibility_in_air": 24.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 7, + "high_type_cloud_area_fraction": 1, + "cloud_base_altitude": 1084, + "cloud_top_altitude": 1870, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 28, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T12:00:00Z", + "intervalParametersStartTime": "2026-04-04T11:00:00Z", + "data": { + "air_temperature": 7.1, + "wind_from_direction": 277, + "wind_speed": 7.0, + "wind_speed_of_gust": 13.3, + "relative_humidity": 64, + "air_pressure_at_mean_sea_level": 1001.9, + "visibility_in_air": 28.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 6, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 1311, + "cloud_top_altitude": 1572, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-04T13:00:00Z", + "intervalParametersStartTime": "2026-04-04T12:00:00Z", + "data": { + "air_temperature": 7.8, + "wind_from_direction": 294, + "wind_speed": 6.4, + "wind_speed_of_gust": 13.3, + "relative_humidity": 59, + "air_pressure_at_mean_sea_level": 1002.4, + "visibility_in_air": 31.4, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 6, + "medium_type_cloud_area_fraction": 5, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 1575, + "cloud_top_altitude": 5153, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 24, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } + }, + { + "time": "2026-04-04T14:00:00Z", + "intervalParametersStartTime": "2026-04-04T13:00:00Z", + "data": { + "air_temperature": 8.4, + "wind_from_direction": 289, + "wind_speed": 7.6, + "wind_speed_of_gust": 14.2, + "relative_humidity": 54, + "air_pressure_at_mean_sea_level": 1003.1, + "visibility_in_air": 35.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 14, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } + }, + { + "time": "2026-04-04T15:00:00Z", + "intervalParametersStartTime": "2026-04-04T14:00:00Z", + "data": { + "air_temperature": 8.4, + "wind_from_direction": 290, + "wind_speed": 7.5, + "wind_speed_of_gust": 14.8, + "relative_humidity": 51, + "air_pressure_at_mean_sea_level": 1003.8, + "visibility_in_air": 37.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 4, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 14, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-04T16:00:00Z", + "intervalParametersStartTime": "2026-04-04T15:00:00Z", + "data": { + "air_temperature": 8.1, + "wind_from_direction": 288, + "wind_speed": 6.8, + "wind_speed_of_gust": 14.0, + "relative_humidity": 51, + "air_pressure_at_mean_sea_level": 1004.7, + "visibility_in_air": 36.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 4, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 2048, + "cloud_top_altitude": 2223, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 10, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-04T17:00:00Z", + "intervalParametersStartTime": "2026-04-04T16:00:00Z", + "data": { + "air_temperature": 7.6, + "wind_from_direction": 280, + "wind_speed": 5.7, + "wind_speed_of_gust": 12.3, + "relative_humidity": 52, + "air_pressure_at_mean_sea_level": 1005.6, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 3, + "low_type_cloud_area_fraction": 1, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 3, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 2 + } + }, + { + "time": "2026-04-04T18:00:00Z", + "intervalParametersStartTime": "2026-04-04T17:00:00Z", + "data": { + "air_temperature": 6.2, + "wind_from_direction": 271, + "wind_speed": 5.0, + "wind_speed_of_gust": 10.3, + "relative_humidity": 56, + "air_pressure_at_mean_sea_level": 1006.5, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-04T19:00:00Z", + "intervalParametersStartTime": "2026-04-04T18:00:00Z", + "data": { + "air_temperature": 5.1, + "wind_from_direction": 254, + "wind_speed": 3.9, + "wind_speed_of_gust": 9.1, + "relative_humidity": 64, + "air_pressure_at_mean_sea_level": 1007.3, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-04T20:00:00Z", + "intervalParametersStartTime": "2026-04-04T19:00:00Z", + "data": { + "air_temperature": 3.9, + "wind_from_direction": 257, + "wind_speed": 3.9, + "wind_speed_of_gust": 7.1, + "relative_humidity": 75, + "air_pressure_at_mean_sea_level": 1008.1, + "visibility_in_air": 21.1, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-04T21:00:00Z", + "intervalParametersStartTime": "2026-04-04T20:00:00Z", + "data": { + "air_temperature": 2.8, + "wind_from_direction": 265, + "wind_speed": 2.9, + "wind_speed_of_gust": 7.0, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1008.8, + "visibility_in_air": 16.5, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-04T22:00:00Z", + "intervalParametersStartTime": "2026-04-04T21:00:00Z", + "data": { + "air_temperature": 1.7, + "wind_from_direction": 251, + "wind_speed": 2.4, + "wind_speed_of_gust": 5.1, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 1009.5, + "visibility_in_air": 13.7, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 9999, + "cloud_top_altitude": 9999, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1 + } + }, + { + "time": "2026-04-05T00:00:00Z", + "intervalParametersStartTime": "2026-04-04T22:00:00Z", + "data": { + "air_temperature": 2.3, + "wind_from_direction": 254, + "wind_speed": 4.3, + "wind_speed_of_gust": 8.4, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1009.4, + "visibility_in_air": 13.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 8703, + "cloud_top_altitude": 9263, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 3 + } + }, + { + "time": "2026-04-05T06:00:00Z", + "intervalParametersStartTime": "2026-04-05T00:00:00Z", + "data": { + "air_temperature": 2.1, + "wind_from_direction": 161, + "wind_speed": 3.4, + "wind_speed_of_gust": 5.6, + "relative_humidity": 83, + "air_pressure_at_mean_sea_level": 1006.8, + "visibility_in_air": 12.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 5039, + "cloud_top_altitude": 9847, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 2.0, + "precipitation_amount_max": 2.7, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 9, + "precipitation_frozen_part": 3, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } + }, + { + "time": "2026-04-05T12:00:00Z", + "intervalParametersStartTime": "2026-04-05T06:00:00Z", + "data": { + "air_temperature": 8.3, + "wind_from_direction": 182, + "wind_speed": 6.9, + "wind_speed_of_gust": 13.0, + "relative_humidity": 81, + "air_pressure_at_mean_sea_level": 999.0, + "visibility_in_air": 20.6, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 8, + "cloud_base_altitude": 1059, + "cloud_top_altitude": 9831, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 1.8, + "precipitation_amount_min": 1.3, + "precipitation_amount_max": 2.7, + "precipitation_amount_median": 1.9, + "probability_of_precipitation": 83, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 18 + } + }, + { + "time": "2026-04-05T18:00:00Z", + "intervalParametersStartTime": "2026-04-05T12:00:00Z", + "data": { + "air_temperature": 6.7, + "wind_from_direction": 227, + "wind_speed": 8.2, + "wind_speed_of_gust": 14.5, + "relative_humidity": 47, + "air_pressure_at_mean_sea_level": 995.8, + "visibility_in_air": 31.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 2, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 1, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.9, + "precipitation_amount_min": 0.5, + "precipitation_amount_max": 1.6, + "precipitation_amount_median": 0.7, + "probability_of_precipitation": 77, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 8 + } + }, + { + "time": "2026-04-06T00:00:00Z", + "intervalParametersStartTime": "2026-04-05T18:00:00Z", + "data": { + "air_temperature": 4.0, + "wind_from_direction": 240, + "wind_speed": 8.8, + "wind_speed_of_gust": 16.9, + "relative_humidity": 69, + "air_pressure_at_mean_sea_level": 995.0, + "visibility_in_air": 50.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 7, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 3159, + "cloud_top_altitude": 4379, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.5, + "precipitation_amount_max": 1.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 56, + "predominant_precipitation_type_at_surface": 6, + "symbol_code": 4 + } + }, + { + "time": "2026-04-06T06:00:00Z", + "intervalParametersStartTime": "2026-04-06T00:00:00Z", + "data": { + "air_temperature": 4.2, + "wind_from_direction": 261, + "wind_speed": 7.9, + "wind_speed_of_gust": 15.7, + "relative_humidity": 67, + "air_pressure_at_mean_sea_level": 996.3, + "visibility_in_air": 20.7, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 7, + "cloud_base_altitude": 7383, + "cloud_top_altitude": 7383, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.5, + "precipitation_amount_max": 1.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 21, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-06T12:00:00Z", + "intervalParametersStartTime": "2026-04-06T06:00:00Z", + "data": { + "air_temperature": 10.3, + "wind_from_direction": 290, + "wind_speed": 9.5, + "wind_speed_of_gust": 19.6, + "relative_humidity": 40, + "air_pressure_at_mean_sea_level": 1001.5, + "visibility_in_air": 36.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 1, + "medium_type_cloud_area_fraction": 6, + "high_type_cloud_area_fraction": 6, + "cloud_base_altitude": 6911, + "cloud_top_altitude": 6911, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.4, + "precipitation_amount_max": 0.8, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 28, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } + }, + { + "time": "2026-04-06T18:00:00Z", + "intervalParametersStartTime": "2026-04-06T12:00:00Z", + "data": { + "air_temperature": 8.7, + "wind_from_direction": 297, + "wind_speed": 5.8, + "wind_speed_of_gust": 15.5, + "relative_humidity": 44, + "air_pressure_at_mean_sea_level": 1007.5, + "visibility_in_air": 33.2, + "thunderstorm_probability": 1, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 1, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 1, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.2, + "precipitation_amount_min": 0.3, + "precipitation_amount_max": 1.2, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 25, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 1 + } + }, + { + "time": "2026-04-07T00:00:00Z", + "intervalParametersStartTime": "2026-04-06T18:00:00Z", + "data": { + "air_temperature": 5.4, + "wind_from_direction": 315, + "wind_speed": 5.3, + "wind_speed_of_gust": 10.0, + "relative_humidity": 57, + "air_pressure_at_mean_sea_level": 1013.3, + "visibility_in_air": 25.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 4, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 1 + } + }, + { + "time": "2026-04-07T06:00:00Z", + "intervalParametersStartTime": "2026-04-07T00:00:00Z", + "data": { + "air_temperature": 5.2, + "wind_from_direction": 327, + "wind_speed": 4.9, + "wind_speed_of_gust": 9.4, + "relative_humidity": 61, + "air_pressure_at_mean_sea_level": 1017.7, + "visibility_in_air": 24.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 1, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 1, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 2 + } + }, + { + "time": "2026-04-07T12:00:00Z", + "intervalParametersStartTime": "2026-04-07T06:00:00Z", + "data": { + "air_temperature": 10.3, + "wind_from_direction": 352, + "wind_speed": 6.1, + "wind_speed_of_gust": 12.5, + "relative_humidity": 37, + "air_pressure_at_mean_sea_level": 1021.1, + "visibility_in_air": 37.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 1, + "medium_type_cloud_area_fraction": 5, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.5, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 2, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-07T18:00:00Z", + "intervalParametersStartTime": "2026-04-07T12:00:00Z", + "data": { + "air_temperature": 5.6, + "wind_from_direction": 17, + "wind_speed": 3.7, + "wind_speed_of_gust": 10.3, + "relative_humidity": 62, + "air_pressure_at_mean_sea_level": 1024.1, + "visibility_in_air": 23.5, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 3, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 1.3, + "precipitation_amount_max": 1.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 2, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 2 + } + }, + { + "time": "2026-04-08T00:00:00Z", + "intervalParametersStartTime": "2026-04-07T18:00:00Z", + "data": { + "air_temperature": 2.2, + "wind_from_direction": 3, + "wind_speed": 3.5, + "wind_speed_of_gust": 6.5, + "relative_humidity": 83, + "air_pressure_at_mean_sea_level": 1027.0, + "visibility_in_air": 12.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 2, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 2 + } + }, + { + "time": "2026-04-08T06:00:00Z", + "intervalParametersStartTime": "2026-04-08T00:00:00Z", + "data": { + "air_temperature": 3.1, + "wind_from_direction": 19, + "wind_speed": 4.2, + "wind_speed_of_gust": 7.5, + "relative_humidity": 81, + "air_pressure_at_mean_sea_level": 1028.6, + "visibility_in_air": 13.5, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 371, + "cloud_top_altitude": 543, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6 + } + }, + { + "time": "2026-04-08T12:00:00Z", + "intervalParametersStartTime": "2026-04-08T06:00:00Z", + "data": { + "air_temperature": 7.8, + "wind_from_direction": 46, + "wind_speed": 4.8, + "wind_speed_of_gust": 10.6, + "relative_humidity": 57, + "air_pressure_at_mean_sea_level": 1028.8, + "visibility_in_air": 26.2, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 6, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": 1059, + "cloud_top_altitude": 1171, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4 + } + }, + { + "time": "2026-04-09T00:00:00Z", + "intervalParametersStartTime": "2026-04-08T12:00:00Z", + "data": { + "air_temperature": -1.5, + "wind_from_direction": 57, + "wind_speed": 3.0, + "wind_speed_of_gust": 7.2, + "relative_humidity": 85, + "air_pressure_at_mean_sea_level": 1028.4, + "visibility_in_air": 11.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.01, + "cloud_area_fraction": 1, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "cloud_base_altitude": -8, + "cloud_top_altitude": -8, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 1.4, + "precipitation_amount_max": 1.4, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 2, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 1 + } + }, + { + "time": "2026-04-09T12:00:00Z", + "intervalParametersStartTime": "2026-04-09T00:00:00Z", + "data": { + "air_temperature": 6.5, + "wind_from_direction": 104, + "wind_speed": 5.0, + "wind_speed_of_gust": 10.3, + "relative_humidity": 48, + "air_pressure_at_mean_sea_level": 1024.4, + "visibility_in_air": 34.8, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 6299, + "cloud_top_altitude": 6560, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 1.9, + "precipitation_amount_max": 2.7, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 6, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-10T00:00:00Z", + "intervalParametersStartTime": "2026-04-09T12:00:00Z", + "data": { + "air_temperature": -0.1, + "wind_from_direction": 87, + "wind_speed": 3.5, + "wind_speed_of_gust": 7.9, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1021.1, + "visibility_in_air": 17.3, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.03, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 5774, + "cloud_top_altitude": 6062, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.6, + "precipitation_amount_min": 2.8, + "precipitation_amount_max": 4.7, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 16, + "precipitation_frozen_part": 88, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 3 + } + }, + { + "time": "2026-04-10T12:00:00Z", + "intervalParametersStartTime": "2026-04-10T00:00:00Z", + "data": { + "air_temperature": 6.7, + "wind_from_direction": 103, + "wind_speed": 4.8, + "wind_speed_of_gust": 10.4, + "relative_humidity": 47, + "air_pressure_at_mean_sea_level": 1020.2, + "visibility_in_air": 36.1, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 5273, + "cloud_top_altitude": 5638, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.5, + "precipitation_amount_min": 0.9, + "precipitation_amount_max": 2.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 25, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-11T00:00:00Z", + "intervalParametersStartTime": "2026-04-10T12:00:00Z", + "data": { + "air_temperature": 0.6, + "wind_from_direction": 111, + "wind_speed": 3.2, + "wind_speed_of_gust": 7.2, + "relative_humidity": 79, + "air_pressure_at_mean_sea_level": 1019.0, + "visibility_in_air": 22.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.01, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 5778, + "cloud_top_altitude": 6023, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.7, + "precipitation_amount_max": 1.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 13, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-11T12:00:00Z", + "intervalParametersStartTime": "2026-04-11T00:00:00Z", + "data": { + "air_temperature": 7.4, + "wind_from_direction": 136, + "wind_speed": 5.5, + "wind_speed_of_gust": 11.5, + "relative_humidity": 50, + "air_pressure_at_mean_sea_level": 1016.6, + "visibility_in_air": 26.0, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 5, + "high_type_cloud_area_fraction": 3, + "cloud_base_altitude": 4348, + "cloud_top_altitude": 6051, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.7, + "precipitation_amount_min": 0.9, + "precipitation_amount_max": 2.9, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 26, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } + }, + { + "time": "2026-04-12T00:00:00Z", + "intervalParametersStartTime": "2026-04-11T12:00:00Z", + "data": { + "air_temperature": 1.8, + "wind_from_direction": 125, + "wind_speed": 3.6, + "wind_speed_of_gust": 8.1, + "relative_humidity": 79, + "air_pressure_at_mean_sea_level": 1016.4, + "visibility_in_air": 15.5, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 2, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 4, + "cloud_base_altitude": 5746, + "cloud_top_altitude": 6725, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.8, + "precipitation_amount_min": 1.3, + "precipitation_amount_max": 5.0, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 24, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 3 + } + }, + { + "time": "2026-04-12T12:00:00Z", + "intervalParametersStartTime": "2026-04-12T00:00:00Z", + "data": { + "air_temperature": 8.3, + "wind_from_direction": 119, + "wind_speed": 5.1, + "wind_speed_of_gust": 11.1, + "relative_humidity": 51, + "air_pressure_at_mean_sea_level": 1016.3, + "visibility_in_air": 20.6, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 4, + "cloud_base_altitude": 5129, + "cloud_top_altitude": 6507, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.6, + "precipitation_amount_min": 0.8, + "precipitation_amount_max": 2.8, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 31, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 4 + } } ] } diff --git a/tests/components/smhi/fixtures/smhi_short.json b/tests/components/smhi/fixtures/smhi_short.json index ad9567b7f572b4..784077647d0c3f 100644 --- a/tests/components/smhi/fixtures/smhi_short.json +++ b/tests/components/smhi/fixtures/smhi_short.json @@ -1,148 +1,37 @@ { - "approvedTime": "2023-08-07T07:07:34Z", - "referenceTime": "2023-08-07T07:00:00Z", - "geometry": { - "type": "Point", - "coordinates": [[15.990068, 57.997072]] - }, + "createdTime": "2026-04-02T11:01:32Z", + "referenceTime": "2026-04-02T10:45:00Z", + "geometry": { "type": "Point", "coordinates": [16.158549, 58.577821] }, "timeSeries": [ { - "validTime": "2023-08-07T08:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [8] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [18.4] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [992.4] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [0.4] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [93] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [100] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [37] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [6.2] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [7] - } - ] + "time": "2026-04-02T11:00:00Z", + "intervalParametersStartTime": "2026-04-02T10:00:00Z", + "data": { + "air_temperature": 10.4, + "wind_from_direction": 255, + "wind_speed": 3.4, + "wind_speed_of_gust": 6.8, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 1011.5, + "visibility_in_air": 17.9, + "thunderstorm_probability": 0, + "probability_of_frozen_precipitation": 0.0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 8, + "high_type_cloud_area_fraction": 2, + "cloud_base_altitude": 528, + "cloud_top_altitude": 2250, + "precipitation_amount_mean_deterministic": 0.0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "probability_of_precipitation": 10, + "precipitation_frozen_part": 0, + "predominant_precipitation_type_at_surface": 1, + "symbol_code": 6 + } } ] } diff --git a/tests/components/smhi/snapshots/test_sensor.ambr b/tests/components/smhi/snapshots/test_sensor.ambr index b481652ef143a1..9edd63c127c45f 100644 --- a/tests/components/smhi/snapshots/test_sensor.ambr +++ b/tests/components/smhi/snapshots/test_sensor.ambr @@ -508,7 +508,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '88', + 'state': '25', }) # --- # name: test_sensor_setup[load_platforms0][sensor.test_highest_grass_fire_risk-entry] @@ -735,7 +735,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '88', + 'state': '100', }) # --- # name: test_sensor_setup[load_platforms0][sensor.test_potential_rate_of_spread-entry] @@ -805,13 +805,19 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '1', - '2', - '3', - '4', - '5', - '6', + 'no_precipitation', + 'rain', + 'thunderstorm', + 'freezing_rain', + 'mixed_ice', + 'snow', + 'wet_snow', + 'rain_snow_mixed', + 'ice_pellets', + 'graupel', + 'hail', + 'drizzle', + 'freezing_drizzle', ]), }), 'config_entry_id': , @@ -851,13 +857,19 @@ 'device_class': 'enum', 'friendly_name': 'Test Precipitation category', 'options': list([ - '0', - '1', - '2', - '3', - '4', - '5', - '6', + 'no_precipitation', + 'rain', + 'thunderstorm', + 'freezing_rain', + 'mixed_ice', + 'snow', + 'wet_snow', + 'rain_snow_mixed', + 'ice_pellets', + 'graupel', + 'hail', + 'drizzle', + 'freezing_drizzle', ]), }), 'context': , @@ -865,7 +877,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'rain', }) # --- # name: test_sensor_setup[load_platforms0][sensor.test_thunder_probability-entry] @@ -917,7 +929,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '37', + 'state': '0', }) # --- # name: test_sensor_setup[load_platforms0][sensor.test_total_cloud_coverage-entry] diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index 2df5bb01a3c020..72f514560854a5 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -6,54 +6,600 @@ dict({ 'cloud_coverage': 100, 'condition': 'clear-night', - 'datetime': '2023-08-08T00:00:00+00:00', - 'humidity': 100, - 'precipitation': 0.0, - 'pressure': 992.4, - 'temperature': 18.2, - 'templow': 18.2, - 'wind_bearing': 103, + 'datetime': '2026-04-03T01:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.2, + 'pressure': 1007.8, + 'temperature': 5.0, + 'templow': 5.0, + 'wind_bearing': 193, + 'wind_gust_speed': 6.84, + 'wind_speed': 2.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T02:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.3, + 'pressure': 1007.4, + 'temperature': 5.0, + 'templow': 5.0, + 'wind_bearing': 192, + 'wind_gust_speed': 3.96, + 'wind_speed': 2.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T03:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.3, + 'pressure': 1007.1, + 'temperature': 4.8, + 'templow': 4.8, + 'wind_bearing': 172, + 'wind_gust_speed': 4.68, + 'wind_speed': 2.52, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T04:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.3, + 'pressure': 1006.9, + 'temperature': 4.7, + 'templow': 4.7, + 'wind_bearing': 32, + 'wind_gust_speed': 4.68, + 'wind_speed': 1.44, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T05:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.3, + 'pressure': 1007.0, + 'temperature': 4.4, + 'templow': 4.4, + 'wind_bearing': 48, + 'wind_gust_speed': 20.16, + 'wind_speed': 10.44, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T06:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.3, + 'pressure': 1007.1, + 'temperature': 4.2, + 'templow': 4.2, + 'wind_bearing': 45, + 'wind_gust_speed': 21.24, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T07:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.3, + 'pressure': 1007.4, + 'temperature': 4.0, + 'templow': 4.0, + 'wind_bearing': 79, + 'wind_gust_speed': 23.76, + 'wind_speed': 12.6, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T08:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.4, + 'pressure': 1007.7, + 'temperature': 4.0, + 'templow': 4.0, + 'wind_bearing': 72, 'wind_gust_speed': 23.76, + 'wind_speed': 11.52, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T09:00:00+00:00', + 'humidity': 96, + 'precipitation': 0.3, + 'pressure': 1007.8, + 'temperature': 3.9, + 'templow': 3.9, + 'wind_bearing': 55, + 'wind_gust_speed': 21.96, + 'wind_speed': 11.52, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T10:00:00+00:00', + 'humidity': 95, + 'precipitation': 0.2, + 'pressure': 1007.9, + 'temperature': 3.7, + 'templow': 3.7, + 'wind_bearing': 44, + 'wind_gust_speed': 22.32, + 'wind_speed': 10.8, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T11:00:00+00:00', + 'humidity': 94, + 'precipitation': 0.2, + 'pressure': 1008.1, + 'temperature': 3.3, + 'templow': 3.3, + 'wind_bearing': 31, + 'wind_gust_speed': 21.24, + 'wind_speed': 10.44, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T12:00:00+00:00', + 'humidity': 94, + 'precipitation': 0.2, + 'pressure': 1008.1, + 'temperature': 3.1, + 'templow': 3.1, + 'wind_bearing': 27, + 'wind_gust_speed': 20.16, 'wind_speed': 9.72, }), dict({ 'cloud_coverage': 100, - 'condition': 'clear-night', - 'datetime': '2023-08-08T01:00:00+00:00', - 'humidity': 100, + 'condition': 'rainy', + 'datetime': '2026-04-03T13:00:00+00:00', + 'humidity': 93, + 'precipitation': 0.1, + 'pressure': 1008.0, + 'temperature': 3.2, + 'templow': 3.2, + 'wind_bearing': 30, + 'wind_gust_speed': 18.72, + 'wind_speed': 7.92, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-03T14:00:00+00:00', + 'humidity': 93, + 'precipitation': 0.1, + 'pressure': 1008.0, + 'temperature': 3.3, + 'templow': 3.3, + 'wind_bearing': 19, + 'wind_gust_speed': 15.12, + 'wind_speed': 5.04, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-03T15:00:00+00:00', + 'humidity': 93, 'precipitation': 0.0, - 'pressure': 992.4, - 'temperature': 17.5, - 'templow': 17.5, - 'wind_bearing': 104, - 'wind_gust_speed': 27.36, - 'wind_speed': 9.72, + 'pressure': 1007.6, + 'temperature': 3.4, + 'templow': 3.4, + 'wind_bearing': 19, + 'wind_gust_speed': 10.44, + 'wind_speed': 5.04, }), dict({ 'cloud_coverage': 100, - 'condition': 'clear-night', - 'datetime': '2023-08-08T02:00:00+00:00', + 'condition': 'cloudy', + 'datetime': '2026-04-03T16:00:00+00:00', + 'humidity': 93, + 'precipitation': 0.0, + 'pressure': 1007.5, + 'temperature': 3.5, + 'templow': 3.5, + 'wind_bearing': 13, + 'wind_gust_speed': 10.44, + 'wind_speed': 4.68, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-03T17:00:00+00:00', + 'humidity': 93, + 'precipitation': 0.0, + 'pressure': 1007.6, + 'temperature': 3.4, + 'templow': 3.4, + 'wind_bearing': 349, + 'wind_gust_speed': 9.36, + 'wind_speed': 2.52, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-03T18:00:00+00:00', + 'humidity': 95, + 'precipitation': 0.0, + 'pressure': 1007.7, + 'temperature': 2.9, + 'templow': 2.9, + 'wind_bearing': 259, + 'wind_gust_speed': 6.12, + 'wind_speed': 2.88, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-03T19:00:00+00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 1007.5, + 'temperature': 2.3, + 'templow': 2.3, + 'wind_bearing': 248, + 'wind_gust_speed': 8.64, + 'wind_speed': 3.96, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-03T20:00:00+00:00', 'humidity': 97, 'precipitation': 0.0, - 'pressure': 992.2, - 'temperature': 17.6, - 'templow': 17.6, - 'wind_bearing': 109, - 'wind_gust_speed': 32.4, - 'wind_speed': 12.96, + 'pressure': 1006.9, + 'temperature': 2.0, + 'templow': 2.0, + 'wind_bearing': 126, + 'wind_gust_speed': 7.56, + 'wind_speed': 3.6, }), dict({ 'cloud_coverage': 100, - 'condition': 'sunny', - 'datetime': '2023-08-08T03:00:00+00:00', + 'condition': 'cloudy', + 'datetime': '2026-04-03T21:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.0, + 'pressure': 1006.7, + 'temperature': 1.9, + 'templow': 1.9, + 'wind_bearing': 177, + 'wind_gust_speed': 11.16, + 'wind_speed': 5.76, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-03T22:00:00+00:00', + 'humidity': 98, + 'precipitation': 0.0, + 'pressure': 1006.0, + 'temperature': 1.8, + 'templow': 1.8, + 'wind_bearing': 169, + 'wind_gust_speed': 12.24, + 'wind_speed': 5.04, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-03T23:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.0, + 'pressure': 1005.5, + 'temperature': 1.9, + 'templow': 1.9, + 'wind_bearing': 193, + 'wind_gust_speed': 14.76, + 'wind_speed': 7.56, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T00:00:00+00:00', + 'humidity': 97, + 'precipitation': 0.0, + 'pressure': 1004.6, + 'temperature': 1.9, + 'templow': 1.9, + 'wind_bearing': 161, + 'wind_gust_speed': 14.76, + 'wind_speed': 7.56, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T01:00:00+00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 1003.9, + 'temperature': 1.8, + 'templow': 1.8, + 'wind_bearing': 172, + 'wind_gust_speed': 16.56, + 'wind_speed': 7.92, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T02:00:00+00:00', + 'humidity': 95, + 'precipitation': 0.2, + 'pressure': 1003.2, + 'temperature': 1.7, + 'templow': 1.7, + 'wind_bearing': 169, + 'wind_gust_speed': 15.84, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-04T03:00:00+00:00', + 'humidity': 95, + 'precipitation': 0.5, + 'pressure': 1002.3, + 'temperature': 1.7, + 'templow': 1.7, + 'wind_bearing': 159, + 'wind_gust_speed': 16.2, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-04T04:00:00+00:00', + 'humidity': 95, + 'precipitation': 0.6, + 'pressure': 1001.7, + 'temperature': 1.5, + 'templow': 1.5, + 'wind_bearing': 180, + 'wind_gust_speed': 21.6, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2026-04-04T05:00:00+00:00', 'humidity': 96, + 'precipitation': 0.5, + 'pressure': 1001.3, + 'temperature': 1.7, + 'templow': 1.7, + 'wind_bearing': 191, + 'wind_gust_speed': 23.04, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T06:00:00+00:00', + 'humidity': 93, + 'precipitation': 0.3, + 'pressure': 1001.0, + 'temperature': 2.8, + 'templow': 2.8, + 'wind_bearing': 209, + 'wind_gust_speed': 23.04, + 'wind_speed': 12.24, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T07:00:00+00:00', + 'humidity': 90, + 'precipitation': 0.3, + 'pressure': 1000.9, + 'temperature': 3.6, + 'templow': 3.6, + 'wind_bearing': 233, + 'wind_gust_speed': 27.0, + 'wind_speed': 14.4, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T08:00:00+00:00', + 'humidity': 82, + 'precipitation': 0.2, + 'pressure': 1001.0, + 'temperature': 4.3, + 'templow': 4.3, + 'wind_bearing': 253, + 'wind_gust_speed': 45.0, + 'wind_speed': 24.48, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T09:00:00+00:00', + 'humidity': 79, + 'precipitation': 0.1, + 'pressure': 1001.1, + 'temperature': 5.0, + 'templow': 5.0, + 'wind_bearing': 257, + 'wind_gust_speed': 45.0, + 'wind_speed': 22.68, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T10:00:00+00:00', + 'humidity': 74, + 'precipitation': 0.1, + 'pressure': 1001.2, + 'temperature': 5.7, + 'templow': 5.7, + 'wind_bearing': 261, + 'wind_gust_speed': 45.0, + 'wind_speed': 24.48, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T11:00:00+00:00', + 'humidity': 69, + 'precipitation': 0.1, + 'pressure': 1001.4, + 'temperature': 6.3, + 'templow': 6.3, + 'wind_bearing': 269, + 'wind_gust_speed': 47.52, + 'wind_speed': 25.56, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-04T12:00:00+00:00', + 'humidity': 64, + 'precipitation': 0.1, + 'pressure': 1001.9, + 'temperature': 7.1, + 'templow': 7.1, + 'wind_bearing': 277, + 'wind_gust_speed': 47.88, + 'wind_speed': 25.2, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2026-04-04T13:00:00+00:00', + 'humidity': 59, 'precipitation': 0.0, - 'pressure': 991.7, - 'temperature': 17.1, - 'templow': 17.1, - 'wind_bearing': 114, + 'pressure': 1002.4, + 'temperature': 7.8, + 'templow': 7.8, + 'wind_bearing': 294, + 'wind_gust_speed': 47.88, + 'wind_speed': 23.04, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2026-04-04T14:00:00+00:00', + 'humidity': 54, + 'precipitation': 0.0, + 'pressure': 1003.1, + 'temperature': 8.4, + 'templow': 8.4, + 'wind_bearing': 289, + 'wind_gust_speed': 51.12, + 'wind_speed': 27.36, + }), + dict({ + 'cloud_coverage': 62, + 'condition': 'partlycloudy', + 'datetime': '2026-04-04T15:00:00+00:00', + 'humidity': 51, + 'precipitation': 0.0, + 'pressure': 1003.8, + 'temperature': 8.4, + 'templow': 8.4, + 'wind_bearing': 290, + 'wind_gust_speed': 53.28, + 'wind_speed': 27.0, + }), + dict({ + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2026-04-04T16:00:00+00:00', + 'humidity': 51, + 'precipitation': 0.0, + 'pressure': 1004.7, + 'temperature': 8.1, + 'templow': 8.1, + 'wind_bearing': 288, + 'wind_gust_speed': 50.4, + 'wind_speed': 24.48, + }), + dict({ + 'cloud_coverage': 38, + 'condition': 'sunny', + 'datetime': '2026-04-04T17:00:00+00:00', + 'humidity': 52, + 'precipitation': 0.0, + 'pressure': 1005.6, + 'temperature': 7.6, + 'templow': 7.6, + 'wind_bearing': 280, + 'wind_gust_speed': 44.28, + 'wind_speed': 20.52, + }), + dict({ + 'cloud_coverage': 0, + 'condition': 'clear-night', + 'datetime': '2026-04-04T18:00:00+00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'pressure': 1006.5, + 'temperature': 6.2, + 'templow': 6.2, + 'wind_bearing': 271, + 'wind_gust_speed': 37.08, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 0, + 'condition': 'clear-night', + 'datetime': '2026-04-04T19:00:00+00:00', + 'humidity': 64, + 'precipitation': 0.0, + 'pressure': 1007.3, + 'temperature': 5.1, + 'templow': 5.1, + 'wind_bearing': 254, 'wind_gust_speed': 32.76, - 'wind_speed': 10.08, + 'wind_speed': 14.04, + }), + dict({ + 'cloud_coverage': 0, + 'condition': 'clear-night', + 'datetime': '2026-04-04T20:00:00+00:00', + 'humidity': 75, + 'precipitation': 0.0, + 'pressure': 1008.1, + 'temperature': 3.9, + 'templow': 3.9, + 'wind_bearing': 257, + 'wind_gust_speed': 25.56, + 'wind_speed': 14.04, + }), + dict({ + 'cloud_coverage': 0, + 'condition': 'clear-night', + 'datetime': '2026-04-04T21:00:00+00:00', + 'humidity': 82, + 'precipitation': 0.0, + 'pressure': 1008.8, + 'temperature': 2.8, + 'templow': 2.8, + 'wind_bearing': 265, + 'wind_gust_speed': 25.2, + 'wind_speed': 10.44, + }), + dict({ + 'cloud_coverage': 0, + 'condition': 'clear-night', + 'datetime': '2026-04-04T22:00:00+00:00', + 'humidity': 86, + 'precipitation': 0.0, + 'pressure': 1009.5, + 'temperature': 1.7, + 'templow': 1.7, + 'wind_bearing': 251, + 'wind_gust_speed': 18.36, + 'wind_speed': 8.64, }), ]), }), @@ -64,19 +610,19 @@ 'attribution': 'Swedish weather institute (SMHI)', 'cloud_coverage': 100, 'friendly_name': 'Test', - 'humidity': 100, + 'humidity': 97, 'precipitation_unit': , - 'pressure': 992.4, + 'pressure': 1008.2, 'pressure_unit': , 'supported_features': , - 'temperature': 18.4, + 'temperature': 5.3, 'temperature_unit': , - 'thunder_probability': 37, - 'visibility': 0.4, + 'thunder_probability': 1, + 'visibility': 8.3, 'visibility_unit': , - 'wind_bearing': 93, - 'wind_gust_speed': 22.32, - 'wind_speed': 9.0, + 'wind_bearing': 138, + 'wind_gust_speed': 4.32, + 'wind_speed': 1.8, 'wind_speed_unit': , }) # --- @@ -87,132 +633,145 @@ dict({ 'cloud_coverage': 100, 'condition': 'cloudy', - 'datetime': '2023-08-07T12:00:00+00:00', - 'humidity': 96, - 'precipitation': 0.0, - 'pressure': 991.7, - 'temperature': 18.4, - 'templow': 14.8, - 'wind_bearing': 114, - 'wind_gust_speed': 32.76, - 'wind_speed': 10.08, - }), - dict({ - 'cloud_coverage': 100, - 'condition': 'rainy', - 'datetime': '2023-08-08T12:00:00+00:00', - 'humidity': 97, - 'precipitation': 10.6, - 'pressure': 984.1, - 'temperature': 14.8, - 'templow': 10.6, - 'wind_bearing': 183, - 'wind_gust_speed': 27.36, + 'datetime': '2026-04-02T12:00:00+00:00', + 'humidity': 79, + 'precipitation': 0.9, + 'pressure': 1011.3, + 'temperature': 10.4, + 'templow': 5.7, + 'wind_bearing': 245, + 'wind_gust_speed': 24.12, 'wind_speed': 11.16, }), dict({ 'cloud_coverage': 100, 'condition': 'rainy', - 'datetime': '2023-08-09T12:00:00+00:00', - 'humidity': 95, - 'precipitation': 6.3, - 'pressure': 1001.4, - 'temperature': 12.5, - 'templow': 11.0, - 'wind_bearing': 166, - 'wind_gust_speed': 48.24, - 'wind_speed': 18.0, + 'datetime': '2026-04-03T12:00:00+00:00', + 'humidity': 94, + 'precipitation': 3.7, + 'pressure': 1008.1, + 'temperature': 5.3, + 'templow': 1.8, + 'wind_bearing': 27, + 'wind_gust_speed': 20.16, + 'wind_speed': 9.72, }), dict({ 'cloud_coverage': 100, 'condition': 'cloudy', - 'datetime': '2023-08-10T12:00:00+00:00', - 'humidity': 75, - 'precipitation': 4.8, - 'pressure': 1011.1, - 'temperature': 13.9, - 'templow': 10.4, - 'wind_bearing': 174, - 'wind_gust_speed': 29.16, - 'wind_speed': 11.16, + 'datetime': '2026-04-04T12:00:00+00:00', + 'humidity': 64, + 'precipitation': 3.0, + 'pressure': 1001.9, + 'temperature': 8.4, + 'templow': 1.5, + 'wind_bearing': 277, + 'wind_gust_speed': 47.88, + 'wind_speed': 25.2, }), dict({ 'cloud_coverage': 100, - 'condition': 'cloudy', - 'datetime': '2023-08-11T12:00:00+00:00', - 'humidity': 69, - 'precipitation': 0.6, - 'pressure': 1015.3, - 'temperature': 17.6, - 'templow': 11.7, - 'wind_bearing': 197, - 'wind_gust_speed': 27.36, - 'wind_speed': 10.08, + 'condition': 'rainy', + 'datetime': '2026-04-05T12:00:00+00:00', + 'humidity': 81, + 'precipitation': 17.4, + 'pressure': 999.0, + 'temperature': 8.3, + 'templow': 2.1, + 'wind_bearing': 182, + 'wind_gust_speed': 46.8, + 'wind_speed': 24.84, }), dict({ - 'cloud_coverage': 100, - 'condition': 'cloudy', - 'datetime': '2023-08-12T12:00:00+00:00', - 'humidity': 82, - 'precipitation': 0.0, - 'pressure': 1014.0, - 'temperature': 17.0, - 'templow': 12.3, - 'wind_bearing': 225, - 'wind_gust_speed': 28.08, - 'wind_speed': 8.64, + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2026-04-06T12:00:00+00:00', + 'humidity': 40, + 'precipitation': 4.8, + 'pressure': 1001.5, + 'temperature': 10.3, + 'templow': 4.0, + 'wind_bearing': 290, + 'wind_gust_speed': 70.56, + 'wind_speed': 34.2, }), dict({ 'cloud_coverage': 75, 'condition': 'partlycloudy', - 'datetime': '2023-08-13T12:00:00+00:00', - 'humidity': 59, + 'datetime': '2026-04-07T12:00:00+00:00', + 'humidity': 37, 'precipitation': 0.0, - 'pressure': 1013.6, - 'temperature': 20.0, - 'templow': 13.6, - 'wind_bearing': 234, - 'wind_gust_speed': 35.64, - 'wind_speed': 14.76, + 'pressure': 1021.1, + 'temperature': 10.3, + 'templow': 5.2, + 'wind_bearing': 352, + 'wind_gust_speed': 45.0, + 'wind_speed': 21.96, }), dict({ - 'cloud_coverage': 100, + 'cloud_coverage': 75, 'condition': 'partlycloudy', - 'datetime': '2023-08-14T12:00:00+00:00', - 'humidity': 56, + 'datetime': '2026-04-08T12:00:00+00:00', + 'humidity': 57, 'precipitation': 0.0, - 'pressure': 1015.3, - 'temperature': 20.8, - 'templow': 13.5, - 'wind_bearing': 216, - 'wind_gust_speed': 33.12, - 'wind_speed': 13.68, + 'pressure': 1028.8, + 'temperature': 7.8, + 'templow': 2.2, + 'wind_bearing': 46, + 'wind_gust_speed': 38.16, + 'wind_speed': 17.28, }), dict({ - 'cloud_coverage': 88, + 'cloud_coverage': 62, 'condition': 'partlycloudy', - 'datetime': '2023-08-15T12:00:00+00:00', - 'humidity': 64, - 'precipitation': 3.6, - 'pressure': 1014.3, - 'temperature': 20.4, - 'templow': 14.3, - 'wind_bearing': 226, - 'wind_gust_speed': 33.12, - 'wind_speed': 13.68, + 'datetime': '2026-04-09T12:00:00+00:00', + 'humidity': 48, + 'precipitation': 1.2, + 'pressure': 1024.4, + 'temperature': 6.5, + 'templow': -1.5, + 'wind_bearing': 104, + 'wind_gust_speed': 37.08, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 62, + 'condition': 'partlycloudy', + 'datetime': '2026-04-10T12:00:00+00:00', + 'humidity': 47, + 'precipitation': 13.2, + 'pressure': 1020.2, + 'temperature': 6.7, + 'templow': -0.1, + 'wind_bearing': 103, + 'wind_gust_speed': 37.44, + 'wind_speed': 17.28, }), dict({ 'cloud_coverage': 75, 'condition': 'partlycloudy', - 'datetime': '2023-08-16T12:00:00+00:00', - 'humidity': 61, - 'precipitation': 2.4, - 'pressure': 1014.0, - 'temperature': 20.2, - 'templow': 13.8, - 'wind_bearing': 233, - 'wind_gust_speed': 33.48, - 'wind_speed': 14.04, + 'datetime': '2026-04-11T12:00:00+00:00', + 'humidity': 50, + 'precipitation': 9.6, + 'pressure': 1016.6, + 'temperature': 7.4, + 'templow': 0.6, + 'wind_bearing': 136, + 'wind_gust_speed': 41.4, + 'wind_speed': 19.8, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2026-04-12T12:00:00+00:00', + 'humidity': 51, + 'precipitation': 16.8, + 'pressure': 1016.3, + 'temperature': 8.3, + 'templow': 1.8, + 'wind_bearing': 119, + 'wind_gust_speed': 39.96, + 'wind_speed': 18.36, }), ]), }), @@ -222,60 +781,60 @@ dict({ 'cloud_coverage': 100, 'condition': 'cloudy', - 'datetime': '2023-08-07T12:00:00+00:00', - 'humidity': 96, - 'precipitation': 0.0, - 'pressure': 991.7, - 'temperature': 18.4, - 'templow': 14.8, - 'wind_bearing': 114, - 'wind_gust_speed': 32.76, - 'wind_speed': 10.08, + 'datetime': '2026-04-02T12:00:00+00:00', + 'humidity': 79, + 'precipitation': 0.9, + 'pressure': 1011.3, + 'temperature': 10.4, + 'templow': 5.7, + 'wind_bearing': 245, + 'wind_gust_speed': 24.12, + 'wind_speed': 11.16, }) # --- # name: test_forecast_services[load_platforms0].1 dict({ 'cloud_coverage': 75, 'condition': 'partlycloudy', - 'datetime': '2023-08-13T12:00:00+00:00', - 'humidity': 59, + 'datetime': '2026-04-08T12:00:00+00:00', + 'humidity': 57, 'precipitation': 0.0, - 'pressure': 1013.6, - 'temperature': 20.0, - 'templow': 13.6, - 'wind_bearing': 234, - 'wind_gust_speed': 35.64, - 'wind_speed': 14.76, + 'pressure': 1028.8, + 'temperature': 7.8, + 'templow': 2.2, + 'wind_bearing': 46, + 'wind_gust_speed': 38.16, + 'wind_speed': 17.28, }) # --- # name: test_forecast_services[load_platforms0].2 dict({ 'cloud_coverage': 100, - 'condition': 'fog', - 'datetime': '2023-08-07T09:00:00+00:00', - 'humidity': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-02T12:00:00+00:00', + 'humidity': 79, 'precipitation': 0.0, - 'pressure': 992.4, - 'temperature': 18.2, - 'templow': 18.2, - 'wind_bearing': 103, - 'wind_gust_speed': 23.76, - 'wind_speed': 9.72, + 'pressure': 1011.3, + 'temperature': 10.4, + 'templow': 10.4, + 'wind_bearing': 245, + 'wind_gust_speed': 24.12, + 'wind_speed': 11.16, }) # --- # name: test_forecast_services[load_platforms0].3 dict({ 'cloud_coverage': 100, 'condition': 'cloudy', - 'datetime': '2023-08-07T15:00:00+00:00', - 'humidity': 89, + 'datetime': '2026-04-02T18:00:00+00:00', + 'humidity': 98, 'precipitation': 0.0, - 'pressure': 991.7, - 'temperature': 16.2, - 'templow': 16.2, - 'wind_bearing': 108, - 'wind_gust_speed': 31.68, - 'wind_speed': 12.24, + 'pressure': 1009.7, + 'temperature': 7.2, + 'templow': 7.2, + 'wind_bearing': 180, + 'wind_gust_speed': 6.48, + 'wind_speed': 3.24, }) # --- # name: test_setup_hass[load_platforms0] @@ -283,19 +842,19 @@ 'attribution': 'Swedish weather institute (SMHI)', 'cloud_coverage': 100, 'friendly_name': 'Test', - 'humidity': 100, + 'humidity': 80, 'precipitation_unit': , - 'pressure': 992.4, + 'pressure': 1011.5, 'pressure_unit': , 'supported_features': , - 'temperature': 18.4, + 'temperature': 10.4, 'temperature_unit': , - 'thunder_probability': 37, - 'visibility': 0.4, + 'thunder_probability': 0, + 'visibility': 17.9, 'visibility_unit': , - 'wind_bearing': 93, - 'wind_gust_speed': 22.32, - 'wind_speed': 9.0, + 'wind_bearing': 255, + 'wind_gust_speed': 24.48, + 'wind_speed': 12.24, 'wind_speed_unit': , }) # --- @@ -305,283 +864,311 @@ 'forecast': list([ dict({ 'cloud_coverage': 100, - 'condition': 'fog', - 'datetime': '2023-08-07T08:00:00+00:00', - 'humidity': 100, + 'condition': 'cloudy', + 'datetime': '2026-04-02T11:00:00+00:00', + 'humidity': 80, 'is_daytime': False, 'precipitation': 0.0, - 'pressure': 992.4, - 'temperature': 18.4, - 'templow': 18.4, - 'wind_bearing': 93, - 'wind_gust_speed': 22.32, - 'wind_speed': 9.0, + 'pressure': 1011.5, + 'temperature': 10.4, + 'templow': 10.4, + 'wind_bearing': 255, + 'wind_gust_speed': 24.48, + 'wind_speed': 12.24, }), dict({ 'cloud_coverage': 100, 'condition': 'cloudy', - 'datetime': '2023-08-07T12:00:00+00:00', - 'humidity': 96, + 'datetime': '2026-04-02T12:00:00+00:00', + 'humidity': 79, 'is_daytime': True, 'precipitation': 0.0, - 'pressure': 991.7, - 'temperature': 18.4, - 'templow': 17.1, - 'wind_bearing': 114, - 'wind_gust_speed': 32.76, - 'wind_speed': 10.08, + 'pressure': 1011.3, + 'temperature': 10.4, + 'templow': 10.4, + 'wind_bearing': 245, + 'wind_gust_speed': 24.12, + 'wind_speed': 11.16, }), dict({ 'cloud_coverage': 100, - 'condition': 'cloudy', - 'datetime': '2023-08-08T00:00:00+00:00', - 'humidity': 99, + 'condition': 'rainy', + 'datetime': '2026-04-03T00:00:00+00:00', + 'humidity': 97, 'is_daytime': False, - 'precipitation': 0.1, - 'pressure': 987.5, - 'temperature': 18.4, - 'templow': 14.8, - 'wind_bearing': 357, - 'wind_gust_speed': 10.44, - 'wind_speed': 3.96, + 'precipitation': 0.2, + 'pressure': 1008.2, + 'temperature': 10.4, + 'templow': 5.3, + 'wind_bearing': 138, + 'wind_gust_speed': 4.32, + 'wind_speed': 1.8, }), dict({ 'cloud_coverage': 100, 'condition': 'rainy', - 'datetime': '2023-08-08T12:00:00+00:00', - 'humidity': 97, + 'datetime': '2026-04-03T12:00:00+00:00', + 'humidity': 94, 'is_daytime': True, - 'precipitation': 0.3, - 'pressure': 984.1, - 'temperature': 18.4, - 'templow': 12.8, - 'wind_bearing': 183, - 'wind_gust_speed': 27.36, - 'wind_speed': 11.16, + 'precipitation': 0.2, + 'pressure': 1008.1, + 'temperature': 10.4, + 'templow': 3.1, + 'wind_bearing': 27, + 'wind_gust_speed': 20.16, + 'wind_speed': 9.72, }), dict({ 'cloud_coverage': 100, 'condition': 'cloudy', - 'datetime': '2023-08-09T00:00:00+00:00', - 'humidity': 85, + 'datetime': '2026-04-04T00:00:00+00:00', + 'humidity': 97, 'is_daytime': False, - 'precipitation': 0.1, - 'pressure': 995.6, - 'temperature': 18.4, - 'templow': 11.2, - 'wind_bearing': 193, - 'wind_gust_speed': 48.6, - 'wind_speed': 19.8, + 'precipitation': 0.0, + 'pressure': 1004.6, + 'temperature': 10.4, + 'templow': 1.9, + 'wind_bearing': 161, + 'wind_gust_speed': 14.76, + 'wind_speed': 7.56, }), dict({ 'cloud_coverage': 100, - 'condition': 'rainy', - 'datetime': '2023-08-09T12:00:00+00:00', - 'humidity': 95, + 'condition': 'cloudy', + 'datetime': '2026-04-04T12:00:00+00:00', + 'humidity': 64, 'is_daytime': True, - 'precipitation': 1.1, - 'pressure': 1001.4, - 'temperature': 18.4, - 'templow': 11.1, - 'wind_bearing': 166, - 'wind_gust_speed': 48.24, - 'wind_speed': 18.0, + 'precipitation': 0.1, + 'pressure': 1001.9, + 'temperature': 10.4, + 'templow': 7.1, + 'wind_bearing': 277, + 'wind_gust_speed': 47.88, + 'wind_speed': 25.2, }), dict({ - 'cloud_coverage': 100, - 'condition': 'rainy', - 'datetime': '2023-08-10T00:00:00+00:00', - 'humidity': 99, + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2026-04-05T00:00:00+00:00', + 'humidity': 82, 'is_daytime': False, - 'precipitation': 3.6, - 'pressure': 1007.8, - 'temperature': 18.4, - 'templow': 10.4, - 'wind_bearing': 200, - 'wind_gust_speed': 28.08, - 'wind_speed': 14.4, + 'precipitation': 0.0, + 'pressure': 1009.4, + 'temperature': 10.4, + 'templow': 2.3, + 'wind_bearing': 254, + 'wind_gust_speed': 30.24, + 'wind_speed': 15.48, }), dict({ 'cloud_coverage': 100, - 'condition': 'cloudy', - 'datetime': '2023-08-10T12:00:00+00:00', - 'humidity': 75, + 'condition': 'rainy', + 'datetime': '2026-04-05T12:00:00+00:00', + 'humidity': 81, 'is_daytime': True, - 'precipitation': 0.0, - 'pressure': 1011.1, - 'temperature': 18.4, - 'templow': 13.9, - 'wind_bearing': 174, - 'wind_gust_speed': 29.16, - 'wind_speed': 11.16, + 'precipitation': 10.8, + 'pressure': 999.0, + 'temperature': 10.4, + 'templow': 8.3, + 'wind_bearing': 182, + 'wind_gust_speed': 46.8, + 'wind_speed': 24.84, }), dict({ - 'cloud_coverage': 100, - 'condition': 'cloudy', - 'datetime': '2023-08-11T00:00:00+00:00', - 'humidity': 98, + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2026-04-06T00:00:00+00:00', + 'humidity': 69, 'is_daytime': False, - 'precipitation': 0.0, - 'pressure': 1012.3, - 'temperature': 18.4, - 'templow': 11.7, - 'wind_bearing': 169, - 'wind_gust_speed': 16.56, - 'wind_speed': 7.56, + 'precipitation': 1.2, + 'pressure': 995.0, + 'temperature': 10.4, + 'templow': 4.0, + 'wind_bearing': 240, + 'wind_gust_speed': 60.84, + 'wind_speed': 31.68, }), dict({ - 'cloud_coverage': 100, - 'condition': 'cloudy', - 'datetime': '2023-08-11T12:00:00+00:00', - 'humidity': 69, + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2026-04-06T12:00:00+00:00', + 'humidity': 40, 'is_daytime': True, - 'precipitation': 0.0, - 'pressure': 1015.3, - 'temperature': 18.4, - 'templow': 17.6, - 'wind_bearing': 197, - 'wind_gust_speed': 27.36, - 'wind_speed': 10.08, + 'precipitation': 1.2, + 'pressure': 1001.5, + 'temperature': 10.4, + 'templow': 10.3, + 'wind_bearing': 290, + 'wind_gust_speed': 70.56, + 'wind_speed': 34.2, }), dict({ 'cloud_coverage': 0, 'condition': 'clear-night', - 'datetime': '2023-08-12T00:00:00+00:00', - 'humidity': 97, + 'datetime': '2026-04-07T00:00:00+00:00', + 'humidity': 57, 'is_daytime': False, 'precipitation': 0.0, - 'pressure': 1015.8, - 'temperature': 18.4, - 'templow': 12.3, - 'wind_bearing': 191, - 'wind_gust_speed': 18.0, - 'wind_speed': 8.64, + 'pressure': 1013.3, + 'temperature': 10.4, + 'templow': 5.4, + 'wind_bearing': 315, + 'wind_gust_speed': 36.0, + 'wind_speed': 19.08, }), dict({ - 'cloud_coverage': 100, - 'condition': 'cloudy', - 'datetime': '2023-08-12T12:00:00+00:00', - 'humidity': 82, + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2026-04-07T12:00:00+00:00', + 'humidity': 37, 'is_daytime': True, 'precipitation': 0.0, - 'pressure': 1014.0, - 'temperature': 18.4, - 'templow': 17.0, - 'wind_bearing': 225, - 'wind_gust_speed': 28.08, - 'wind_speed': 8.64, + 'pressure': 1021.1, + 'temperature': 10.4, + 'templow': 10.3, + 'wind_bearing': 352, + 'wind_gust_speed': 45.0, + 'wind_speed': 21.96, }), dict({ - 'cloud_coverage': 12, + 'cloud_coverage': 25, 'condition': 'clear-night', - 'datetime': '2023-08-13T00:00:00+00:00', - 'humidity': 92, + 'datetime': '2026-04-08T00:00:00+00:00', + 'humidity': 83, 'is_daytime': False, 'precipitation': 0.0, - 'pressure': 1013.9, - 'temperature': 18.4, - 'templow': 13.6, - 'wind_bearing': 233, - 'wind_gust_speed': 20.16, - 'wind_speed': 10.08, + 'pressure': 1027.0, + 'temperature': 10.4, + 'templow': 2.2, + 'wind_bearing': 3, + 'wind_gust_speed': 23.4, + 'wind_speed': 12.6, }), dict({ 'cloud_coverage': 75, 'condition': 'partlycloudy', - 'datetime': '2023-08-13T12:00:00+00:00', - 'humidity': 59, + 'datetime': '2026-04-08T12:00:00+00:00', + 'humidity': 57, 'is_daytime': True, 'precipitation': 0.0, - 'pressure': 1013.6, - 'temperature': 20.0, - 'templow': 18.4, - 'wind_bearing': 234, - 'wind_gust_speed': 35.64, - 'wind_speed': 14.76, + 'pressure': 1028.8, + 'temperature': 10.4, + 'templow': 7.8, + 'wind_bearing': 46, + 'wind_gust_speed': 38.16, + 'wind_speed': 17.28, }), dict({ - 'cloud_coverage': 50, - 'condition': 'partlycloudy', - 'datetime': '2023-08-14T00:00:00+00:00', - 'humidity': 91, + 'cloud_coverage': 12, + 'condition': 'clear-night', + 'datetime': '2026-04-09T00:00:00+00:00', + 'humidity': 85, 'is_daytime': False, 'precipitation': 0.0, - 'pressure': 1015.2, - 'temperature': 18.4, - 'templow': 13.5, - 'wind_bearing': 227, - 'wind_gust_speed': 23.4, + 'pressure': 1028.4, + 'temperature': 10.4, + 'templow': -1.5, + 'wind_bearing': 57, + 'wind_gust_speed': 25.92, 'wind_speed': 10.8, }), dict({ - 'cloud_coverage': 100, + 'cloud_coverage': 62, 'condition': 'partlycloudy', - 'datetime': '2023-08-14T12:00:00+00:00', - 'humidity': 56, + 'datetime': '2026-04-09T12:00:00+00:00', + 'humidity': 48, 'is_daytime': True, - 'precipitation': 0.0, - 'pressure': 1015.3, - 'temperature': 20.8, - 'templow': 18.4, - 'wind_bearing': 216, - 'wind_gust_speed': 33.12, - 'wind_speed': 13.68, + 'precipitation': 1.2, + 'pressure': 1024.4, + 'temperature': 10.4, + 'templow': 6.5, + 'wind_bearing': 104, + 'wind_gust_speed': 37.08, + 'wind_speed': 18.0, }), dict({ - 'cloud_coverage': 100, - 'condition': 'cloudy', - 'datetime': '2023-08-15T00:00:00+00:00', - 'humidity': 93, + 'cloud_coverage': 62, + 'condition': 'partlycloudy', + 'datetime': '2026-04-10T00:00:00+00:00', + 'humidity': 82, 'is_daytime': False, - 'precipitation': 1.2, - 'pressure': 1014.9, - 'temperature': 18.4, - 'templow': 14.3, - 'wind_bearing': 196, - 'wind_gust_speed': 22.32, - 'wind_speed': 10.08, + 'precipitation': 7.2, + 'pressure': 1021.1, + 'temperature': 10.4, + 'templow': -0.1, + 'wind_bearing': 87, + 'wind_gust_speed': 28.44, + 'wind_speed': 12.6, }), dict({ - 'cloud_coverage': 88, + 'cloud_coverage': 62, 'condition': 'partlycloudy', - 'datetime': '2023-08-15T12:00:00+00:00', - 'humidity': 64, + 'datetime': '2026-04-10T12:00:00+00:00', + 'humidity': 47, 'is_daytime': True, - 'precipitation': 2.4, - 'pressure': 1014.3, - 'temperature': 20.4, - 'templow': 18.4, - 'wind_bearing': 226, - 'wind_gust_speed': 33.12, - 'wind_speed': 13.68, + 'precipitation': 6.0, + 'pressure': 1020.2, + 'temperature': 10.4, + 'templow': 6.7, + 'wind_bearing': 103, + 'wind_gust_speed': 37.44, + 'wind_speed': 17.28, }), dict({ - 'cloud_coverage': 38, - 'condition': 'clear-night', - 'datetime': '2023-08-16T00:00:00+00:00', - 'humidity': 93, + 'cloud_coverage': 62, + 'condition': 'partlycloudy', + 'datetime': '2026-04-11T00:00:00+00:00', + 'humidity': 79, 'is_daytime': False, 'precipitation': 1.2, - 'pressure': 1014.9, - 'temperature': 18.4, - 'templow': 13.8, - 'wind_bearing': 228, - 'wind_gust_speed': 21.24, - 'wind_speed': 10.08, + 'pressure': 1019.0, + 'temperature': 10.4, + 'templow': 0.6, + 'wind_bearing': 111, + 'wind_gust_speed': 25.92, + 'wind_speed': 11.52, }), dict({ 'cloud_coverage': 75, 'condition': 'partlycloudy', - 'datetime': '2023-08-16T12:00:00+00:00', - 'humidity': 61, + 'datetime': '2026-04-11T12:00:00+00:00', + 'humidity': 50, 'is_daytime': True, - 'precipitation': 1.2, - 'pressure': 1014.0, - 'temperature': 20.2, - 'templow': 18.4, - 'wind_bearing': 233, - 'wind_gust_speed': 33.48, - 'wind_speed': 14.04, + 'precipitation': 8.4, + 'pressure': 1016.6, + 'temperature': 10.4, + 'templow': 7.4, + 'wind_bearing': 136, + 'wind_gust_speed': 41.4, + 'wind_speed': 19.8, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2026-04-12T00:00:00+00:00', + 'humidity': 79, + 'is_daytime': False, + 'precipitation': 9.6, + 'pressure': 1016.4, + 'temperature': 10.4, + 'templow': 1.8, + 'wind_bearing': 125, + 'wind_gust_speed': 29.16, + 'wind_speed': 12.96, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2026-04-12T12:00:00+00:00', + 'humidity': 51, + 'is_daytime': True, + 'precipitation': 7.2, + 'pressure': 1016.3, + 'temperature': 10.4, + 'templow': 8.3, + 'wind_bearing': 119, + 'wind_gust_speed': 39.96, + 'wind_speed': 18.36, }), ]), }), diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 45e34be650f388..eef212aaca7c0b 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -57,7 +57,7 @@ async def test_setup_hass( state = hass.states.get(ENTITY_ID) assert state - assert state.state == "fog" + assert state.state == "cloudy" assert state.attributes == snapshot @@ -65,7 +65,7 @@ async def test_setup_hass( "to_load", [1], ) -@pytest.mark.freeze_time(datetime(2023, 8, 7, 1, tzinfo=dt_util.UTC)) +@pytest.mark.freeze_time(datetime(2026, 4, 3, 1, tzinfo=dt_util.UTC)) async def test_clear_night( hass: HomeAssistant, mock_client: SMHIPointForecast, @@ -134,7 +134,7 @@ async def test_properties_no_data( assert state assert state.name == "Test" - assert state.state == "fog" + assert state.state == "cloudy" assert ATTR_SMHI_THUNDER_PROBABILITY not in state.attributes assert state.attributes[ATTR_ATTRIBUTION] == "Swedish weather institute (SMHI)" @@ -356,7 +356,7 @@ async def test_custom_speed_unit( assert state assert state.name == "Test" - assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 22.32 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 24.48 entity_registry.async_update_entity_options( state.entity_id, @@ -367,7 +367,7 @@ async def test_custom_speed_unit( await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 6.2 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 6.8 @pytest.mark.parametrize( @@ -400,7 +400,7 @@ async def test_forecast_services( assert msg["type"] == "event" forecast1 = msg["event"]["forecast"] - assert len(forecast1) == 10 + assert len(forecast1) == 11 assert forecast1[0] == snapshot assert forecast1[6] == snapshot @@ -421,7 +421,7 @@ async def test_forecast_services( assert msg["type"] == "event" forecast1 = msg["event"]["forecast"] - assert len(forecast1) == 52 + assert len(forecast1) == 59 assert forecast1[0] == snapshot assert forecast1[6] == snapshot From 0e9c17fb163b664438d3992c9e477c197b098e81 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 2 Apr 2026 23:32:36 +0200 Subject: [PATCH 0404/1707] Bump holiday library to 0.93 (#167217) --- .../components/holiday/manifest.json | 2 +- .../components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/holiday/test_config_flow.py | 36 +++++++++---------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 5845f091fa7bbe..a3771b354cce2f 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.84", "babel==2.15.0"] + "requirements": ["holidays==0.93", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index ff67d631e82843..ce93a7e823b292 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.84"] + "requirements": ["holidays==0.93"] } diff --git a/requirements_all.txt b/requirements_all.txt index 76e35ae609dbfd..60c0168cd28895 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1226,7 +1226,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.84 +holidays==0.93 # homeassistant.components.frontend home-assistant-frontend==20260325.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d842e5af6c24fd..d4292a90055bf6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1090,7 +1090,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.84 +holidays==0.93 # homeassistant.components.frontend home-assistant-frontend==20260325.5 diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py index f561c4a4b9fd18..953b02fd3c28be 100644 --- a/tests/components/holiday/test_config_flow.py +++ b/tests/components/holiday/test_config_flow.py @@ -66,15 +66,15 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_COUNTRY: "SE", + CONF_COUNTRY: "AL", }, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Sweden" + assert result2["title"] == "Albania" assert result2["data"] == { - "country": "SE", + "country": "AL", } @@ -90,12 +90,12 @@ async def test_form_translated_title(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_COUNTRY: "SE", + CONF_COUNTRY: "AL", }, ) await hass.async_block_till_done() - assert result2["title"] == "Schweden" + assert result2["title"] == "Albanien" @pytest.mark.usefixtures("mock_setup_entry") @@ -105,20 +105,20 @@ async def test_single_combination_country_province(hass: HomeAssistant) -> None: CONF_COUNTRY: "DE", CONF_PROVINCE: "BW", } - data_se = { - CONF_COUNTRY: "SE", + data_al = { + CONF_COUNTRY: "AL", } MockConfigEntry(domain=DOMAIN, data=data_de).add_to_hass(hass) - MockConfigEntry(domain=DOMAIN, data=data_se).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=data_al).add_to_hass(hass) # Test for country without subdivisions - result_se = await hass.config_entries.flow.async_init( + result_al = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=data_se, + data=data_al, ) - assert result_se["type"] is FlowResultType.ABORT - assert result_se["reason"] == "already_configured" + assert result_al["type"] is FlowResultType.ABORT + assert result_al["reason"] == "already_configured" # Test for country with subdivisions result_de_step1 = await hass.config_entries.flow.async_init( @@ -150,12 +150,12 @@ async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_COUNTRY: "SE", + CONF_COUNTRY: "AL", }, ) await hass.async_block_till_done() - assert result["title"] == "Sweden" + assert result["title"] == "Albania" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -197,12 +197,12 @@ async def test_form_babel_replace_dash_with_underscore(hass: HomeAssistant) -> N result = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_COUNTRY: "SE", + CONF_COUNTRY: "AL", }, ) await hass.async_block_till_done() - assert result["title"] == "Sweden" + assert result["title"] == "Albania" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -430,8 +430,8 @@ async def test_options_abort_no_categories(hass: HomeAssistant) -> None: """Test the options flow abort if no categories to select.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={CONF_COUNTRY: "SE"}, - title="Sweden", + data={CONF_COUNTRY: "AL"}, + title="Albania", ) config_entry.add_to_hass(hass) From 7e061262ad36f32bbb7e45c92bb5239375826b42 Mon Sep 17 00:00:00 2001 From: Max R Date: Thu, 2 Apr 2026 17:35:53 -0400 Subject: [PATCH 0405/1707] Add pre-commit hook for copilot instructions (#167219) --- .pre-commit-config.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 018b971cbe2e51..5792e4a903c330 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -87,6 +87,13 @@ repos: language: script types: [text] files: ^(homeassistant/.+/manifest\.json|homeassistant/brands/.+\.json|pyproject\.toml|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$ + - id: gen_copilot_instructions + name: gen_copilot_instructions + entry: script/run-in-env.sh python3 -m script.gen_copilot_instructions + pass_filenames: false + language: script + types: [text] + files: ^(AGENTS\.md|\.claude/skills/(?!github-pr-reviewer/).+/SKILL\.md|\.github/copilot-instructions\.md|script/gen_copilot_instructions\.py)$ - id: hassfest name: hassfest entry: script/run-in-env.sh python3 -m script.hassfest From fbd0cb866679ef0d8c72a8967bb7029377026c02 Mon Sep 17 00:00:00 2001 From: LTek Date: Thu, 2 Apr 2026 14:37:14 -0700 Subject: [PATCH 0406/1707] Fix Ring snapshots (#164337) Co-authored-by: Joostlek --- homeassistant/components/ring/camera.py | 21 ++++---- homeassistant/components/ring/strings.json | 3 -- tests/components/ring/test_camera.py | 60 +++++++++------------- 3 files changed, 36 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index ee4ab050aca98b..21ce0bfb2b3815 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -128,9 +128,8 @@ def _handle_coordinator_update(self) -> None: self._device = self._get_coordinator_data().get_video_device( self._device.device_api_id ) - history_data = self._device.last_history - if history_data and self._device.has_subscription: + if history_data: self._last_event = history_data[0] # will call async_update to update the attributes and get the # video url from the api @@ -155,16 +154,13 @@ async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - if self._video_url is None: - if not self._device.has_subscription: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="no_subscription", - ) - return None + # For live_view cameras, get a fresh snapshot + if self.entity_description.key == "live_view": + return await self._async_get_fresh_snapshot() + # For last_recording cameras, use the cached video frame key = (width, height) - if not (image := self._images.get(key)): + if not (image := self._images.get(key)) and self._video_url is not None: image = await ffmpeg.async_get_image( self.hass, self._video_url, @@ -177,6 +173,11 @@ async def async_camera_image( return image + @exception_wrap + async def _async_get_fresh_snapshot(self) -> bytes | None: + """Get a fresh snapshot from the camera.""" + return await self._device.async_get_snapshot() + async def handle_async_mjpeg_stream( self, request: web.Request ) -> web.StreamResponse | None: diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 1159a8b906e690..09f36d6dd7424c 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -151,9 +151,6 @@ "api_timeout": { "message": "Timeout communicating with Ring API" }, - "no_subscription": { - "message": "Ring Protect subscription required for snapshots" - }, "sdp_m_line_index_required": { "message": "Error negotiating stream for {device}" } diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index 95ee0d4b5fd800..a40611ea2ce4c1 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -294,10 +294,32 @@ async def test_camera_image( await setup_platform(hass, Platform.CAMERA) front_camera_mock = mock_ring_devices.get_device(765432) + front_camera_mock.async_get_snapshot.return_value = SMALLEST_VALID_JPEG_BYTES state = hass.states.get("camera.front_live_view") assert state is not None + # For live_view camera, snapshot should use async_get_snapshot + image = await async_get_image(hass, "camera.front_live_view") + assert image.content == SMALLEST_VALID_JPEG_BYTES + front_camera_mock.async_get_snapshot.assert_called_once() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_camera_last_recording_image( + hass: HomeAssistant, + mock_ring_client, + mock_ring_devices, + freezer: FrozenDateTimeFactory, +) -> None: + """Test last recording camera will return still image from video when available.""" + await setup_platform(hass, Platform.CAMERA) + + front_camera_mock = mock_ring_devices.get_device(765432) + + state = hass.states.get("camera.front_last_recording") + assert state is not None + # history not updated yet front_camera_mock.async_history.assert_not_called() front_camera_mock.async_recording_url.assert_not_called() @@ -308,55 +330,23 @@ async def test_camera_image( ), pytest.raises(HomeAssistantError), ): - image = await async_get_image(hass, "camera.front_live_view") + await async_get_image(hass, "camera.front_last_recording") freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) # history updated so image available front_camera_mock.async_history.assert_called_once() - front_camera_mock.async_recording_url.assert_called_once() + assert front_camera_mock.async_recording_url.call_count == 2 with patch( "homeassistant.components.ring.camera.ffmpeg.async_get_image", return_value=SMALLEST_VALID_JPEG_BYTES, ): - image = await async_get_image(hass, "camera.front_live_view") + image = await async_get_image(hass, "camera.front_last_recording") assert image.content == SMALLEST_VALID_JPEG_BYTES -async def test_camera_live_view_no_subscription( - hass: HomeAssistant, - mock_ring_client, - mock_ring_devices, - freezer: FrozenDateTimeFactory, -) -> None: - """Test live view camera skips recording URL when no subscription.""" - await setup_platform(hass, Platform.CAMERA) - - front_camera_mock = mock_ring_devices.get_device(765432) - # Set device to not have subscription - front_camera_mock.has_subscription = False - - state = hass.states.get("camera.front_live_view") - assert state is not None - - # Reset mock call counts - front_camera_mock.async_recording_url.reset_mock() - - # Trigger coordinator update - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - - # For cameras without subscription, recording URL should NOT be fetched - front_camera_mock.async_recording_url.assert_not_called() - - # Requesting an image without subscription should raise an error - with pytest.raises(HomeAssistantError): - await async_get_image(hass, "camera.front_live_view") - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_stream_attributes( hass: HomeAssistant, From 375bd55ae6ea27814de1eaaf7c66846c4e767dc5 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 2 Apr 2026 23:39:00 +0200 Subject: [PATCH 0407/1707] Update arcam to 1.8.3 (#167249) --- homeassistant/components/arcam_fmj/manifest.json | 2 +- homeassistant/components/arcam_fmj/sensor.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index ae1f86c6d35df8..084d26e19ae513 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.8.2"], + "requirements": ["arcam-fmj==1.8.3"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/arcam_fmj/sensor.py b/homeassistant/components/arcam_fmj/sensor.py index f57ab2649dc701..a415f92864a098 100644 --- a/homeassistant/components/arcam_fmj/sensor.py +++ b/homeassistant/components/arcam_fmj/sensor.py @@ -91,6 +91,7 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): value_fn=lambda state: ( vp.colorspace.name.lower() if (vp := state.get_incoming_video_parameters()) is not None + and vp.colorspace is not None else None ), ), diff --git a/requirements_all.txt b/requirements_all.txt index 60c0168cd28895..1a272b144d9460 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -533,7 +533,7 @@ aqualogic==2.6 aranet4==2.6.0 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.2 +arcam-fmj==1.8.3 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4292a90055bf6..2e4f05a776508c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ apsystems-ez1==2.7.0 aranet4==2.6.0 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.2 +arcam-fmj==1.8.3 # homeassistant.components.asuswrt asusrouter==1.21.3 From 05b7fa9602ed59a42c24eac19e5ed8bcab76da0e Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 3 Apr 2026 08:49:38 +0100 Subject: [PATCH 0408/1707] Remove Transmission port forward sensor (#167269) --- .../components/transmission/__init__.py | 2 +- .../components/transmission/binary_sensor.py | 64 ------------------- .../components/transmission/coordinator.py | 2 - .../components/transmission/icons.json | 8 --- .../components/transmission/strings.json | 9 --- .../snapshots/test_binary_sensor.ambr | 52 --------------- .../transmission/test_binary_sensor.py | 31 --------- 7 files changed, 1 insertion(+), 167 deletions(-) delete mode 100644 homeassistant/components/transmission/binary_sensor.py delete mode 100644 tests/components/transmission/snapshots/test_binary_sensor.ambr delete mode 100644 tests/components/transmission/test_binary_sensor.py diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index c9eff349f7ffac..bbcec43224094e 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.EVENT, Platform.SENSOR, Platform.SWITCH] MIGRATION_NAME_TO_KEY = { # Sensors diff --git a/homeassistant/components/transmission/binary_sensor.py b/homeassistant/components/transmission/binary_sensor.py deleted file mode 100644 index a00291eb3ec2be..00000000000000 --- a/homeassistant/components/transmission/binary_sensor.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Binary sensor platform for Transmission integration.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator -from .entity import TransmissionEntity - -PARALLEL_UPDATES = 0 - - -@dataclass(frozen=True, kw_only=True) -class TransmissionBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describe a Transmission binary sensor entity.""" - - is_on_fn: Callable[[TransmissionDataUpdateCoordinator], bool | None] - - -BINARY_SENSOR_TYPES: tuple[TransmissionBinarySensorEntityDescription, ...] = ( - TransmissionBinarySensorEntityDescription( - key="port_forwarding", - translation_key="port_forwarding", - device_class=BinarySensorDeviceClass.CONNECTIVITY, - entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda coordinator: coordinator.port_forwarding, - entity_registry_enabled_default=False, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: TransmissionConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Transmission binary sensors from a config entry.""" - coordinator = config_entry.runtime_data - - async_add_entities( - TransmissionBinarySensor(coordinator, description) - for description in BINARY_SENSOR_TYPES - ) - - -class TransmissionBinarySensor(TransmissionEntity, BinarySensorEntity): - """Representation of a Transmission binary sensor.""" - - entity_description: TransmissionBinarySensorEntityDescription - - @property - def is_on(self) -> bool | None: - """Return True if the port is open.""" - return self.entity_description.is_on_fn(self.coordinator) diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 56f4f7666cdb7e..c6af4eded27d10 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -65,7 +65,6 @@ def __init__( self.api = api self.host = entry.data[CONF_HOST] self._session: transmission_rpc.Session | None = None - self.port_forwarding: bool | None = None self._all_torrents: list[transmission_rpc.Torrent] = [] self._completed_torrents: list[transmission_rpc.Torrent] = [] self._started_torrents: list[transmission_rpc.Torrent] = [] @@ -122,7 +121,6 @@ def update(self) -> SessionStats: data = self.api.session_stats() self.torrents = self.api.get_torrents() self._session = self.api.get_session() - self.port_forwarding = self.api.port_test() except transmission_rpc.TransmissionError as err: raise UpdateFailed("Unable to connect to Transmission client") from err diff --git a/homeassistant/components/transmission/icons.json b/homeassistant/components/transmission/icons.json index 1d5b149487e1ed..9cc3b1dfad3fd8 100644 --- a/homeassistant/components/transmission/icons.json +++ b/homeassistant/components/transmission/icons.json @@ -1,13 +1,5 @@ { "entity": { - "binary_sensor": { - "port_forwarding": { - "default": "mdi:shield-check", - "state": { - "off": "mdi:shield-off" - } - } - }, "event": { "torrent": { "default": "mdi:folder-file-outline" diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index c7ec6ea742670d..509191c5349202 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -41,15 +41,6 @@ } }, "entity": { - "binary_sensor": { - "port_forwarding": { - "name": "Port forwarding", - "state": { - "off": "Closed", - "on": "Open" - } - } - }, "event": { "torrent": { "name": "Torrent", diff --git a/tests/components/transmission/snapshots/test_binary_sensor.ambr b/tests/components/transmission/snapshots/test_binary_sensor.ambr deleted file mode 100644 index a1d9aafb99dbdc..00000000000000 --- a/tests/components/transmission/snapshots/test_binary_sensor.ambr +++ /dev/null @@ -1,52 +0,0 @@ -# serializer version: 1 -# name: test_binary_sensors[binary_sensor.transmission_port_forwarding-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.transmission_port_forwarding', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Port forwarding', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Port forwarding', - 'platform': 'transmission', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'port_forwarding', - 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-port_forwarding', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[binary_sensor.transmission_port_forwarding-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Transmission Port forwarding', - }), - 'context': , - 'entity_id': 'binary_sensor.transmission_port_forwarding', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- diff --git a/tests/components/transmission/test_binary_sensor.py b/tests/components/transmission/test_binary_sensor.py deleted file mode 100644 index a306dc4c4323fd..00000000000000 --- a/tests/components/transmission/test_binary_sensor.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Tests for the Transmission binary sensor platform.""" - -from unittest.mock import AsyncMock, patch - -import pytest -from syrupy.assertion import SnapshotAssertion - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import MockConfigEntry, snapshot_platform - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_binary_sensors( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, - mock_transmission_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the binary sensor entities.""" - with patch( - "homeassistant.components.transmission.PLATFORMS", [Platform.BINARY_SENSOR] - ): - await setup_integration(hass, mock_config_entry) - - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From c6527c9f6dcc5c5616991bcafdeef638d861c6df Mon Sep 17 00:00:00 2001 From: Hai-Nam Nguyen Date: Fri, 3 Apr 2026 10:00:35 +0200 Subject: [PATCH 0409/1707] Bump hyponcloud to 0.9.3 (#167273) --- homeassistant/components/hypontech/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hypontech/snapshots/test_sensor.ambr | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hypontech/manifest.json b/homeassistant/components/hypontech/manifest.json index 0f417f491c1a21..54cefce5476400 100644 --- a/homeassistant/components/hypontech/manifest.json +++ b/homeassistant/components/hypontech/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["hyponcloud==0.9.0"] + "requirements": ["hyponcloud==0.9.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a272b144d9460..9043e9a70cda1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1262,7 +1262,7 @@ huum==0.8.2 hyperion-py==0.7.6 # homeassistant.components.hypontech -hyponcloud==0.9.0 +hyponcloud==0.9.3 # homeassistant.components.iammeter iammeter==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e4f05a776508c..754efa738dcfa1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1123,7 +1123,7 @@ huum==0.8.2 hyperion-py==0.7.6 # homeassistant.components.hypontech -hyponcloud==0.9.0 +hyponcloud==0.9.3 # homeassistant.components.iaqualink iaqualink==0.6.0 diff --git a/tests/components/hypontech/snapshots/test_sensor.ambr b/tests/components/hypontech/snapshots/test_sensor.ambr index 225d5f54fb23d3..f375be4d26735c 100644 --- a/tests/components/hypontech/snapshots/test_sensor.ambr +++ b/tests/components/hypontech/snapshots/test_sensor.ambr @@ -112,7 +112,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '123', + 'state': '123.0', }) # --- # name: test_sensors[sensor.balcon_today_energy-entry] @@ -286,7 +286,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1223', + 'state': '1223.0', }) # --- # name: test_sensors[sensor.overview_today_energy-entry] @@ -460,7 +460,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '1.1', }) # --- # name: test_sensors[sensor.rooftop_today_energy-entry] From 92a1c4568d64224180e21bc8c0d8e5a582536008 Mon Sep 17 00:00:00 2001 From: g4bri3lDev Date: Fri, 3 Apr 2026 10:00:51 +0200 Subject: [PATCH 0410/1707] Bump py-opendisplay version to 5.9.0 (#167250) --- homeassistant/components/opendisplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/opendisplay/snapshots/test_diagnostics.ambr | 7 +++++++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opendisplay/manifest.json b/homeassistant/components/opendisplay/manifest.json index f055d425e1cfce..60b850eff51dd6 100644 --- a/homeassistant/components/opendisplay/manifest.json +++ b/homeassistant/components/opendisplay/manifest.json @@ -15,5 +15,5 @@ "iot_class": "local_push", "loggers": ["opendisplay"], "quality_scale": "silver", - "requirements": ["py-opendisplay==5.5.0"] + "requirements": ["py-opendisplay==5.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9043e9a70cda1c..4557c9242fdd1f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1880,7 +1880,7 @@ py-nightscout==1.2.2 py-nymta==0.4.0 # homeassistant.components.opendisplay -py-opendisplay==5.5.0 +py-opendisplay==5.9.0 # homeassistant.components.schluter py-schluter==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 754efa738dcfa1..61adb4dba0b6eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1635,7 +1635,7 @@ py-nightscout==1.2.2 py-nymta==0.4.0 # homeassistant.components.opendisplay -py-opendisplay==5.5.0 +py-opendisplay==5.9.0 # homeassistant.components.ecovacs py-sucks==0.9.11 diff --git a/tests/components/opendisplay/snapshots/test_diagnostics.ambr b/tests/components/opendisplay/snapshots/test_diagnostics.ambr index 423bea32cac022..d3f1f2b23dc094 100644 --- a/tests/components/opendisplay/snapshots/test_diagnostics.ambr +++ b/tests/components/opendisplay/snapshots/test_diagnostics.ambr @@ -4,6 +4,8 @@ 'device_config': dict({ 'binary_inputs': list([ ]), + 'buzzers': list([ + ]), 'data_buses': list([ ]), 'displays': list([ @@ -56,6 +58,7 @@ 'tx_power': 0, 'voltage_scaling_factor': 0, }), + 'security_config': None, 'sensors': list([ ]), 'system': dict({ @@ -63,8 +66,12 @@ 'device_flags': 0, 'ic_type': 0, 'pwr_pin': 255, + 'pwr_pin_2': 255, + 'pwr_pin_3': 255, 'reserved': '0000000000000000000000000000000000', }), + 'touch_controllers': list([ + ]), 'version': 0, 'wifi_config': None, }), From 7529b9252c68aed73ae658853b13dfd85c976f07 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:00:55 +0200 Subject: [PATCH 0411/1707] Remove unnecessary None checks in Renault numbers and binary sensors (#167271) --- homeassistant/components/renault/binary_sensor.py | 11 ++++------- homeassistant/components/renault/number.py | 6 ++---- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 5e4f08e9d5c7b2..702e23d1489ee5 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -84,15 +84,12 @@ def is_on(self) -> bool | None: def _plugged_in_value_lambda(self: RenaultBinarySensor) -> bool | None: """Return true if the vehicle is plugged in.""" - - data = self.coordinator.data - plug_status = data.get_plug_status() if data else None - - if plug_status is not None: + if (plug_status := self.coordinator.data.get_plug_status()) is not None: return plug_status == PlugState.PLUGGED - charging_status = data.get_charging_status() if data else None - if charging_status is not None and charging_status in _PLUG_FROM_CHARGE_STATUS: + if ( + charging_status := self.coordinator.data.get_charging_status() + ) is not None and charging_status in _PLUG_FROM_CHARGE_STATUS: return True return None diff --git a/homeassistant/components/renault/number.py b/homeassistant/components/renault/number.py index 555bb9b9e72b9e..b487eedd3dd75b 100644 --- a/homeassistant/components/renault/number.py +++ b/homeassistant/components/renault/number.py @@ -43,9 +43,7 @@ async def _set_charge_limit_min(entity: RenaultNumberEntity, value: float) -> No The target SOC is required to set the minimum SOC, so we need to fetch it first. """ - if (data := entity.coordinator.data) is None or ( - target_soc := data.socTarget - ) is None: + if (target_soc := entity.coordinator.data.socTarget) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="battery_soc_unavailable", @@ -58,7 +56,7 @@ async def _set_charge_limit_target(entity: RenaultNumberEntity, value: float) -> The minimum SOC is required to set the target SOC, so we need to fetch it first. """ - if (data := entity.coordinator.data) is None or (min_soc := data.socMin) is None: + if (min_soc := entity.coordinator.data.socMin) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="battery_soc_unavailable", From 24530d13af323e86daa839c4fbea4bd0ef00f4d5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 3 Apr 2026 10:04:32 +0200 Subject: [PATCH 0412/1707] Fix spelling of "cannot" in `dwd_weather_warnings` error string (#167138) --- homeassistant/components/dwd_weather_warnings/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json index cf92c537bc8780..a4f69618775567 100644 --- a/homeassistant/components/dwd_weather_warnings/strings.json +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -5,7 +5,7 @@ "invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]" }, "error": { - "ambiguous_identifier": "The region identifier and device tracker can not be specified together.", + "ambiguous_identifier": "The region identifier and device tracker cannot be specified together.", "attribute_not_found": "The required attributes 'Latitude' and 'Longitude' were not found in the specified device tracker.", "entity_not_found": "The specified device tracker entity was not found.", "invalid_identifier": "The specified region identifier / device tracker is invalid.", From 745107c192bc60d5072cdb8d8f29a040eac7f3cf Mon Sep 17 00:00:00 2001 From: Kevin O'Brien Date: Fri, 3 Apr 2026 01:41:09 -0700 Subject: [PATCH 0413/1707] Fix Proxmox VE storage usage percentage crash on missing used_fraction (#167136) Co-authored-by: Claude Opus 4.6 --- homeassistant/components/proxmoxve/sensor.py | 6 +++- tests/components/proxmoxve/test_sensor.py | 34 ++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py index cb7977745cef1e..3cd8b3717fa7f0 100644 --- a/homeassistant/components/proxmoxve/sensor.py +++ b/homeassistant/components/proxmoxve/sensor.py @@ -426,7 +426,11 @@ class ProxmoxStorageSensorEntityDescription(SensorEntityDescription): ProxmoxStorageSensorEntityDescription( key="storage_used_percentage", translation_key="storage_used_percentage", - value_fn=lambda data: round(data["used_fraction"] * 100, 1), + value_fn=lambda data: ( + round(value * 100, 1) + if (value := data.get("used_fraction")) is not None + else None + ), native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, diff --git a/tests/components/proxmoxve/test_sensor.py b/tests/components/proxmoxve/test_sensor.py index 72b71685a7ec52..f4fc55cb97e5b1 100644 --- a/tests/components/proxmoxve/test_sensor.py +++ b/tests/components/proxmoxve/test_sensor.py @@ -5,13 +5,17 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + snapshot_platform, +) @pytest.fixture(autouse=True) @@ -38,3 +42,29 @@ async def test_all_entities( snapshot, mock_config_entry.entry_id, ) + + +async def test_storage_missing_used_fraction( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test storage usage percentage sensor when used_fraction is missing.""" + storage_data = await async_load_json_array_fixture( + hass, "nodes/storage.json", "proxmoxve" + ) + # Remove used_fraction from all storage entries + storage_without_fraction = [ + {key: value for key, value in storage.items() if key != "used_fraction"} + for storage in storage_data + ] + mock_proxmox_client._node_mock.storage.get.return_value = storage_without_fraction + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.storage_local_storage_usage_percentage") + assert state.state == STATE_UNKNOWN From 3970a273693bd5575a28757e51e5480f66d6d8f3 Mon Sep 17 00:00:00 2001 From: dotlambda Date: Fri, 3 Apr 2026 02:38:19 -0700 Subject: [PATCH 0414/1707] Bump psutil to 7.2.2 (#167263) --- homeassistant/components/systemmonitor/coordinator.py | 2 +- homeassistant/components/systemmonitor/manifest.json | 2 +- homeassistant/components/systemmonitor/util.py | 2 +- requirements_all.txt | 2 +- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/systemmonitor/conftest.py | 2 +- tests/components/systemmonitor/test_sensor.py | 2 +- tests/components/systemmonitor/test_util.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index 225940e0d44721..29467daa28b930 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from psutil import Process -from psutil._common import sbattery, sdiskusage, shwtemp, snetio, snicaddr, sswap +from psutil._ntuples import sbattery, sdiskusage, shwtemp, snetio, snicaddr, sswap import psutil_home_assistant as ha_psutil from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index c64fff86d10c5a..444d5fb9596e71 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.1.2"], + "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.2.2"], "single_config_entry": true } diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 07790479c78672..4fd5f9eaeb6396 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -5,7 +5,7 @@ import re from typing import Any -from psutil._common import sfan, shwtemp +from psutil._ntuples import sfan, shwtemp import psutil_home_assistant as ha_psutil from homeassistant.core import HomeAssistant diff --git a/requirements_all.txt b/requirements_all.txt index 4557c9242fdd1f..23c4cd82e771d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1832,7 +1832,7 @@ proxmoxer==2.3.0 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==7.1.2 +psutil==7.2.2 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 diff --git a/requirements_test.txt b/requirements_test.txt index e82c77721c009b..56681afbe2b158 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -44,7 +44,7 @@ types-chardet==0.1.5 types-decorator==5.2.0.20251101 types-pexpect==4.9.0.20250916 types-protobuf==6.30.2.20250914 -types-psutil==7.1.1.20251122 +types-psutil==7.2.2.20260402 types-pyserial==3.5.0.20251001 types-python-dateutil==2.9.0.20260124 types-python-slugify==8.0.2.20240310 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 61adb4dba0b6eb..195b96fc0ce02c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1590,7 +1590,7 @@ proxmoxer==2.3.0 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==7.1.2 +psutil==7.2.2 # homeassistant.components.pushbullet pushbullet.py==0.11.0 diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index 8b01b8e39ac85e..0900df28edb9ee 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock, NonCallableMock, patch from psutil import NoSuchProcess, Process -from psutil._common import ( +from psutil._ntuples import ( sbattery, sdiskpart, sdiskusage, diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index 75cf03bb1f42a8..b9e12784dc38f4 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory -from psutil._common import sdiskpart, sdiskusage, shwtemp, snetio, snicaddr +from psutil._ntuples import sdiskpart, sdiskusage, shwtemp, snetio, snicaddr import pytest from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/systemmonitor/test_util.py b/tests/components/systemmonitor/test_util.py index 471f2f9e2cb49b..482c7b01d12256 100644 --- a/tests/components/systemmonitor/test_util.py +++ b/tests/components/systemmonitor/test_util.py @@ -2,7 +2,7 @@ from unittest.mock import Mock -from psutil._common import sdiskpart +from psutil._ntuples import sdiskpart import pytest from homeassistant.core import HomeAssistant From 1bbecec991ef468ecba2663c986ba586579aa66f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 3 Apr 2026 11:49:40 +0200 Subject: [PATCH 0415/1707] Fix tuya energy sensor units (#160392) --- homeassistant/components/tuya/sensor.py | 3 + .../tuya/snapshots/test_sensor.ambr | 243 +++++++++++++++--- 2 files changed, 206 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index c950064e7f2caf..59307204567069 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -35,6 +35,7 @@ EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, + UnitOfEnergy, UnitOfPower, UnitOfTime, ) @@ -656,6 +657,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): translation_key="total_energy", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), TuyaSensorEntityDescription( key=DPCode.PRO_ADD_ELE, diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index a695c77a682cf3..3201bb125edc88 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -144,8 +144,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -154,14 +160,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.pykascx9yfqrxtbgzcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.3dprinter_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': '3DPrinter Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.3dprinter_total_energy', @@ -380,6 +388,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -795,8 +806,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -805,15 +822,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.ZDldMHS0tjmQgGxEzcadd_ele', - 'unit_of_measurement': '', + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.ak1_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'AK1 Total energy', 'state_class': , - 'unit_of_measurement': '', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.ak1_total_energy', @@ -1640,8 +1658,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -1650,14 +1674,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.cju47ovcbeuapei2zcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.aubess_cooker_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Aubess Cooker Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.aubess_cooker_total_energy', @@ -1873,8 +1899,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -1883,14 +1915,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.zjh9xhtm3gibs9kizcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Aubess Washing Machine Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.aubess_washing_machine_total_energy', @@ -2445,6 +2479,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -5000,6 +5037,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -5458,8 +5498,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -5468,14 +5514,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.jgsopsvzh2ec3itjzcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.dehumidifier_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Dehumidifier Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.dehumidifier_total_energy', @@ -5964,8 +6012,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -5974,15 +6028,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.l8uxezzkc7c5a0jhzcadd_ele', - 'unit_of_measurement': '', + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.droger_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'droger Total energy', 'state_class': , - 'unit_of_measurement': '', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.droger_total_energy', @@ -7326,8 +7381,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -7336,14 +7397,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Elivco Kitchen Socket Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.elivco_kitchen_socket_total_energy', @@ -7559,8 +7622,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -7569,14 +7638,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.pz2xuth8hczv6zrwzcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.elivco_tv_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Elivco TV Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.elivco_tv_total_energy', @@ -7847,8 +7918,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -7857,14 +7934,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.51tdkcsamisw9ukycpadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.framboisier_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Framboisier Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.framboisier_total_energy', @@ -8359,6 +8438,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -9271,8 +9353,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -9281,14 +9369,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.sxa4ealyi9cotiugzcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.ha_socket_delta_test_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'HA Socket Delta Test Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.ha_socket_delta_test_total_energy', @@ -10334,6 +10424,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -10732,8 +10825,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -10742,14 +10841,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.vx2owjsg86g2ys93zcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.ineox_sp2_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Ineox SP2 Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.ineox_sp2_total_energy', @@ -11077,6 +11178,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -11851,8 +11955,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -11861,15 +11971,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.g7af6lrt4miugbstcpadd_ele', - 'unit_of_measurement': '', + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.keller_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Keller Total energy', 'state_class': , - 'unit_of_measurement': '', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.keller_total_energy', @@ -12192,6 +12303,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -12427,8 +12541,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -12437,15 +12557,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.uvh6oeqrfliovfiwzcadd_ele', - 'unit_of_measurement': '度', + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.licht_drucker_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Licht drucker Total energy', 'state_class': , - 'unit_of_measurement': '度', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.licht_drucker_total_energy', @@ -15266,8 +15387,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -15276,15 +15403,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.uc9fL2NpR79iCzGIzcadd_ele', - 'unit_of_measurement': '', + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.n4_auto_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'N4-Auto Total energy', 'state_class': , - 'unit_of_measurement': '', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.n4_auto_total_energy', @@ -15668,8 +15796,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -15678,14 +15812,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.2x473nefusdo7af6zcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.office_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Office Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.office_total_energy', @@ -17995,6 +18131,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -19829,8 +19968,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -19839,14 +19984,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.4q5c2am8n1bwb6bszcadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.socket4_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Socket4 Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.socket4_total_energy', @@ -20506,6 +20653,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -22964,6 +23114,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -23764,8 +23917,14 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -23774,15 +23933,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.dNBnmtjLU8eRWHf0zcadd_ele', - 'unit_of_measurement': '', + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.weihnachten3_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Weihnachten3 Total energy', 'state_class': , - 'unit_of_measurement': '', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.weihnachten3_total_energy', @@ -24001,6 +24161,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, From 86d80c96d732036f085e15679c25acfebbaa15a3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 3 Apr 2026 12:36:54 +0200 Subject: [PATCH 0416/1707] Improve Assist satellite action naming consistency (#167278) --- .../components/assist_satellite/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 70f167f323e0af..f2d32336ef68ec 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -75,7 +75,7 @@ }, "services": { "announce": { - "description": "Lets a satellite announce a message.", + "description": "Lets an Assist satellite announce a message.", "fields": { "media_id": { "description": "The media ID to announce instead of using text-to-speech.", @@ -94,10 +94,10 @@ "name": "Preannounce media ID" } }, - "name": "Announce" + "name": "Announce on satellite" }, "ask_question": { - "description": "Asks a question and gets the user's response.", + "description": "Lets an Assist satellite ask a question and get the user's response.", "fields": { "answers": { "description": "Possible answers to the question.", @@ -124,10 +124,10 @@ "name": "Question media ID" } }, - "name": "Ask question" + "name": "Ask question on satellite" }, "start_conversation": { - "description": "Starts a conversation from a satellite.", + "description": "Starts a conversation from an Assist satellite.", "fields": { "extra_system_prompt": { "description": "Provide background information to the AI about the request.", @@ -150,13 +150,13 @@ "name": "Message" } }, - "name": "Start conversation" + "name": "Start conversation on satellite" } }, "title": "Assist satellite", "triggers": { "idle": { - "description": "Triggers after one or more voice assistant satellites become idle after having processed a command.", + "description": "Triggers after one or more Assist satellites become idle after having processed a command.", "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" @@ -165,7 +165,7 @@ "name": "Satellite became idle" }, "listening": { - "description": "Triggers after one or more voice assistant satellites start listening for a command from someone.", + "description": "Triggers after one or more Assist satellites start listening for a command from someone.", "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" @@ -174,7 +174,7 @@ "name": "Satellite started listening" }, "processing": { - "description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.", + "description": "Triggers after one or more Assist satellites start processing a command after having heard it.", "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" @@ -183,7 +183,7 @@ "name": "Satellite started processing" }, "responding": { - "description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.", + "description": "Triggers after one or more Assist satellites start responding to a command after having processed it, or start announcing something.", "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" From e30c3799791258c97cd9ba00581766015b87216d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 3 Apr 2026 14:11:20 +0200 Subject: [PATCH 0417/1707] Bump zinvolt to 0.4.0 (#167276) --- homeassistant/components/zinvolt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zinvolt/manifest.json b/homeassistant/components/zinvolt/manifest.json index c0be07030c60b2..2973f104e5c129 100644 --- a/homeassistant/components/zinvolt/manifest.json +++ b/homeassistant/components/zinvolt/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["zinvolt"], "quality_scale": "bronze", - "requirements": ["zinvolt==0.3.0"] + "requirements": ["zinvolt==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23c4cd82e771d2..54a5c5324f77f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3395,7 +3395,7 @@ zhong-hong-hvac==1.0.13 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zinvolt -zinvolt==0.3.0 +zinvolt==0.4.0 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 195b96fc0ce02c..555bf9c2640ce6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2871,7 +2871,7 @@ zeversolar==0.3.2 zha==1.1.1 # homeassistant.components.zinvolt -zinvolt==0.3.0 +zinvolt==0.4.0 # homeassistant.components.zoneminder zm-py==0.5.4 From 90045e55395a5f8455d36b4926260c61abb919fc Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:18:37 +0200 Subject: [PATCH 0418/1707] Fix zwave_js subscribe_rebuild_routes_progress initial event (#167178) --- homeassistant/components/zwave_js/api.py | 21 +++++++++++++-------- tests/components/zwave_js/test_api.py | 6 +++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 2388cc085faf60..835ba41b433b67 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1753,16 +1753,21 @@ def forward_event(key: str, event: dict) -> None: controller.on("rebuild routes done", partial(forward_event, "result")), ] + connection.send_result(msg[ID]) + if controller.rebuild_routes_progress: - connection.send_result( - msg[ID], - { - node.node_id: status - for node, status in controller.rebuild_routes_progress.items() - }, + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "rebuild routes progress", + "rebuild_routes_status": { + node.node_id: status + for node, status in controller.rebuild_routes_progress.items() + }, + }, + ) ) - else: - connection.send_result(msg[ID], None) @websocket_api.require_admin diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 9b3d7697067212..d41c13a06b0839 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2578,7 +2578,11 @@ async def test_subscribe_rebuild_routes_progress_initial_value( msg = await ws_client.receive_json() assert msg["success"] - assert msg["result"] == {"67": "pending"} + assert msg["result"] is None + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "rebuild routes progress" + assert msg["event"]["rebuild_routes_status"] == {"67": "pending"} async def test_stop_rebuilding_routes( From 374a05063605e8bc5855a8c7f7e0f1494db92ee7 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 3 Apr 2026 14:29:17 +0200 Subject: [PATCH 0419/1707] Update frontend to 20260325.6 (#167285) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ce977e3fd614b3..284e9f4b77fa4a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.5"] + "requirements": ["home-assistant-frontend==20260325.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b6a184eb8178c6..7b566c233192bd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.5 +home-assistant-frontend==20260325.6 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 54a5c5324f77f5..e69182658f5fbe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.93 # homeassistant.components.frontend -home-assistant-frontend==20260325.5 +home-assistant-frontend==20260325.6 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 555bf9c2640ce6..aad63ceafb725b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.93 # homeassistant.components.frontend -home-assistant-frontend==20260325.5 +home-assistant-frontend==20260325.6 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From e33727a75a61fb3feebf30af53edaffdb454f2d8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 3 Apr 2026 15:09:33 +0200 Subject: [PATCH 0420/1707] Bump Zinvolt to 0.4.1 (#167296) --- homeassistant/components/zinvolt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zinvolt/manifest.json b/homeassistant/components/zinvolt/manifest.json index 2973f104e5c129..53e7b74ed004d6 100644 --- a/homeassistant/components/zinvolt/manifest.json +++ b/homeassistant/components/zinvolt/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["zinvolt"], "quality_scale": "bronze", - "requirements": ["zinvolt==0.4.0"] + "requirements": ["zinvolt==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e69182658f5fbe..8cf5e7c08a1f97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3395,7 +3395,7 @@ zhong-hong-hvac==1.0.13 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zinvolt -zinvolt==0.4.0 +zinvolt==0.4.1 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aad63ceafb725b..6753952f60cfb6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2871,7 +2871,7 @@ zeversolar==0.3.2 zha==1.1.1 # homeassistant.components.zinvolt -zinvolt==0.4.0 +zinvolt==0.4.1 # homeassistant.components.zoneminder zm-py==0.5.4 From 7f700c891aac41bb4bcb8318178788b92d55932f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 3 Apr 2026 15:11:35 +0200 Subject: [PATCH 0421/1707] Improve Media player action naming consistency (#167274) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/media_player/strings.json | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index d9ef8c051035cb..3a3c4408f2d724 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -260,7 +260,7 @@ }, "clear_playlist": { "description": "Removes all items from a media player's playlist.", - "name": "Clear playlist" + "name": "Clear media player playlist" }, "join": { "description": "Groups media players together for synchronous playback. Only works on supported multiroom audio systems.", @@ -270,44 +270,44 @@ "name": "Group members" } }, - "name": "Join" + "name": "Join media players" }, "media_next_track": { - "description": "Selects the next track.", - "name": "Next" + "description": "Selects the next track on a media player.", + "name": "Next track" }, "media_pause": { "description": "Pauses playback on a media player.", - "name": "[%key:common::action::pause%]" + "name": "Pause media" }, "media_play": { "description": "Starts playback on a media player.", - "name": "Play" + "name": "Play media" }, "media_play_pause": { "description": "Toggles play/pause on a media player.", - "name": "Play/Pause" + "name": "Play/Pause media" }, "media_previous_track": { - "description": "Selects the previous track.", - "name": "Previous" + "description": "Selects the previous track on a media player.", + "name": "Previous track" }, "media_seek": { - "description": "Allows you to go to a different part of the media that is currently playing.", + "description": "Allows you to go to a different part of the media that is currently playing on a media player.", "fields": { "seek_position": { "description": "Target position in the currently playing media. The format is platform dependent.", "name": "Position" } }, - "name": "Seek" + "name": "Seek media" }, "media_stop": { "description": "Stops playback on a media player.", - "name": "[%key:common::action::stop%]" + "name": "Stop media" }, "play_media": { - "description": "Starts playing specified media.", + "description": "Starts playing specified media on a media player.", "fields": { "announce": { "description": "If the media should be played as an announcement.", @@ -325,14 +325,14 @@ "name": "Play media" }, "repeat_set": { - "description": "Sets the repeat mode.", + "description": "Sets the repeat mode of a media player.", "fields": { "repeat": { "description": "Whether the media (one or all) should be played in a loop or not.", "name": "Repeat mode" } }, - "name": "Set repeat" + "name": "Set media player repeat" }, "search_media": { "description": "Searches the available media.", @@ -357,14 +357,14 @@ "name": "Search media" }, "select_sound_mode": { - "description": "Selects a specific sound mode.", + "description": "Selects a specific sound mode of a media player.", "fields": { "sound_mode": { "description": "Name of the sound mode to switch to.", "name": "Sound mode" } }, - "name": "Select sound mode" + "name": "Select media player sound mode" }, "select_source": { "description": "Sends a media player the command to change the input source.", @@ -374,37 +374,37 @@ "name": "Source" } }, - "name": "Select source" + "name": "Select media player source" }, "shuffle_set": { - "description": "Enables or disables the shuffle mode.", + "description": "Enables or disables the shuffle mode of a media player.", "fields": { "shuffle": { "description": "Whether the media should be played in randomized order or not.", "name": "Shuffle mode" } }, - "name": "Set shuffle" + "name": "Set media player shuffle" }, "toggle": { "description": "Toggles a media player on/off.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle media player" }, "turn_off": { "description": "Turns off the power of a media player.", - "name": "[%key:common::action::turn_off%]" + "name": "Turn off media player" }, "turn_on": { "description": "Turns on the power of a media player.", - "name": "[%key:common::action::turn_on%]" + "name": "Turn on media player" }, "unjoin": { "description": "Removes a media player from a group. Only works on platforms which support player groups.", - "name": "Unjoin" + "name": "Unjoin media player" }, "volume_down": { "description": "Turns down the volume of a media player.", - "name": "Turn down volume" + "name": "Turn down media player volume" }, "volume_mute": { "description": "Mutes or unmutes a media player.", @@ -414,7 +414,7 @@ "name": "Muted" } }, - "name": "Mute/unmute volume" + "name": "Mute/unmute media player" }, "volume_set": { "description": "Sets the volume level of a media player.", @@ -424,11 +424,11 @@ "name": "Level" } }, - "name": "Set volume" + "name": "Set media player volume" }, "volume_up": { "description": "Turns up the volume of a media player.", - "name": "Turn up volume" + "name": "Turn up media player volume" } }, "title": "Media player", From 68cc6df3e0e49e85bf1d2779424910c8fcfc2df2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 3 Apr 2026 15:13:17 +0200 Subject: [PATCH 0422/1707] Make sure we take all Zinvolt battery units in account (#167294) --- .../components/zinvolt/binary_sensor.py | 21 +- .../components/zinvolt/coordinator.py | 46 ++- homeassistant/components/zinvolt/entity.py | 58 ++- tests/components/zinvolt/conftest.py | 5 +- tests/components/zinvolt/fixtures/units.json | 48 +++ .../zinvolt/snapshots/test_binary_sensor.ambr | 357 ++++++++++++++++++ .../zinvolt/snapshots/test_diagnostics.ambr | 39 +- .../zinvolt/snapshots/test_init.ambr | 35 +- tests/components/zinvolt/test_init.py | 7 +- 9 files changed, 579 insertions(+), 37 deletions(-) create mode 100644 tests/components/zinvolt/fixtures/units.json diff --git a/homeassistant/components/zinvolt/binary_sensor.py b/homeassistant/components/zinvolt/binary_sensor.py index b34fada6ee4e5f..52e44ec7c77ec0 100644 --- a/homeassistant/components/zinvolt/binary_sensor.py +++ b/homeassistant/components/zinvolt/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ZinvoltConfigEntry, ZinvoltData, ZinvoltDeviceCoordinator -from .entity import ZinvoltEntity +from .entity import ZinvoltEntity, ZinvoltUnitEntity POINT_ENTITIES = { "communication": BinarySensorDeviceClass.PROBLEM, @@ -57,9 +57,10 @@ async def async_setup_entry( for coordinator in entry.runtime_data.values() ] entities.extend( - ZinvoltPointBinarySensor(coordinator, point) + ZinvoltPointBinarySensor(coordinator, battery.serial_number, point) for coordinator in entry.runtime_data.values() - for point in coordinator.data.points + for battery in coordinator.battery_units.values() + for point in coordinator.data.batteries[battery.serial_number].points if point in POINT_ENTITIES ) async_add_entities(entities) @@ -88,25 +89,27 @@ def is_on(self) -> bool: return self.entity_description.is_on_fn(self.coordinator.data) -class ZinvoltPointBinarySensor(ZinvoltEntity, BinarySensorEntity): +class ZinvoltPointBinarySensor(ZinvoltUnitEntity, BinarySensorEntity): """Zinvolt battery state binary sensor.""" _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, coordinator: ZinvoltDeviceCoordinator, point: str) -> None: + def __init__( + self, coordinator: ZinvoltDeviceCoordinator, unit_serial_number: str, point: str + ) -> None: """Initialize the binary sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, unit_serial_number) self.point = point self._attr_translation_key = point self._attr_device_class = POINT_ENTITIES[point] - self._attr_unique_id = f"{coordinator.data.battery.serial_number}.{point}" + self._attr_unique_id = f"{self.serial_number}.{point}" @property def available(self) -> bool: """Return the availability of the binary sensor.""" - return super().available and self.point in self.coordinator.data.points + return super().available and self.point in self.battery.points @property def is_on(self) -> bool: """Return the state of the binary sensor.""" - return not self.coordinator.data.points[self.point] + return not self.battery.points[self.point] diff --git a/homeassistant/components/zinvolt/coordinator.py b/homeassistant/components/zinvolt/coordinator.py index 862a4cf8718610..c2471f162da334 100644 --- a/homeassistant/components/zinvolt/coordinator.py +++ b/homeassistant/components/zinvolt/coordinator.py @@ -6,7 +6,7 @@ from zinvolt import ZinvoltClient from zinvolt.exceptions import ZinvoltError -from zinvolt.models import Battery, BatteryState +from zinvolt.models import Battery, BatteryState, Unit, UnitType from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -24,6 +24,13 @@ class ZinvoltData: """Data for the Zinvolt integration.""" battery: BatteryState + batteries: dict[str, BatteryData] + + +@dataclass +class BatteryData: + """Data per battery unit.""" + sw_version: str model: str points: dict[str, bool] @@ -32,6 +39,8 @@ class ZinvoltData: class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]): """Class for Zinvolt devices.""" + battery_units: dict[str, Unit] + def __init__( self, hass: HomeAssistant, @@ -50,15 +59,30 @@ def __init__( self.battery = battery self.client = client + async def _async_setup(self) -> None: + """Set up the Zinvolt integration.""" + try: + units = await self.client.get_units(self.battery.identifier) + except ZinvoltError as err: + raise UpdateFailed( + translation_key="update_failed", translation_domain=DOMAIN + ) from err + self.battery_units = { + unit.serial_number: unit for unit in units if unit.type is UnitType.BATTERY + } + async def _async_update_data(self) -> ZinvoltData: """Update data from Zinvolt.""" try: battery_state = await self.client.get_battery_status( self.battery.identifier ) - battery_unit = await self.client.get_battery_unit( - self.battery.identifier, self.battery.serial_number - ) + battery_units = { + unit_serial_number: await self.client.get_battery_unit( + self.battery.identifier, unit_serial_number + ) + for unit_serial_number in self.battery_units + } except ZinvoltError as err: raise UpdateFailed( translation_key="update_failed", @@ -66,7 +90,15 @@ async def _async_update_data(self) -> ZinvoltData: ) from err return ZinvoltData( battery_state, - battery_unit.version.current_version, - battery_unit.battery_model, - {point.point.lower(): point.normal for point in battery_unit.points}, + { + serial_number: BatteryData( + battery_unit.version.current_version, + battery_unit.battery_model, + { + point.point.lower(): point.normal + for point in battery_unit.points + }, + ) + for serial_number, battery_unit in battery_units.items() + }, ) diff --git a/homeassistant/components/zinvolt/entity.py b/homeassistant/components/zinvolt/entity.py index a9e9a2c89df1b3..932e18fb09571e 100644 --- a/homeassistant/components/zinvolt/entity.py +++ b/homeassistant/components/zinvolt/entity.py @@ -1,10 +1,13 @@ """Base entity for Zinvolt integration.""" +from zinvolt.models import Unit + +from homeassistant.const import ATTR_VIA_DEVICE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ZinvoltDeviceCoordinator +from .coordinator import BatteryData, ZinvoltDeviceCoordinator class ZinvoltEntity(CoordinatorEntity[ZinvoltDeviceCoordinator]): @@ -20,6 +23,55 @@ def __init__(self, coordinator: ZinvoltDeviceCoordinator) -> None: manufacturer="Zinvolt", name=coordinator.battery.name, serial_number=coordinator.data.battery.serial_number, - model_id=coordinator.data.model, - sw_version=coordinator.data.sw_version, + ) + + +class ZinvoltUnitEntity(ZinvoltEntity): + """Base entity for Zinvolt units.""" + + def __init__( + self, coordinator: ZinvoltDeviceCoordinator, unit_serial_number: str + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.unit_serial_number = unit_serial_number + is_main_device = ( + list(coordinator.battery_units).index(self.unit_serial_number) == 0 + ) + self.serial_number = ( + coordinator.data.battery.serial_number + if is_main_device + else self.battery_unit.serial_number + ) + name = coordinator.battery.name if is_main_device else self.battery_unit.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.serial_number)}, + manufacturer="Zinvolt", + name=name, + serial_number=self.serial_number, + sw_version=self.battery_unit.version.current_version, + model_id=self.battery.model, + ) + if not is_main_device: + self._attr_device_info[ATTR_VIA_DEVICE] = ( + DOMAIN, + coordinator.data.battery.serial_number, + ) + + @property + def battery(self) -> BatteryData: + """Return the battery data.""" + return self.coordinator.data.batteries[self.unit_serial_number] + + @property + def battery_unit(self) -> Unit: + """Return the battery unit.""" + return self.coordinator.battery_units[self.unit_serial_number] + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return ( + super().available + and self.unit_serial_number in self.coordinator.data.batteries ) diff --git a/tests/components/zinvolt/conftest.py b/tests/components/zinvolt/conftest.py index a01a62d80519da..5ed74c96bc0989 100644 --- a/tests/components/zinvolt/conftest.py +++ b/tests/components/zinvolt/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest -from zinvolt.models import BatteryListResponse, BatteryState, BatteryUnit +from zinvolt.models import BatteryListResponse, BatteryState, BatteryUnit, UnitsResponse from homeassistant.components.zinvolt.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN @@ -57,4 +57,7 @@ def mock_zinvolt_client() -> Generator[AsyncMock]: client.get_battery_unit.return_value = BatteryUnit.from_json( load_fixture("battery_unit.json", DOMAIN) ) + client.get_units.return_value = UnitsResponse.from_json( + load_fixture("units.json", DOMAIN) + ).units yield client diff --git a/tests/components/zinvolt/fixtures/units.json b/tests/components/zinvolt/fixtures/units.json new file mode 100644 index 00000000000000..aa709b024bf312 --- /dev/null +++ b/tests/components/zinvolt/fixtures/units.json @@ -0,0 +1,48 @@ +{ + "units": [ + { + "usn": "INV001", + "name": "Inverter", + "type": "INVERTER", + "abnormalAmount": 0, + "resettable": false, + "version": { + "currentVersion": "V1.032", + "status": "NO_UPDATE" + } + }, + { + "usn": "ems", + "name": "EMS", + "type": "EMS", + "abnormalAmount": 0, + "resettable": false, + "version": { + "currentVersion": "V1.01.45E", + "status": "NO_UPDATE" + } + }, + { + "usn": "BAT001", + "name": "Battery - 1", + "type": "BATTERY", + "abnormalAmount": 0, + "resettable": false, + "version": { + "currentVersion": "V1.20", + "status": "NO_UPDATE" + } + }, + { + "usn": "BAT002", + "name": "Battery - 2", + "type": "BATTERY", + "abnormalAmount": 0, + "resettable": false, + "version": { + "currentVersion": "V1.20", + "status": "NO_UPDATE" + } + } + ] +} diff --git a/tests/components/zinvolt/snapshots/test_binary_sensor.ambr b/tests/components/zinvolt/snapshots/test_binary_sensor.ambr index 27de2b38003c31..60166ab295520b 100644 --- a/tests/components/zinvolt/snapshots/test_binary_sensor.ambr +++ b/tests/components/zinvolt/snapshots/test_binary_sensor.ambr @@ -1,4 +1,361 @@ # serializer version: 1 +# name: test_all_entities[binary_sensor.battery_2_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.battery_2_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge', + 'unique_id': 'BAT002.charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Battery - 2 Charge', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_2_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_communication-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.battery_2_communication', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Communication', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Communication', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'communication', + 'unique_id': 'BAT002.communication', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_communication-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Battery - 2 Communication', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_2_communication', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.battery_2_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'BAT002.current', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Battery - 2 Current', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_2_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_discharge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.battery_2_discharge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discharge', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discharge', + 'unique_id': 'BAT002.discharge', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_discharge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Battery - 2 Discharge', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_2_discharge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.battery_2_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heat', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heat', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'BAT002.temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Battery - 2 Heat', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_2_heat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_other_problems-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.battery_2_other_problems', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Other problems', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Other problems', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'other', + 'unique_id': 'BAT002.other', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_other_problems-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Battery - 2 Other problems', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_2_other_problems', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.battery_2_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'zinvolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'BAT002.voltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.battery_2_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Battery - 2 Voltage', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_2_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[binary_sensor.zinvolt_batterij_charge-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/zinvolt/snapshots/test_diagnostics.ambr b/tests/components/zinvolt/snapshots/test_diagnostics.ambr index dd97e06b4084bd..21520546352898 100644 --- a/tests/components/zinvolt/snapshots/test_diagnostics.ambr +++ b/tests/components/zinvolt/snapshots/test_diagnostics.ambr @@ -4,6 +4,34 @@ 'coordinators': list([ dict({ 'a125ef17-6bdf-45ad-b106-ce54e95e4634': dict({ + 'batteries': dict({ + 'BAT001': dict({ + 'model': 'ZVS4000', + 'points': dict({ + 'charge': True, + 'communication': True, + 'current': True, + 'discharge': True, + 'other': True, + 'temperature': True, + 'voltage': True, + }), + 'sw_version': 'V1.02-V0.00.000', + }), + 'BAT002': dict({ + 'model': 'ZVS4000', + 'points': dict({ + 'charge': True, + 'communication': True, + 'current': True, + 'discharge': True, + 'other': True, + 'temperature': True, + 'voltage': True, + }), + 'sw_version': 'V1.02-V0.00.000', + }), + }), 'battery': dict({ 'current_power': dict({ 'is_dormant': False, @@ -29,17 +57,6 @@ 'serial_number': 'ZVG011025120088', 'smart_mode': 'CHARGED', }), - 'model': 'ZVS4000', - 'points': dict({ - 'charge': True, - 'communication': True, - 'current': True, - 'discharge': True, - 'other': True, - 'temperature': True, - 'voltage': True, - }), - 'sw_version': 'V1.02-V0.00.000', }), }), ]), diff --git a/tests/components/zinvolt/snapshots/test_init.ambr b/tests/components/zinvolt/snapshots/test_init.ambr index e6ec512fa7948e..657cb27c219b31 100644 --- a/tests/components/zinvolt/snapshots/test_init.ambr +++ b/tests/components/zinvolt/snapshots/test_init.ambr @@ -1,5 +1,36 @@ # serializer version: 1 -# name: test_device +# name: test_device[BAT002] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'zinvolt', + 'BAT002', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Zinvolt', + 'model': None, + 'model_id': 'ZVS4000', + 'name': 'Battery - 2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'BAT002', + 'sw_version': 'V1.20', + 'via_device_id': , + }) +# --- +# name: test_device[ZVG011025120088] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -26,7 +57,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'ZVG011025120088', - 'sw_version': 'V1.02-V0.00.000', + 'sw_version': 'V1.20', 'via_device_id': None, }) # --- diff --git a/tests/components/zinvolt/test_init.py b/tests/components/zinvolt/test_init.py index 5c755d987bd723..e197458ea9610f 100644 --- a/tests/components/zinvolt/test_init.py +++ b/tests/components/zinvolt/test_init.py @@ -4,7 +4,6 @@ from syrupy.assertion import SnapshotAssertion -from homeassistant.components.zinvolt.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -22,6 +21,6 @@ async def test_device( ) -> None: """Test the Zinvolt device.""" await setup_integration(hass, mock_config_entry) - device = device_registry.async_get_device({(DOMAIN, "ZVG011025120088")}) - assert device - assert device == snapshot + devices = device_registry.devices + for device in devices.values(): + assert device == snapshot(name=list(device.identifiers)[0][1]) From 815c30b2134360102ea45d9d9d8eadf329dfb678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 3 Apr 2026 15:20:17 +0200 Subject: [PATCH 0423/1707] Fix Matter water heater off mode (#167286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- homeassistant/components/matter/water_heater.py | 8 +++++++- tests/components/matter/test_water_heater.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/water_heater.py b/homeassistant/components/matter/water_heater.py index fc67d663a6e656..5f3c0ce3a95e67 100644 --- a/homeassistant/components/matter/water_heater.py +++ b/homeassistant/components/matter/water_heater.py @@ -168,10 +168,15 @@ def _update_from_device(self) -> None: self._attr_target_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) + system_mode = self.get_matter_attribute_value( + clusters.Thermostat.Attributes.SystemMode + ) boost_state = self.get_matter_attribute_value( clusters.WaterHeaterManagement.Attributes.BoostState ) - if boost_state == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: + if system_mode == clusters.Thermostat.Enums.SystemModeEnum.kOff: + self._attr_current_operation = STATE_OFF + elif boost_state == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: self._attr_current_operation = STATE_HIGH_DEMAND else: self._attr_current_operation = STATE_ECO @@ -218,6 +223,7 @@ def _get_temperature_in_degrees( clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit, clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.Attributes.LocalTemperature, + clusters.Thermostat.Attributes.SystemMode, clusters.WaterHeaterManagement.Attributes.FeatureMap, ), optional_attributes=( diff --git a/tests/components/matter/test_water_heater.py b/tests/components/matter/test_water_heater.py index 9c2def1d08a8c3..9311425689f22b 100644 --- a/tests/components/matter/test_water_heater.py +++ b/tests/components/matter/test_water_heater.py @@ -226,6 +226,20 @@ async def test_update_from_water_heater( assert state assert state.state == STATE_ECO + # confirm water heater state is 'off' when SystemMode is set to 0 (kOff) + set_node_attribute(matter_node, 2, 513, 28, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + # confirm water heater state returns to 'eco' when SystemMode is set back to 4 (kHeat) + set_node_attribute(matter_node, 2, 513, 28, 4) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ECO + @pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) async def test_water_heater_turn_on_off( From 6305ea8cf213e8c17c95d77c2a513129a873edee Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 3 Apr 2026 15:20:49 +0200 Subject: [PATCH 0424/1707] Bump pyportainer 1.0.35 (#167288) --- homeassistant/components/portainer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index ecbbd05e4dcfa7..e2710c38453fa4 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.33"] + "requirements": ["pyportainer==1.0.35"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8cf5e7c08a1f97..2de6376ff2d8ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2397,7 +2397,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.33 +pyportainer==1.0.35 # homeassistant.components.probe_plus pyprobeplus==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6753952f60cfb6..66674d0dc2eb83 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2053,7 +2053,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.33 +pyportainer==1.0.35 # homeassistant.components.probe_plus pyprobeplus==1.1.2 From 72cd7ed178d4c33987c7eb77c5fa5d021c94045b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 3 Apr 2026 15:21:14 +0200 Subject: [PATCH 0425/1707] Fix to allow Matter Fan percent setting to be null when FanMode is Auto (#167279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- homeassistant/components/matter/fan.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 823451113e0edd..6195030a7405cf 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -323,7 +323,11 @@ def _calculate_features( required_attributes=( clusters.FanControl.Attributes.FanMode, clusters.FanControl.Attributes.PercentCurrent, + clusters.FanControl.Attributes.PercentSetting, ), + # PercentSetting SHALL be null when FanMode is Auto (spec 4.4.6.3), + # so allow null values to not block discovery in that state. + allow_none_value=True, optional_attributes=( clusters.FanControl.Attributes.SpeedSetting, clusters.FanControl.Attributes.RockSetting, From abf37849fbd2400ccad9d407e3d3f6e0f9a8c1ca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:21:36 +0200 Subject: [PATCH 0426/1707] Remove unnecessary attribute from Renault sensor entity descriptions (#167268) --- homeassistant/components/renault/sensor.py | 75 ++++++++-------------- 1 file changed, 25 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 66e1a4be93b81f..e035eb82068132 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -54,7 +54,6 @@ class RenaultSensorEntityDescription( """Class describing Renault sensor entities.""" data_key: str - entity_class: type[RenaultSensor[T]] condition_lambda: Callable[[RenaultVehicleProxy], bool] | None = None requires_fuel: bool = False value_lambda: Callable[[RenaultSensor[T]], StateType | datetime] | None = None @@ -67,7 +66,7 @@ async def async_setup_entry( ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultSensor[Any]] = [ - description.entity_class(vehicle, description) + RenaultSensor(vehicle, description) for vehicle in config_entry.runtime_data.vehicles.values() for description in SENSOR_TYPES if description.coordinator in vehicle.coordinators @@ -137,22 +136,20 @@ def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | Non SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_level", coordinator="battery", data_key="batteryLevel", device_class=SensorDeviceClass.BATTERY, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="charge_state", coordinator="battery", data_key="chargingStatus", translation_key="charge_state", device_class=SensorDeviceClass.ENUM, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], options=[ "not_in_charge", "waiting_for_a_planned_charge", @@ -165,17 +162,16 @@ def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | Non ], value_lambda=_get_charge_state_formatted, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="charging_remaining_time", coordinator="battery", data_key="chargingRemainingTime", device_class=SensorDeviceClass.DURATION, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, translation_key="charging_remaining_time", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( # For vehicles that DO NOT report charging power in watts, this seems to # correspond to the maximum power that would be admissible by the car based # on the battery state, regardless of the type of charger. @@ -184,12 +180,11 @@ def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | Non coordinator="battery", data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, translation_key="admissible_charging_power", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( # For vehicles that DO report charging power in watts, this is the power # effectively being transferred to the car. key="charging_power", @@ -197,19 +192,17 @@ def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | Non coordinator="battery", data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, value_lambda=_get_charging_power, translation_key="charging_power", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="plug_state", coordinator="battery", data_key="plugStatus", translation_key="plug_state", device_class=SensorDeviceClass.ENUM, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], options=[ "unplugged", "plugged", @@ -219,140 +212,126 @@ def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | Non ], value_lambda=_get_plug_state_formatted, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_autonomy", coordinator="battery", data_key="batteryAutonomy", device_class=SensorDeviceClass.DISTANCE, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, translation_key="battery_autonomy", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_available_energy", coordinator="battery", data_key="batteryAvailableEnergy", - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, translation_key="battery_available_energy", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_temperature", coordinator="battery", data_key="batteryTemperature", device_class=SensorDeviceClass.TEMPERATURE, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, translation_key="battery_temperature", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_last_activity", coordinator="battery", device_class=SensorDeviceClass.TIMESTAMP, data_key="timestamp", - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], entity_registry_enabled_default=False, value_lambda=_get_utc_value, translation_key="battery_last_activity", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleCockpitData]( key="mileage", coordinator="cockpit", data_key="totalMileage", device_class=SensorDeviceClass.DISTANCE, - entity_class=RenaultSensor[KamereonVehicleCockpitData], native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, value_lambda=_get_rounded_value, translation_key="mileage", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleCockpitData]( key="fuel_autonomy", coordinator="cockpit", data_key="fuelAutonomy", device_class=SensorDeviceClass.DISTANCE, - entity_class=RenaultSensor[KamereonVehicleCockpitData], native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, requires_fuel=True, value_lambda=_get_rounded_value, translation_key="fuel_autonomy", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleCockpitData]( key="fuel_quantity", coordinator="cockpit", data_key="fuelQuantity", device_class=SensorDeviceClass.VOLUME, - entity_class=RenaultSensor[KamereonVehicleCockpitData], native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.TOTAL, requires_fuel=True, value_lambda=_get_rounded_value, translation_key="fuel_quantity", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleHvacStatusData]( key="outside_temperature", coordinator="hvac_status", device_class=SensorDeviceClass.TEMPERATURE, data_key="externalTemperature", - entity_class=RenaultSensor[KamereonVehicleHvacStatusData], native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, translation_key="outside_temperature", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleHvacStatusData]( key="hvac_soc_threshold", coordinator="hvac_status", data_key="socThreshold", - entity_class=RenaultSensor[KamereonVehicleHvacStatusData], native_unit_of_measurement=PERCENTAGE, translation_key="hvac_soc_threshold", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleHvacStatusData]( key="hvac_last_activity", coordinator="hvac_status", device_class=SensorDeviceClass.TIMESTAMP, data_key="lastUpdateTime", - entity_class=RenaultSensor[KamereonVehicleHvacStatusData], entity_registry_enabled_default=False, translation_key="hvac_last_activity", value_lambda=_get_utc_value, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleLocationData]( key="location_last_activity", coordinator="location", device_class=SensorDeviceClass.TIMESTAMP, data_key="lastUpdateTime", - entity_class=RenaultSensor[KamereonVehicleLocationData], entity_registry_enabled_default=False, translation_key="location_last_activity", value_lambda=_get_utc_value, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleResStateData]( key="res_state", coordinator="res_state", data_key="details", - entity_class=RenaultSensor[KamereonVehicleResStateData], translation_key="res_state", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleResStateData]( key="res_state_code", coordinator="res_state", data_key="code", - entity_class=RenaultSensor[KamereonVehicleResStateData], entity_registry_enabled_default=False, translation_key="res_state_code", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleChargingSettingsData]( key="charging_settings_mode", coordinator="charging_settings", data_key="mode", translation_key="charging_settings_mode", - entity_class=RenaultSensor[KamereonVehicleChargingSettingsData], device_class=SensorDeviceClass.ENUM, options=[ "always", @@ -361,42 +340,38 @@ def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | Non ], value_lambda=_get_charging_settings_mode_formatted, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="front_left_pressure", coordinator="pressure", data_key="flPressure", device_class=SensorDeviceClass.PRESSURE, - entity_class=RenaultSensor[KamereonVehicleTyrePressureData], native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="front_left_pressure", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="front_right_pressure", coordinator="pressure", data_key="frPressure", device_class=SensorDeviceClass.PRESSURE, - entity_class=RenaultSensor[KamereonVehicleTyrePressureData], native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="front_right_pressure", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="rear_left_pressure", coordinator="pressure", data_key="rlPressure", device_class=SensorDeviceClass.PRESSURE, - entity_class=RenaultSensor[KamereonVehicleTyrePressureData], native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="rear_left_pressure", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="rear_right_pressure", coordinator="pressure", data_key="rrPressure", device_class=SensorDeviceClass.PRESSURE, - entity_class=RenaultSensor[KamereonVehicleTyrePressureData], native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="rear_right_pressure", From 01ffa6a676cc3cb9d3c28ab785d3e65cdc933c28 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 3 Apr 2026 15:21:58 +0200 Subject: [PATCH 0427/1707] Improve Recorder action naming consistency (#167244) --- homeassistant/components/recorder/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 35286836318635..d0afa2d3ddfab0 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -12,11 +12,11 @@ "services": { "disable": { "description": "Stops the recording of events and state changes.", - "name": "[%key:common::action::disable%]" + "name": "Disable Recorder" }, "enable": { "description": "Starts the recording of events and state changes.", - "name": "[%key:common::action::enable%]" + "name": "Enable Recorder" }, "get_statistics": { "description": "Retrieves statistics data for entities within a specific time period.", @@ -46,7 +46,7 @@ "name": "Units" } }, - "name": "Get statistics" + "name": "Get Recorder statistics" }, "purge": { "description": "Starts purge task - to clean up old data from your database.", @@ -64,7 +64,7 @@ "name": "Repack" } }, - "name": "Purge" + "name": "Purge Recorder database" }, "purge_entities": { "description": "Starts a purge task to remove the data related to specific entities from your database.", @@ -86,7 +86,7 @@ "name": "[%key:component::recorder::services::purge::fields::keep_days::name%]" } }, - "name": "Purge entities" + "name": "Purge Recorder entities" } }, "system_health": { From 22a96583a96a3eb44cc747af0a3edb0f543ea795 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:22:12 +0200 Subject: [PATCH 0428/1707] Bump codecov/codecov-action from 5.5.3 to 6.0.0 (#167267) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6b3613284820f9..3268fd2c71915c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1392,7 +1392,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: fail_ci_if_error: true flags: full-suite @@ -1563,7 +1563,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] @@ -1591,7 +1591,7 @@ jobs: with: pattern: test-results-* - name: Upload test results to Codecov - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: report_type: test_results fail_ci_if_error: true From a1b93b418ba8f7c2571b21e6f43a1f80ce078395 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:38:59 -0400 Subject: [PATCH 0429/1707] Bump soco to 0.30.15 (#167299) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index beac2ffc3437fe..d9730170c81315 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "bronze", "requirements": [ "defusedxml==0.7.1", - "soco==0.30.14", + "soco==0.30.15", "sonos-websocket==0.1.3" ], "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 2de6376ff2d8ee..a9221b35b996e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2975,7 +2975,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.7 # homeassistant.components.sonos -soco==0.30.14 +soco==0.30.15 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66674d0dc2eb83..806e44b22f9e81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.7 # homeassistant.components.sonos -soco==0.30.14 +soco==0.30.15 # homeassistant.components.solaredge solaredge-web==0.0.1 From 5dac5ef099f23906ddb00baee9b40e8b42953669 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:44:23 +0200 Subject: [PATCH 0430/1707] Add missing availability check in device_tracker _async_write_ha_state (#167297) --- .../components/device_tracker/config_entry.py | 2 +- .../device_tracker/test_config_entry.py | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 3802e31e2833d9..15beb879ae4fb8 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -263,7 +263,7 @@ def longitude(self) -> float | None: @callback def _async_write_ha_state(self) -> None: """Calculate active zones.""" - if self.latitude is not None and self.longitude is not None: + if self.available and self.latitude is not None and self.longitude is not None: self.__active_zone, self.__in_zones = zone.async_in_zones( self.hass, self.latitude, self.longitude, self.location_accuracy ) diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index 0a467a6ad8c5fc..efc17dddaf4d8a 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -827,3 +827,39 @@ def device_info(self) -> dr.DeviceInfo: entity_registry.entities[f"{DOMAIN}.test_device"].config_entry_id == config_entry.entry_id ) + + +async def test_tracker_entity_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test unavailable tracker entity does not fail on bad latitude/longitude.""" + + class _MockTrackerEntity(MockTrackerEntity): + """Test tracker entity that starts with unavailable state.""" + + _attr_available = False + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + raise ValueError("Upstream error") + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + raise ValueError("Upstream error") + + tracker_entity = _MockTrackerEntity() + tracker_entity.entity_id = entity_id + + config_entry = await create_mock_platform(hass, config_entry, [tracker_entity]) + assert config_entry.state is ConfigEntryState.LOADED + + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + assert state.attributes == {} From d51afe20e0a936c7ff8acc0d9df55b2b987a27d4 Mon Sep 17 00:00:00 2001 From: Richard Polzer Date: Fri, 3 Apr 2026 15:45:06 +0200 Subject: [PATCH 0431/1707] Clarify ekeybionyx config flow oauth2 implementation handling (#167169) Co-authored-by: Martin Hjelmare --- homeassistant/components/ekeybionyx/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ekeybionyx/config_flow.py b/homeassistant/components/ekeybionyx/config_flow.py index cdf0538eea50bd..a4a4f759726c99 100644 --- a/homeassistant/components/ekeybionyx/config_flow.py +++ b/homeassistant/components/ekeybionyx/config_flow.py @@ -29,9 +29,11 @@ class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth): - """ekey bionyx authentication before a ConfigEntry exists. + """Authentication implementation used during config flow, without refresh. - This implementation directly provides the token without supporting refresh. + This exists to allow the config flow to use the API before it has fully + created a config entry required by OAuth2Session. This does not support + refreshing tokens, which is fine since it should have been just created. """ def __init__( From c6c469cc7aae3536a7cdeb626523720d4009632e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 3 Apr 2026 16:39:35 +0200 Subject: [PATCH 0432/1707] Migrate image unique_id for Fritz (#167209) --- homeassistant/components/fritz/image.py | 32 +++++++++++++- tests/components/fritz/test_image.py | 58 +++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index fd5900558254c8..b752aa2cdcd4df 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -10,9 +10,11 @@ from homeassistant.components.image import ImageEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify +from .const import DOMAIN, Platform from .coordinator import AvmWrapper, FritzConfigEntry from .entity import FritzBoxBaseEntity @@ -22,6 +24,32 @@ PARALLEL_UPDATES = 0 +async def _migrate_to_new_unique_id( + hass: HomeAssistant, avm_wrapper: AvmWrapper, ssid: str +) -> None: + """Migrate old unique id to new unique id.""" + + old_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code") + new_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code" + + entity_registry = er.async_get(hass) + entity_id = entity_registry.async_get_entity_id( + Platform.IMAGE, + DOMAIN, + old_unique_id, + ) + + if entity_id is None: + return + + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + _LOGGER.debug( + "Migrating guest Wi-Fi image unique_id from [%s] to [%s]", + old_unique_id, + new_unique_id, + ) + + async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, @@ -34,6 +62,8 @@ async def async_setup_entry( avm_wrapper.fritz_guest_wifi.get_info ) + await _migrate_to_new_unique_id(hass, avm_wrapper, guest_wifi_info["NewSSID"]) + async_add_entities( [ FritzGuestWifiQRImage( @@ -60,7 +90,7 @@ def __init__( ) -> None: """Initialize the image entity.""" self._attr_name = ssid - self._attr_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code") + self._attr_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code" self._current_qr_bytes: bytes | None = None super().__init__(avm_wrapper, device_friendly_name) ImageEntity.__init__(self, hass) diff --git a/tests/components/fritz/test_image.py b/tests/components/fritz/test_image.py index 0f42e2fd06fe51..f7ff9178ab3ffc 100644 --- a/tests/components/fritz/test_image.py +++ b/tests/components/fritz/test_image.py @@ -13,9 +13,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.util import slugify -from .const import MOCK_FB_SERVICES, MOCK_USER_DATA +from .const import MOCK_FB_SERVICES, MOCK_MESH_MASTER_MAC, MOCK_USER_DATA from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -126,7 +128,7 @@ async def test_image_entity( } assert (state := entity_registry.async_get("image.mock_title_guestwifi")) - assert state.unique_id == "1c_ed_6f_12_34_11_guestwifi_qr_code" + assert state.unique_id == "1C:ED:6F:12:34:11-guest_wifi_qr_code" # test image download client = await hass_client() @@ -222,3 +224,53 @@ async def test_image_update_unavailable( assert (state := hass.states.get(entity_id)) assert state.state != STATE_UNKNOWN + + +async def test_migrate_to_new_unique_id( + hass: HomeAssistant, + fc_class_mock, + fh_class_mock, + entity_registry: EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test migrate from old unique id to new unique id.""" + + mock_unique_id = "1234567890" + + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_USER_DATA, + unique_id=mock_unique_id, + ) + entry.add_to_hass(hass) + + old_unique_id = slugify(f"{MOCK_MESH_MASTER_MAC}-MyWifi-qr-code") + new_unique_id = f"{MOCK_MESH_MASTER_MAC}-guest_wifi_qr_code" + + entity_registry.async_get_or_create( + suggested_object_id="mock_title_mywifi", + disabled_by=None, + domain=IMAGE_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, mock_unique_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_MESH_MASTER_MAC)}, + ) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get("image.mock_title_mywifi") + assert entity_entry + assert entity_entry.unique_id == old_unique_id + + with patch("homeassistant.components.fritz.PLATFORMS", [Platform.IMAGE]): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get("image.mock_title_mywifi") + assert entity_entry + assert entity_entry.unique_id == new_unique_id From e8fa61ae63b7a66b1141c1bb2c2d961e39519b2c Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:42:09 -0400 Subject: [PATCH 0433/1707] Sonos alarm switch entities may not be created when speaker offline initially (#167303) --- homeassistant/components/sonos/__init__.py | 1 + homeassistant/components/sonos/alarms.py | 4 ++ .../components/sonos/household_coordinator.py | 3 ++ tests/components/sonos/test_switch.py | 38 +++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 33d82e072882c7..6f5a8033620fcf 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -417,6 +417,7 @@ def _add_speaker( ) new_coordinator.setup(soco) c_dict[soco.household_id] = new_coordinator + c_dict[soco.household_id].add_speaker(soco) speaker.setup(self.entry) except (OSError, SoCoException, Timeout) as ex: _LOGGER.warning("Failed to add SonosSpeaker using %s: %s", soco, ex) diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index c3c3b14545fac7..c318151454e6a0 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -99,3 +99,7 @@ def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: ) self.last_processed_event_id = self.alarms.last_id return True + + def add_speaker(self, soco: SoCo) -> None: + """Update any skipped alarms when speaker is added.""" + self.alarms.update_skipped(soco) diff --git a/homeassistant/components/sonos/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py index a2c128dce946b4..02c6fcdecab523 100644 --- a/homeassistant/components/sonos/household_coordinator.py +++ b/homeassistant/components/sonos/household_coordinator.py @@ -85,3 +85,6 @@ async def async_update_entities( def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: """Update the cache of the household-level feature and return if cache has changed.""" raise NotImplementedError + + def add_speaker(self, soco: SoCo) -> None: + """Additional processing when a speaker is added if needed.""" diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index f2dd3478a904bc..e36014d8b4e20e 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -21,6 +21,7 @@ ATTR_SPEECH_ENHANCEMENT_ENABLED, ATTR_VOLUME, ) +from homeassistant.components.ssdp import SsdpChange from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( @@ -33,6 +34,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -337,3 +339,39 @@ async def test_alarm_change_device( alarm_14 = entity_registry.async_get(entity_id) device = device_registry.async_get(alarm_14.device_id) assert device.name == soco_br.get_speaker_info()["zone_name"] + + +async def test_alarm_setup_for_undiscovered_speaker( + hass: HomeAssistant, + async_setup_sonos, + alarm_clock, + entity_registry: er.EntityRegistry, + soco_factory: SoCoMockFactory, + discover, +) -> None: + """Test for creation of alarm on a speaker that is discovered after the integration is setup.""" + + soco_bedroom = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + one_alarm = copy(alarm_clock.ListAlarms.return_value) + one_alarm["CurrentAlarmList"] = one_alarm["CurrentAlarmList"].replace( + "RINCON_test", soco_bedroom.uid + ) + alarm_clock.ListAlarms.return_value = one_alarm + await async_setup_sonos() + + # Switch should not be created since the speaker isn't discovered yet + assert "switch.sonos_alarm_14" not in entity_registry.entities + + # Simulate discovery of the bedroom speaker + discover.call_args.args[1]( + SsdpServiceInfo( + ssdp_location=f"http://{soco_bedroom.ip_address}/", + ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", + ssdp_usn=f"uuid:{soco_bedroom.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", + upnp={ATTR_UPNP_UDN: f"uuid:{soco_bedroom.uid}"}, + ), + SsdpChange.ALIVE, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "switch.sonos_alarm_14" in entity_registry.entities From 4ffc4b8f71fc2b1a45472c1ab9cf2963d4abd480 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 3 Apr 2026 18:45:00 +0300 Subject: [PATCH 0434/1707] Allow users to overwrite content type for AI task attachments (#167302) --- homeassistant/components/ai_task/task.py | 5 +- tests/components/ai_task/test_task.py | 101 +++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 1d27f75b6c7f1d..d064ee2f5e6256 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -74,7 +74,8 @@ async def _resolve_attachments( resolved_attachments.append( conversation.Attachment( media_content_id=media_content_id, - mime_type=image_data.content_type, + mime_type=attachment.get("media_content_type") + or image_data.content_type, path=temp_filename, ) ) @@ -89,7 +90,7 @@ async def _resolve_attachments( resolved_attachments.append( conversation.Attachment( media_content_id=media_content_id, - mime_type=media.mime_type, + mime_type=attachment.get("media_content_type") or media.mime_type, path=media.path, ) ) diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index 7826e1cdb59a35..93798a79d1ecd4 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -278,6 +278,107 @@ async def test_generate_data_mixed_attachments( assert media_attachment.path == Path("/media/test.mp4") +async def test_generate_data_content_type( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test that user-provided content type of an attachment is respected.""" + with ( + patch( # Intentionally broken content type + "homeassistant.components.camera.async_get_image", + return_value=Image(content_type="image/png", content=b"fake_camera_jpeg"), + ) as mock_get_camera_image, + patch( # Same + "homeassistant.components.image.async_get_image", + return_value=Image(content_type="image/png", content=b"fake_image_jpeg"), + ) as mock_get_image_image, + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=media_source.PlayMedia( + url="http://example.com/test.png", # jpeg image saved as png + mime_type="image/png", + path=Path("/media/test.png"), + ), + ) as mock_resolve_media, + ): + await async_generate_data( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Describe these images", + attachments=[ + { # supply corrected content type from the user input + "media_content_id": "media-source://camera/camera.front_door", + "media_content_type": "image/jpeg", + }, + { # User did not provide content type, fallback to the integration + "media_content_id": "media-source://image/image.floorplan", + }, + { + "media_content_id": "media-source://media_player/test.png", + "media_content_type": "image/jpeg", + }, + ], + ) + + # Verify both methods were called + mock_get_camera_image.assert_called_once_with(hass, "camera.front_door") + mock_get_image_image.assert_called_once_with(hass, "image.floorplan") + mock_resolve_media.assert_called_once_with( + hass, "media-source://media_player/test.png", None + ) + + # Check attachments + assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 + task = mock_ai_task_entity.mock_generate_data_tasks[0] + assert task.attachments is not None + assert len(task.attachments) == 3 + + # Check camera attachment + camera_attachment = task.attachments[0] + assert ( + camera_attachment.media_content_id == "media-source://camera/camera.front_door" + ) + assert camera_attachment.mime_type == "image/jpeg" + assert isinstance(camera_attachment.path, Path) + assert camera_attachment.path.suffix == ".png" # This is fine + + # Verify camera snapshot content + assert camera_attachment.path.exists() + content = await hass.async_add_executor_job(camera_attachment.path.read_bytes) + assert content == b"fake_camera_jpeg" + + # Check image attachment + image_attachment = task.attachments[1] + assert image_attachment.media_content_id == "media-source://image/image.floorplan" + assert image_attachment.mime_type == "image/png" + assert isinstance(image_attachment.path, Path) + assert image_attachment.path.suffix == ".png" + + # Verify image snapshot content + assert image_attachment.path.exists() + content = await hass.async_add_executor_job(image_attachment.path.read_bytes) + assert content == b"fake_image_jpeg" + + # Trigger clean up + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify the temporary file cleaned up + assert not camera_attachment.path.exists() + assert not image_attachment.path.exists() + + # Check regular media attachment + media_attachment = task.attachments[2] + assert media_attachment.media_content_id == "media-source://media_player/test.png" + assert media_attachment.mime_type == "image/jpeg" + assert media_attachment.path == Path("/media/test.png") + + @pytest.mark.freeze_time("2025-06-14 22:59:00") async def test_generate_image( hass: HomeAssistant, From 4efcb5a70002568ca3ed6c0cc44996c1d46e5603 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:54:07 +0200 Subject: [PATCH 0435/1707] Use PEP-695 syntax in Renault sensors (#167301) --- .../components/renault/coordinator.py | 8 ++-- homeassistant/components/renault/entity.py | 6 ++- homeassistant/components/renault/sensor.py | 45 ++++++++++++------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index c768c436133707..481c27c42db765 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from renault_api.kamereon.exceptions import ( AccessDeniedException, @@ -23,13 +23,13 @@ from . import RenaultConfigEntry from .renault_hub import RenaultHub -T = TypeVar("T", bound=KamereonVehicleDataAttributes) - # We have potentially 7 coordinators per vehicle _PARALLEL_SEMAPHORE = asyncio.Semaphore(1) -class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): +class RenaultDataUpdateCoordinator[T: KamereonVehicleDataAttributes]( + DataUpdateCoordinator[T] +): """Handle vehicle communication with Renault servers.""" config_entry: RenaultConfigEntry diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index 81d81a18b7f59a..a608b5a7b22043 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -5,11 +5,13 @@ from dataclasses import dataclass from typing import cast +from renault_api.kamereon.models import KamereonVehicleDataAttributes + from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .coordinator import RenaultDataUpdateCoordinator, T +from .coordinator import RenaultDataUpdateCoordinator from .renault_vehicle import RenaultVehicleProxy @@ -43,7 +45,7 @@ def __init__( self._attr_unique_id = f"{self.vehicle.details.vin}_{description.key}".lower() -class RenaultDataEntity( +class RenaultDataEntity[T: KamereonVehicleDataAttributes]( CoordinatorEntity[RenaultDataUpdateCoordinator[T]], RenaultEntity ): """Implementation of a Renault entity with a data coordinator.""" diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index e035eb82068132..e2df7ab7a4488d 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -5,12 +5,13 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING, Any, Generic, cast +from typing import TYPE_CHECKING, Any, cast from renault_api.kamereon.models import ( KamereonVehicleBatteryStatusData, KamereonVehicleChargingSettingsData, KamereonVehicleCockpitData, + KamereonVehicleDataAttributes, KamereonVehicleHvacStatusData, KamereonVehicleLocationData, KamereonVehicleResStateData, @@ -39,7 +40,6 @@ from homeassistant.util.dt import as_utc, parse_datetime from . import RenaultConfigEntry -from .coordinator import T from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_vehicle import RenaultVehicleProxy @@ -48,8 +48,8 @@ @dataclass(frozen=True, kw_only=True) -class RenaultSensorEntityDescription( - SensorEntityDescription, RenaultDataEntityDescription, Generic[T] +class RenaultSensorEntityDescription[T: KamereonVehicleDataAttributes]( + SensorEntityDescription, RenaultDataEntityDescription ): """Class describing Renault sensor entities.""" @@ -76,7 +76,9 @@ async def async_setup_entry( async_add_entities(entities) -class RenaultSensor(RenaultDataEntity[T], SensorEntity): +class RenaultSensor[T: KamereonVehicleDataAttributes]( + RenaultDataEntity[T], SensorEntity +): """Mixin for sensor specific attributes.""" entity_description: RenaultSensorEntityDescription[T] @@ -96,31 +98,39 @@ def native_value(self) -> StateType | datetime: return self.entity_description.value_lambda(self) -def _get_charging_power(entity: RenaultSensor[T]) -> StateType: +def _get_charging_power( + entity: RenaultSensor[KamereonVehicleBatteryStatusData], +) -> StateType: """Return the charging_power of this entity.""" return cast(float, entity.data) / 1000 -def _get_charge_state_formatted(entity: RenaultSensor[T]) -> str | None: +def _get_charge_state_formatted( + entity: RenaultSensor[KamereonVehicleBatteryStatusData], +) -> str | None: """Return the charging_status of this entity.""" - data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) - charging_status = data.get_charging_status() if data else None + charging_status = entity.coordinator.data.get_charging_status() return charging_status.name.lower() if charging_status else None -def _get_plug_state_formatted(entity: RenaultSensor[T]) -> str | None: +def _get_plug_state_formatted( + entity: RenaultSensor[KamereonVehicleBatteryStatusData], +) -> str | None: """Return the plug_status of this entity.""" - data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) - plug_status = data.get_plug_status() if data else None + plug_status = entity.coordinator.data.get_plug_status() return plug_status.name.lower() if plug_status else None -def _get_rounded_value(entity: RenaultSensor[T]) -> float: +def _get_rounded_value[T: KamereonVehicleDataAttributes]( + entity: RenaultSensor[T], +) -> float: """Return the rounded value of this entity.""" return round(cast(float, entity.data)) -def _get_utc_value(entity: RenaultSensor[T]) -> datetime: +def _get_utc_value[T: KamereonVehicleDataAttributes]( + entity: RenaultSensor[T], +) -> datetime: """Return the UTC value of this entity.""" original_dt = parse_datetime(cast(str, entity.data)) if TYPE_CHECKING: @@ -128,10 +138,11 @@ def _get_utc_value(entity: RenaultSensor[T]) -> datetime: return as_utc(original_dt) -def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | None: +def _get_charging_settings_mode_formatted( + entity: RenaultSensor[KamereonVehicleChargingSettingsData], +) -> str | None: """Return the charging_settings mode of this entity.""" - data = cast(KamereonVehicleChargingSettingsData, entity.coordinator.data) - charging_mode = data.mode if data else None + charging_mode = entity.coordinator.data.mode return charging_mode.lower() if charging_mode else None From 27365a4457c7599df31fd00a42c1f1c348ab3f4d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:54:51 +0200 Subject: [PATCH 0436/1707] Refactor None handling renault device_tracker (#167298) --- homeassistant/components/renault/device_tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index c55ddeb2190a99..795e0ce80b2035 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -52,12 +52,12 @@ class RenaultDeviceTracker( @property def latitude(self) -> float | None: """Return latitude value of the device.""" - return self.coordinator.data.gpsLatitude if self.coordinator.data else None + return self.coordinator.data.gpsLatitude @property def longitude(self) -> float | None: """Return longitude value of the device.""" - return self.coordinator.data.gpsLongitude if self.coordinator.data else None + return self.coordinator.data.gpsLongitude DEVICE_TRACKER_TYPES: tuple[RenaultTrackerEntityDescription, ...] = ( From c8e2a2b520c3085e7d3f2bc33648106341c14c7a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Apr 2026 18:00:58 +0200 Subject: [PATCH 0437/1707] Extract type casting template functions into a type cast Jinja2 extension (#167280) --- .../components/utility_meter/sensor.py | 9 +- homeassistant/helpers/template/__init__.py | 104 +----------- .../helpers/template/extensions/__init__.py | 2 + .../helpers/template/extensions/type_cast.py | 93 +++++++++++ homeassistant/helpers/template/helpers.py | 41 ++++- .../template/extensions/test_type_cast.py | 153 ++++++++++++++++++ tests/helpers/template/test_init.py | 126 --------------- 7 files changed, 295 insertions(+), 233 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/type_cast.py create mode 100644 tests/helpers/template/extensions/test_type_cast.py diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index f7e6f6e3008235..c9c737c4999dc8 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta from decimal import Decimal, DecimalException, InvalidOperation import logging +import math from typing import Any, Self from cronsim import CronSim @@ -52,7 +53,6 @@ async_track_state_change_event, ) from homeassistant.helpers.start import async_at_started -from homeassistant.helpers.template import is_number from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify from homeassistant.util.enum import try_parse_enum @@ -113,8 +113,11 @@ def validate_is_number(value): """Validate value is a number.""" - if is_number(value): - return value + try: + if math.isfinite(float(value)): + return value + except ValueError, TypeError: + pass raise vol.Invalid("Value is not a number") diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 22a476fb941e29..c11d09e4d7e4f3 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -32,7 +32,6 @@ from lru import LRU import orjson from propcache.api import under_cached_property -import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, @@ -76,7 +75,7 @@ template_context_manager, template_cv, ) -from .helpers import raise_no_default +from .helpers import raise_no_default, result_as_boolean as result_as_boolean from .render_info import RenderInfo, render_info_cv if TYPE_CHECKING: @@ -1127,42 +1126,6 @@ def _resolve_state( return None -@overload -def forgiving_boolean(value: Any) -> bool | object: ... - - -@overload -def forgiving_boolean[_T](value: Any, default: _T) -> bool | _T: ... - - -def forgiving_boolean[_T]( - value: Any, default: _T | object = _SENTINEL -) -> bool | _T | object: - """Try to convert value to a boolean.""" - try: - # Import here, not at top-level to avoid circular import - from homeassistant.helpers import config_validation as cv # noqa: PLC0415 - - return cv.boolean(value) - except vol.Invalid: - if default is _SENTINEL: - raise_no_default("bool", value) - return default - - -def result_as_boolean(template_result: Any | None) -> bool: - """Convert the template result to a boolean. - - True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy - False/0/None/'0'/'false'/'no'/'off'/'disable' are considered falsy - All other values are falsy - """ - if template_result is None: - return False - - return forgiving_boolean(template_result, default=False) - - def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: """Expand out any groups and zones into entity states.""" # circular import. @@ -1601,58 +1564,6 @@ def fail_when_undefined(value): return value -def forgiving_float(value, default=_SENTINEL): - """Try to convert value to a float.""" - try: - return float(value) - except ValueError, TypeError: - if default is _SENTINEL: - raise_no_default("float", value) - return default - - -def forgiving_float_filter(value, default=_SENTINEL): - """Try to convert value to a float.""" - try: - return float(value) - except ValueError, TypeError: - if default is _SENTINEL: - raise_no_default("float", value) - return default - - -def forgiving_int(value, default=_SENTINEL, base=10): - """Try to convert value to an int, and raise if it fails.""" - result = jinja2.filters.do_int(value, default=default, base=base) - if result is _SENTINEL: - raise_no_default("int", value) - return result - - -def forgiving_int_filter(value, default=_SENTINEL, base=10): - """Try to convert value to an int, and raise if it fails.""" - result = jinja2.filters.do_int(value, default=default, base=base) - if result is _SENTINEL: - raise_no_default("int", value) - return result - - -def is_number(value): - """Try to convert value to a float.""" - try: - fvalue = float(value) - except ValueError, TypeError: - return False - if not math.isfinite(fvalue): - return False - return True - - -def _is_string_like(value: Any) -> bool: - """Return whether a value is a string or string like object.""" - return isinstance(value, (str, bytes, bytearray)) - - def struct_pack(value: Any | None, format_string: str) -> bytes | None: """Pack an object into a bytes object.""" try: @@ -1944,15 +1855,14 @@ def __init__( self.add_extension("homeassistant.helpers.template.extensions.MathExtension") self.add_extension("homeassistant.helpers.template.extensions.RegexExtension") self.add_extension("homeassistant.helpers.template.extensions.StringExtension") + self.add_extension( + "homeassistant.helpers.template.extensions.TypeCastExtension" + ) self.globals["apply"] = apply self.globals["as_function"] = as_function - self.globals["bool"] = forgiving_boolean self.globals["combine"] = combine - self.globals["float"] = forgiving_float self.globals["iif"] = iif - self.globals["int"] = forgiving_int - self.globals["is_number"] = is_number self.globals["merge_response"] = merge_response self.globals["pack"] = struct_pack self.globals["typeof"] = typeof @@ -1963,16 +1873,12 @@ def __init__( self.filters["add"] = add self.filters["apply"] = apply self.filters["as_function"] = as_function - self.filters["bool"] = forgiving_boolean self.filters["combine"] = combine self.filters["contains"] = contains - self.filters["float"] = forgiving_float_filter self.filters["from_json"] = from_json self.filters["from_hex"] = from_hex self.filters["iif"] = iif - self.filters["int"] = forgiving_int_filter self.filters["is_defined"] = fail_when_undefined - self.filters["is_number"] = is_number self.filters["multiply"] = multiply self.filters["ord"] = ord self.filters["pack"] = struct_pack @@ -1985,8 +1891,6 @@ def __init__( self.tests["apply"] = apply self.tests["contains"] = contains - self.tests["is_number"] = is_number - self.tests["string_like"] = _is_string_like if hass is None: return diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 588d5aaf38b395..9c4e32516cf7fe 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -12,6 +12,7 @@ from .math import MathExtension from .regex import RegexExtension from .string import StringExtension +from .type_cast import TypeCastExtension __all__ = [ "AreaExtension", @@ -26,4 +27,5 @@ "MathExtension", "RegexExtension", "StringExtension", + "TypeCastExtension", ] diff --git a/homeassistant/helpers/template/extensions/type_cast.py b/homeassistant/helpers/template/extensions/type_cast.py new file mode 100644 index 00000000000000..600f0ddb4d45df --- /dev/null +++ b/homeassistant/helpers/template/extensions/type_cast.py @@ -0,0 +1,93 @@ +"""Type casting functions for Home Assistant templates.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +import jinja2.filters + +from homeassistant.helpers.template.helpers import forgiving_boolean, raise_no_default + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + +_SENTINEL = object() + + +class TypeCastExtension(BaseTemplateExtension): + """Jinja2 extension for type casting functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the type cast extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "bool", + forgiving_boolean, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "float", + self.forgiving_float, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "int", + self.forgiving_int, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "is_number", + self.is_number, + as_global=True, + as_filter=True, + as_test=True, + ), + TemplateFunction( + "string_like", + self.is_string_like, + as_test=True, + ), + ], + ) + + @staticmethod + def forgiving_float(value: Any, default: Any = _SENTINEL) -> Any: + """Try to convert value to a float.""" + try: + return float(value) + except ValueError, TypeError: + if default is _SENTINEL: + raise_no_default("float", value) + return default + + @staticmethod + def forgiving_int(value: Any, default: Any = _SENTINEL, base: int = 10) -> Any: + """Try to convert value to an int, and raise if it fails.""" + result = jinja2.filters.do_int(value, default=default, base=base) + if result is _SENTINEL: + raise_no_default("int", value) + return result + + @staticmethod + def is_number(value: Any) -> bool: + """Try to convert value to a float.""" + try: + fvalue = float(value) + except ValueError, TypeError: + return False + if not math.isfinite(fvalue): + return False + return True + + @staticmethod + def is_string_like(value: Any) -> bool: + """Return whether a value is a string or string like object.""" + return isinstance(value, (str, bytes, bytearray)) diff --git a/homeassistant/helpers/template/helpers.py b/homeassistant/helpers/template/helpers.py index 71c95f77c47b86..921faa0c1b632e 100644 --- a/homeassistant/helpers/template/helpers.py +++ b/homeassistant/helpers/template/helpers.py @@ -2,12 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NoReturn +from typing import TYPE_CHECKING, Any, NoReturn, overload import voluptuous as vol from homeassistant.helpers import ( area_registry as ar, + config_validation as cv, device_registry as dr, entity_registry as er, ) @@ -17,6 +18,8 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant +_SENTINEL = object() + def raise_no_default(function: str, value: Any) -> NoReturn: """Raise ValueError when no default is specified for template functions.""" @@ -47,9 +50,6 @@ def resolve_area_id(hass: HomeAssistant, lookup_value: Any) -> str | None: if areas_list: return areas_list[0].id - # Import here, not at top-level to avoid circular import - from homeassistant.helpers import config_validation as cv # noqa: PLC0415 - # Check if it's an entity ID try: cv.entity_id(lookup_value) @@ -69,3 +69,36 @@ def resolve_area_id(hass: HomeAssistant, lookup_value: Any) -> str | None: return device.area_id return None + + +@overload +def forgiving_boolean(value: Any) -> bool | object: ... + + +@overload +def forgiving_boolean[_T](value: Any, default: _T) -> bool | _T: ... + + +def forgiving_boolean[_T]( + value: Any, default: _T | object = _SENTINEL +) -> bool | _T | object: + """Try to convert value to a boolean.""" + try: + return cv.boolean(value) + except vol.Invalid: + if default is _SENTINEL: + raise_no_default("bool", value) + return default + + +def result_as_boolean(template_result: Any | None) -> bool: + """Convert the template result to a boolean. + + True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy + False/0/None/'0'/'false'/'no'/'off'/'disable' are considered falsy + All other values are falsy + """ + if template_result is None: + return False + + return forgiving_boolean(template_result, default=False) diff --git a/tests/helpers/template/extensions/test_type_cast.py b/tests/helpers/template/extensions/test_type_cast.py new file mode 100644 index 00000000000000..93b9d3edee3fde --- /dev/null +++ b/tests/helpers/template/extensions/test_type_cast.py @@ -0,0 +1,153 @@ +"""Test type casting functions for Home Assistant templates.""" + +from __future__ import annotations + +import math + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError + +from tests.helpers.template.helpers import render + + +def test_float_function(hass: HomeAssistant) -> None: + """Test float function.""" + hass.states.async_set("sensor.temperature", "12") + + assert render(hass, "{{ float(states.sensor.temperature.state) }}") == 12.0 + + assert render(hass, "{{ float(states.sensor.temperature.state) > 11 }}") is True + + # Test handling of invalid input + with pytest.raises(TemplateError): + render(hass, "{{ float('forgiving') }}") + + # Test handling of default return value + assert render(hass, "{{ float('bad', 1) }}") == 1 + assert render(hass, "{{ float('bad', default=1) }}") == 1 + + +def test_float_filter(hass: HomeAssistant) -> None: + """Test float filter.""" + hass.states.async_set("sensor.temperature", "12") + + assert render(hass, "{{ states.sensor.temperature.state | float }}") == 12.0 + assert render(hass, "{{ states.sensor.temperature.state | float > 11 }}") is True + + # Test handling of invalid input + with pytest.raises(TemplateError): + render(hass, "{{ 'bad' | float }}") + + # Test handling of default return value + assert render(hass, "{{ 'bad' | float(1) }}") == 1 + assert render(hass, "{{ 'bad' | float(default=1) }}") == 1 + + +def test_int_filter(hass: HomeAssistant) -> None: + """Test int filter.""" + hass.states.async_set("sensor.temperature", "12.2") + assert render(hass, "{{ states.sensor.temperature.state | int }}") == 12 + assert render(hass, "{{ states.sensor.temperature.state | int > 11 }}") is True + + hass.states.async_set("sensor.temperature", "0x10") + assert render(hass, "{{ states.sensor.temperature.state | int(base=16) }}") == 16 + + # Test handling of invalid input + with pytest.raises(TemplateError): + render(hass, "{{ 'bad' | int }}") + + # Test handling of default return value + assert render(hass, "{{ 'bad' | int(1) }}") == 1 + assert render(hass, "{{ 'bad' | int(default=1) }}") == 1 + + +def test_int_function(hass: HomeAssistant) -> None: + """Test int filter.""" + hass.states.async_set("sensor.temperature", "12.2") + assert render(hass, "{{ int(states.sensor.temperature.state) }}") == 12 + assert render(hass, "{{ int(states.sensor.temperature.state) > 11 }}") is True + + hass.states.async_set("sensor.temperature", "0x10") + assert render(hass, "{{ int(states.sensor.temperature.state, base=16) }}") == 16 + + # Test handling of invalid input + with pytest.raises(TemplateError): + render(hass, "{{ int('bad') }}") + + # Test handling of default return value + assert render(hass, "{{ int('bad', 1) }}") == 1 + assert render(hass, "{{ int('bad', default=1) }}") == 1 + + +def test_bool_function(hass: HomeAssistant) -> None: + """Test bool function.""" + assert render(hass, "{{ bool(true) }}") is True + assert render(hass, "{{ bool(false) }}") is False + assert render(hass, "{{ bool('on') }}") is True + assert render(hass, "{{ bool('off') }}") is False + with pytest.raises(TemplateError): + render(hass, "{{ bool('unknown') }}") + with pytest.raises(TemplateError): + render(hass, "{{ bool(none) }}") + assert render(hass, "{{ bool('unavailable', none) }}") is None + assert render(hass, "{{ bool('unavailable', default=none) }}") is None + + +def test_bool_filter(hass: HomeAssistant) -> None: + """Test bool filter.""" + assert render(hass, "{{ true | bool }}") is True + assert render(hass, "{{ false | bool }}") is False + assert render(hass, "{{ 'on' | bool }}") is True + assert render(hass, "{{ 'off' | bool }}") is False + with pytest.raises(TemplateError): + render(hass, "{{ 'unknown' | bool }}") + with pytest.raises(TemplateError): + render(hass, "{{ none | bool }}") + assert render(hass, "{{ 'unavailable' | bool(none) }}") is None + assert render(hass, "{{ 'unavailable' | bool(default=none) }}") is None + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (0, True), + (0.0, True), + ("0", True), + ("0.0", True), + (True, True), + (False, True), + ("True", False), + ("False", False), + (None, False), + ("None", False), + ("horse", False), + (math.pi, True), + (math.nan, False), + (math.inf, False), + ("nan", False), + ("inf", False), + ], +) +def test_isnumber(hass: HomeAssistant, value: object, expected: bool) -> None: + """Test is_number.""" + assert render(hass, "{{ is_number(value) }}", {"value": value}) == expected + assert render(hass, "{{ value | is_number }}", {"value": value}) == expected + assert render(hass, "{{ value is is_number }}", {"value": value}) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("hello", True), + (b"hello", True), + (bytearray(b"hello"), True), + (42, False), + ([1, 2], False), + (None, False), + ], +) +def test_string_like(hass: HomeAssistant, value: object, expected: bool) -> None: + """Test string_like.""" + assert render(hass, "{{ value is string_like }}", {"value": value}) == expected diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 30846eef202ade..26dc45f9c561d8 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -6,7 +6,6 @@ from datetime import datetime, timedelta import json import logging -import math import random from unittest.mock import patch @@ -299,131 +298,6 @@ def test_loop_controls(hass: HomeAssistant) -> None: assert render(hass, tpl) == "02" -def test_float_function(hass: HomeAssistant) -> None: - """Test float function.""" - hass.states.async_set("sensor.temperature", "12") - - assert render(hass, "{{ float(states.sensor.temperature.state) }}") == 12.0 - - assert render(hass, "{{ float(states.sensor.temperature.state) > 11 }}") is True - - # Test handling of invalid input - with pytest.raises(TemplateError): - render(hass, "{{ float('forgiving') }}") - - # Test handling of default return value - assert render(hass, "{{ float('bad', 1) }}") == 1 - assert render(hass, "{{ float('bad', default=1) }}") == 1 - - -def test_float_filter(hass: HomeAssistant) -> None: - """Test float filter.""" - hass.states.async_set("sensor.temperature", "12") - - assert render(hass, "{{ states.sensor.temperature.state | float }}") == 12.0 - assert render(hass, "{{ states.sensor.temperature.state | float > 11 }}") is True - - # Test handling of invalid input - with pytest.raises(TemplateError): - render(hass, "{{ 'bad' | float }}") - - # Test handling of default return value - assert render(hass, "{{ 'bad' | float(1) }}") == 1 - assert render(hass, "{{ 'bad' | float(default=1) }}") == 1 - - -def test_int_filter(hass: HomeAssistant) -> None: - """Test int filter.""" - hass.states.async_set("sensor.temperature", "12.2") - assert render(hass, "{{ states.sensor.temperature.state | int }}") == 12 - assert render(hass, "{{ states.sensor.temperature.state | int > 11 }}") is True - - hass.states.async_set("sensor.temperature", "0x10") - assert render(hass, "{{ states.sensor.temperature.state | int(base=16) }}") == 16 - - # Test handling of invalid input - with pytest.raises(TemplateError): - render(hass, "{{ 'bad' | int }}") - - # Test handling of default return value - assert render(hass, "{{ 'bad' | int(1) }}") == 1 - assert render(hass, "{{ 'bad' | int(default=1) }}") == 1 - - -def test_int_function(hass: HomeAssistant) -> None: - """Test int filter.""" - hass.states.async_set("sensor.temperature", "12.2") - assert render(hass, "{{ int(states.sensor.temperature.state) }}") == 12 - assert render(hass, "{{ int(states.sensor.temperature.state) > 11 }}") is True - - hass.states.async_set("sensor.temperature", "0x10") - assert render(hass, "{{ int(states.sensor.temperature.state, base=16) }}") == 16 - - # Test handling of invalid input - with pytest.raises(TemplateError): - render(hass, "{{ int('bad') }}") - - # Test handling of default return value - assert render(hass, "{{ int('bad', 1) }}") == 1 - assert render(hass, "{{ int('bad', default=1) }}") == 1 - - -def test_bool_function(hass: HomeAssistant) -> None: - """Test bool function.""" - assert render(hass, "{{ bool(true) }}") is True - assert render(hass, "{{ bool(false) }}") is False - assert render(hass, "{{ bool('on') }}") is True - assert render(hass, "{{ bool('off') }}") is False - with pytest.raises(TemplateError): - render(hass, "{{ bool('unknown') }}") - with pytest.raises(TemplateError): - render(hass, "{{ bool(none) }}") - assert render(hass, "{{ bool('unavailable', none) }}") is None - assert render(hass, "{{ bool('unavailable', default=none) }}") is None - - -def test_bool_filter(hass: HomeAssistant) -> None: - """Test bool filter.""" - assert render(hass, "{{ true | bool }}") is True - assert render(hass, "{{ false | bool }}") is False - assert render(hass, "{{ 'on' | bool }}") is True - assert render(hass, "{{ 'off' | bool }}") is False - with pytest.raises(TemplateError): - render(hass, "{{ 'unknown' | bool }}") - with pytest.raises(TemplateError): - render(hass, "{{ none | bool }}") - assert render(hass, "{{ 'unavailable' | bool(none) }}") is None - assert render(hass, "{{ 'unavailable' | bool(default=none) }}") is None - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - (0, True), - (0.0, True), - ("0", True), - ("0.0", True), - (True, True), - (False, True), - ("True", False), - ("False", False), - (None, False), - ("None", False), - ("horse", False), - (math.pi, True), - (math.nan, False), - (math.inf, False), - ("nan", False), - ("inf", False), - ], -) -def test_isnumber(hass: HomeAssistant, value, expected) -> None: - """Test is_number.""" - assert render(hass, "{{ is_number(value) }}", {"value": value}) == expected - assert render(hass, "{{ value | is_number }}", {"value": value}) == expected - assert render(hass, "{{ value is is_number }}", {"value": value}) == expected - - def test_converting_datetime_to_iterable(hass: HomeAssistant) -> None: """Test converting a datetime to an iterable raises an error.""" dt_ = datetime(2020, 1, 1, 0, 0, 0) From b6f49d20636503e0a6f81a0430a2f0ddf3b8b94e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:01:36 +0200 Subject: [PATCH 0438/1707] Refactor None handling in renault diagnostics (#167295) --- .../components/renault/diagnostics.py | 8 +++-- tests/components/renault/test_diagnostics.py | 29 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/diagnostics.py b/homeassistant/components/renault/diagnostics.py index 5d1849f4b207a5..5a8cb41beca337 100644 --- a/homeassistant/components/renault/diagnostics.py +++ b/homeassistant/components/renault/diagnostics.py @@ -56,8 +56,12 @@ def _get_vehicle_diagnostics(vehicle: RenaultVehicleProxy) -> dict[str, Any]: return { "details": async_redact_data(vehicle.details.raw_data, TO_REDACT), "data": { - key: async_redact_data( - coordinator.data.raw_data if coordinator.data else None, TO_REDACT + key: ( + async_redact_data(coordinator.data.raw_data, TO_REDACT) + # Renault coordinators override async_config_entry_first_refresh + # to not raise ConfigEntryNotReady, so coordinator data can be None + if coordinator.data + else None ) for key, coordinator in vehicle.coordinators.items() }, diff --git a/tests/components/renault/test_diagnostics.py b/tests/components/renault/test_diagnostics.py index 1e238b15225921..fd62f41e4a2701 100644 --- a/tests/components/renault/test_diagnostics.py +++ b/tests/components/renault/test_diagnostics.py @@ -44,7 +44,7 @@ async def test_device_diagnostics( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: - """Test config entry diagnostics.""" + """Test device diagnostics.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -55,3 +55,30 @@ async def test_device_diagnostics( await get_diagnostics_for_device(hass, hass_client, config_entry, device) == snapshot ) + + +@pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_device_diagnostics_invalid_upstream_exception( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test device diagnostics with invalid upstream exception.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, "VF1ZOE40VIN")}) + assert device is not None + + data = await get_diagnostics_for_device(hass, hass_client, config_entry, device) + assert data["data"] == { + "battery": None, + "battery_soc": None, + "charge_mode": None, + "charging_settings": None, + "cockpit": None, + "hvac_status": None, + } From b0ba740024db93b49f739b11ffe515b13ad1655c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 3 Apr 2026 18:44:29 +0200 Subject: [PATCH 0439/1707] Matter Pir unoccupied to occupied delay (#162435) Co-authored-by: Norbert Rittel Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/matter/number.py | 41 ++++ homeassistant/components/matter/strings.json | 6 + tests/components/matter/common.py | 1 + .../nodes/mock_occupancy_sensor_pir.json | 106 +++++++++++ .../matter/snapshots/test_binary_sensor.ambr | 51 +++++ .../matter/snapshots/test_button.ambr | 51 +++++ .../matter/snapshots/test_number.ambr | 179 ++++++++++++++++++ tests/components/matter/test_number.py | 76 ++++++++ 8 files changed, 511 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/mock_occupancy_sensor_pir.json diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 91b5fd05c4b390..21be4cd90d15a8 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -399,6 +399,47 @@ def _update_from_device(self) -> None: ), entity_class=MatterNumber, required_attributes=(clusters.OccupancySensing.Attributes.HoldTime,), + # HoldTime is shared by PIR-specific numbers as a required attribute. + # Keep discovery open so this generic schema does not block them. + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="OccupancySensingPIRUnoccupiedToOccupiedDelay", + entity_category=EntityCategory.CONFIG, + translation_key="detection_delay", + native_max_value=65534, + native_min_value=0, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedDelay, + # This attribute is mandatory when the PIRUnoccupiedToOccupiedDelay is present + clusters.OccupancySensing.Attributes.HoldTime, + ), + featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="OccupancySensingPIRUnoccupiedToOccupiedThreshold", + entity_category=EntityCategory.CONFIG, + translation_key="detection_threshold", + native_max_value=254, + native_min_value=1, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedThreshold, + clusters.OccupancySensing.Attributes.HoldTime, + ), + featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared, + allow_multi=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index b790c0b7213da9..1286b8bae94487 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -223,6 +223,12 @@ "cook_time": { "name": "Cooking time" }, + "detection_delay": { + "name": "Detection delay" + }, + "detection_threshold": { + "name": "Detection threshold" + }, "hold_time": { "name": "Hold time" }, diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index 96b42d63fd09bf..c6fcd206b15f91 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -70,6 +70,7 @@ "mock_microwave_oven", "mock_mounted_dimmable_load_control_fixture", "mock_occupancy_sensor", + "mock_occupancy_sensor_pir", "mock_on_off_plugin_unit", "mock_onoff_light", "mock_onoff_light_alt_name", diff --git a/tests/components/matter/fixtures/nodes/mock_occupancy_sensor_pir.json b/tests/components/matter/fixtures/nodes/mock_occupancy_sensor_pir.json new file mode 100644 index 00000000000000..c6b28d7977625e --- /dev/null +++ b/tests/components/matter/fixtures/nodes/mock_occupancy_sensor_pir.json @@ -0,0 +1,106 @@ +{ + "node_id": 98, + "date_commissioned": "2022-11-29T21:23:48.485051", + "last_interview": "2022-11-29T21:23:48.485057", + "interview_version": 2, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock PIR Occupancy Sensor", + "0/40/4": 32768, + "0/40/5": "Mock PIR Occupancy Sensor", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20260206", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-pir-occupancy-sensor", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 263, + "1": 1 + } + ], + "1/29/1": [ + 3, 4, 5, 6, 7, 8, 15, 29, 30, 37, 47, 59, 64, 65, 69, 80, 257, 258, 259, + 512, 513, 514, 516, 768, 1024, 1026, 1027, 1028, 1029, 1030, 1283, 1284, + 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293, 1294, 2820, + 4294048773 + ], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/30/0": [], + "1/30/65532": 0, + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/1030/0": 1, + "1/1030/1": 0, + "1/1030/2": 1, + "1/1030/3": 10, + "1/1030/4": { + "0": 1, + "1": 65534, + "2": 10 + }, + "1/1030/17": 10, + "1/1030/18": 1, + "1/1030/65532": 2, + "1/1030/65533": 5, + "1/1030/65528": [], + "1/1030/65529": [], + "1/1030/65531": [0, 1, 2, 3, 4, 17, 18, 65528, 65529, 65531, 65532, 65533] + }, + "available": true, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index 8db2e9f416d994..f000171a17e20a 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -1623,6 +1623,57 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[mock_occupancy_sensor_pir][binary_sensor.mock_pir_occupancy_sensor_occupancy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.mock_pir_occupancy_sensor_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Occupancy', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000062-MatterNodeDevice-1-OccupancySensor-1030-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[mock_occupancy_sensor_pir][binary_sensor.mock_pir_occupancy_sensor_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'occupancy', + 'friendly_name': 'Mock PIR Occupancy Sensor Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_pir_occupancy_sensor_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[mock_onoff_light_alt_name][binary_sensor.mock_onoff_light_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 818a021e21f103..39ef9b81827c1a 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -3147,6 +3147,57 @@ 'state': 'unknown', }) # --- +# name: test_buttons[mock_occupancy_sensor_pir][button.mock_pir_occupancy_sensor_identify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_pir_occupancy_sensor_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Identify', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000062-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[mock_occupancy_sensor_pir][button.mock_pir_occupancy_sensor_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Mock PIR Occupancy Sensor Identify', + }), + 'context': , + 'entity_id': 'button.mock_pir_occupancy_sensor_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[mock_on_off_plugin_unit][button.mock_onoffpluginunit_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 33a8fddf56b5be..d0392b49717a6e 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -3040,6 +3040,185 @@ 'state': 'unavailable', }) # --- +# name: test_numbers[mock_occupancy_sensor_pir][number.mock_pir_occupancy_sensor_detection_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 65534, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_pir_occupancy_sensor_detection_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Detection delay', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Detection delay', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'detection_delay', + 'unique_id': '00000000000004D2-0000000000000062-MatterNodeDevice-1-OccupancySensingPIRUnoccupiedToOccupiedDelay-1030-17', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[mock_occupancy_sensor_pir][number.mock_pir_occupancy_sensor_detection_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock PIR Occupancy Sensor Detection delay', + 'max': 65534, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_pir_occupancy_sensor_detection_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_numbers[mock_occupancy_sensor_pir][number.mock_pir_occupancy_sensor_detection_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 254, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_pir_occupancy_sensor_detection_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Detection threshold', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Detection threshold', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'detection_threshold', + 'unique_id': '00000000000004D2-0000000000000062-MatterNodeDevice-1-OccupancySensingPIRUnoccupiedToOccupiedThreshold-1030-18', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[mock_occupancy_sensor_pir][number.mock_pir_occupancy_sensor_detection_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock PIR Occupancy Sensor Detection threshold', + 'max': 254, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.mock_pir_occupancy_sensor_detection_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_numbers[mock_occupancy_sensor_pir][number.mock_pir_occupancy_sensor_hold_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 65534, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mock_pir_occupancy_sensor_hold_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hold time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hold time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hold_time', + 'unique_id': '00000000000004D2-0000000000000062-MatterNodeDevice-1-OccupancySensingHoldTime-1030-3', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[mock_occupancy_sensor_pir][number.mock_pir_occupancy_sensor_hold_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock PIR Occupancy Sensor Hold time', + 'max': 65534, + 'min': 1, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_pir_occupancy_sensor_hold_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- # name: test_numbers[mock_on_off_plugin_unit][number.mock_onoffpluginunit_off_transition_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 05a4bc4aa9cb6b..8e82c04bed6bc1 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -395,3 +395,79 @@ async def test_matter_exception_on_door_lock_write_attribute( ) assert str(exc_info.value) == "Boom!" + + +@pytest.mark.parametrize("node_fixture", ["mock_occupancy_sensor_pir"]) +async def test_occupancy_sensing_pir_attributes( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test PIR occupancy sensor attributes.""" + # PIRUnoccupiedToOccupiedDelay + state = hass.states.get("number.mock_pir_occupancy_sensor_detection_delay") + assert state + assert state.state == "10" + assert state.attributes["min"] == 0 + assert state.attributes["max"] == 65534 + assert state.attributes["unit_of_measurement"] == "s" + + set_node_attribute(matter_node, 1, 1030, 17, 20) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.mock_pir_occupancy_sensor_detection_delay") + assert state + assert state.state == "20" + + # PIRUnoccupiedToOccupiedThreshold + state = hass.states.get("number.mock_pir_occupancy_sensor_detection_threshold") + assert state + assert state.state == "1" + assert state.attributes["min"] == 1 + assert state.attributes["max"] == 254 + + set_node_attribute(matter_node, 1, 1030, 18, 5) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.mock_pir_occupancy_sensor_detection_threshold") + assert state + assert state.state == "5" + + # Test set value for delay + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.mock_pir_occupancy_sensor_detection_delay", + "value": 15, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedDelay, + ), + value=15, + ) + + # Test set value for threshold + matter_client.write_attribute.reset_mock() + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.mock_pir_occupancy_sensor_detection_threshold", + "value": 3, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedThreshold, + ), + value=3, + ) From 4871344138fcfac85e53d74dc111d9642d2725cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 3 Apr 2026 19:41:41 +0200 Subject: [PATCH 0440/1707] Add Hisense AC (0x138C/0x0101) to Matter dry and fan mode device lists (#167282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- homeassistant/components/matter/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index d1699beaa6060d..4b508a57954d1a 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -132,6 +132,7 @@ (0x1209, 0x8027), (0x1209, 0x8028), (0x1209, 0x8029), + (0x138C, 0x0101), } SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { @@ -172,6 +173,7 @@ (0x1209, 0x8028), (0x1209, 0x8029), (0x131A, 0x1000), + (0x138C, 0x0101), } SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum From 7f79da2f755dc9871073a0986cdd8d0445a448b7 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 3 Apr 2026 19:45:55 +0200 Subject: [PATCH 0441/1707] Add prune volumes button to Portainer (#167314) --- homeassistant/components/portainer/button.py | 8 +++ homeassistant/components/portainer/icons.json | 3 ++ .../components/portainer/strings.json | 3 ++ .../portainer/snapshots/test_button.ambr | 50 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index daa17452379736..31e0cbaf16f582 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -60,6 +60,14 @@ class PortainerButtonDescription(ButtonEntityDescription): ) ), ), + PortainerButtonDescription( + key="volumes_prune", + translation_key="volumes_prune", + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, _: portainer.prune_volumes(endpoint_id) + ), + ), ) CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index 319efef85dc061..f74e8d4e4eb5c4 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -6,6 +6,9 @@ }, "resume_container": { "default": "mdi:play" + }, + "volumes_prune": { + "default": "mdi:delete-sweep" } }, "sensor": { diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index e50d48849dbe93..83decd83e165fc 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -74,6 +74,9 @@ }, "resume_container": { "name": "Resume container" + }, + "volumes_prune": { + "name": "Prune unused volumes" } }, "sensor": { diff --git a/tests/components/portainer/snapshots/test_button.ambr b/tests/components/portainer/snapshots/test_button.ambr index b153142031ea67..983e831136ad73 100644 --- a/tests/components/portainer/snapshots/test_button.ambr +++ b/tests/components/portainer/snapshots/test_button.ambr @@ -503,6 +503,56 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities_snapshot[button.my_environment_prune_unused_volumes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_environment_prune_unused_volumes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Prune unused volumes', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prune unused volumes', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volumes_prune', + 'unique_id': 'portainer_test_entry_123_1_volumes_prune', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.my_environment_prune_unused_volumes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my-environment Prune unused volumes', + }), + 'context': , + 'entity_id': 'button.my_environment_prune_unused_volumes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities_snapshot[button.practical_morse_pause_container-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From d187e61274cc1f6d1516ec3c72030f95f75468b2 Mon Sep 17 00:00:00 2001 From: MarkGodwin <10632972+MarkGodwin@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:46:53 +0100 Subject: [PATCH 0442/1707] Update to tplink-omada-client 1.5.7 (#167313) --- homeassistant/components/tplink_omada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 3a68dfe91bfa88..4e348ecb1cff59 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["tplink-omada-client==1.5.6"] + "requirements": ["tplink-omada-client==1.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index a9221b35b996e3..0a466b289d2813 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3139,7 +3139,7 @@ toonapi==0.3.0 total-connect-client==2025.12.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.5.6 +tplink-omada-client==1.5.7 # homeassistant.components.transmission transmission-rpc==7.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 806e44b22f9e81..30cea3d47b5703 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2651,7 +2651,7 @@ toonapi==0.3.0 total-connect-client==2025.12.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.5.6 +tplink-omada-client==1.5.7 # homeassistant.components.transmission transmission-rpc==7.0.3 From 2fb44bce5d36eef5085e26af5aab646300de5086 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:48:41 -0400 Subject: [PATCH 0443/1707] Fix victron ble reauth flow title (#167307) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/victron_ble/config_flow.py | 2 +- .../components/victron_ble/strings.json | 2 +- .../victron_ble/test_config_flow.py | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/victron_ble/config_flow.py b/homeassistant/components/victron_ble/config_flow.py index bde04783a4f1c9..f003f1623750ce 100644 --- a/homeassistant/components/victron_ble/config_flow.py +++ b/homeassistant/components/victron_ble/config_flow.py @@ -54,7 +54,7 @@ async def async_step_bluetooth( self._discovered_devices_info[discovery_info.address] = discovery_info self._discovered_devices[discovery_info.address] = discovery_info.name - self.context["title_placeholders"] = {"title": discovery_info.name} + self.context["title_placeholders"] = {"name": discovery_info.name} return await self.async_step_access_token() diff --git a/homeassistant/components/victron_ble/strings.json b/homeassistant/components/victron_ble/strings.json index 901594b473efa3..5478a595cd4811 100644 --- a/homeassistant/components/victron_ble/strings.json +++ b/homeassistant/components/victron_ble/strings.json @@ -18,7 +18,7 @@ "invalid_access_token": "Invalid encryption key for instant readout", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, - "flow_title": "{title}", + "flow_title": "{name}", "step": { "access_token": { "data": { diff --git a/tests/components/victron_ble/test_config_flow.py b/tests/components/victron_ble/test_config_flow.py index 8f3115911867d6..0e6b7145815ab0 100644 --- a/tests/components/victron_ble/test_config_flow.py +++ b/tests/components/victron_ble/test_config_flow.py @@ -307,3 +307,23 @@ async def test_async_step_reauth_device_not_found( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {"base": "no_devices_found"} + + +async def test_reauth_flow_sets_title_placeholders( + hass: HomeAssistant, + mock_config_entry_added_to_hass: MockConfigEntry, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test that reauth flow has title_placeholders set for flow_title rendering. + + Regression test for https://github.com/home-assistant/core/issues/167105 where + the flow_title used '{title}' but HA only automatically provides 'name' in + title_placeholders for reauth flows, causing a frontend MISSING_VALUE error. + """ + await mock_config_entry_added_to_hass.start_reauth_flow(hass) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["title_placeholders"]["name"] == ( + mock_config_entry_added_to_hass.title + ) From 2ac3979f83dc063c62a77a6d2edf5c08facb0c5f Mon Sep 17 00:00:00 2001 From: g4bri3lDev Date: Fri, 3 Apr 2026 20:11:10 +0200 Subject: [PATCH 0444/1707] Add opendisplay encryption support (#167251) --- .../components/opendisplay/__init__.py | 31 +- .../components/opendisplay/config_flow.py | 120 +++++++- homeassistant/components/opendisplay/const.py | 1 + .../components/opendisplay/quality_scale.yaml | 4 +- .../components/opendisplay/services.py | 24 +- .../components/opendisplay/strings.json | 26 ++ tests/components/opendisplay/__init__.py | 1 + tests/components/opendisplay/conftest.py | 37 ++- .../opendisplay/test_config_flow.py | 264 +++++++++++++++++- tests/components/opendisplay/test_init.py | 73 ++++- tests/components/opendisplay/test_services.py | 110 +++++++- 11 files changed, 664 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/opendisplay/__init__.py b/homeassistant/components/opendisplay/__init__.py index 346f64b5c52729..30f88df8ed0fd6 100644 --- a/homeassistant/components/opendisplay/__init__.py +++ b/homeassistant/components/opendisplay/__init__.py @@ -8,6 +8,8 @@ from typing import TYPE_CHECKING from opendisplay import ( + AuthenticationFailedError, + AuthenticationRequiredError, BLEConnectionError, BLETimeoutError, GlobalConfig, @@ -19,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.typing import ConfigType @@ -27,7 +29,7 @@ if TYPE_CHECKING: from opendisplay.models import FirmwareVersion -from .const import DOMAIN +from .const import CONF_ENCRYPTION_KEY, DOMAIN from .coordinator import OpenDisplayCoordinator from .services import async_setup_services @@ -51,6 +53,23 @@ class OpenDisplayRuntimeData: type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] +def _get_encryption_key(entry: OpenDisplayConfigEntry) -> bytes | None: + """Return the encryption key bytes from entry data, or None.""" + raw = entry.data.get(CONF_ENCRYPTION_KEY) + if raw is None: + return None + if len(raw) != 32: + raise ConfigEntryAuthFailed( + "Stored OpenDisplay encryption key is invalid; reauthentication required" + ) + try: + return bytes.fromhex(raw) + except ValueError as err: + raise ConfigEntryAuthFailed( + "Stored OpenDisplay encryption key is invalid; reauthentication required" + ) from err + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the OpenDisplay integration.""" async_setup_services(hass) @@ -69,12 +88,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) f"Could not find OpenDisplay device with address {address}" ) + encryption_key = _get_encryption_key(entry) + try: async with OpenDisplayDevice( - mac_address=address, ble_device=ble_device + mac_address=address, ble_device=ble_device, encryption_key=encryption_key ) as device: fw = await device.read_firmware_version() is_flex = device.is_flex + except (AuthenticationFailedError, AuthenticationRequiredError) as err: + raise ConfigEntryAuthFailed( + f"Encryption key rejected by OpenDisplay device: {err}" + ) from err except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: raise ConfigEntryNotReady( f"Failed to connect to OpenDisplay device: {err}" diff --git a/homeassistant/components/opendisplay/config_flow.py b/homeassistant/components/opendisplay/config_flow.py index 9dc37489eb8809..4551cfc3b6d912 100644 --- a/homeassistant/components/opendisplay/config_flow.py +++ b/homeassistant/components/opendisplay/config_flow.py @@ -2,11 +2,14 @@ from __future__ import annotations +from collections.abc import Mapping import logging -from typing import Any +from typing import TYPE_CHECKING, Any from opendisplay import ( MANUFACTURER_ID, + AuthenticationFailedError, + AuthenticationRequiredError, BLEConnectionError, OpenDisplayDevice, OpenDisplayError, @@ -21,11 +24,14 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN +from .const import CONF_ENCRYPTION_KEY, DOMAIN _LOGGER = logging.getLogger(__name__) +_ENCRYPTION_KEY_VALIDATOR = vol.All(str.strip, str.lower, vol.Match(r"^[0-9a-f]{32}$")) + + class OpenDisplayConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenDisplay.""" @@ -34,14 +40,16 @@ def __init__(self) -> None: self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} - async def _async_test_connection(self, address: str) -> None: + async def _async_test_connection( + self, address: str, encryption_key: bytes | None = None + ) -> None: """Connect to the device and verify it responds.""" ble_device = async_ble_device_from_address(self.hass, address, connectable=True) if ble_device is None: raise BLEConnectionError(f"Could not find connectable device for {address}") async with OpenDisplayDevice( - mac_address=address, ble_device=ble_device + mac_address=address, ble_device=ble_device, encryption_key=encryption_key ) as device: await device.read_firmware_version() @@ -56,6 +64,8 @@ async def async_step_bluetooth( try: await self._async_test_connection(discovery_info.address) + except AuthenticationRequiredError: + return await self.async_step_encryption_key() except OpenDisplayError: return self.async_abort(reason="cannot_connect") except Exception: @@ -92,6 +102,11 @@ async def async_step_user( try: await self._async_test_connection(address) + except AuthenticationRequiredError: + self.context["title_placeholders"] = { + "name": self._discovered_devices[address].name + } + return await self.async_step_encryption_key() except OpenDisplayError: errors["base"] = "cannot_connect" except Exception: @@ -128,3 +143,100 @@ async def async_step_user( ), errors=errors, ) + + async def _async_try_connection( + self, + address: str, + encryption_key: bytes | None, + errors: dict[str, str], + ) -> bool: + """Test connection, populate errors, and return True on success.""" + try: + await self._async_test_connection(address, encryption_key) + except AuthenticationFailedError, AuthenticationRequiredError: + errors[CONF_ENCRYPTION_KEY] = "invalid_auth" + except OpenDisplayError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return True + return False + + async def async_step_encryption_key( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the encryption key step.""" + errors: dict[str, str] = {} + name: str = self.context["title_placeholders"]["name"] + + if user_input is not None: + try: + key: str = _ENCRYPTION_KEY_VALIDATOR(user_input[CONF_ENCRYPTION_KEY]) + except vol.Invalid: + errors[CONF_ENCRYPTION_KEY] = "invalid_key_format" + else: + if TYPE_CHECKING: + assert self.unique_id is not None + if await self._async_try_connection( + self.unique_id, bytes.fromhex(key), errors + ): + return self.async_create_entry( + title=name, + data={CONF_ENCRYPTION_KEY: key}, + ) + + return self.async_show_form( + step_id="encryption_key", + data_schema=vol.Schema({vol.Required(CONF_ENCRYPTION_KEY): str}), + description_placeholders={"name": name}, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirmation.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + if user_input is not None: + key: str | None = None + if user_input[CONF_ENCRYPTION_KEY].strip(): + try: + key = _ENCRYPTION_KEY_VALIDATOR(user_input[CONF_ENCRYPTION_KEY]) + except vol.Invalid: + errors[CONF_ENCRYPTION_KEY] = "invalid_key_format" + + if not errors: + address = reauth_entry.unique_id + if TYPE_CHECKING: + assert address is not None + if await self._async_try_connection( + address, bytes.fromhex(key) if key is not None else None, errors + ): + new_data = dict(reauth_entry.data) + if key is not None: + new_data[CONF_ENCRYPTION_KEY] = key + else: + new_data.pop(CONF_ENCRYPTION_KEY, None) + return self.async_update_reload_and_abort( + reauth_entry, + data=new_data, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + {vol.Optional(CONF_ENCRYPTION_KEY, default=""): str} + ), + description_placeholders={"name": reauth_entry.title}, + errors=errors, + ) diff --git a/homeassistant/components/opendisplay/const.py b/homeassistant/components/opendisplay/const.py index 0db0b2f08fde49..664f7e8d306ead 100644 --- a/homeassistant/components/opendisplay/const.py +++ b/homeassistant/components/opendisplay/const.py @@ -1,3 +1,4 @@ """Constants for the OpenDisplay integration.""" DOMAIN = "opendisplay" +CONF_ENCRYPTION_KEY = "encryption_key" diff --git a/homeassistant/components/opendisplay/quality_scale.yaml b/homeassistant/components/opendisplay/quality_scale.yaml index 07239f26d9bd76..6a14ae56adc751 100644 --- a/homeassistant/components/opendisplay/quality_scale.yaml +++ b/homeassistant/components/opendisplay/quality_scale.yaml @@ -33,9 +33,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: - status: exempt - comment: Devices do not require authentication. + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/opendisplay/services.py b/homeassistant/components/opendisplay/services.py index 98de6f677f9c34..bbbd129b294cf5 100644 --- a/homeassistant/components/opendisplay/services.py +++ b/homeassistant/components/opendisplay/services.py @@ -12,6 +12,8 @@ import aiohttp from opendisplay import ( + AuthenticationFailedError, + AuthenticationRequiredError, DitherMode, FitMode, OpenDisplayDevice, @@ -38,7 +40,7 @@ if TYPE_CHECKING: from . import OpenDisplayConfigEntry -from .const import DOMAIN +from .const import CONF_ENCRYPTION_KEY, DOMAIN ATTR_IMAGE = "image" ATTR_ROTATION = "rotation" @@ -193,10 +195,25 @@ async def _async_upload_image(call: ServiceCall) -> None: else: pil_image = await _async_download_image(call.hass, media.url) + raw_key = entry.data.get(CONF_ENCRYPTION_KEY) + if raw_key is not None and len(raw_key) != 32: + entry.async_start_reauth(call.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="authentication_error" + ) + try: + encryption_key = bytes.fromhex(raw_key) if raw_key is not None else None + except ValueError as err: + entry.async_start_reauth(call.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="authentication_error" + ) from err + async with OpenDisplayDevice( mac_address=address, ble_device=ble_device, config=entry.runtime_data.device_config, + encryption_key=encryption_key, ) as device: await device.upload_image( pil_image, @@ -208,6 +225,11 @@ async def _async_upload_image(call: ServiceCall) -> None: ) except asyncio.CancelledError: return + except (AuthenticationFailedError, AuthenticationRequiredError) as err: + entry.async_start_reauth(call.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="authentication_error" + ) from err except OpenDisplayError as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="upload_error" diff --git a/homeassistant/components/opendisplay/strings.json b/homeassistant/components/opendisplay/strings.json index 751ba8ccfec295..bfc4ffe900143e 100644 --- a/homeassistant/components/opendisplay/strings.json +++ b/homeassistant/components/opendisplay/strings.json @@ -5,10 +5,13 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_key_format": "The encryption key must be exactly 32 hexadecimal characters (0-9, a-f).", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name}", @@ -16,6 +19,26 @@ "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, + "encryption_key": { + "data": { + "encryption_key": "Encryption key" + }, + "data_description": { + "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." + }, + "description": "{name} requires an encryption key to connect.", + "title": "Encryption required" + }, + "reauth_confirm": { + "data": { + "encryption_key": "[%key:component::opendisplay::config::step::encryption_key::data::encryption_key%]" + }, + "data_description": { + "encryption_key": "[%key:component::opendisplay::config::step::encryption_key::data_description::encryption_key%]" + }, + "description": "Authentication failed for {name}. Enter the correct encryption key, or leave blank if encryption has been disabled on the device.", + "title": "Re-authentication required" + }, "user": { "data": { "address": "[%key:common::config_flow::data::device%]" @@ -35,6 +58,9 @@ } }, "exceptions": { + "authentication_error": { + "message": "Authentication failed. Please update the encryption key." + }, "device_not_found": { "message": "Could not find Bluetooth device with address `{address}`." }, diff --git a/tests/components/opendisplay/__init__.py b/tests/components/opendisplay/__init__.py index aa3fcf6e2ec755..732df3736ba51c 100644 --- a/tests/components/opendisplay/__init__.py +++ b/tests/components/opendisplay/__init__.py @@ -24,6 +24,7 @@ TEST_ADDRESS = "AA:BB:CC:DD:EE:FF" TEST_TITLE = "OpenDisplay 1234" +ENCRYPTION_KEY = "aabbccddee112233aabbccddee112233" # 32 hex chars = 16 bytes # Firmware version response: major=1, minor=2, sha="abc123" FIRMWARE_VERSION = {"major": 1, "minor": 2, "sha": "abc123"} diff --git a/tests/components/opendisplay/conftest.py b/tests/components/opendisplay/conftest.py index bda9a98abc2eca..cd6eab11a20511 100644 --- a/tests/components/opendisplay/conftest.py +++ b/tests/components/opendisplay/conftest.py @@ -5,9 +5,9 @@ import pytest -from homeassistant.components.opendisplay.const import DOMAIN +from homeassistant.components.opendisplay.const import CONF_ENCRYPTION_KEY, DOMAIN -from . import DEVICE_CONFIG, FIRMWARE_VERSION, TEST_ADDRESS, TEST_TITLE +from . import DEVICE_CONFIG, ENCRYPTION_KEY, FIRMWARE_VERSION, TEST_ADDRESS, TEST_TITLE from tests.common import MockConfigEntry from tests.components.bluetooth import generate_ble_device @@ -39,29 +39,35 @@ def mock_ble_device() -> Generator[None]: yield -@pytest.fixture(autouse=True) -def mock_opendisplay_device() -> Generator[MagicMock]: - """Mock the OpenDisplayDevice for setup entry.""" +@pytest.fixture +def mock_opendisplay_device_class() -> Generator[MagicMock]: + """Yield the OpenDisplayDevice class mock (for asserting constructor args).""" with ( patch( "homeassistant.components.opendisplay.OpenDisplayDevice", autospec=True, - ) as mock_device_init, + ) as mock_class, patch( "homeassistant.components.opendisplay.config_flow.OpenDisplayDevice", - new=mock_device_init, + new=mock_class, ), patch( "homeassistant.components.opendisplay.services.OpenDisplayDevice", - new=mock_device_init, + new=mock_class, ), ): - mock_device = mock_device_init.return_value + mock_device = mock_class.return_value mock_device.__aenter__.return_value = mock_device mock_device.read_firmware_version.return_value = FIRMWARE_VERSION mock_device.config = DEVICE_CONFIG mock_device.is_flex = True - yield mock_device + yield mock_class + + +@pytest.fixture(autouse=True) +def mock_opendisplay_device(mock_opendisplay_device_class: MagicMock) -> MagicMock: + """Mock the OpenDisplayDevice for setup entry; yields the instance mock.""" + return mock_opendisplay_device_class.return_value @pytest.fixture @@ -73,3 +79,14 @@ def mock_config_entry() -> MockConfigEntry: title=TEST_TITLE, data={}, ) + + +@pytest.fixture +def mock_encrypted_config_entry() -> MockConfigEntry: + """Create a mock config entry with an encryption key.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + title=TEST_TITLE, + data={CONF_ENCRYPTION_KEY: ENCRYPTION_KEY}, + ) diff --git a/tests/components/opendisplay/test_config_flow.py b/tests/components/opendisplay/test_config_flow.py index 41e9aec65841e5..4c7b4cf23a6173 100644 --- a/tests/components/opendisplay/test_config_flow.py +++ b/tests/components/opendisplay/test_config_flow.py @@ -3,15 +3,21 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch -from opendisplay import BLEConnectionError, BLETimeoutError, OpenDisplayError +from opendisplay import ( + AuthenticationFailedError, + AuthenticationRequiredError, + BLEConnectionError, + BLETimeoutError, + OpenDisplayError, +) import pytest from homeassistant import config_entries -from homeassistant.components.opendisplay.const import DOMAIN +from homeassistant.components.opendisplay.const import CONF_ENCRYPTION_KEY, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import NOT_OPENDISPLAY_SERVICE_INFO, VALID_SERVICE_INFO +from . import ENCRYPTION_KEY, NOT_OPENDISPLAY_SERVICE_INFO, VALID_SERVICE_INFO from tests.common import MockConfigEntry @@ -242,3 +248,255 @@ async def test_user_step_already_configured( # Device is filtered out since it's already configured assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" + + +async def test_bluetooth_discovery_encrypted_device( + hass: HomeAssistant, + mock_opendisplay_device: MagicMock, +) -> None: + """Test Bluetooth discovery prompts for key when device requires encryption.""" + mock_opendisplay_device.__aenter__.side_effect = [ + AuthenticationRequiredError("auth required"), + mock_opendisplay_device, + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=VALID_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: ENCRYPTION_KEY}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ENCRYPTION_KEY: ENCRYPTION_KEY} + + +async def test_bluetooth_discovery_encrypted_invalid_key_format( + hass: HomeAssistant, + mock_opendisplay_device: MagicMock, +) -> None: + """Test encryption_key step shows error on invalid key format.""" + mock_opendisplay_device.__aenter__.side_effect = [ + AuthenticationRequiredError("auth required"), + mock_opendisplay_device, + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=VALID_SERVICE_INFO, + ) + assert result["step_id"] == "encryption_key" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: "tooshort"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + assert result["errors"] == {CONF_ENCRYPTION_KEY: "invalid_key_format"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: ENCRYPTION_KEY}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_bluetooth_discovery_encrypted_wrong_key( + hass: HomeAssistant, + mock_opendisplay_device: MagicMock, +) -> None: + """Test encryption_key step shows error on wrong key, then succeeds.""" + mock_opendisplay_device.__aenter__.side_effect = [ + AuthenticationRequiredError("auth required"), + AuthenticationFailedError("wrong key"), + mock_opendisplay_device, + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=VALID_SERVICE_INFO, + ) + assert result["step_id"] == "encryption_key" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: ENCRYPTION_KEY}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_ENCRYPTION_KEY: "invalid_auth"} + + mock_opendisplay_device.__aenter__.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: ENCRYPTION_KEY}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ENCRYPTION_KEY: ENCRYPTION_KEY} + + +async def test_user_step_encrypted_device( + hass: HomeAssistant, + mock_opendisplay_device: MagicMock, +) -> None: + """Test user step prompts for key when device requires encryption.""" + mock_opendisplay_device.__aenter__.side_effect = [ + AuthenticationRequiredError("auth required"), + mock_opendisplay_device, + ] + + with patch( + "homeassistant.components.opendisplay.config_flow.async_discovered_service_info", + return_value=[VALID_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "AA:BB:CC:DD:EE:FF"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encryption_key" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: ENCRYPTION_KEY}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ENCRYPTION_KEY: ENCRYPTION_KEY} + + +async def test_reauth_update_key( + hass: HomeAssistant, + mock_opendisplay_device: MagicMock, + mock_encrypted_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow updates the encryption key.""" + mock_encrypted_config_entry.add_to_hass(hass) + new_key = "11223344556677881122334455667788" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_encrypted_config_entry.entry_id, + }, + data=mock_encrypted_config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: new_key}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_encrypted_config_entry.data[CONF_ENCRYPTION_KEY] == new_key + + +async def test_reauth_remove_key( + hass: HomeAssistant, + mock_opendisplay_device: MagicMock, + mock_encrypted_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow removes the encryption key when left blank.""" + mock_encrypted_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_encrypted_config_entry.entry_id, + }, + data=mock_encrypted_config_entry.data, + ) + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: ""}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert CONF_ENCRYPTION_KEY not in mock_encrypted_config_entry.data + + +async def test_reauth_wrong_key( + hass: HomeAssistant, + mock_opendisplay_device: MagicMock, + mock_encrypted_config_entry: MockConfigEntry, +) -> None: + """Test reauth form shows error for wrong key, then succeeds.""" + mock_encrypted_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_encrypted_config_entry.entry_id, + }, + data=mock_encrypted_config_entry.data, + ) + assert result["step_id"] == "reauth_confirm" + + mock_opendisplay_device.__aenter__.side_effect = [ + AuthenticationFailedError("wrong key"), + mock_opendisplay_device, + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: ENCRYPTION_KEY}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_ENCRYPTION_KEY: "invalid_auth"} + + mock_opendisplay_device.__aenter__.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: ENCRYPTION_KEY}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_invalid_key_format( + hass: HomeAssistant, + mock_encrypted_config_entry: MockConfigEntry, +) -> None: + """Test reauth form shows error for a malformed encryption key.""" + mock_encrypted_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_encrypted_config_entry.entry_id, + }, + data=mock_encrypted_config_entry.data, + ) + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ENCRYPTION_KEY: "notvalidhex!"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_ENCRYPTION_KEY: "invalid_key_format"} diff --git a/tests/components/opendisplay/test_init.py b/tests/components/opendisplay/test_init.py index aaf01f85a8e2e0..0586d1e9257541 100644 --- a/tests/components/opendisplay/test_init.py +++ b/tests/components/opendisplay/test_init.py @@ -3,13 +3,22 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch -from opendisplay import BLEConnectionError, BLETimeoutError, OpenDisplayError +from opendisplay import ( + AuthenticationFailedError, + AuthenticationRequiredError, + BLEConnectionError, + BLETimeoutError, + OpenDisplayError, +) import pytest +from homeassistant.components.opendisplay.const import CONF_ENCRYPTION_KEY from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import ENCRYPTION_KEY + from tests.common import MockConfigEntry @@ -30,6 +39,25 @@ async def test_setup_and_unload( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_setup_encrypted_device( + hass: HomeAssistant, + mock_encrypted_config_entry: MockConfigEntry, + mock_opendisplay_device: MagicMock, + mock_opendisplay_device_class: MagicMock, +) -> None: + """Test setup passes the encryption key to OpenDisplayDevice.""" + mock_opendisplay_device.is_flex = False + mock_encrypted_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_encrypted_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_encrypted_config_entry.state is ConfigEntryState.LOADED + assert mock_opendisplay_device_class.call_args.kwargs[ + "encryption_key" + ] == bytes.fromhex(ENCRYPTION_KEY) + + async def test_setup_device_not_found( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -135,3 +163,46 @@ async def test_unload_cancels_active_upload_task( assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert task.cancelled() + + +@pytest.mark.parametrize( + "exception", + [ + AuthenticationFailedError("wrong key"), + AuthenticationRequiredError("auth required"), + ], +) +async def test_setup_authentication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test that auth errors result in SETUP_ERROR and trigger reauth.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.opendisplay.OpenDisplayDevice", + return_value=AsyncMock(__aenter__=AsyncMock(side_effect=exception)), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_invalid_encryption_key_format( + hass: HomeAssistant, +) -> None: + """Test that a malformed stored encryption key triggers reauth.""" + entry = MockConfigEntry( + domain="opendisplay", + unique_id="AA:BB:CC:DD:EE:FF", + title="OpenDisplay 1234", + data={CONF_ENCRYPTION_KEY: "not-valid-hex!"}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/opendisplay/test_services.py b/tests/components/opendisplay/test_services.py index 42b6555a32f8ed..8ec1f7ed01e4e1 100644 --- a/tests/components/opendisplay/test_services.py +++ b/tests/components/opendisplay/test_services.py @@ -7,16 +7,23 @@ from unittest.mock import MagicMock, patch import aiohttp -from opendisplay import BLEConnectionError +from opendisplay import ( + AuthenticationFailedError, + AuthenticationRequiredError, + BLEConnectionError, +) from PIL import Image as PILImage import pytest import voluptuous as vol -from homeassistant.components.opendisplay.const import DOMAIN +from homeassistant import config_entries +from homeassistant.components.opendisplay.const import CONF_ENCRYPTION_KEY, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from . import ENCRYPTION_KEY + from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -288,3 +295,102 @@ async def test_upload_image_cancels_previous_task( await hass.async_block_till_done() assert prev_task.cancelled() + + +async def test_upload_image_with_encryption_key( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opendisplay_device_class: MagicMock, + mock_resolve_media: MagicMock, +) -> None: + """Test that upload_image passes the encryption key to OpenDisplayDevice.""" + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_ENCRYPTION_KEY: ENCRYPTION_KEY}, + ) + + device_id = _device_id(hass, mock_config_entry) + + await hass.services.async_call( + DOMAIN, + "upload_image", + { + "device_id": device_id, + "image": { + "media_content_id": "media-source://local/test.png", + "media_content_type": "image/png", + }, + }, + blocking=True, + ) + + assert mock_opendisplay_device_class.call_args.kwargs[ + "encryption_key" + ] == bytes.fromhex(ENCRYPTION_KEY) + + +@pytest.mark.parametrize( + "exception", + [ + AuthenticationFailedError("wrong key"), + AuthenticationRequiredError("auth required"), + ], +) +async def test_upload_image_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opendisplay_device: MagicMock, + mock_resolve_media: MagicMock, + exception: Exception, +) -> None: + """Test that auth errors during upload trigger a reauth flow.""" + device_id = _device_id(hass, mock_config_entry) + + mock_opendisplay_device.__aenter__.side_effect = exception + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "upload_image", + { + "device_id": device_id, + "image": { + "media_content_id": "media-source://local/test.png", + "media_content_type": "image/png", + }, + }, + blocking=True, + ) + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert any(f["context"]["source"] == config_entries.SOURCE_REAUTH for f in flows) + + +async def test_upload_image_invalid_encryption_key_format( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_resolve_media: MagicMock, +) -> None: + """Test that a malformed stored encryption key triggers reauth and raises an error.""" + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_ENCRYPTION_KEY: "not-valid-hex!"}, + ) + device_id = _device_id(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "upload_image", + { + "device_id": device_id, + "image": { + "media_content_id": "media-source://local/test.png", + "media_content_type": "image/png", + }, + }, + blocking=True, + ) + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert any(f["context"]["source"] == config_entries.SOURCE_REAUTH for f in flows) From 6b2a4df6e0adfb5f1b25989c6f54475302615809 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 3 Apr 2026 21:09:05 +0200 Subject: [PATCH 0445/1707] Bump pyportainer 1.0.36 (#167319) --- homeassistant/components/portainer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index e2710c38453fa4..4640fb49673bd0 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.35"] + "requirements": ["pyportainer==1.0.36"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a466b289d2813..56ded7b1a51747 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2397,7 +2397,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.35 +pyportainer==1.0.36 # homeassistant.components.probe_plus pyprobeplus==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30cea3d47b5703..64cda4b3f7a94d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2053,7 +2053,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.35 +pyportainer==1.0.36 # homeassistant.components.probe_plus pyprobeplus==1.1.2 From c3c1c3eb46eefbb789019e67cd02fa2acf9de53c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 3 Apr 2026 21:57:00 +0200 Subject: [PATCH 0446/1707] Improve Shopping List action naming consistency (#167248) --- .../components/shopping_list/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json index 7826f06618a608..925fffffb4b621 100644 --- a/homeassistant/components/shopping_list/strings.json +++ b/homeassistant/components/shopping_list/strings.json @@ -30,11 +30,11 @@ }, "clear_completed_items": { "description": "Removes completed items from the shopping list.", - "name": "Clear completed items" + "name": "Clear completed shopping list items" }, "complete_all": { "description": "Marks all items as completed in the shopping list (without removing them from the list).", - "name": "Complete all" + "name": "Complete all shopping list items" }, "complete_item": { "description": "Marks the first item with matching name as completed in the shopping list.", @@ -44,11 +44,11 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Complete item" + "name": "Complete shopping list item" }, "incomplete_all": { "description": "Marks all items as incomplete in the shopping list.", - "name": "Incomplete all" + "name": "Incomplete all shopping list items" }, "incomplete_item": { "description": "Marks the first item with matching name as incomplete in the shopping list.", @@ -58,7 +58,7 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Incomplete item" + "name": "Incomplete shopping list item" }, "remove_item": { "description": "Removes the first item with matching name from the shopping list.", @@ -68,7 +68,7 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Remove item" + "name": "Remove shopping list item" }, "sort": { "description": "Sorts all items by name in the shopping list.", @@ -78,7 +78,7 @@ "name": "Sort reverse" } }, - "name": "Sort all items" + "name": "Sort shopping list" } }, "title": "Shopping List" From 409b3f17dba9469f2768e9205beab3c81094f307 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:59:05 +0200 Subject: [PATCH 0447/1707] Fix apple_tv RuntimeWarnings in tests (#167325) --- tests/components/apple_tv/test_services.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/apple_tv/test_services.py b/tests/components/apple_tv/test_services.py index d74383124bc1da..e9b4f82d9fc0a6 100644 --- a/tests/components/apple_tv/test_services.py +++ b/tests/components/apple_tv/test_services.py @@ -20,7 +20,10 @@ def mock_atv() -> AsyncMock: """Create a mock Apple TV interface with keyboard support.""" atv = AsyncMock() + atv.close = MagicMock() + atv.features = MagicMock() atv.keyboard = AsyncMock() + atv.push_updater = MagicMock() atv.keyboard.text_focus_state = KeyboardFocusState.Focused atv.device_info.model = DeviceModel.Gen4K atv.device_info.raw_model = "AppleTV6,2" From 193720b4c6c04ae397ae50654ee4ca6143999123 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Apr 2026 22:51:44 +0200 Subject: [PATCH 0448/1707] Pin actions/helpers/info to fix release build (#167327) --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0400f1a0f899b6..7e95120659bc04 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -49,7 +49,7 @@ jobs: - name: Get information id: info - uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses] + uses: home-assistant/actions/helpers/info@5f5b077d63a1e4c53019231409a0c4d791fb74e5 # zizmor: ignore[unpinned-uses] - name: Get version id: version From d904175a2f70f5152fefdbc32228c8571306011e Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 3 Apr 2026 23:36:33 -0400 Subject: [PATCH 0449/1707] Bump aiorussound to 4.10.0 (#167341) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 588f13960366fb..4b55b542a72fbf 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.9.1"], + "requirements": ["aiorussound==4.10.0"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 56ded7b1a51747..28ee55ffc5e152 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -389,7 +389,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.9.1 +aiorussound==4.10.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64cda4b3f7a94d..6ca2b1f13d9712 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -374,7 +374,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.9.1 +aiorussound==4.10.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From f8476e4e84378d756e2c195b299281f9f6e2bd1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Apr 2026 17:50:47 -1000 Subject: [PATCH 0450/1707] Bump habluetooth to 6.0.0 (#167340) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_init.py | 53 +++++++------------ 5 files changed, 23 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index d187e749b3e6a5..e1ffd3c822b9f9 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.3", "bluetooth-data-tools==1.28.4", "dbus-fast==3.1.2", - "habluetooth==5.11.1" + "habluetooth==6.0.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7b566c233192bd..f407965aecd0ec 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ file-read-backwards==2.0.0 fnv-hash-fast==2.0.0 go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 -habluetooth==5.11.1 +habluetooth==6.0.0 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 28ee55ffc5e152..b404b04b2b14bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1176,7 +1176,7 @@ ha-silabs-firmware-client==0.3.0 habiticalib==0.4.7 # homeassistant.components.bluetooth -habluetooth==5.11.1 +habluetooth==6.0.0 # homeassistant.components.hanna hanna-cloud==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ca2b1f13d9712..660822bbae250a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1049,7 +1049,7 @@ ha-silabs-firmware-client==0.3.0 habiticalib==0.4.7 # homeassistant.components.bluetooth -habluetooth==5.11.1 +habluetooth==6.0.0 # homeassistant.components.hanna hanna-cloud==0.0.7 diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 0e461503fc85a7..3b6dc066765af0 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -121,9 +121,6 @@ async def start(self, *args, **kwargs): async def stop(self, *args, **kwargs): """Stop the scanner.""" - def register_detection_callback(self, *args, **kwargs): - """Register a callback.""" - with patch( "habluetooth.scanner.OriginalBleakScanner", MockPassiveBleakScanner, @@ -171,9 +168,6 @@ async def start(self, *args, **kwargs): async def stop(self, *args, **kwargs): """Stop the scanner.""" - def register_detection_callback(self, *args, **kwargs): - """Register a callback.""" - with patch( "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, @@ -2571,9 +2565,9 @@ def _device_detected( assert _get_manager() is not None scanner = HaBleakScannerWrapper( - filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} + detection_callback=_device_detected, + filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}, ) - scanner.register_detection_callback(_device_detected) inject_advertisement(hass, switchbot_device, switchbot_adv_2) await hass.async_block_till_done() @@ -2583,25 +2577,18 @@ def _device_detected( assert discovered == [switchbot_device] assert len(detected) == 1 - scanner.register_detection_callback(_device_detected) - # We should get a reply from the history when we register again - assert len(detected) == 2 - scanner.register_detection_callback(_device_detected) - # We should get a reply from the history when we register again - assert len(detected) == 3 - with patch_discovered_devices([]): discovered = await scanner.discover(timeout=0) assert len(discovered) == 0 assert discovered == [] inject_advertisement(hass, switchbot_device, switchbot_adv) - assert len(detected) == 4 + assert len(detected) == 2 # The filter we created in the wrapped scanner with should be respected # and we should not get another callback inject_advertisement(hass, empty_device, empty_adv) - assert len(detected) == 4 + assert len(detected) == 2 @pytest.mark.usefixtures("enable_bluetooth") @@ -2643,10 +2630,10 @@ def _device_detected( empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None - scanner = HaBleakScannerWrapper( - service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + _scanner = HaBleakScannerWrapper( + detection_callback=_device_detected, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) - scanner.register_detection_callback(_device_detected) inject_advertisement(hass, switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv_2) @@ -2703,10 +2690,10 @@ async def _device_detected( empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None - scanner = HaBleakScannerWrapper( - service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + _scanner = HaBleakScannerWrapper( + detection_callback=_device_detected, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) - scanner.register_detection_callback(_device_detected) inject_advertisement(hass, switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv_2) @@ -2757,10 +2744,10 @@ def _device_detected( ) assert _get_manager() is not None - scanner = HaBleakScannerWrapper( - service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + _scanner = HaBleakScannerWrapper( + detection_callback=_device_detected, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) - scanner.register_detection_callback(_device_detected) inject_advertisement(hass, switchbot_device, switchbot_adv) await hass.async_block_till_done() @@ -2807,11 +2794,10 @@ def _device_detected( empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None - scanner = HaBleakScannerWrapper() - scanner.set_scanning_filter( - service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + _scanner = HaBleakScannerWrapper( + detection_callback=_device_detected, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) - scanner.register_detection_callback(_device_detected) inject_advertisement(hass, switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv_2) @@ -2863,11 +2849,10 @@ def _device_detected( empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None - scanner = HaBleakScannerWrapper() - scanner.set_scanning_filter( - filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} + _scanner = HaBleakScannerWrapper( + detection_callback=_device_detected, + filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}, ) - scanner.register_detection_callback(_device_detected) inject_advertisement(hass, switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv_2) From 2333e9e8b152fab39a23392d228e2c454df11e9b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Apr 2026 10:08:54 +0200 Subject: [PATCH 0451/1707] Extract serialization template functions into a serialization Jinja2 extension (#167332) --- homeassistant/helpers/template/__init__.py | 106 +---------- .../helpers/template/extensions/__init__.py | 2 + .../template/extensions/serialization.py | 157 +++++++++++++++ .../template/extensions/test_serialization.py | 178 ++++++++++++++++++ tests/helpers/template/test_init.py | 166 ---------------- 5 files changed, 340 insertions(+), 269 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/serialization.py create mode 100644 tests/helpers/template/extensions/test_serialization.py diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index c11d09e4d7e4f3..4ef3502002c69a 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -10,14 +10,12 @@ from datetime import datetime, timedelta from enum import Enum from functools import cache, lru_cache, partial, wraps -import json import logging import math from operator import contains import pathlib import random import re -from struct import error as StructError, pack, unpack_from import sys from types import CodeType from typing import TYPE_CHECKING, Any, Concatenate, Literal, NoReturn, Self, overload @@ -30,7 +28,6 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace from lru import LRU -import orjson from propcache.api import under_cached_property from homeassistant.const import ( @@ -143,10 +140,6 @@ CACHED_TEMPLATE_NO_COLLECT_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) ENTITY_COUNT_GROWTH_FACTOR = 1.2 -ORJSON_PASSTHROUGH_OPTIONS = ( - orjson.OPT_PASSTHROUGH_DATACLASS | orjson.OPT_PASSTHROUGH_DATETIME -) - def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: """Return a TemplateState for a state without collecting.""" @@ -1564,95 +1557,6 @@ def fail_when_undefined(value): return value -def struct_pack(value: Any | None, format_string: str) -> bytes | None: - """Pack an object into a bytes object.""" - try: - return pack(format_string, value) - except StructError: - _LOGGER.warning( - ( - "Template warning: 'pack' unable to pack object '%s' with type '%s' and" - " format_string '%s' see https://docs.python.org/3/library/struct.html" - " for more information" - ), - str(value), - type(value).__name__, - format_string, - ) - return None - - -def struct_unpack(value: bytes, format_string: str, offset: int = 0) -> Any | None: - """Unpack an object from bytes an return the first native object.""" - try: - return unpack_from(format_string, value, offset)[0] - except StructError: - _LOGGER.warning( - ( - "Template warning: 'unpack' unable to unpack object '%s' with" - " format_string '%s' and offset %s see" - " https://docs.python.org/3/library/struct.html for more information" - ), - value, - format_string, - offset, - ) - return None - - -def from_hex(value: str) -> bytes: - """Perform hex string decode.""" - return bytes.fromhex(value) - - -def from_json(value, default=_SENTINEL): - """Convert a JSON string to an object.""" - try: - return json_loads(value) - except JSON_DECODE_EXCEPTIONS: - if default is _SENTINEL: - raise_no_default("from_json", value) - return default - - -def _to_json_default(obj: Any) -> None: - """Disable custom types in json serialization.""" - raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") - - -def to_json( - value: Any, - ensure_ascii: bool = False, - pretty_print: bool = False, - sort_keys: bool = False, -) -> str: - """Convert an object to a JSON string.""" - if ensure_ascii: - # For those who need ascii, we can't use orjson, so we fall back to the json library. - return json.dumps( - value, - ensure_ascii=ensure_ascii, - indent=2 if pretty_print else None, - sort_keys=sort_keys, - ) - - option = ( - ORJSON_PASSTHROUGH_OPTIONS - # OPT_NON_STR_KEYS is added as a workaround to - # ensure subclasses of str are allowed as dict keys - # See: https://github.com/ijl/orjson/issues/445 - | orjson.OPT_NON_STR_KEYS - | (orjson.OPT_INDENT_2 if pretty_print else 0) - | (orjson.OPT_SORT_KEYS if sort_keys else 0) - ) - - return orjson.dumps( - value, - option=option, - default=_to_json_default, - ).decode("utf-8") - - @pass_context def random_every_time(context, values): """Choose a random value. @@ -1854,6 +1758,9 @@ def __init__( self.add_extension("homeassistant.helpers.template.extensions.LabelExtension") self.add_extension("homeassistant.helpers.template.extensions.MathExtension") self.add_extension("homeassistant.helpers.template.extensions.RegexExtension") + self.add_extension( + "homeassistant.helpers.template.extensions.SerializationExtension" + ) self.add_extension("homeassistant.helpers.template.extensions.StringExtension") self.add_extension( "homeassistant.helpers.template.extensions.TypeCastExtension" @@ -1864,9 +1771,7 @@ def __init__( self.globals["combine"] = combine self.globals["iif"] = iif self.globals["merge_response"] = merge_response - self.globals["pack"] = struct_pack self.globals["typeof"] = typeof - self.globals["unpack"] = struct_unpack self.globals["version"] = version self.globals["zip"] = zip @@ -1875,18 +1780,13 @@ def __init__( self.filters["as_function"] = as_function self.filters["combine"] = combine self.filters["contains"] = contains - self.filters["from_json"] = from_json - self.filters["from_hex"] = from_hex self.filters["iif"] = iif self.filters["is_defined"] = fail_when_undefined self.filters["multiply"] = multiply self.filters["ord"] = ord - self.filters["pack"] = struct_pack self.filters["random"] = random_every_time self.filters["round"] = forgiving_round - self.filters["to_json"] = to_json self.filters["typeof"] = typeof - self.filters["unpack"] = struct_unpack self.filters["version"] = version self.tests["apply"] = apply diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 9c4e32516cf7fe..47c4ae648dca25 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -11,6 +11,7 @@ from .labels import LabelExtension from .math import MathExtension from .regex import RegexExtension +from .serialization import SerializationExtension from .string import StringExtension from .type_cast import TypeCastExtension @@ -26,6 +27,7 @@ "LabelExtension", "MathExtension", "RegexExtension", + "SerializationExtension", "StringExtension", "TypeCastExtension", ] diff --git a/homeassistant/helpers/template/extensions/serialization.py b/homeassistant/helpers/template/extensions/serialization.py new file mode 100644 index 00000000000000..fa5ca499bd4094 --- /dev/null +++ b/homeassistant/helpers/template/extensions/serialization.py @@ -0,0 +1,157 @@ +"""Serialization functions for Home Assistant templates.""" + +from __future__ import annotations + +import json +import logging +from struct import error as StructError, pack, unpack_from +from typing import TYPE_CHECKING, Any + +import orjson + +from homeassistant.helpers.template.helpers import raise_no_default +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + +_LOGGER = logging.getLogger(__name__) + +_SENTINEL = object() + +_ORJSON_PASSTHROUGH_OPTIONS = ( + orjson.OPT_PASSTHROUGH_DATACLASS | orjson.OPT_PASSTHROUGH_DATETIME +) + + +class SerializationExtension(BaseTemplateExtension): + """Jinja2 extension for serialization functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the serialization extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "pack", + self.struct_pack, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "unpack", + self.struct_unpack, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "from_json", + self.from_json, + as_filter=True, + ), + TemplateFunction( + "to_json", + self.to_json, + as_filter=True, + ), + TemplateFunction( + "from_hex", + self.from_hex, + as_filter=True, + ), + ], + ) + + @staticmethod + def struct_pack(value: Any | None, format_string: str) -> bytes | None: + """Pack an object into a bytes object.""" + try: + return pack(format_string, value) + except StructError: + _LOGGER.warning( + ( + "Template warning: 'pack' unable to pack object '%s' with type '%s'" + " and format_string '%s' see" + " https://docs.python.org/3/library/struct.html" + " for more information" + ), + str(value), + type(value).__name__, + format_string, + ) + return None + + @staticmethod + def struct_unpack(value: bytes, format_string: str, offset: int = 0) -> Any | None: + """Unpack an object from bytes and return the first native object.""" + try: + return unpack_from(format_string, value, offset)[0] + except StructError: + _LOGGER.warning( + ( + "Template warning: 'unpack' unable to unpack object '%s' with" + " format_string '%s' and offset %s see" + " https://docs.python.org/3/library/struct.html" + " for more information" + ), + value, + format_string, + offset, + ) + return None + + @staticmethod + def from_json(value: Any, default: Any = _SENTINEL) -> Any: + """Convert a JSON string to an object.""" + try: + return json_loads(value) + except JSON_DECODE_EXCEPTIONS: + if default is _SENTINEL: + raise_no_default("from_json", value) + return default + + @staticmethod + def to_json( + value: Any, + ensure_ascii: bool = False, + pretty_print: bool = False, + sort_keys: bool = False, + ) -> str: + """Convert an object to a JSON string.""" + if ensure_ascii: + # For those who need ascii, we can't use orjson, + # so we fall back to the json library. + return json.dumps( + value, + ensure_ascii=ensure_ascii, + indent=2 if pretty_print else None, + sort_keys=sort_keys, + ) + + option = ( + _ORJSON_PASSTHROUGH_OPTIONS + # OPT_NON_STR_KEYS is added as a workaround to + # ensure subclasses of str are allowed as dict keys + # See: https://github.com/ijl/orjson/issues/445 + | orjson.OPT_NON_STR_KEYS + | (orjson.OPT_INDENT_2 if pretty_print else 0) + | (orjson.OPT_SORT_KEYS if sort_keys else 0) + ) + + return orjson.dumps( + value, + option=option, + default=_to_json_default, + ).decode("utf-8") + + @staticmethod + def from_hex(value: str) -> bytes: + """Perform hex string decode.""" + return bytes.fromhex(value) + + +def _to_json_default(obj: Any) -> None: + """Disable custom types in json serialization.""" + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") diff --git a/tests/helpers/template/extensions/test_serialization.py b/tests/helpers/template/extensions/test_serialization.py new file mode 100644 index 00000000000000..32d0c425814370 --- /dev/null +++ b/tests/helpers/template/extensions/test_serialization.py @@ -0,0 +1,178 @@ +"""Test serialization functions for Home Assistant templates.""" + +from __future__ import annotations + +import json + +import orjson +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError + +from tests.helpers.template.helpers import render, render_to_info + + +def test_to_json(hass: HomeAssistant) -> None: + """Test the object to JSON string filter.""" + + # Note that we're not testing the actual json.loads and json.dumps methods, + # only the filters, so we don't need to be exhaustive with our sample JSON. + expected_result = {"Foo": "Bar"} + actual_result = render(hass, "{{ {'Foo': 'Bar'} | to_json }}") + assert actual_result == expected_result + + expected_result = orjson.dumps({"Foo": "Bar"}, option=orjson.OPT_INDENT_2).decode() + actual_result = render( + hass, "{{ {'Foo': 'Bar'} | to_json(pretty_print=True) }}", parse_result=False + ) + assert actual_result == expected_result + + expected_result = orjson.dumps( + {"Z": 26, "A": 1, "M": 13}, option=orjson.OPT_SORT_KEYS + ).decode() + actual_result = render( + hass, + "{{ {'Z': 26, 'A': 1, 'M': 13} | to_json(sort_keys=True) }}", + parse_result=False, + ) + assert actual_result == expected_result + + with pytest.raises(TemplateError): + render(hass, "{{ {'Foo': now()} | to_json }}") + + # Test special case where substring class cannot be rendered + # See: https://github.com/ijl/orjson/issues/445 + class MyStr(str): + __slots__ = () + + expected_result = '{"mykey1":11.0,"mykey2":"myvalue2","mykey3":["opt3b","opt3a"]}' + test_dict = { + MyStr("mykey2"): "myvalue2", + MyStr("mykey1"): 11.0, + MyStr("mykey3"): ["opt3b", "opt3a"], + } + actual_result = render( + hass, + "{{ test_dict | to_json(sort_keys=True) }}", + {"test_dict": test_dict}, + parse_result=False, + ) + assert actual_result == expected_result + + +def test_to_json_ensure_ascii(hass: HomeAssistant) -> None: + """Test the object to JSON string filter.""" + + # Note that we're not testing the actual json.loads and json.dumps methods, + # only the filters, so we don't need to be exhaustive with our sample JSON. + actual_value_ascii = render(hass, "{{ 'Bar ҝ éèà' | to_json(ensure_ascii=True) }}") + assert actual_value_ascii == '"Bar \\u049d \\u00e9\\u00e8\\u00e0"' + actual_value = render(hass, "{{ 'Bar ҝ éèà' | to_json(ensure_ascii=False) }}") + assert actual_value == '"Bar ҝ éèà"' + + expected_result = json.dumps({"Foo": "Bar"}, indent=2) + actual_result = render( + hass, + "{{ {'Foo': 'Bar'} | to_json(pretty_print=True, ensure_ascii=True) }}", + parse_result=False, + ) + assert actual_result == expected_result + + expected_result = json.dumps({"Z": 26, "A": 1, "M": 13}, sort_keys=True) + actual_result = render( + hass, + "{{ {'Z': 26, 'A': 1, 'M': 13} | to_json(sort_keys=True, ensure_ascii=True) }}", + parse_result=False, + ) + assert actual_result == expected_result + + +def test_from_json(hass: HomeAssistant) -> None: + """Test the JSON string to object filter.""" + + # Note that we're not testing the actual json.loads and json.dumps methods, + # only the filters, so we don't need to be exhaustive with our sample JSON. + expected_result = "Bar" + actual_result = render(hass, '{{ (\'{"Foo": "Bar"}\' | from_json).Foo }}') + assert actual_result == expected_result + + info = render_to_info(hass, "{{ 'garbage string' | from_json }}") + with pytest.raises(TemplateError, match="no default was specified"): + info.result() + + actual_result = render(hass, "{{ 'garbage string' | from_json('Bar') }}") + assert actual_result == expected_result + + +def test_from_hex(hass: HomeAssistant) -> None: + """Test the fromhex filter.""" + assert render(hass, "{{ '0F010003' | from_hex }}") == b"\x0f\x01\x00\x03" + + +def test_pack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test struct pack method.""" + + # render as filter + variables = {"value": 0xDEADBEEF} + assert render(hass, "{{ value | pack('>I') }}", variables) == b"\xde\xad\xbe\xef" + + # render as function + assert render(hass, "{{ pack(value, '>I') }}", variables) == b"\xde\xad\xbe\xef" + + # test with None value + # "Template warning: 'pack' unable to pack object with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information" + assert render(hass, "{{ pack(value, '>I') }}", {"value": None}) is None + assert ( + "Template warning: 'pack' unable to pack object 'None' with type 'NoneType' and" + " format_string '>I' see https://docs.python.org/3/library/struct.html for more" + " information" in caplog.text + ) + + # test with invalid filter + # "Template warning: 'pack' unable to pack object with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information" + assert render(hass, "{{ pack(value, 'invalid filter') }}", variables) is None + assert ( + "Template warning: 'pack' unable to pack object '3735928559' with type 'int'" + " and format_string 'invalid filter' see" + " https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) + + +def test_unpack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test struct unpack method.""" + + variables = {"value": b"\xde\xad\xbe\xef"} + + # render as filter + result = render(hass, """{{ value | unpack('>I') }}""", variables) + assert result == 0xDEADBEEF + + # render as function + result = render(hass, """{{ unpack(value, '>I') }}""", variables) + assert result == 0xDEADBEEF + + # unpack with offset + result = render(hass, """{{ unpack(value, '>H', offset=2) }}""", variables) + assert result == 0xBEEF + + # test with an empty bytes object + assert render(hass, """{{ unpack(value, '>I') }}""", {"value": b""}) is None + assert ( + "Template warning: 'unpack' unable to unpack object 'b''' with format_string" + " '>I' and offset 0 see https://docs.python.org/3/library/struct.html for more" + " information" in caplog.text + ) + + # test with invalid filter + assert ( + render(hass, """{{ unpack(value, 'invalid filter') }}""", {"value": b""}) + is None + ) + assert ( + "Template warning: 'unpack' unable to unpack object 'b''' with format_string" + " 'invalid filter' and offset 0 see" + " https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 26dc45f9c561d8..01a6757af97a8c 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -10,7 +10,6 @@ from unittest.mock import patch from freezegun import freeze_time -import orjson import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -446,108 +445,11 @@ def test_as_function_no_arguments(hass: HomeAssistant) -> None: assert render(hass, tpl) == "Hello" -def test_to_json(hass: HomeAssistant) -> None: - """Test the object to JSON string filter.""" - - # Note that we're not testing the actual json.loads and json.dumps methods, - # only the filters, so we don't need to be exhaustive with our sample JSON. - expected_result = {"Foo": "Bar"} - actual_result = render(hass, "{{ {'Foo': 'Bar'} | to_json }}") - assert actual_result == expected_result - - expected_result = orjson.dumps({"Foo": "Bar"}, option=orjson.OPT_INDENT_2).decode() - actual_result = render( - hass, "{{ {'Foo': 'Bar'} | to_json(pretty_print=True) }}", parse_result=False - ) - assert actual_result == expected_result - - expected_result = orjson.dumps( - {"Z": 26, "A": 1, "M": 13}, option=orjson.OPT_SORT_KEYS - ).decode() - actual_result = render( - hass, - "{{ {'Z': 26, 'A': 1, 'M': 13} | to_json(sort_keys=True) }}", - parse_result=False, - ) - assert actual_result == expected_result - - with pytest.raises(TemplateError): - render(hass, "{{ {'Foo': now()} | to_json }}") - - # Test special case where substring class cannot be rendered - # See: https://github.com/ijl/orjson/issues/445 - class MyStr(str): - __slots__ = () - - expected_result = '{"mykey1":11.0,"mykey2":"myvalue2","mykey3":["opt3b","opt3a"]}' - test_dict = { - MyStr("mykey2"): "myvalue2", - MyStr("mykey1"): 11.0, - MyStr("mykey3"): ["opt3b", "opt3a"], - } - actual_result = render( - hass, - "{{ test_dict | to_json(sort_keys=True) }}", - {"test_dict": test_dict}, - parse_result=False, - ) - assert actual_result == expected_result - - -def test_to_json_ensure_ascii(hass: HomeAssistant) -> None: - """Test the object to JSON string filter.""" - - # Note that we're not testing the actual json.loads and json.dumps methods, - # only the filters, so we don't need to be exhaustive with our sample JSON. - actual_value_ascii = render(hass, "{{ 'Bar ҝ éèà' | to_json(ensure_ascii=True) }}") - assert actual_value_ascii == '"Bar \\u049d \\u00e9\\u00e8\\u00e0"' - actual_value = render(hass, "{{ 'Bar ҝ éèà' | to_json(ensure_ascii=False) }}") - assert actual_value == '"Bar ҝ éèà"' - - expected_result = json.dumps({"Foo": "Bar"}, indent=2) - actual_result = render( - hass, - "{{ {'Foo': 'Bar'} | to_json(pretty_print=True, ensure_ascii=True) }}", - parse_result=False, - ) - assert actual_result == expected_result - - expected_result = json.dumps({"Z": 26, "A": 1, "M": 13}, sort_keys=True) - actual_result = render( - hass, - "{{ {'Z': 26, 'A': 1, 'M': 13} | to_json(sort_keys=True, ensure_ascii=True) }}", - parse_result=False, - ) - assert actual_result == expected_result - - -def test_from_json(hass: HomeAssistant) -> None: - """Test the JSON string to object filter.""" - - # Note that we're not testing the actual json.loads and json.dumps methods, - # only the filters, so we don't need to be exhaustive with our sample JSON. - expected_result = "Bar" - actual_result = render(hass, '{{ (\'{"Foo": "Bar"}\' | from_json).Foo }}') - assert actual_result == expected_result - - info = render_to_info(hass, "{{ 'garbage string' | from_json }}") - with pytest.raises(TemplateError, match="no default was specified"): - info.result() - - actual_result = render(hass, "{{ 'garbage string' | from_json('Bar') }}") - assert actual_result == expected_result - - def test_ord(hass: HomeAssistant) -> None: """Test the ord filter.""" assert render(hass, '{{ "d" | ord }}') == 100 -def test_from_hex(hass: HomeAssistant) -> None: - """Test the fromhex filter.""" - assert render(hass, "{{ '0F010003' | from_hex }}") == b"\x0f\x01\x00\x03" - - @patch.object(random, "choice") def test_random_every_time(test_choice, hass: HomeAssistant) -> None: """Ensure the random filter runs every time, not just once.""" @@ -1181,74 +1083,6 @@ def test_version(hass: HomeAssistant) -> None: render(hass, "{{ version(None) < '2099.9.10' }}") -def test_pack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: - """Test struct pack method.""" - - # render as filter - variables = {"value": 0xDEADBEEF} - assert render(hass, "{{ value | pack('>I') }}", variables) == b"\xde\xad\xbe\xef" - - # render as function - assert render(hass, "{{ pack(value, '>I') }}", variables) == b"\xde\xad\xbe\xef" - - # test with None value - # "Template warning: 'pack' unable to pack object with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information" - assert render(hass, "{{ pack(value, '>I') }}", {"value": None}) is None - assert ( - "Template warning: 'pack' unable to pack object 'None' with type 'NoneType' and" - " format_string '>I' see https://docs.python.org/3/library/struct.html for more" - " information" in caplog.text - ) - - # test with invalid filter - # "Template warning: 'pack' unable to pack object with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information" - assert render(hass, "{{ pack(value, 'invalid filter') }}", variables) is None - assert ( - "Template warning: 'pack' unable to pack object '3735928559' with type 'int'" - " and format_string 'invalid filter' see" - " https://docs.python.org/3/library/struct.html for more information" - in caplog.text - ) - - -def test_unpack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: - """Test struct unpack method.""" - - variables = {"value": b"\xde\xad\xbe\xef"} - - # render as filter - result = render(hass, """{{ value | unpack('>I') }}""", variables) - assert result == 0xDEADBEEF - - # render as function - result = render(hass, """{{ unpack(value, '>I') }}""", variables) - assert result == 0xDEADBEEF - - # unpack with offset - result = render(hass, """{{ unpack(value, '>H', offset=2) }}""", variables) - assert result == 0xBEEF - - # test with an empty bytes object - assert render(hass, """{{ unpack(value, '>I') }}""", {"value": b""}) is None - assert ( - "Template warning: 'unpack' unable to unpack object 'b''' with format_string" - " '>I' and offset 0 see https://docs.python.org/3/library/struct.html for more" - " information" in caplog.text - ) - - # test with invalid filter - assert ( - render(hass, """{{ unpack(value, 'invalid filter') }}""", {"value": b""}) - is None - ) - assert ( - "Template warning: 'unpack' unable to unpack object 'b''' with format_string" - " 'invalid filter' and offset 0 see" - " https://docs.python.org/3/library/struct.html for more information" - in caplog.text - ) - - def test_distance_function_with_1_state(hass: HomeAssistant) -> None: """Test distance function with 1 state.""" _set_up_units(hass) From c86eb38cdb282359e02020b2337d71c3f2391695 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 4 Apr 2026 11:01:19 +0200 Subject: [PATCH 0452/1707] Automate device tracker cleanup process for Fritz (#166864) --- homeassistant/components/fritz/button.py | 40 ++++++++++++++++++- homeassistant/components/fritz/coordinator.py | 5 ++- .../components/fritz/quality_scale.yaml | 4 +- homeassistant/components/fritz/strings.json | 6 +++ tests/components/fritz/test_button.py | 28 ++++++++++++- 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index af5c1b0e8699aa..25053abaca358c 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -14,11 +14,12 @@ ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles +from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzDeviceBase from .helpers import _is_tracked @@ -63,10 +64,38 @@ class FritzButtonDescription(ButtonEntityDescription): translation_key="cleanup", entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_cleanup(), + entity_registry_enabled_default=False, ), ] +def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: + """Repair issue for cleanup button.""" + entity_registry = er.async_get(hass) + + if ( + ( + entity_button := entity_registry.async_get_entity_id( + "button", DOMAIN, f"{avm_wrapper.unique_id}-cleanup" + ) + ) + and (entity_entry := entity_registry.async_get(entity_button)) + and not entity_entry.disabled + ): + # Deprecate the 'cleanup' button: create a Repairs issue for users + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id="deprecated_cleanup_button", + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_cleanup_button", + translation_placeholders={"removal_version": "2026.11.0"}, + breaks_in_ha_version="2026.11.0", + ) + + async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, @@ -82,6 +111,7 @@ async def async_setup_entry( if avm_wrapper.mesh_role == MeshRoles.SLAVE: async_add_entities(entities_list) + repair_issue_cleanup(hass, avm_wrapper) return data_fritz = hass.data[FRITZ_DATA_KEY] @@ -100,6 +130,8 @@ def async_update_avm_device() -> None: ) ) + repair_issue_cleanup(hass, avm_wrapper) + class FritzButton(ButtonEntity): """Defines a Fritz!Box base button.""" @@ -126,6 +158,12 @@ def __init__( async def async_press(self) -> None: """Triggers Fritz!Box service.""" + if self.entity_description.key == "cleanup": + _LOGGER.warning( + "The 'cleanup' button is deprecated and will be removed in Home Assistant Core 2026.11.0. " + "Please update your automations and dashboards to remove any usage of this button. " + "The action is now performed automatically at each data refresh", + ) await self.entity_description.press_action(self.avm_wrapper) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 0cc359b318acc2..f2e28e06366aca 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -332,7 +332,10 @@ async def _async_update_data(self) -> UpdateCoordinatorDataType: translation_placeholders={"error": str(ex)}, ) from ex - _LOGGER.debug("enity_data: %s", entity_data) + _LOGGER.debug("entity_data: %s", entity_data) + + await self.async_trigger_cleanup() + return entity_data @property diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 8818bc04cdb393..3eec68bea5fb18 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -56,9 +56,7 @@ rules: repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet - stale-devices: - status: todo - comment: automate the current cleanup process and deprecate the corresponding button + stale-devices: done # Platinum async-dependency: diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index c2aa92818b1ce2..73bf2cbffbfcb7 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -195,6 +195,12 @@ "message": "Error while updating the data: {error}" } }, + "issues": { + "deprecated_cleanup_button": { + "description": "The 'Cleanup' button is deprecated and will be removed in Home Assistant Core {removal_version}. Please update your automations and dashboards to remove any usage of this button. The action is now performed automatically at each data refresh.", + "title": "'Cleanup' button is deprecated" + } + }, "options": { "step": { "init": { diff --git a/tests/components/fritz/test_button.py b/tests/components/fritz/test_button.py index d2be55769e17b3..d5d5606b993c29 100644 --- a/tests/components/fritz/test_button.py +++ b/tests/components/fritz/test_button.py @@ -12,7 +12,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from .const import ( MOCK_HOST_ATTRIBUTES_DATA, @@ -54,6 +58,7 @@ async def test_button_setup( ("button.mock_title_cleanup", "async_trigger_cleanup"), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_buttons( hass: HomeAssistant, entity_id: str, @@ -201,6 +206,7 @@ async def test_wol_button_absent_for_non_lan_device( assert hass.states.get("button.printer_wake_on_lan") is None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cleanup_button( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -258,3 +264,23 @@ async def test_cleanup_button( if entity.unique_id.startswith("AA:BB:CC:00:11:22") ] assert not entities + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_cleanup_button_deprecation_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + fc_class_mock, + fh_class_mock, + fs_class_mock, +) -> None: + """Test deprecation issue is created when legacy cleanup button is enabled.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + issue_registry = ir.async_get(hass) + assert issue_registry.async_get_issue(DOMAIN, "deprecated_cleanup_button") From ef8a0a404ce39f7822691cfcb391c97aa2553b4a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Apr 2026 12:06:26 +0200 Subject: [PATCH 0453/1707] Fix lingering task in TTS stream override tests (#167356) --- tests/components/tts/test_init.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index ee7878e603a11a..3c67ac25716287 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2115,7 +2115,6 @@ async def test_stream_override( await mock_config_entry_setup(hass, mock_tts_entity) stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) - stream.async_set_message("beer") with tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as wav_file: with wave.open(wav_file, "wb") as wav_writer: @@ -2155,7 +2154,6 @@ async def test_stream_override_with_conversion( tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, }, ) - stream.async_set_message("beer") # Use a temp file here since ffmpeg will read it directly with tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as wav_file: From d1224e3f92bccce270c66ed9de243c1cff88b8c9 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 4 Apr 2026 12:09:49 +0200 Subject: [PATCH 0454/1707] Refactor Proxmox async_setup (#167328) --- homeassistant/components/proxmoxve/coordinator.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index 6231a989a215c7..4a4891d60c465f 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -22,11 +22,7 @@ CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryError, - ConfigEntryNotReady, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .common import sanitize_config_entry @@ -112,13 +108,13 @@ async def _async_setup(self) -> None: translation_placeholders={"error": repr(err)}, ) from err except ConnectTimeout as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", translation_placeholders={"error": repr(err)}, ) from err except ProxmoxServerError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_error_details", translation_placeholders={"error": repr(err)}, From f3a020780d0cf834e992f0f8eef3544bd46623ad Mon Sep 17 00:00:00 2001 From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:29:24 +0200 Subject: [PATCH 0455/1707] Bump satel_integra to 1.1.0 (#167353) --- homeassistant/components/satel_integra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index 653051e851499e..d1f707cf6ff968 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["satel_integra"], "quality_scale": "bronze", - "requirements": ["satel-integra==1.0.0"] + "requirements": ["satel-integra==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b404b04b2b14bd..04ba2eb0b2cfe0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2886,7 +2886,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.satel_integra -satel-integra==1.0.0 +satel-integra==1.1.0 # homeassistant.components.screenlogic screenlogicpy==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 660822bbae250a..aa2d485307df43 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2449,7 +2449,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.satel_integra -satel-integra==1.0.0 +satel-integra==1.1.0 # homeassistant.components.screenlogic screenlogicpy==0.10.2 From cb69e638b420314a3a9379ef76af5a40becc463a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 4 Apr 2026 13:04:56 +0200 Subject: [PATCH 0456/1707] 100% coverage of device_tracker for Vodafone Station (#165824) --- .../vodafone_station/test_device_tracker.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/components/vodafone_station/test_device_tracker.py b/tests/components/vodafone_station/test_device_tracker.py index 2c8c206551048a..7b32914744c39e 100644 --- a/tests/components/vodafone_station/test_device_tracker.py +++ b/tests/components/vodafone_station/test_device_tracker.py @@ -11,6 +11,7 @@ from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send from . import setup_integration from .const import DEVICE_1_HOST, DEVICE_1_MAC @@ -60,3 +61,29 @@ async def test_consider_home( assert (state := hass.states.get(device_tracker)) assert state.state == STATE_NOT_HOME + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_already_tracked_devices( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that already tracked devices are skipped when signal fires again.""" + await setup_integration(hass, mock_config_entry) + + # Capture the number of existing device_tracker entities before firing the signal + tracker_count_before = len(hass.states.async_all(Platform.DEVICE_TRACKER)) + assert tracker_count_before > 0 + + coordinator = mock_config_entry.runtime_data + + # Fire signal_device_new again; all devices are already tracked so no new + # entities should be added and the continue branch in async_add_new_tracked_entities + # is exercised. + async_dispatcher_send(hass, coordinator.signal_device_new) + await hass.async_block_till_done() + + # Ensure no additional device_tracker entities were created + tracker_count_after = len(hass.states.async_all(Platform.DEVICE_TRACKER)) + assert tracker_count_after == tracker_count_before From 8b7d1f60a06c257fe84d3b9f66f17b72953ed125 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Apr 2026 01:55:47 -1000 Subject: [PATCH 0457/1707] Bump dbus-fast to 4.0.4 (#167135) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e1ffd3c822b9f9..bade33eb36139a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==2.1.0", "bluetooth-auto-recovery==1.5.3", "bluetooth-data-tools==1.28.4", - "dbus-fast==3.1.2", + "dbus-fast==4.0.4", "habluetooth==6.0.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f407965aecd0ec..f9322d10994215 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 cryptography==46.0.5 -dbus-fast==3.1.2 +dbus-fast==4.0.4 file-read-backwards==2.0.0 fnv-hash-fast==2.0.0 go2rtc-client==0.4.0 diff --git a/requirements_all.txt b/requirements_all.txt index 04ba2eb0b2cfe0..caab351c263bf5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -781,7 +781,7 @@ datadog==0.52.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==3.1.2 +dbus-fast==4.0.4 # homeassistant.components.debugpy debugpy==1.8.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa2d485307df43..d8836d0946f9a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ datadog==0.52.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==3.1.2 +dbus-fast==4.0.4 # homeassistant.components.debugpy debugpy==1.8.17 From 4232db480a83ba3b64ac772731343c5f285b71f4 Mon Sep 17 00:00:00 2001 From: 007hacky007 <007hacky007@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:55:36 +0200 Subject: [PATCH 0458/1707] Bump afsapi to 0.3.1 (#167321) --- homeassistant/components/frontier_silicon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index baa684786bfb14..2a3fc0255e6568 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["afsapi==0.2.7"], + "requirements": ["afsapi==0.3.1"], "ssdp": [ { "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" diff --git a/requirements_all.txt b/requirements_all.txt index caab351c263bf5..e1955ac732c1f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -151,7 +151,7 @@ adguardhome==0.8.1 advantage-air==0.4.4 # homeassistant.components.frontier_silicon -afsapi==0.2.7 +afsapi==0.3.1 # homeassistant.components.agent_dvr agent-py==0.0.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8836d0946f9a3..6c994216a9834e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -142,7 +142,7 @@ adguardhome==0.8.1 advantage-air==0.4.4 # homeassistant.components.frontier_silicon -afsapi==0.2.7 +afsapi==0.3.1 # homeassistant.components.agent_dvr agent-py==0.0.24 From d798aad2d7a9371e32870eb54f79cd92467374a2 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 4 Apr 2026 15:16:39 +0200 Subject: [PATCH 0459/1707] Add kill button to Portainer (#167277) Co-authored-by: Joostlek --- homeassistant/components/portainer/button.py | 10 + homeassistant/components/portainer/icons.json | 3 + .../components/portainer/strings.json | 3 + .../portainer/snapshots/test_button.ambr | 300 ++++++++++++++++++ 4 files changed, 316 insertions(+) diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index 31e0cbaf16f582..c12c67bf81633d 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -102,6 +102,16 @@ class PortainerButtonDescription(ButtonEntityDescription): ) ), ), + PortainerButtonDescription( + key="kill", + translation_key="kill_container", + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, container_id: portainer.kill_container( + endpoint_id, container_id + ) + ), + ), ) diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index f74e8d4e4eb5c4..db152c8c8ed977 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -1,6 +1,9 @@ { "entity": { "button": { + "kill_container": { + "default": "mdi:cog-stop" + }, "pause_container": { "default": "mdi:pause-circle" }, diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 83decd83e165fc..d1d2d99839f671 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -66,6 +66,9 @@ "images_prune": { "name": "Prune unused images" }, + "kill_container": { + "name": "Kill container" + }, "pause_container": { "name": "Pause container" }, diff --git a/tests/components/portainer/snapshots/test_button.ambr b/tests/components/portainer/snapshots/test_button.ambr index 983e831136ad73..adb4188961e5dd 100644 --- a/tests/components/portainer/snapshots/test_button.ambr +++ b/tests/components/portainer/snapshots/test_button.ambr @@ -1,4 +1,54 @@ # serializer version: 1 +# name: test_all_button_entities_snapshot[button.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_kill_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_kill_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Kill container', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kill container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kill_container', + 'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_kill', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_kill_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Kill container', + }), + 'context': , + 'entity_id': 'button.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_kill_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities_snapshot[button.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_pause_container-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -150,6 +200,56 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities_snapshot[button.focused_einstein_kill_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.focused_einstein_kill_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Kill container', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kill container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kill_container', + 'unique_id': 'portainer_test_entry_123_focused_einstein_kill', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.focused_einstein_kill_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'focused_einstein Kill container', + }), + 'context': , + 'entity_id': 'button.focused_einstein_kill_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities_snapshot[button.focused_einstein_pause_container-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -301,6 +401,56 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities_snapshot[button.funny_chatelet_kill_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.funny_chatelet_kill_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Kill container', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kill container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kill_container', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_kill', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.funny_chatelet_kill_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'funny_chatelet Kill container', + }), + 'context': , + 'entity_id': 'button.funny_chatelet_kill_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities_snapshot[button.funny_chatelet_pause_container-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -553,6 +703,56 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities_snapshot[button.practical_morse_kill_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.practical_morse_kill_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Kill container', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kill container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kill_container', + 'unique_id': 'portainer_test_entry_123_practical_morse_kill', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.practical_morse_kill_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'practical_morse Kill container', + }), + 'context': , + 'entity_id': 'button.practical_morse_kill_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities_snapshot[button.practical_morse_pause_container-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -704,6 +904,56 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities_snapshot[button.serene_banach_kill_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.serene_banach_kill_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Kill container', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kill container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kill_container', + 'unique_id': 'portainer_test_entry_123_serene_banach_kill', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.serene_banach_kill_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'serene_banach Kill container', + }), + 'context': , + 'entity_id': 'button.serene_banach_kill_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities_snapshot[button.serene_banach_pause_container-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -855,6 +1105,56 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities_snapshot[button.stoic_turing_kill_container-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.stoic_turing_kill_container', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Kill container', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kill container', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kill_container', + 'unique_id': 'portainer_test_entry_123_stoic_turing_kill', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.stoic_turing_kill_container-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'stoic_turing Kill container', + }), + 'context': , + 'entity_id': 'button.stoic_turing_kill_container', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities_snapshot[button.stoic_turing_pause_container-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 43210b845b0a6e95a1d506282c5a2646d4a307bd Mon Sep 17 00:00:00 2001 From: Niracler Date: Sat, 4 Apr 2026 21:35:25 +0800 Subject: [PATCH 0460/1707] Mark icon-translations as exempt in sunricher_dali (#166857) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/sunricher_dali/quality_scale.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sunricher_dali/quality_scale.yaml b/homeassistant/components/sunricher_dali/quality_scale.yaml index 2ee5dd92a14cc0..3e05845ad6bbc9 100644 --- a/homeassistant/components/sunricher_dali/quality_scale.yaml +++ b/homeassistant/components/sunricher_dali/quality_scale.yaml @@ -66,7 +66,12 @@ rules: comment: No noisy or non-essential entities to disable. entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: + status: exempt + comment: | + No entities define custom icons (no icon/_attr_icon); icons are provided + by the entity platforms via their defaults and device classes where + applicable. reconfiguration-flow: todo repair-issues: todo stale-devices: todo From 5632d308dd80983dfb539dc7c1441d88efc27d65 Mon Sep 17 00:00:00 2001 From: Matt Philips Date: Sat, 4 Apr 2026 09:50:35 -0400 Subject: [PATCH 0461/1707] Improve handling of disconnected meters with Rainforest Automation Eagle-200 (#161185) Co-authored-by: Joostlek --- .../rainforest_eagle/config_flow.py | 40 ++- .../components/rainforest_eagle/data.py | 13 +- .../components/rainforest_eagle/strings.json | 5 +- .../rainforest_eagle/test_config_flow.py | 257 +++++++++++++++++- 4 files changed, 291 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/config_flow.py b/homeassistant/components/rainforest_eagle/config_flow.py index 867bc5886dbf99..b7ac70527dcea4 100644 --- a/homeassistant/components/rainforest_eagle/config_flow.py +++ b/homeassistant/components/rainforest_eagle/config_flow.py @@ -10,7 +10,14 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE -from .const import CONF_CLOUD_ID, CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN +from .const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_100, + TYPE_EAGLE_200, +) from .data import CannotConnect, InvalidAuth, async_get_type _LOGGER = logging.getLogger(__name__) @@ -63,11 +70,32 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - user_input[CONF_TYPE] = eagle_type - user_input[CONF_HARDWARE_ADDRESS] = hardware_address - return self.async_create_entry( - title=user_input[CONF_CLOUD_ID], data=user_input - ) + # Verify it is a known device, first + if not eagle_type: + errors["base"] = "unknown_device_type" + elif eagle_type == TYPE_EAGLE_100: + user_input[CONF_TYPE] = eagle_type + + # For EAGLE-100, there is no hardware address to select, so set it to None and move on + user_input[CONF_HARDWARE_ADDRESS] = None + elif eagle_type == TYPE_EAGLE_200: + user_input[CONF_TYPE] = eagle_type + + # For EAGLE-200, a connected meter's hardware address is required to create the entry + if not hardware_address: + # hardware_address will be None if there are no meters at all or if none are currently Connected + errors["base"] = "no_meters_connected" + else: + user_input[CONF_HARDWARE_ADDRESS] = hardware_address + else: + # This is a device that isn't supported, yet, but was detected by async_get_type + errors["base"] = "unsupported_device_type" + + # All information gathering is done, so if there are no errors at this point, create the entry + if not errors: + return self.async_create_entry( + title=user_input[CONF_CLOUD_ID], data=user_input + ) return self.async_show_form( step_id="user", data_schema=create_schema(user_input), errors=errors diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index 01f373f3178c6c..adf135d53f5983 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -34,7 +34,7 @@ class InvalidAuth(RainforestError): async def async_get_type(hass, cloud_id, install_code, host): """Try API call 'get_network_info' to see if target device is Eagle-100 or Eagle-200.""" - # For EAGLE-200, fetch the hardware address of the meter too. + # For EAGLE-200, fetch the hardware address of the first connected meter, too. hub = aioeagle.EagleHub( aiohttp_client.async_get_clientsession(hass), cloud_id, install_code, host=host ) @@ -50,8 +50,17 @@ async def async_get_type(hass, cloud_id, install_code, host): if meters is not None: if meters: - hardware_address = meters[0].hardware_address + # If there is at least one meter, use the first one with a connection status of "Connected" + hardware_address = next( + ( + m.hardware_address + for m in meters + if getattr(m, "connection_status", None) == "Connected" + ), + None, + ) else: + # If there are no meters (empty list, since None was already checked for), set the hardware address to None hardware_address = None return TYPE_EAGLE_200, hardware_address diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index a874770baa9a84..b3eed05110c60f 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,7 +6,10 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "no_meters_connected": "No meters are currently connected. Ensure your meter is connected and try again.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_device_type": "Unable to determine the type of Rainforest Eagle device. Please ensure your device is supported.", + "unsupported_device_type": "This type of Rainforest Eagle device is not supported." }, "step": { "user": { diff --git a/tests/components/rainforest_eagle/test_config_flow.py b/tests/components/rainforest_eagle/test_config_flow.py index 0d3b477b3d5c95..adf705e3925967 100644 --- a/tests/components/rainforest_eagle/test_config_flow.py +++ b/tests/components/rainforest_eagle/test_config_flow.py @@ -8,6 +8,7 @@ CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN, + TYPE_EAGLE_100, TYPE_EAGLE_200, ) from homeassistant.components.rainforest_eagle.data import CannotConnect, InvalidAuth @@ -16,8 +17,8 @@ from homeassistant.data_entry_flow import FlowResultType -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_form_multiple_meters_first_connected(hass: HomeAssistant) -> None: + """Test proper flow with an EAGLE-200 with a list of meters, one of which is connected (should auto-select it).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -25,17 +26,29 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] is None + # Simulate multiple meters with one connected + class MockElectricMeter: + def __init__(self, hardware_address, connection_status) -> None: + self.hardware_address = hardware_address + self.connection_status = connection_status + + meters = [ + MockElectricMeter("meter-1", "Not Joined"), + MockElectricMeter("meter-2", "Connected"), + MockElectricMeter("meter-3", "Not Joined"), + ] + with ( patch( - "homeassistant.components.rainforest_eagle.config_flow.async_get_type", - return_value=(TYPE_EAGLE_200, "mock-hw"), + "aioeagle.EagleHub.get_device_list", + return_value=meters, ), patch( "homeassistant.components.rainforest_eagle.async_setup_entry", return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CLOUD_ID: "abcdef", @@ -45,18 +58,232 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "abcdef" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "abcdef" + assert result["data"] == { CONF_TYPE: TYPE_EAGLE_200, CONF_HOST: "192.168.1.55", CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456", - CONF_HARDWARE_ADDRESS: "mock-hw", + CONF_HARDWARE_ADDRESS: "meter-2", } + assert result["result"].unique_id == "abcdef" assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_eagle_200_meters_none_connected(hass: HomeAssistant) -> None: + """Test proper flow with an EAGLE-200 with a list of meters, but all are disconnected (Error should be shown).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Simulate all meters being disconnected + class MockElectricMeter: + def __init__(self, hardware_address, connection_status) -> None: + self.hardware_address = hardware_address + self.connection_status = connection_status + + meters = [ + MockElectricMeter("meter-1", "Not Joined"), + MockElectricMeter("meter-2", "Not Joined"), + MockElectricMeter("meter-3", "Not Joined"), + ] + + with patch( + "aioeagle.EagleHub.get_device_list", + return_value=meters, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_meters_connected"} + + +async def test_form_eagle_200_no_meters(hass: HomeAssistant) -> None: + """Test proper flow with an EAGLE-200 with an empty list of meters (Error should be shown).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Simulate no meters (empty list) + with ( + patch( + "aioeagle.EagleHub.get_device_list", + return_value=[], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_meters_connected"} + + +async def test_form_eagle_100(hass: HomeAssistant) -> None: + """Test proper flow for EAGLE-100 (KeyError from get_device_list, then legacy response).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Patch get_device_list to raise KeyError (expected from EAGLE-100), and async_add_executor_job to return proper EAGLE-100 response + eagle_100_response = {"NetworkInfo": {"ModelId": "Z109-EAGLE"}} + + with ( + patch( + "aioeagle.EagleHub.get_device_list", + side_effect=KeyError, + ), + patch( + "eagle100.Eagle.get_network_info", + return_value=eagle_100_response, + ), + patch( + "homeassistant.core.HomeAssistant.async_add_executor_job", + return_value=eagle_100_response, + ), + patch( + "homeassistant.components.rainforest_eagle.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "abcdef" + assert result["data"] == { + CONF_TYPE: TYPE_EAGLE_100, + CONF_HOST: "192.168.1.55", + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HARDWARE_ADDRESS: None, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown_device_type(hass: HomeAssistant) -> None: + """Test flow when device type cannot be determined (get_device_list raises an error but other responses aren't the expected values).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Patch get_device_list to raise KeyError (expected from EAGLE-100), and async_add_executor_job to return an unknown device response + unknown_device_response = {"NetworkInfo": {"ModelId": "UNKNOWN-DEVICE"}} + + with ( + patch( + "aioeagle.EagleHub.get_device_list", + side_effect=KeyError, + ), + patch( + "eagle100.Eagle.get_network_info", + return_value=unknown_device_response, + ), + patch( + "homeassistant.core.HomeAssistant.async_add_executor_job", + return_value=unknown_device_response, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown_device_type"} + + +async def test_form_unsupported_device_type(hass: HomeAssistant) -> None: + """Test flow when device type is unsupported (async_get_type returns an unexpected device type).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.rainforest_eagle.config_flow.async_get_type", + return_value=("UNSUPPORTED_DEVICE_TYPE", None), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unsupported_device_type"} + + +async def test_form_unexpected_exception(hass: HomeAssistant) -> None: + """Test flow when an unexpected exception occurs.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.rainforest_eagle.config_flow.async_get_type", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -67,7 +294,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "aioeagle.EagleHub.get_device_list", side_effect=InvalidAuth, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CLOUD_ID: "abcdef", @@ -76,8 +303,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -90,7 +317,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "aioeagle.EagleHub.get_device_list", side_effect=CannotConnect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CLOUD_ID: "abcdef", @@ -99,5 +326,5 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} From 5ef506623d8af24cb9eafe064fc82a78ef28487f Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:07:45 +0200 Subject: [PATCH 0462/1707] Change attribute that is used as unique ID for lunatone (#165200) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/lunatone/__init__.py | 57 ++++++++++++++++++- .../components/lunatone/config_flow.py | 7 ++- homeassistant/components/lunatone/light.py | 26 +++++---- .../components/lunatone/strings.json | 3 +- tests/components/lunatone/__init__.py | 43 ++++++++++++-- tests/components/lunatone/conftest.py | 27 ++++++--- .../lunatone/snapshots/test_diagnostics.ambr | 2 +- .../lunatone/snapshots/test_light.ambr | 8 +-- tests/components/lunatone/test_config_flow.py | 14 +++-- tests/components/lunatone/test_init.py | 57 ++++++++++++++++++- 10 files changed, 202 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py index 2e280168a86acd..c650fca92eca59 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -1,5 +1,6 @@ """The Lunatone integration.""" +import logging from typing import Final from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info @@ -7,9 +8,10 @@ from homeassistant.const import CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .config_flow import LunatoneConfigFlow from .const import DOMAIN, MANUFACTURER from .coordinator import ( LunatoneConfigEntry, @@ -18,9 +20,51 @@ LunatoneInfoDataUpdateCoordinator, ) +_LOGGER = logging.getLogger(__name__) PLATFORMS: Final[list[Platform]] = [Platform.LIGHT] +async def _update_unique_id( + hass: HomeAssistant, entry: LunatoneConfigEntry, new_unique_id: str +) -> None: + _LOGGER.debug("Update unique ID") + + # Update all associated entities + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + for entity in entities: + parts = list(entity.unique_id.partition("-")) + parts[0] = new_unique_id + + entity_registry.async_update_entity( + entity.entity_id, new_unique_id="".join(parts) + ) + + # Update all associated devices + device_registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + + for device in devices: + identifier = device.identifiers.pop() + parts = list(identifier[1].partition("-")) + parts[0] = new_unique_id + + device_registry.async_update_device( + device.id, new_identifiers={(identifier[0], "".join(parts))} + ) + + # Update the config entry itself + hass.config_entries.async_update_entry( + entry, + unique_id=new_unique_id, + minor_version=LunatoneConfigFlow.MINOR_VERSION, + version=LunatoneConfigFlow.VERSION, + ) + + _LOGGER.debug("Update of unique ID successful") + + async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool: """Set up Lunatone from a config entry.""" auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL]) @@ -30,15 +74,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api) await coordinator_info.async_config_entry_first_refresh() - if info_api.serial_number is None: + if info_api.data is None or info_api.serial_number is None: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="missing_device_info" ) + if info_api.uid is not None: + new_unique_id = info_api.uid.replace("-", "") + if new_unique_id != entry.unique_id: + await _update_unique_id(hass, entry, new_unique_id) + + assert entry.unique_id + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, str(info_api.serial_number))}, + identifiers={(DOMAIN, entry.unique_id)}, name=info_api.name, manufacturer=MANUFACTURER, sw_version=info_api.version, diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py index b5004ffdce4af7..bb48361299e7d6 100644 --- a/homeassistant/components/lunatone/config_flow.py +++ b/homeassistant/components/lunatone/config_flow.py @@ -52,14 +52,17 @@ async def async_step_user( if info_api.serial_number is None: errors["base"] = "missing_device_info" else: - await self.async_set_unique_id(str(info_api.serial_number)) + unique_id = str(info_api.serial_number) + if info_api.uid is not None: + unique_id = info_api.uid.replace("-", "") + await self.async_set_unique_id(unique_id) if self.source == SOURCE_RECONFIGURE: self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( self._get_reconfigure_entry(), data_updates=data, title=url ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=url, data={CONF_URL: url}) + return self.async_create_entry(title=url, data=data) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index a733fd6588b0aa..72243ae713a095 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -41,17 +41,20 @@ async def async_setup_entry( coordinator_devices = config_entry.runtime_data.coordinator_devices dali_line_broadcasts = config_entry.runtime_data.dali_line_broadcasts + assert config_entry.unique_id is not None + entities: list[LightEntity] = [ LunatoneLineBroadcastLight( - coordinator_info, coordinator_devices, dali_line_broadcast + coordinator_info, + coordinator_devices, + dali_line_broadcast, + config_entry.unique_id, ) for dali_line_broadcast in dali_line_broadcasts ] entities.extend( [ - LunatoneLight( - coordinator_devices, device_id, coordinator_info.data.device.serial - ) + LunatoneLight(coordinator_devices, device_id, config_entry.unique_id) for device_id in coordinator_devices.data ] ) @@ -76,14 +79,14 @@ def __init__( self, coordinator: LunatoneDevicesDataUpdateCoordinator, device_id: int, - interface_serial_number: int, + config_entry_unique_id: str, ) -> None: """Initialize a Lunatone light.""" super().__init__(coordinator) self._device_id = device_id - self._interface_serial_number = interface_serial_number - self._device = self.coordinator.data[self._device_id] - self._attr_unique_id = f"{interface_serial_number}-device{device_id}" + self._config_entry_unique_id = config_entry_unique_id + self._device = self.coordinator.data[device_id] + self._attr_unique_id = f"{config_entry_unique_id}-device{device_id}" @property def device_info(self) -> DeviceInfo: @@ -94,7 +97,7 @@ def device_info(self) -> DeviceInfo: name=self._device.name, via_device=( DOMAIN, - f"{self._interface_serial_number}-line{self._device.data.line}", + f"{self._config_entry_unique_id}-line{self._device.data.line}", ), ) @@ -179,6 +182,7 @@ def __init__( coordinator_info: LunatoneInfoDataUpdateCoordinator, coordinator_devices: LunatoneDevicesDataUpdateCoordinator, broadcast: DALIBroadcast, + config_entry_unique_id: str, ) -> None: """Initialize a Lunatone line broadcast light.""" super().__init__(coordinator_info) @@ -187,7 +191,7 @@ def __init__( line = broadcast.line - self._attr_unique_id = f"{coordinator_info.data.device.serial}-line{line}" + self._attr_unique_id = f"{config_entry_unique_id}-line{line}" line_device = self.coordinator.data.lines[str(line)].device extra_info: dict = {} @@ -202,7 +206,7 @@ def __init__( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, name=f"DALI Line {line}", - via_device=(DOMAIN, str(coordinator_info.data.device.serial)), + via_device=(DOMAIN, config_entry_unique_id), **extra_info, ) diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json index 1ba52be8e54a3d..438d67782fb4cc 100644 --- a/homeassistant/components/lunatone/strings.json +++ b/homeassistant/components/lunatone/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index 0ddee686c94991..12fd58b2a80abb 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -21,11 +21,12 @@ BASE_URL: Final = "http://10.0.0.131" PRODUCT_NAME: Final = "Test Product" SERIAL_NUMBER: Final = 12345 +UUID: Final = "be37ca9c-47c2-4498-a38b-c62c7c711840" VERSION: Final = "v1.14.1/1.4.3" DEVICE_INFO_DATA: Final[DeviceInfoData] = DeviceInfoData( - serial=SERIAL_NUMBER, + serial=12345, gtin=192837465, pcb="2a", articleNumber=87654321, @@ -35,6 +36,38 @@ INFO_DATA: Final[InfoData] = InfoData( name="Test", version=VERSION, + uid=UUID, + device=DEVICE_INFO_DATA, + lines={ + "0": DALIBusData( + sendBlockedInitialize=False, + sendBlockedQuiescent=False, + sendBlockedMacroRunning=False, + sendBufferFull=False, + lineStatus=LineStatus.OK, + device=DEVICE_INFO_DATA, + ), + "1": DALIBusData( + sendBlockedInitialize=False, + sendBlockedQuiescent=False, + sendBlockedMacroRunning=False, + sendBufferFull=False, + lineStatus=LineStatus.OK, + device=DeviceInfoData( + serial=54321, + gtin=101010101, + pcb="1a", + articleNumber=12345678, + productionYear=22, + productionWeek=10, + ), + ), + }, +) +LEGACY_INFO_DATA: Final[InfoData] = InfoData( + name="Test", + version=VERSION, + uid=None, device=DEVICE_INFO_DATA, lines={ "0": DALIBusData( @@ -96,10 +129,8 @@ def build_device_data_list() -> list[DeviceData]: ] -async def setup_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Set up the Lunatone integration for testing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py index 2469633c17a566..8d279bcb80a85c 100644 --- a/tests/components/lunatone/conftest.py +++ b/tests/components/lunatone/conftest.py @@ -3,13 +3,15 @@ from collections.abc import Generator from unittest.mock import AsyncMock, PropertyMock, patch -from lunatone_rest_api_client import Device, Devices +from lunatone_rest_api_client import Device, Devices, Info +from lunatone_rest_api_client.models import InfoData import pytest +from homeassistant.components.lunatone.config_flow import LunatoneConfigFlow from homeassistant.components.lunatone.const import DOMAIN from homeassistant.const import CONF_URL -from . import BASE_URL, INFO_DATA, PRODUCT_NAME, SERIAL_NUMBER, build_devices_data +from . import BASE_URL, INFO_DATA, PRODUCT_NAME, UUID, build_devices_data from tests.common import MockConfigEntry @@ -71,11 +73,18 @@ def mock_lunatone_info() -> Generator[AsyncMock]: ), ): info = mock_info.return_value - info.data = INFO_DATA - info.name = info.data.name - info.version = info.data.version - info.serial_number = info.data.device.serial - info.product_name = PRODUCT_NAME + + def _set_data(data: InfoData) -> Info: + info.data = data + info.name = info.data.name + info.product_name = PRODUCT_NAME + info.serial_number = info.data.device.serial + info.uid = info.data.uid + info.version = info.data.version + return info + + info.set_data = _set_data + info.set_data(INFO_DATA) yield info @@ -98,5 +107,7 @@ def mock_config_entry() -> MockConfigEntry: title=BASE_URL, domain=DOMAIN, data={CONF_URL: BASE_URL}, - unique_id=str(SERIAL_NUMBER), + unique_id=UUID.replace("-", ""), + version=LunatoneConfigFlow.VERSION, + minor_version=LunatoneConfigFlow.MINOR_VERSION, ) diff --git a/tests/components/lunatone/snapshots/test_diagnostics.ambr b/tests/components/lunatone/snapshots/test_diagnostics.ambr index 3298ba110766a0..ca291b9726fc1c 100644 --- a/tests/components/lunatone/snapshots/test_diagnostics.ambr +++ b/tests/components/lunatone/snapshots/test_diagnostics.ambr @@ -178,7 +178,7 @@ 'node_red': False, 'startup_mode': 'normal', 'tier': 'basic', - 'uid': None, + 'uid': 'be37ca9c-47c2-4498-a38b-c62c7c711840', 'version': 'v1.14.1/1.4.3', }), }) diff --git a/tests/components/lunatone/snapshots/test_light.ambr b/tests/components/lunatone/snapshots/test_light.ambr index 45dc12c0e5fc80..a429bbf1de66ae 100644 --- a/tests/components/lunatone/snapshots/test_light.ambr +++ b/tests/components/lunatone/snapshots/test_light.ambr @@ -36,7 +36,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '12345-line0', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-line0', 'unit_of_measurement': None, }) # --- @@ -97,7 +97,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '12345-line1', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-line1', 'unit_of_measurement': None, }) # --- @@ -158,7 +158,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '12345-device1', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device1', 'unit_of_measurement': None, }) # --- @@ -217,7 +217,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '12345-device2', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device2', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lunatone/test_config_flow.py b/tests/components/lunatone/test_config_flow.py index d3fbb684f54207..f48c5179bf8868 100644 --- a/tests/components/lunatone/test_config_flow.py +++ b/tests/components/lunatone/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock import aiohttp +from lunatone_rest_api_client.models import InfoData import pytest from homeassistant.components.lunatone.const import DOMAIN @@ -11,15 +12,21 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import BASE_URL +from . import BASE_URL, INFO_DATA, LEGACY_INFO_DATA from tests.common import MockConfigEntry +@pytest.mark.parametrize(("info_data"), [INFO_DATA, LEGACY_INFO_DATA]) async def test_full_flow( - hass: HomeAssistant, mock_lunatone_info: AsyncMock, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_setup_entry: AsyncMock, + info_data: InfoData, ) -> None: """Test full user flow.""" + mock_lunatone_info.set_data(info_data) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -37,8 +44,7 @@ async def test_full_flow( async def test_full_flow_fail_because_of_missing_device_infos( - hass: HomeAssistant, - mock_lunatone_info: AsyncMock, + hass: HomeAssistant, mock_lunatone_info: AsyncMock ) -> None: """Test full flow.""" mock_lunatone_info.serial_number = None diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py index be57b5802eec96..b3073feca091be 100644 --- a/tests/components/lunatone/test_init.py +++ b/tests/components/lunatone/test_init.py @@ -6,10 +6,11 @@ from homeassistant.components.lunatone.const import DOMAIN, MANUFACTURER from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_URL 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 BASE_URL, PRODUCT_NAME, VERSION, setup_integration +from . import BASE_URL, PRODUCT_NAME, SERIAL_NUMBER, UUID, VERSION, setup_integration from tests.common import MockConfigEntry @@ -133,3 +134,55 @@ async def test_config_entry_not_ready_no_serial_number( mock_lunatone_info.async_update.assert_called_once() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_config_entry_unique_id_update( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the Lunatone config entry migration to be successful.""" + config_entry = MockConfigEntry( + title=BASE_URL, + domain=DOMAIN, + data={CONF_URL: BASE_URL}, + unique_id=str(SERIAL_NUMBER), + ) + + expected_unique_id = str(SERIAL_NUMBER) + mock_lunatone_info.uid = None + + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == expected_unique_id + + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + for device in devices: + for identifier in device.identifiers: + assert identifier[1].startswith(expected_unique_id) + + entities = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + for entity in entities: + assert entity.unique_id.startswith(expected_unique_id) + + expected_unique_id = UUID.replace("-", "") + mock_lunatone_info.uid = UUID + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == expected_unique_id + + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + for device in devices: + for identifier in device.identifiers: + assert identifier[1].startswith(expected_unique_id) + + entities = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + for entity in entities: + assert entity.unique_id.startswith(expected_unique_id) From dc45f86beb150d6cd459e985144e1865d2625aee Mon Sep 17 00:00:00 2001 From: Jan Weltmeyer <1668465+weltmeyer@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:43:45 +0200 Subject: [PATCH 0463/1707] Add battery and supply voltage sensors to traccar_server (#167247) Co-authored-by: Joostlek --- .../components/traccar_server/sensor.py | 28 ++++++++- .../components/traccar_server/strings.json | 6 ++ .../traccar_server/fixtures/positions.json | 4 +- .../snapshots/test_diagnostics.ambr | 58 +++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/traccar_server/sensor.py b/homeassistant/components/traccar_server/sensor.py index 09d85520dfb309..84132c9c17f761 100644 --- a/homeassistant/components/traccar_server/sensor.py +++ b/homeassistant/components/traccar_server/sensor.py @@ -14,7 +14,13 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfSpeed +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricPotential, + UnitOfLength, + UnitOfSpeed, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -45,6 +51,26 @@ class TraccarServerSensorEntityDescription[_T](SensorEntityDescription): suggested_display_precision=0, value_fn=lambda x: x["attributes"].get("batteryLevel"), ), + TraccarServerSensorEntityDescription[PositionModel]( + key="attributes.power", + data_key="position", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda x: x["attributes"].get("power"), + translation_key="power", + ), + TraccarServerSensorEntityDescription[PositionModel]( + key="attributes.battery", + data_key="position", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda x: x["attributes"].get("battery"), + translation_key="battery", + ), TraccarServerSensorEntityDescription[PositionModel]( key="speed", data_key="position", diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index c3571d251b5612..514636e105c4d9 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -55,8 +55,14 @@ "altitude": { "name": "Altitude" }, + "battery": { + "name": "Battery voltage" + }, "geofence": { "name": "Geofence" + }, + "power": { + "name": "Supply voltage" } } }, diff --git a/tests/components/traccar_server/fixtures/positions.json b/tests/components/traccar_server/fixtures/positions.json index 7f661a7092aa75..5476126b1477f7 100644 --- a/tests/components/traccar_server/fixtures/positions.json +++ b/tests/components/traccar_server/fixtures/positions.json @@ -19,7 +19,9 @@ "geofenceIds": [0], "attributes": { "custom_attr_1": "custom_attr_1_value", - "batteryLevel": 15.00000867601 + "batteryLevel": 15.00000867601, + "battery": 4.08, + "power": 12.56 } } ] diff --git a/tests/components/traccar_server/snapshots/test_diagnostics.ambr b/tests/components/traccar_server/snapshots/test_diagnostics.ambr index 40b4bb4bb0865e..7d97448c5d1eff 100644 --- a/tests/components/traccar_server/snapshots/test_diagnostics.ambr +++ b/tests/components/traccar_server/snapshots/test_diagnostics.ambr @@ -47,8 +47,10 @@ 'address': '**REDACTED**', 'altitude': 546841384638, 'attributes': dict({ + 'battery': 4.08, 'batteryLevel': 15.00000867601, 'custom_attr_1': 'custom_attr_1_value', + 'power': 12.56, }), 'course': 360, 'deviceId': 0, @@ -153,6 +155,20 @@ }), 'unit_of_measurement': '%', }), + dict({ + 'disabled': False, + 'entity_id': 'sensor.x_wing_battery_voltage', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'voltage', + 'friendly_name': 'X-Wing Battery voltage', + 'state_class': 'measurement', + 'unit_of_measurement': 'V', + }), + 'state': '4.08', + }), + 'unit_of_measurement': 'V', + }), dict({ 'disabled': False, 'entity_id': 'sensor.x_wing_geofence', @@ -178,6 +194,20 @@ }), 'unit_of_measurement': 'kn', }), + dict({ + 'disabled': False, + 'entity_id': 'sensor.x_wing_supply_voltage', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'voltage', + 'friendly_name': 'X-Wing Supply voltage', + 'state_class': 'measurement', + 'unit_of_measurement': 'V', + }), + 'state': '12.56', + }), + 'unit_of_measurement': 'V', + }), ]), 'subscription_status': 'disconnected', }) @@ -230,8 +260,10 @@ 'address': '**REDACTED**', 'altitude': 546841384638, 'attributes': dict({ + 'battery': 4.08, 'batteryLevel': 15.00000867601, 'custom_attr_1': 'custom_attr_1_value', + 'power': 12.56, }), 'course': 360, 'deviceId': 0, @@ -290,6 +322,12 @@ 'state': None, 'unit_of_measurement': '%', }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_battery_voltage', + 'state': None, + 'unit_of_measurement': 'V', + }), dict({ 'disabled': True, 'entity_id': 'sensor.x_wing_geofence', @@ -302,6 +340,12 @@ 'state': None, 'unit_of_measurement': 'kn', }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_supply_voltage', + 'state': None, + 'unit_of_measurement': 'V', + }), ]), 'subscription_status': 'disconnected', }) @@ -354,8 +398,10 @@ 'address': '**REDACTED**', 'altitude': 546841384638, 'attributes': dict({ + 'battery': 4.08, 'batteryLevel': 15.00000867601, 'custom_attr_1': 'custom_attr_1_value', + 'power': 12.56, }), 'course': 360, 'deviceId': 0, @@ -429,6 +475,12 @@ 'state': None, 'unit_of_measurement': '%', }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_battery_voltage', + 'state': None, + 'unit_of_measurement': 'V', + }), dict({ 'disabled': True, 'entity_id': 'sensor.x_wing_geofence', @@ -441,6 +493,12 @@ 'state': None, 'unit_of_measurement': 'kn', }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_supply_voltage', + 'state': None, + 'unit_of_measurement': 'V', + }), ]), 'subscription_status': 'disconnected', }) From 5c890a8a2b8d8ba29c47652781b142f02bd84c3e Mon Sep 17 00:00:00 2001 From: Niracler Date: Sat, 4 Apr 2026 22:43:56 +0800 Subject: [PATCH 0464/1707] Use exception translations in sunricher_dali (#166858) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sunricher_dali/__init__.py | 7 +++++-- .../components/sunricher_dali/quality_scale.yaml | 2 +- homeassistant/components/sunricher_dali/strings.json | 8 ++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index dfb49e414b6aa1..56b85bafb98855 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -84,7 +84,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) - await gateway.connect() except DaliGatewayError as exc: raise ConfigEntryNotReady( - "You can try to delete the gateway and add it again" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": entry.data[CONF_HOST]}, ) from exc try: @@ -94,7 +96,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) - ) except DaliGatewayError as exc: raise ConfigEntryNotReady( - "Unable to discover devices from the gateway" + translation_domain=DOMAIN, + translation_key="cannot_discover_devices", ) from exc _LOGGER.debug("Discovered %d devices on gateway %s", len(devices), gw_sn) diff --git a/homeassistant/components/sunricher_dali/quality_scale.yaml b/homeassistant/components/sunricher_dali/quality_scale.yaml index 3e05845ad6bbc9..33615ffd869a38 100644 --- a/homeassistant/components/sunricher_dali/quality_scale.yaml +++ b/homeassistant/components/sunricher_dali/quality_scale.yaml @@ -65,7 +65,7 @@ rules: status: exempt comment: No noisy or non-essential entities to disable. entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: | diff --git a/homeassistant/components/sunricher_dali/strings.json b/homeassistant/components/sunricher_dali/strings.json index 5a2eccf42b2268..64fbe7ad1aac07 100644 --- a/homeassistant/components/sunricher_dali/strings.json +++ b/homeassistant/components/sunricher_dali/strings.json @@ -23,5 +23,13 @@ "description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your Sunricher DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press." } } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to the gateway at {host}. Please check that the device is powered on and reachable" + }, + "cannot_discover_devices": { + "message": "Unable to discover devices and scenes from the gateway." + } } } From a5a8cca4249d5ba516bbb8eae0c4e6cd57dc4016 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 4 Apr 2026 20:59:34 +0200 Subject: [PATCH 0465/1707] Bump aiohue to 4.8.1 (#167369) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 0adc0dfc3b3e80..58272b5b1a53e0 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "requirements": ["aiohue==4.8.0"], + "requirements": ["aiohue==4.8.1"], "zeroconf": ["_hue._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e1955ac732c1f8..bc68d3a7f86200 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -288,7 +288,7 @@ aiohomekit==3.2.20 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.8.0 +aiohue==4.8.1 # homeassistant.components.imap aioimaplib==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c994216a9834e..dda35c19367fc4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -276,7 +276,7 @@ aiohomekit==3.2.20 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.8.0 +aiohue==4.8.1 # homeassistant.components.imap aioimaplib==2.0.1 From b5480da68ed0e0244fbcedc1a055392168b3957f Mon Sep 17 00:00:00 2001 From: Jonas <17726681+jonilala796@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:13:57 +0200 Subject: [PATCH 0466/1707] Add manual and remaining watering time to Gardena Bluetooth for Aquaprecise (#167381) --- .../components/gardena_bluetooth/number.py | 31 ++++- .../components/gardena_bluetooth/strings.json | 3 + .../snapshots/test_number.ambr | 128 +++++++++++++++++- .../gardena_bluetooth/test_number.py | 56 ++++++-- 4 files changed, 202 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index 03c342f7478942..ef0c751cc50b72 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -4,7 +4,13 @@ from dataclasses import dataclass, field -from gardena_bluetooth.const import DeviceConfiguration, Sensor, Spray, Valve +from gardena_bluetooth.const import ( + AquaContourWatering, + DeviceConfiguration, + Sensor, + Spray, + Valve, +) from gardena_bluetooth.parse import ( Characteristic, CharacteristicInt, @@ -58,6 +64,18 @@ def context(self) -> set[str]: char=Valve.manual_watering_time, device_class=NumberDeviceClass.DURATION, ), + GardenaBluetoothNumberEntityDescription( + key=AquaContourWatering.manual_watering_time.unique_id, + translation_key="manual_watering_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + native_min_value=0.0, + native_max_value=24 * 60 * 60, + native_step=60, + entity_category=EntityCategory.CONFIG, + char=AquaContourWatering.manual_watering_time, + device_class=NumberDeviceClass.DURATION, + ), GardenaBluetoothNumberEntityDescription( key=Valve.remaining_open_time.unique_id, translation_key="remaining_open_time", @@ -69,6 +87,17 @@ def context(self) -> set[str]: char=Valve.remaining_open_time, device_class=NumberDeviceClass.DURATION, ), + GardenaBluetoothNumberEntityDescription( + key=AquaContourWatering.remaining_watering_time.unique_id, + translation_key="remaining_watering_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0.0, + native_max_value=24 * 60 * 60, + native_step=60.0, + entity_category=EntityCategory.DIAGNOSTIC, + char=AquaContourWatering.remaining_watering_time, + device_class=NumberDeviceClass.DURATION, + ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.rain_pause.unique_id, translation_key="rain_pause", diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 8c8815631eb5cf..80fdd63bf682c8 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -50,6 +50,9 @@ "remaining_open_time": { "name": "Remaining open time" }, + "remaining_watering_time": { + "name": "Remaining watering time" + }, "seasonal_adjust": { "name": "Seasonal adjust" }, diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index 4bc1e7e8dcb61d..49e7576791ded4 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -111,7 +111,45 @@ 'state': '45.0', }) # --- -# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] +# name: test_setup[service_info0-98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Title Manual watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_manual_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_setup[service_info0-98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Title Manual watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_manual_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_setup[service_info1-98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -130,7 +168,7 @@ 'state': '100.0', }) # --- -# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].1 +# name: test_setup[service_info1-98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -149,7 +187,7 @@ 'state': '10.0', }) # --- -# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].2 +# name: test_setup[service_info1-98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -168,7 +206,7 @@ 'state': 'unknown', }) # --- -# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].3 +# name: test_setup[service_info1-98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -187,7 +225,7 @@ 'state': 'unavailable', }) # --- -# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw2-number.mock_title_open_for] +# name: test_setup[service_info2-98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw2-number.mock_title_open_for] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -206,7 +244,7 @@ 'state': 'unknown', }) # --- -# name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time] +# name: test_setup[service_info3-98bd0d13-0b0e-421a-84e5-ddbf75dc6de4-raw3-number.mock_title_manual_watering_time] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -225,7 +263,7 @@ 'state': '100.0', }) # --- -# name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time].1 +# name: test_setup[service_info3-98bd0d13-0b0e-421a-84e5-ddbf75dc6de4-raw3-number.mock_title_manual_watering_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -244,3 +282,79 @@ 'state': '10.0', }) # --- +# name: test_setup[service_info4-98bd0d12-0b0e-421a-84e5-ddbf75dc6de4-raw4-number.mock_title_remaining_watering_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Title Remaining watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_setup[service_info4-98bd0d12-0b0e-421a-84e5-ddbf75dc6de4-raw4-number.mock_title_remaining_watering_time].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Title Remaining watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_setup[service_info4-98bd0d12-0b0e-421a-84e5-ddbf75dc6de4-raw4-number.mock_title_remaining_watering_time].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Title Remaining watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[service_info4-98bd0d12-0b0e-421a-84e5-ddbf75dc6de4-raw4-number.mock_title_remaining_watering_time].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Title Remaining watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/gardena_bluetooth/test_number.py b/tests/components/gardena_bluetooth/test_number.py index 4c053fca0fa84b..1af772a36f5e73 100644 --- a/tests/components/gardena_bluetooth/test_number.py +++ b/tests/components/gardena_bluetooth/test_number.py @@ -4,12 +4,13 @@ from typing import Any from unittest.mock import Mock, call -from gardena_bluetooth.const import Sensor, Valve +from gardena_bluetooth.const import AquaContourWatering, Sensor, Valve from gardena_bluetooth.exceptions import ( CharacteristicNoAccess, GardenaBluetoothException, ) from gardena_bluetooth.parse import Characteristic +from habluetooth import BluetoothServiceInfo import pytest from syrupy.assertion import SnapshotAssertion @@ -21,15 +22,16 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from . import setup_entry +from . import AQUA_CONTOUR_SERVICE_INFO, WATER_TIMER_SERVICE_INFO, setup_entry from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("uuid", "raw", "entity_id"), + ("service_info", "uuid", "raw", "entity_id"), [ ( + WATER_TIMER_SERVICE_INFO, Valve.manual_watering_time.uuid, [ Valve.manual_watering_time.encode(100), @@ -38,6 +40,7 @@ "number.mock_title_manual_watering_time", ), ( + WATER_TIMER_SERVICE_INFO, Valve.remaining_open_time.uuid, [ Valve.remaining_open_time.encode(100), @@ -48,18 +51,39 @@ "number.mock_title_remaining_open_time", ), ( + WATER_TIMER_SERVICE_INFO, Valve.remaining_open_time.uuid, [Valve.remaining_open_time.encode(100)], "number.mock_title_open_for", ), + ( + AQUA_CONTOUR_SERVICE_INFO, + AquaContourWatering.manual_watering_time.uuid, + [ + AquaContourWatering.manual_watering_time.encode(100), + AquaContourWatering.manual_watering_time.encode(10), + ], + "number.mock_title_manual_watering_time", + ), + ( + AQUA_CONTOUR_SERVICE_INFO, + AquaContourWatering.remaining_watering_time.uuid, + [ + AquaContourWatering.remaining_watering_time.encode(100), + AquaContourWatering.remaining_watering_time.encode(10), + CharacteristicNoAccess("Test for no access"), + GardenaBluetoothException("Test for errors on bluetooth"), + ], + "number.mock_title_remaining_watering_time", + ), ], ) async def test_setup( hass: HomeAssistant, snapshot: SnapshotAssertion, - mock_entry: MockConfigEntry, mock_read_char_raw: dict[str, bytes], scan_step: Callable[[], Awaitable[None]], + service_info: BluetoothServiceInfo, uuid: str, raw: list[bytes], entity_id: str, @@ -67,7 +91,7 @@ async def test_setup( """Test setup creates expected entities.""" mock_read_char_raw[uuid] = raw[0] - await setup_entry(hass, mock_entry, [Platform.NUMBER]) + await setup_entry(hass, platforms=[Platform.NUMBER], service_info=service_info) assert hass.states.get(entity_id) == snapshot for char_raw in raw[1:]: @@ -77,27 +101,43 @@ async def test_setup( @pytest.mark.parametrize( - ("char", "value", "expected", "entity_id"), + ("service_info", "char", "value", "expected", "entity_id"), [ ( + WATER_TIMER_SERVICE_INFO, Valve.manual_watering_time, 100, 100, "number.mock_title_manual_watering_time", ), ( + WATER_TIMER_SERVICE_INFO, Valve.remaining_open_time, 100, 100 * 60, "number.mock_title_open_for", ), + ( + AQUA_CONTOUR_SERVICE_INFO, + AquaContourWatering.manual_watering_time, + 100, + 100, + "number.mock_title_manual_watering_time", + ), + ( + AQUA_CONTOUR_SERVICE_INFO, + AquaContourWatering.remaining_watering_time, + 100, + 100, + "number.mock_title_remaining_watering_time", + ), ], ) async def test_config( hass: HomeAssistant, - mock_entry: MockConfigEntry, mock_read_char_raw: dict[str, bytes], mock_client: Mock, + service_info: BluetoothServiceInfo, char: Characteristic, value: Any, expected: Any, @@ -106,7 +146,7 @@ async def test_config( """Test setup creates expected entities.""" mock_read_char_raw[char.uuid] = char.encode(value) - await setup_entry(hass, mock_entry, [Platform.NUMBER]) + await setup_entry(hass, platforms=[Platform.NUMBER], service_info=service_info) assert hass.states.get(entity_id) await hass.services.async_call( From a30fc9e9d531571c71dbfb81497281c1d88218ee Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 5 Apr 2026 10:43:24 +0200 Subject: [PATCH 0467/1707] Align and cleanup tests data for Fritz (#167363) --- tests/components/fritz/const.py | 19 +++++++ .../fritz/snapshots/test_diagnostics.ambr | 1 + .../fritz/snapshots/test_switch.ambr | 51 +++++++++++++++++++ tests/components/fritz/test_image.py | 36 +++---------- 4 files changed, 79 insertions(+), 28 deletions(-) diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index a007b57d842af8..49ab453dac3c4f 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -200,6 +200,25 @@ "NewBeaconAdvertisementEnabled": True, }, }, + "WLANConfiguration2": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewSSID": "GuestWifi", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:13", + "NewMACAddressControlEnabled": True, + }, + "GetSSID": { + "NewSSID": "GuestWifi", + }, + "GetSecurityKeys": {"NewKeyPassphrase": "1234567890"}, + "GetBeaconAdvertisement": { + "NewBeaconAdvertisementEnabled": True, + }, + }, "X_AVM-DE_Homeauto1": { "GetGenericDeviceInfos": [ { diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 8bf3416df49067..bfea484e7f77d8 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -38,6 +38,7 @@ 'WANIPConn1', 'WANPPPConnection1', 'WLANConfiguration1', + 'WLANConfiguration2', 'X_AVM-DE_Homeauto1', 'X_AVM-DE_HostFilter1', 'X_AVM-DE_UPnP1', diff --git a/tests/components/fritz/snapshots/test_switch.ambr b/tests/components/fritz/snapshots/test_switch.ambr index 75e53fbf5b7045..8789576d970c79 100644 --- a/tests/components/fritz/snapshots/test_switch.ambr +++ b/tests/components/fritz/snapshots/test_switch.ambr @@ -920,6 +920,57 @@ 'state': 'on', }) # --- +# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_guestwifi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_wi_fi_guestwifi', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mock Title Wi-Fi GuestWifi', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Mock Title Wi-Fi GuestWifi', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guestwifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_guestwifi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Wi-Fi GuestWifi', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_wi_fi_guestwifi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_mywifi-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/fritz/test_image.py b/tests/components/fritz/test_image.py index f7ff9178ab3ffc..8f553bfd3bd268 100644 --- a/tests/components/fritz/test_image.py +++ b/tests/components/fritz/test_image.py @@ -22,29 +22,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator -GUEST_WIFI_ENABLED: dict[str, dict] = { - "WLANConfiguration0": {}, - "WLANConfiguration1": { - "GetBeaconAdvertisement": {"NewBeaconAdvertisementEnabled": 1}, - "GetInfo": { - "NewEnable": True, - "NewStatus": "Up", - "NewSSID": "GuestWifi", - "NewBeaconType": "11iandWPA3", - "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", - "NewStandard": "ax", - "NewBSSID": "1C:ED:6F:12:34:13", - }, - "GetSSID": { - "NewSSID": "GuestWifi", - }, - "GetSecurityKeys": {"NewKeyPassphrase": "1234567890"}, - }, -} - GUEST_WIFI_CHANGED: dict[str, dict] = { - "WLANConfiguration0": {}, - "WLANConfiguration1": { + "WLANConfiguration1": {}, + "WLANConfiguration2": { "GetBeaconAdvertisement": {"NewBeaconAdvertisementEnabled": 1}, "GetInfo": { "NewEnable": True, @@ -63,8 +43,8 @@ } GUEST_WIFI_DISABLED: dict[str, dict] = { - "WLANConfiguration0": {}, - "WLANConfiguration1": { + "WLANConfiguration1": {}, + "WLANConfiguration2": { "GetBeaconAdvertisement": {"NewBeaconAdvertisementEnabled": 1}, "GetInfo": { "NewEnable": False, @@ -86,7 +66,7 @@ @pytest.mark.parametrize( ("fc_data"), [ - ({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED}), + ({**MOCK_FB_SERVICES}), ({**MOCK_FB_SERVICES, **GUEST_WIFI_DISABLED}), ], ) @@ -139,7 +119,7 @@ async def test_image_entity( assert body == snapshot -@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED})]) +@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES})]) async def test_image_update( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -180,7 +160,7 @@ async def test_image_update( assert resp_body_new == snapshot -@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED})]) +@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES})]) async def test_image_update_unavailable( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -244,7 +224,7 @@ async def test_migrate_to_new_unique_id( ) entry.add_to_hass(hass) - old_unique_id = slugify(f"{MOCK_MESH_MASTER_MAC}-MyWifi-qr-code") + old_unique_id = slugify(f"{MOCK_MESH_MASTER_MAC}-GuestWifi-qr-code") new_unique_id = f"{MOCK_MESH_MASTER_MAC}-guest_wifi_qr_code" entity_registry.async_get_or_create( From cb15014dc0e75889703a2d8edc0be51144ad7e9a Mon Sep 17 00:00:00 2001 From: Marco Sousa Date: Sun, 5 Apr 2026 10:57:27 +0100 Subject: [PATCH 0468/1707] Bump aiopvpc to 4.3.1 (#167189) --- homeassistant/components/pvpc_hourly_pricing/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index c29cd52cf96780..18287a2d5e9efe 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopvpc"], - "requirements": ["aiopvpc==4.2.2"] + "requirements": ["aiopvpc==4.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bc68d3a7f86200..1d82e73b10168d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -366,7 +366,7 @@ aiopurpleair==2025.08.1 aiopvapi==3.3.0 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.2.2 +aiopvpc==4.3.1 # homeassistant.components.lidarr # homeassistant.components.radarr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dda35c19367fc4..aebc282b705bed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -351,7 +351,7 @@ aiopurpleair==2025.08.1 aiopvapi==3.3.0 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.2.2 +aiopvpc==4.3.1 # homeassistant.components.lidarr # homeassistant.components.radarr From 206d1ab3a8320d5f79a605c80145d61ca00062f7 Mon Sep 17 00:00:00 2001 From: Patrick Date: Sun, 5 Apr 2026 05:58:46 -0400 Subject: [PATCH 0469/1707] Bump starlink-grpc-core to 1.2.5 (#167195) --- homeassistant/components/starlink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index c66896e0d4ead0..fcc397238dc494 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["starlink-grpc-core==1.2.4"] + "requirements": ["starlink-grpc-core==1.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1d82e73b10168d..bd5268dd7aacdd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3020,7 +3020,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.2.4 +starlink-grpc-core==1.2.5 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aebc282b705bed..db7be4695abb56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2559,7 +2559,7 @@ srpenergy==1.3.8 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.2.4 +starlink-grpc-core==1.2.5 # homeassistant.components.statsd statsd==3.2.1 From 0f1dbba65ad64786f0fcfcb4b1794ed7abff08ea Mon Sep 17 00:00:00 2001 From: lzghzr Date: Sun, 5 Apr 2026 17:59:31 +0800 Subject: [PATCH 0470/1707] Bump xiaomi-ble to 1.10.1 (#167384) --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 8dfbe0a1c74bd8..156a9f9e6c4dbb 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -25,5 +25,5 @@ "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["xiaomi-ble==1.10.0"] + "requirements": ["xiaomi-ble==1.10.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bd5268dd7aacdd..84e9c30c6b35ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3322,7 +3322,7 @@ wsdot==0.0.1 wyoming==1.7.2 # homeassistant.components.xiaomi_ble -xiaomi-ble==1.10.0 +xiaomi-ble==1.10.1 # homeassistant.components.knx xknx==3.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db7be4695abb56..a82250eb16cdc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2813,7 +2813,7 @@ wsdot==0.0.1 wyoming==1.7.2 # homeassistant.components.xiaomi_ble -xiaomi-ble==1.10.0 +xiaomi-ble==1.10.1 # homeassistant.components.knx xknx==3.15.0 From 28ddb3a59065ef8e2baa785592eabc1bcc39aa08 Mon Sep 17 00:00:00 2001 From: G-Two <7310260+G-Two@users.noreply.github.com> Date: Sun, 5 Apr 2026 06:01:40 -0400 Subject: [PATCH 0471/1707] Bump subarulink to 0.7.19 (#167386) --- homeassistant/components/subaru/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 930f497d3fe5b6..a7f384d602f4af 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.15"] + "requirements": ["subarulink==0.7.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index 84e9c30c6b35ef..2cfc5e596477f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3038,7 +3038,7 @@ stookwijzer==1.6.1 streamlabswater==1.0.1 # homeassistant.components.subaru -subarulink==0.7.15 +subarulink==0.7.19 # homeassistant.components.surepetcare surepy==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a82250eb16cdc8..9b1cbcc1d577d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2577,7 +2577,7 @@ stookwijzer==1.6.1 streamlabswater==1.0.1 # homeassistant.components.subaru -subarulink==0.7.15 +subarulink==0.7.19 # homeassistant.components.surepetcare surepy==0.9.0 From 415f7110397a9951de56f1d91471d9d234979c03 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 5 Apr 2026 12:02:32 +0200 Subject: [PATCH 0472/1707] Bump aiocomelit to 2.0.2 (#167414) --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index b5dbacdb66c468..f776cf6b3ee76a 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiocomelit"], "quality_scale": "platinum", - "requirements": ["aiocomelit==2.0.1"] + "requirements": ["aiocomelit==2.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2cfc5e596477f6..d1364ad382e59d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -224,7 +224,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==2.0.1 +aiocomelit==2.0.2 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b1cbcc1d577d3..a5fae0ee25da8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==2.0.1 +aiocomelit==2.0.2 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 From 287b38bebd8c44bba095fb0da84e48ade0ceea54 Mon Sep 17 00:00:00 2001 From: TimL Date: Sun, 5 Apr 2026 20:02:59 +1000 Subject: [PATCH 0473/1707] Bump pysmlight to 0.3.2 (#167421) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 985799ab0e6c52..e727bf20a34e3d 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["pysmlight==0.3.1"], + "requirements": ["pysmlight==0.3.2"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index d1364ad382e59d..c59c27dd3b56e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2512,7 +2512,7 @@ pysmhi==2.0.0 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.3.1 +pysmlight==0.3.2 # homeassistant.components.snmp pysnmp==7.1.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5fae0ee25da8e..0ec3812a958898 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2147,7 +2147,7 @@ pysmhi==2.0.0 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.3.1 +pysmlight==0.3.2 # homeassistant.components.snmp pysnmp==7.1.22 From 42f602a2fd1f4dd4425e6227af9762926f64f8a4 Mon Sep 17 00:00:00 2001 From: Daniel Feinberg Date: Sun, 5 Apr 2026 08:25:01 -0600 Subject: [PATCH 0474/1707] Bump python-roborock from 5.0.0 to 5.3.0 (#167437) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 04f4fbfa29a120..3efdfb0b7e6ad6 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==5.0.0", + "python-roborock==5.3.0", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/requirements_all.txt b/requirements_all.txt index c59c27dd3b56e1..93d5d0af719d20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2663,7 +2663,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==5.0.0 +python-roborock==5.3.0 # homeassistant.components.smarttub python-smarttub==0.0.47 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ec3812a958898..7f5c9c490d1d45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2262,7 +2262,7 @@ python-qube-heatpump==1.8.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==5.0.0 +python-roborock==5.3.0 # homeassistant.components.smarttub python-smarttub==0.0.47 From 735adb61a3f8cfbd2bd967fab67dfa8e89ad5be8 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:32:23 +0200 Subject: [PATCH 0475/1707] Fix blocking SSL context creation in unifi_access integration (#167422) --- .../components/unifi_access/__init__.py | 5 +++ .../components/unifi_access/config_flow.py | 5 +++ .../unifi_access/test_config_flow.py | 39 ++++++++++++++++- tests/components/unifi_access/test_init.py | 42 ++++++++++++++++++- 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi_access/__init__.py b/homeassistant/components/unifi_access/__init__.py index b73b99fbce8cad..ab0c81881b0d2a 100644 --- a/homeassistant/components/unifi_access/__init__.py +++ b/homeassistant/components/unifi_access/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.ssl import create_no_verify_ssl_context from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator @@ -26,11 +27,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) """Set up UniFi Access from a config entry.""" session = async_get_clientsession(hass, verify_ssl=entry.data[CONF_VERIFY_SSL]) + ssl_context = ( + None if entry.data[CONF_VERIFY_SSL] else create_no_verify_ssl_context() + ) client = UnifiAccessApiClient( host=entry.data[CONF_HOST], api_token=entry.data[CONF_API_TOKEN], session=session, verify_ssl=entry.data[CONF_VERIFY_SSL], + ssl_context=ssl_context, ) try: diff --git a/homeassistant/components/unifi_access/config_flow.py b/homeassistant/components/unifi_access/config_flow.py index 87acc7a84ed327..81f99f4473ecab 100644 --- a/homeassistant/components/unifi_access/config_flow.py +++ b/homeassistant/components/unifi_access/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.ssl import create_no_verify_ssl_context from .const import DOMAIN @@ -30,11 +31,15 @@ async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: session = async_get_clientsession( self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] ) + ssl_context = ( + None if user_input[CONF_VERIFY_SSL] else create_no_verify_ssl_context() + ) client = UnifiAccessApiClient( host=user_input[CONF_HOST], api_token=user_input[CONF_API_TOKEN], session=session, verify_ssl=user_input[CONF_VERIFY_SSL], + ssl_context=ssl_context, ) try: await client.authenticate() diff --git a/tests/components/unifi_access/test_config_flow.py b/tests/components/unifi_access/test_config_flow.py index 4c6c77b5f0b5ff..d42e70d6a45340 100644 --- a/tests/components/unifi_access/test_config_flow.py +++ b/tests/components/unifi_access/test_config_flow.py @@ -2,7 +2,8 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +import ssl +from unittest.mock import AsyncMock, MagicMock, patch import pytest from unifi_access_api import ApiAuthError, ApiConnectionError @@ -361,3 +362,39 @@ async def test_reconfigure_flow_errors( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("verify_ssl", "expected_ssl_context_type"), + [ + (False, ssl.SSLContext), + (True, type(None)), + ], +) +async def test_user_flow_ssl_context( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + verify_ssl: bool, + expected_ssl_context_type: type, +) -> None: + """Test that a pre-warmed no-verify SSL context is passed when verify_ssl is False.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.unifi_access.config_flow.UnifiAccessApiClient", + wraps=lambda **kwargs: mock_client, + ) as patched_client: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: verify_ssl, + }, + ) + + _, call_kwargs = patched_client.call_args + assert isinstance(call_kwargs["ssl_context"], expected_ssl_context_type) diff --git a/tests/components/unifi_access/test_init.py b/tests/components/unifi_access/test_init.py index 6d1664c36de0f2..34833297b81157 100644 --- a/tests/components/unifi_access/test_init.py +++ b/tests/components/unifi_access/test_init.py @@ -3,7 +3,8 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock +import ssl +from unittest.mock import MagicMock, patch import pytest from unifi_access_api import ( @@ -25,6 +26,7 @@ from homeassistant.components.unifi_access.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -60,6 +62,44 @@ async def test_setup_entry( mock_client.get_doors.assert_awaited_once() +@pytest.mark.parametrize( + ("verify_ssl", "expected_ssl_context_type"), + [ + (False, ssl.SSLContext), + (True, type(None)), + ], +) +async def test_setup_entry_ssl_context( + hass: HomeAssistant, + mock_client: MagicMock, + verify_ssl: bool, + expected_ssl_context_type: type, +) -> None: + """Test that a pre-warmed no-verify SSL context is passed when verify_ssl is False.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="UniFi Access", + data={ + "host": "192.168.1.1", + "api_token": "test-token", + CONF_VERIFY_SSL: verify_ssl, + }, + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.unifi_access.UnifiAccessApiClient", + wraps=lambda **kwargs: mock_client, + ) as patched_client: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + _, call_kwargs = patched_client.call_args + assert isinstance(call_kwargs["ssl_context"], expected_ssl_context_type) + + @pytest.mark.parametrize( ("exception", "expected_state"), [ From 5d5d00f0b2fcf0de0c206605f6ad3d96bcdd257e Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Sun, 5 Apr 2026 16:56:01 +0100 Subject: [PATCH 0476/1707] Bump cryptography to 46.0.6 (#167330) Co-authored-by: Robert Resch --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f9322d10994215..37ac4db4a9634c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ cached-ipaddress==1.0.1 certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 -cryptography==46.0.5 +cryptography==46.0.6 dbus-fast==4.0.4 file-read-backwards==2.0.0 fnv-hash-fast==2.0.0 diff --git a/pyproject.toml b/pyproject.toml index 5575a41f36da21..194ee7b33a00d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==46.0.5", + "cryptography==46.0.6", "Pillow==12.1.1", "propcache==0.4.1", "pyOpenSSL==26.0.0", diff --git a/requirements.txt b/requirements.txt index fca3d009ed0712..0527a296113749 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ bcrypt==5.0.0 certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 -cryptography==46.0.5 +cryptography==46.0.6 fnv-hash-fast==2.0.0 ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 From 2b0f2bcd122665e39554df46b33726cef7fa9775 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 5 Apr 2026 20:10:08 +0300 Subject: [PATCH 0477/1707] Automatic caching support for Anthropic (#167436) --- .../components/anthropic/config_flow.py | 12 ++++ homeassistant/components/anthropic/const.py | 12 ++++ homeassistant/components/anthropic/entity.py | 32 ++++++--- .../components/anthropic/strings.json | 11 +++ .../components/anthropic/test_config_flow.py | 17 +++++ .../components/anthropic/test_conversation.py | 67 ++++++++++++++----- 6 files changed, 123 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 36c4a80f85d47b..b5e1ef5c3b26ff 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -48,6 +48,7 @@ CONF_CODE_EXECUTION, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_PROMPT_CACHING, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, @@ -66,6 +67,7 @@ NON_ADAPTIVE_THINKING_MODELS, NON_THINKING_MODELS, WEB_SEARCH_UNSUPPORTED_MODELS, + PromptCaching, ) if TYPE_CHECKING: @@ -356,6 +358,16 @@ async def async_step_advanced( CONF_TEMPERATURE, default=DEFAULT[CONF_TEMPERATURE], ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_PROMPT_CACHING, + default=DEFAULT[CONF_PROMPT_CACHING], + ): SelectSelector( + SelectSelectorConfig( + options=[x.value for x in PromptCaching], + translation_key=CONF_PROMPT_CACHING, + mode=SelectSelectorMode.DROPDOWN, + ) + ), } if user_input is not None: diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 8c88d8f47654dc..bed1f5530e34e4 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -1,5 +1,6 @@ """Constants for the Anthropic integration.""" +from enum import StrEnum import logging DOMAIN = "anthropic" @@ -13,6 +14,7 @@ CONF_CHAT_MODEL = "chat_model" CONF_CODE_EXECUTION = "code_execution" CONF_MAX_TOKENS = "max_tokens" +CONF_PROMPT_CACHING = "prompt_caching" CONF_TEMPERATURE = "temperature" CONF_THINKING_BUDGET = "thinking_budget" CONF_THINKING_EFFORT = "thinking_effort" @@ -24,10 +26,20 @@ CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" + +class PromptCaching(StrEnum): + """Prompt caching options.""" + + OFF = "off" + PROMPT = "prompt" + AUTOMATIC = "automatic" + + DEFAULT = { CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_CODE_EXECUTION: False, CONF_MAX_TOKENS: 3000, + CONF_PROMPT_CACHING: PromptCaching.PROMPT.value, CONF_TEMPERATURE: 1.0, CONF_THINKING_BUDGET: 0, CONF_THINKING_EFFORT: "low", diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 400cbe1626d988..bc88cc58c1b49d 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -91,6 +91,7 @@ CONF_CHAT_MODEL, CONF_CODE_EXECUTION, CONF_MAX_TOKENS, + CONF_PROMPT_CACHING, CONF_TEMPERATURE, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, @@ -109,6 +110,7 @@ NON_THINKING_MODELS, PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS, UNSUPPORTED_STRUCTURED_OUTPUT_MODELS, + PromptCaching, ) from .coordinator import AnthropicConfigEntry, AnthropicCoordinator @@ -678,7 +680,7 @@ def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> Non entry_type=dr.DeviceEntryType.SERVICE, ) - async def _async_handle_chat_log( + async def _async_handle_chat_log( # noqa: C901 self, chat_log: conversation.ChatLog, structure_name: str | None = None, @@ -694,15 +696,6 @@ async def _async_handle_chat_log( translation_domain=DOMAIN, translation_key="system_message_not_found" ) - # System prompt with caching enabled - system_prompt: list[TextBlockParam] = [ - TextBlockParam( - type="text", - text=system.content, - cache_control={"type": "ephemeral"}, - ) - ] - messages, container_id = _convert_content(chat_log.content[1:]) model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]) @@ -711,11 +704,28 @@ async def _async_handle_chat_log( model=model, messages=messages, max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]), - system=system_prompt, + system=system.content, stream=True, container=container_id, ) + if ( + options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING]) + == PromptCaching.PROMPT + ): + model_args["system"] = [ + { + "type": "text", + "text": system.content, + "cache_control": {"type": "ephemeral"}, + } + ] + elif ( + options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING]) + == PromptCaching.AUTOMATIC + ): + model_args["cache_control"] = {"type": "ephemeral"} + if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)): thinking_effort = options.get( CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT] diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 72b15fbe2dd720..12ec2115ae43b8 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -47,11 +47,13 @@ "data": { "chat_model": "[%key:common::generic::model%]", "max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]", + "prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]", "temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]" }, "data_description": { "chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]", "max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::max_tokens%]", + "prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]", "temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]" }, "title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]" @@ -103,11 +105,13 @@ "data": { "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", + "prompt_caching": "Caching strategy", "temperature": "Temperature" }, "data_description": { "chat_model": "The model to serve the responses.", "max_tokens": "Limit the number of response tokens.", + "prompt_caching": "Optimize your API cost and response times based on your usage.", "temperature": "Control the randomness of the response, trading off between creativity and coherence." }, "title": "Advanced settings" @@ -210,6 +214,13 @@ } }, "selector": { + "prompt_caching": { + "options": { + "automatic": "Full", + "off": "Disabled", + "prompt": "System prompt" + } + }, "thinking_effort": { "options": { "high": "[%key:common::state::high%]", diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 9d8345113cdb12..8779a569e09c31 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -25,6 +25,7 @@ CONF_CODE_EXECUTION, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_PROMPT_CACHING, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, @@ -324,6 +325,7 @@ async def test_subentry_web_search_user_location( "country": "US", "max_tokens": 8192, "prompt": "You are a helpful assistant", + "prompt_caching": "prompt", "recommended": False, "region": "California", "temperature": 1.0, @@ -431,6 +433,7 @@ async def test_model_list_error( { CONF_CHAT_MODEL: "claude-3-haiku-20240307", CONF_TEMPERATURE: 1.0, + CONF_PROMPT_CACHING: "prompt", }, ), { @@ -439,6 +442,7 @@ async def test_model_list_error( CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "claude-3-haiku-20240307", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], + CONF_PROMPT_CACHING: "prompt", }, ), ( # Model with web search options @@ -446,6 +450,7 @@ async def test_model_list_error( CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_PROMPT: "bla", + CONF_PROMPT_CACHING: "prompt", CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_MAX_USES: 4, CONF_WEB_SEARCH_USER_LOCATION: True, @@ -463,6 +468,7 @@ async def test_model_list_error( { CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_TEMPERATURE: 1.0, + CONF_PROMPT_CACHING: "off", }, { CONF_WEB_SEARCH: False, @@ -474,6 +480,7 @@ async def test_model_list_error( { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "off", CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -489,6 +496,7 @@ async def test_model_list_error( CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_PROMPT: "bla", + CONF_PROMPT_CACHING: "off", CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -504,6 +512,7 @@ async def test_model_list_error( { CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_TEMPERATURE: 1.0, + CONF_PROMPT_CACHING: "automatic", }, { CONF_WEB_SEARCH: False, @@ -516,6 +525,7 @@ async def test_model_list_error( { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "automatic", CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -531,6 +541,7 @@ async def test_model_list_error( CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "claude-opus-4-6", CONF_PROMPT: "bla", + CONF_PROMPT_CACHING: "automatic", CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -546,6 +557,7 @@ async def test_model_list_error( { CONF_CHAT_MODEL: "claude-opus-4-6", CONF_TEMPERATURE: 1.0, + CONF_PROMPT_CACHING: "prompt", }, { CONF_WEB_SEARCH: False, @@ -558,6 +570,7 @@ async def test_model_list_error( { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "prompt", CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "claude-opus-4-6", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -581,12 +594,14 @@ async def test_model_list_error( }, { CONF_TEMPERATURE: 0.3, + CONF_PROMPT_CACHING: "automatic", }, {}, ), { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "automatic", CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: DEFAULT[CONF_CHAT_MODEL], CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -601,6 +616,7 @@ async def test_model_list_error( { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT_CACHING: "off", CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: DEFAULT[CONF_CHAT_MODEL], CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], @@ -790,6 +806,7 @@ async def test_creating_ai_task_subentry_advanced( CONF_WEB_SEARCH_USER_LOCATION: False, CONF_THINKING_BUDGET: 0, CONF_CODE_EXECUTION: False, + CONF_PROMPT_CACHING: "prompt", } diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 285b894309b0be..3addbd8701994d 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -33,6 +33,7 @@ from homeassistant.components.anthropic.const import ( CONF_CHAT_MODEL, CONF_CODE_EXECUTION, + CONF_PROMPT_CACHING, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, CONF_WEB_SEARCH, @@ -168,6 +169,7 @@ async def test_template_variables( mock_config_entry, subentry, data={ + "prompt_caching": "off", "prompt": ( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." @@ -194,12 +196,10 @@ async def test_template_variables( == "Okay, let me take care of that for you." ) - system = mock_create_stream.call_args.kwargs["system"] - assert isinstance(system, list) - system_text = " ".join(block["text"] for block in system if "text" in block) - - assert "The user name is Test User." in system_text - assert "The user id is 12345." in system_text + assert ( + "The user name is Test User." in mock_create_stream.call_args.kwargs["system"] + ) + assert "The user id is 12345." in mock_create_stream.call_args.kwargs["system"] async def test_conversation_agent( @@ -212,9 +212,10 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -async def test_system_prompt_uses_text_block_with_cache_control( +async def test_prompt_caching_system_prompt( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_init_component: None, mock_create_stream: AsyncMock, ) -> None: """Ensure system prompt is sent as TextBlockParam with cache_control.""" @@ -224,16 +225,13 @@ async def test_system_prompt_uses_text_block_with_cache_control( create_content_block(0, ["ok"]), ] - with patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - await conversation.async_converse( - hass, - "hello", - None, - context, - agent_id="conversation.claude_conversation", - ) + await conversation.async_converse( + hass, + "hello", + None, + context, + agent_id="conversation.claude_conversation", + ) system = mock_create_stream.call_args.kwargs["system"] assert isinstance(system, list) @@ -242,6 +240,41 @@ async def test_system_prompt_uses_text_block_with_cache_control( assert block["type"] == "text" assert "Home Assistant" in block["text"] assert block["cache_control"] == {"type": "ephemeral"} + assert "cache_control" not in mock_create_stream.call_args.kwargs + + +async def test_prompt_caching_automatic( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component: None, + mock_create_stream: AsyncMock, +) -> None: + """Ensure model args include cache_control.""" + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={ + CONF_PROMPT_CACHING: "automatic", + }, + ) + + context = Context() + + mock_create_stream.return_value = [ + create_content_block(0, ["ok"]), + ] + + await conversation.async_converse( + hass, + "hello", + None, + context, + agent_id="conversation.claude_conversation", + ) + + assert mock_create_stream.call_args.kwargs["cache_control"] == {"type": "ephemeral"} + system = mock_create_stream.call_args.kwargs["system"] + assert isinstance(system, str) @patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") From 7b19b5a41699e1983e40d35956cb89619d8f02cc Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Sun, 5 Apr 2026 11:10:05 -0700 Subject: [PATCH 0478/1707] Fix overseerr test importing from `future.backports` (#167458) --- tests/components/overseerr/test_event.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/overseerr/test_event.py b/tests/components/overseerr/test_event.py index b11c998d479891..31e796b0e5ce9a 100644 --- a/tests/components/overseerr/test_event.py +++ b/tests/components/overseerr/test_event.py @@ -1,10 +1,9 @@ """Tests for the Overseerr event platform.""" -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from future.backports.datetime import timedelta import pytest from python_overseerr import OverseerrConnectionError from syrupy.assertion import SnapshotAssertion From 1d7eb5ed15dedec332253744b44c327f5ec9e9ce Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 5 Apr 2026 20:31:57 +0100 Subject: [PATCH 0479/1707] Use mark.usefixtures for unreferenced fixtures in Evohome (#167467) --- tests/components/evohome/test_coordinator.py | 4 +--- tests/components/evohome/test_init.py | 9 +++------ tests/components/evohome/test_services.py | 11 ++++------- tests/components/evohome/test_water_heater.py | 12 +++++++----- 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/tests/components/evohome/test_coordinator.py b/tests/components/evohome/test_coordinator.py index 7fb325d55b9bd0..0d2bc5a3bf82a0 100644 --- a/tests/components/evohome/test_coordinator.py +++ b/tests/components/evohome/test_coordinator.py @@ -5,7 +5,6 @@ from datetime import timedelta from unittest.mock import patch -from evohomeasync2 import EvohomeClient from freezegun.api import FrozenDateTimeFactory import pytest @@ -19,10 +18,9 @@ @pytest.mark.parametrize("install", ["minimal"]) +@pytest.mark.usefixtures("evohome") async def test_setup_platform( hass: HomeAssistant, - config: dict[str, str], - evohome: EvohomeClient, freezer: FrozenDateTimeFactory, ) -> None: """Test entities and their states after setup of evohome.""" diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index 87749df579ab4a..db0cbb9e3de9ff 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import Mock, patch import aiohttp -from evohomeasync2 import EvohomeClient, exceptions as evo_exc +from evohomeasync2 import exceptions as evo_exc import pytest from syrupy.assertion import SnapshotAssertion @@ -172,11 +172,8 @@ async def test_client_request_failure_v2( @pytest.mark.parametrize("install", ["default"]) -async def test_setup( - hass: HomeAssistant, - evohome: EvohomeClient, - snapshot: SnapshotAssertion, -) -> None: +@pytest.mark.usefixtures("evohome") +async def test_setup(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test services after setup of evohome. Registered services vary by the type of system. diff --git a/tests/components/evohome/test_services.py b/tests/components/evohome/test_services.py index c03793a8e8f54a..eb65bf19034922 100644 --- a/tests/components/evohome/test_services.py +++ b/tests/components/evohome/test_services.py @@ -6,7 +6,6 @@ from typing import Any from unittest.mock import patch -from evohomeasync2 import EvohomeClient from freezegun.api import FrozenDateTimeFactory import pytest @@ -25,10 +24,8 @@ @pytest.mark.parametrize("install", ["default"]) -async def test_refresh_system( - hass: HomeAssistant, - evohome: EvohomeClient, -) -> None: +@pytest.mark.usefixtures("evohome") +async def test_refresh_system(hass: HomeAssistant) -> None: """Test Evohome's refresh_system service (for all temperature control systems).""" # EvoService.REFRESH_SYSTEM @@ -63,9 +60,9 @@ async def test_reset_system( @pytest.mark.parametrize("install", ["default"]) +@pytest.mark.usefixtures("ctl_id") async def test_set_system_mode( hass: HomeAssistant, - ctl_id: str, freezer: FrozenDateTimeFactory, ) -> None: """Test Evohome's set_system_mode service (for a temperature control system).""" @@ -293,6 +290,7 @@ async def test_zone_services_with_ctl_id( @pytest.mark.parametrize("install", ["default"]) +@pytest.mark.usefixtures("evohome") @pytest.mark.parametrize( ("service_data", "expected_translation_key"), _SET_SYSTEM_MODE_VALIDATOR_PARAMS, @@ -300,7 +298,6 @@ async def test_zone_services_with_ctl_id( ) async def test_set_system_mode_validator( hass: HomeAssistant, - evohome: EvohomeClient, service_data: dict[str, Any], expected_translation_key: str, ) -> None: diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index 56d57db0caef13..ba2e33e85b880e 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -7,7 +7,6 @@ from unittest.mock import patch -from evohomeasync2 import EvohomeClient from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -50,9 +49,9 @@ async def test_setup_platform( @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) +@pytest.mark.usefixtures("evohome") async def test_set_operation_mode( hass: HomeAssistant, - evohome: EvohomeClient, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: @@ -119,7 +118,8 @@ async def test_set_operation_mode( @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) -async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> None: +@pytest.mark.usefixtures("evohome") +async def test_set_away_mode(hass: HomeAssistant) -> None: """Test SERVICE_SET_AWAY_MODE of an evohome DHW zone.""" # set_away_mode: off @@ -152,7 +152,8 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) -async def test_turn_off(hass: HomeAssistant, evohome: EvohomeClient) -> None: +@pytest.mark.usefixtures("evohome") +async def test_turn_off(hass: HomeAssistant) -> None: """Test SERVICE_TURN_OFF of an evohome DHW zone.""" # turn_off @@ -170,7 +171,8 @@ async def test_turn_off(hass: HomeAssistant, evohome: EvohomeClient) -> None: @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) -async def test_turn_on(hass: HomeAssistant, evohome: EvohomeClient) -> None: +@pytest.mark.usefixtures("evohome") +async def test_turn_on(hass: HomeAssistant) -> None: """Test SERVICE_TURN_ON of an evohome DHW zone.""" # turn_on From a084e850281bec1328f4484421d4c36e9f7b3c6c Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:23:26 +0200 Subject: [PATCH 0480/1707] Add `mastodon.update_profile` action to Mastodon integration (#167444) --- homeassistant/components/mastodon/const.py | 13 + homeassistant/components/mastodon/icons.json | 3 + homeassistant/components/mastodon/services.py | 113 ++++++++- .../components/mastodon/services.yaml | 72 ++++-- .../components/mastodon/strings.json | 64 +++++ tests/components/mastodon/conftest.py | 5 + tests/components/mastodon/test_services.py | 227 +++++++++++++++++- 7 files changed, 479 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 592b6a2300ebc1..63d2ef7c66eb33 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -23,3 +23,16 @@ ATTR_LANGUAGE = "language" ATTR_DURATION = "duration" ATTR_HIDE_NOTIFICATIONS = "hide_notifications" + +ATTR_DISPLAY_NAME = "display_name" +ATTR_NOTE = "note" +ATTR_AVATAR = "avatar" +ATTR_AVATAR_MIME_TYPE = "avatar_mime_type" +ATTR_HEADER = "header" +ATTR_HEADER_MIME_TYPE = "header_mime_type" +ATTR_LOCKED = "locked" +ATTR_BOT = "bot" +ATTR_DISCOVERABLE = "discoverable" +ATTR_FIELDS = "fields" +ATTR_ATTRIBUTION_DOMAINS = "attribution_domains" +ATTR_VALUE = "value" diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json index e9185ee13b18e2..dd2974378f0d11 100644 --- a/homeassistant/components/mastodon/icons.json +++ b/homeassistant/components/mastodon/icons.json @@ -43,6 +43,9 @@ }, "unmute_account": { "service": "mdi:account-voice" + }, + "update_profile": { + "service": "mdi:account-edit" } } } diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 2208588570c2f6..5e93447fba5dfd 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -4,6 +4,7 @@ from enum import StrEnum from functools import partial from math import isfinite +from pathlib import Path from typing import Any from mastodon import Mastodon @@ -11,11 +12,14 @@ Account, MastodonAPIError, MastodonNotFoundError, + MastodonUnauthorizedError, MediaAttachment, ) import voluptuous as vol -from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.components import camera, image +from homeassistant.components.media_source import async_resolve_media +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_NAME from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -25,20 +29,34 @@ ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, service +from homeassistant.helpers.selector import MediaSelector from .const import ( ATTR_ACCOUNT_NAME, + ATTR_ATTRIBUTION_DOMAINS, + ATTR_AVATAR, + ATTR_AVATAR_MIME_TYPE, + ATTR_BOT, ATTR_CONTENT_WARNING, + ATTR_DISCOVERABLE, + ATTR_DISPLAY_NAME, ATTR_DURATION, + ATTR_FIELDS, + ATTR_HEADER, + ATTR_HEADER_MIME_TYPE, ATTR_HIDE_NOTIFICATIONS, ATTR_IDEMPOTENCY_KEY, ATTR_LANGUAGE, + ATTR_LOCKED, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, ATTR_MEDIA_WARNING, + ATTR_NOTE, ATTR_STATUS, + ATTR_VALUE, ATTR_VISIBILITY, DOMAIN, + LOGGER, ) from .coordinator import MastodonConfigEntry from .utils import get_media_type @@ -98,6 +116,24 @@ class StatusVisibility(StrEnum): } ) +SERVICE_UPDATE_PROFILE = "update_profile" +SERVICE_UPDATE_PROFILE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Optional(ATTR_DISPLAY_NAME): str, + vol.Optional(ATTR_NOTE): str, + vol.Optional(ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}), + vol.Optional(ATTR_HEADER): MediaSelector({"accept": ["image/*"]}), + vol.Optional(ATTR_LOCKED): bool, + vol.Optional(ATTR_BOT): bool, + vol.Optional(ATTR_DISCOVERABLE): bool, + vol.Optional(ATTR_FIELDS): vol.All( + cv.ensure_list, vol.Length(max=4), [dict[str, str]] + ), + vol.Optional(ATTR_ATTRIBUTION_DOMAINS): vol.All(cv.ensure_list, [str]), + } +) + @callback def async_setup_services(hass: HomeAssistant) -> None: @@ -124,6 +160,13 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, SERVICE_POST, _async_post, schema=SERVICE_POST_SCHEMA ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_PROFILE, + _async_update_profile, + schema=SERVICE_UPDATE_PROFILE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) async def _async_account_lookup( @@ -319,3 +362,71 @@ def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None: translation_domain=DOMAIN, translation_key="unable_to_send_message", ) from err + + +async def _async_update_profile(call: ServiceCall) -> ServiceResponse: + """Update profile information.""" + params = dict(call.data.copy()) + + entry: MastodonConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, params.pop(ATTR_CONFIG_ENTRY_ID) + ) + client = entry.runtime_data.client + + if avatar := params.pop(ATTR_AVATAR, None): + params[ATTR_AVATAR], params[ATTR_AVATAR_MIME_TYPE] = await _resolve_media( + call.hass, avatar + ) + if header := params.pop(ATTR_HEADER, None): + params[ATTR_HEADER], params[ATTR_HEADER_MIME_TYPE] = await _resolve_media( + call.hass, header + ) + if fields := params.get(ATTR_FIELDS): + params[ATTR_FIELDS] = [ + (field[ATTR_NAME].strip(), field[ATTR_VALUE].strip()) + for field in fields + if field[ATTR_NAME].strip() + ] + try: + return await call.hass.async_add_executor_job( + lambda: client.account_update_credentials(**params) + ) + except MastodonUnauthorizedError as error: + entry.async_start_reauth(call.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from error + except MastodonAPIError as err: + LOGGER.debug("Full exception:", exc_info=err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_update_profile", + ) from err + + +async def _resolve_media( + hass: HomeAssistant, media_source: dict[str, str] +) -> tuple[bytes | Path, str | None]: + """Resolve media from a media source.""" + media_content_id: str = media_source["media_content_id"] + if media_content_id.startswith("media-source://camera/"): + entity_id = media_content_id.removeprefix("media-source://camera/") + snapshot = await camera.async_get_image(hass, entity_id) + return snapshot.content, snapshot.content_type + + if media_content_id.startswith("media-source://image/"): + entity_id = media_content_id.removeprefix("media-source://image/") + img = await image.async_get_image(hass, entity_id) + return img.content, img.content_type + + media = await async_resolve_media(hass, media_source["media_content_id"], None) + + if media.path is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="media_source_not_supported", + translation_placeholders={"media_content_id": media_content_id}, + ) + + return media.path, media.mime_type diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index bdeefc8b570870..ec637315c821ab 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -1,6 +1,6 @@ get_account: fields: - config_entry_id: + config_entry_id: &config_entry_id required: true selector: config_entry: @@ -11,11 +11,7 @@ get_account: text: mute_account: fields: - config_entry_id: - required: true - selector: - config_entry: - integration: mastodon + config_entry_id: *config_entry_id account_name: required: true selector: @@ -32,22 +28,14 @@ mute_account: boolean: unmute_account: fields: - config_entry_id: - required: true - selector: - config_entry: - integration: mastodon + config_entry_id: *config_entry_id account_name: required: true selector: text: post: fields: - config_entry_id: - required: true - selector: - config_entry: - integration: mastodon + config_entry_id: *config_entry_id status: required: true selector: @@ -282,3 +270,55 @@ post: required: true selector: boolean: +update_profile: + fields: + config_entry_id: *config_entry_id + display_name: + selector: + text: + note: + selector: + text: + multiline: true + avatar: + required: false + selector: + media: + accept: + - "image/*" + header: + required: false + selector: + media: + accept: + - "image/*" + locked: + selector: + boolean: + bot: + selector: + boolean: + discoverable: + selector: + boolean: + fields: + selector: + object: + label_field: "value" + description_field: "name" + multiple: true + translation_key: fields + fields: + name: + required: true + selector: + text: + value: + required: true + selector: + text: + attribution_domains: + selector: + text: + multiple: true + type: url diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 5bfc629f1f3fbf..c63f9168627dae 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -104,6 +104,9 @@ "idempotency_key_too_short": { "message": "Idempotency key must be at least 4 characters long." }, + "media_source_not_supported": { + "message": "Media source {media_content_id} is not supported." + }, "mute_duration_too_long": { "message": "Mute duration is too long." }, @@ -122,11 +125,26 @@ "unable_to_unmute_account": { "message": "Unable to unmute account \"{account_name}\"" }, + "unable_to_update_profile": { + "message": "Unable to update profile." + }, "unable_to_upload_image": { "message": "Unable to upload image {media_path}." } }, "selector": { + "fields": { + "fields": { + "name": { + "description": "The label for this field.", + "name": "Label" + }, + "value": { + "description": "The value for this field.", + "name": "Value" + } + } + }, "post_visibility": { "options": { "direct": "Direct - Mentioned accounts only", @@ -228,6 +246,52 @@ } }, "name": "Unmute account" + }, + "update_profile": { + "description": "Updates your Mastodon profile information and pictures.", + "fields": { + "attribution_domains": { + "description": "Websites allowed to credit you. Protects from false attributions. Note that setting attribution domains will replace all existing attribution domains, not just the ones specified here.", + "name": "Attribution domains" + }, + "avatar": { + "description": "An image to set as your profile picture. WEBP, PNG, or JPG. At most 8 MB. Will be downscaled to 400x400px.", + "name": "Profile picture" + }, + "bot": { + "description": "Signal to others that the account mainly performs automated actions.", + "name": "Automated account" + }, + "config_entry_id": { + "description": "Select the Mastodon account to update the profile of.", + "name": "[%key:component::mastodon::services::post::fields::config_entry_id::name%]" + }, + "discoverable": { + "description": "Whether your profile should be discoverable. Public posts and the profile may be featured or recommended across Mastodon.", + "name": "Discoverable" + }, + "display_name": { + "description": "The display name to set on your profile.", + "name": "Display name" + }, + "fields": { + "description": "Additional profile fields as key-value pairs. Your homepage, pronouns, age, anything you want. Note that updating fields will replace all existing fields, not just the ones specified here.", + "name": "Extra fields" + }, + "header": { + "description": "An image to set as your profile header. WEBP, PNG, or JPG. At most 8 MB. Will be downscaled to 1500x500px.", + "name": "Header picture" + }, + "locked": { + "description": "Whether to lock your profile. A locked profile requires you to approve followers and hides your posts from non-followers.", + "name": "Lock profile" + }, + "note": { + "description": "The bio to set on your profile. You can @mention other people or #hashtags.", + "name": "Bio" + } + }, + "name": "Update profile" } } } diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py index a5e864477a67e2..bd272c2a64237d 100644 --- a/tests/components/mastodon/conftest.py +++ b/tests/components/mastodon/conftest.py @@ -46,6 +46,11 @@ def mock_mastodon_client() -> Generator[AsyncMock]: ) client.mastodon_api_version = 2 client.status_post.return_value = None + + client.account_update_credentials.return_value = Account.from_json( + load_fixture("account.json", DOMAIN) + ) + yield client diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index 239da9cb00c1fe..28e688660f0a95 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -1,21 +1,39 @@ """Tests for the Mastodon services.""" from datetime import timedelta +from pathlib import Path from unittest.mock import AsyncMock, Mock, patch -from mastodon.Mastodon import MastodonAPIError, MastodonNotFoundError, MediaAttachment +from mastodon.Mastodon import ( + MastodonAPIError, + MastodonNotFoundError, + MastodonUnauthorizedError, + MediaAttachment, +) import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import camera, image, media_source from homeassistant.components.mastodon.const import ( ATTR_ACCOUNT_NAME, + ATTR_ATTRIBUTION_DOMAINS, + ATTR_AVATAR, + ATTR_AVATAR_MIME_TYPE, + ATTR_BOT, ATTR_CONTENT_WARNING, + ATTR_DISCOVERABLE, + ATTR_DISPLAY_NAME, ATTR_DURATION, + ATTR_FIELDS, + ATTR_HEADER, + ATTR_HEADER_MIME_TYPE, ATTR_HIDE_NOTIFICATIONS, ATTR_IDEMPOTENCY_KEY, ATTR_LANGUAGE, + ATTR_LOCKED, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, + ATTR_NOTE, ATTR_STATUS, ATTR_VISIBILITY, DOMAIN, @@ -25,10 +43,13 @@ SERVICE_MUTE_ACCOUNT, SERVICE_POST, SERVICE_UNMUTE_ACCOUNT, + SERVICE_UPDATE_PROFILE, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.setup import async_setup_component from . import setup_integration @@ -651,3 +672,207 @@ async def test_service_entry_availability( return_response=False, ) assert err.value.translation_key == "service_config_entry_not_found" + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + {ATTR_DISPLAY_NAME: "Test User"}, + {ATTR_DISPLAY_NAME: "Test User"}, + ), + ( + {ATTR_NOTE: "bio"}, + {ATTR_NOTE: "bio"}, + ), + ( + {ATTR_LOCKED: True}, + {ATTR_LOCKED: True}, + ), + ( + {ATTR_BOT: False}, + {ATTR_BOT: False}, + ), + ( + {ATTR_DISCOVERABLE: True}, + {ATTR_DISCOVERABLE: True}, + ), + ( + {ATTR_FIELDS: [{"name": "Pronouns", "value": "He/Him, They/Them"}]}, + {ATTR_FIELDS: [("Pronouns", "He/Him, They/Them")]}, + ), + ( + {ATTR_ATTRIBUTION_DOMAINS: ["example.com", "test.com"]}, + {ATTR_ATTRIBUTION_DOMAINS: ["example.com", "test.com"]}, + ), + ( + { + ATTR_AVATAR: { + "media_content_id": "media-source://camera/camera.demo_camera", + "media_content_type": "image/jpeg", + } + }, + {ATTR_AVATAR: b"I play the sax\n", ATTR_AVATAR_MIME_TYPE: "image/jpeg"}, + ), + ( + { + ATTR_AVATAR: { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/png", + } + }, + { + ATTR_AVATAR: Path( + "tests/testing_config/media/screenshot.jpg" + ).resolve(), + ATTR_AVATAR_MIME_TYPE: "image/jpeg", + }, + ), + ( + { + ATTR_AVATAR: { + "media_content_id": "media-source://image/image.test", + "media_content_type": "image/png", + } + }, + {ATTR_AVATAR: b"\x89PNG", ATTR_AVATAR_MIME_TYPE: "image/png"}, + ), + ( + { + ATTR_HEADER: { + "media_content_id": "media-source://camera/camera.demo_camera", + "media_content_type": "image/jpeg", + } + }, + {ATTR_HEADER: b"I play the sax\n", ATTR_HEADER_MIME_TYPE: "image/jpeg"}, + ), + ( + { + ATTR_HEADER: { + "media_content_id": "media-source://image/image.test", + "media_content_type": "image/png", + } + }, + {ATTR_HEADER: b"\x89PNG", ATTR_HEADER_MIME_TYPE: "image/png"}, + ), + ( + { + ATTR_HEADER: { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/png", + } + }, + { + ATTR_HEADER: Path( + "tests/testing_config/media/screenshot.jpg" + ).resolve(), + ATTR_HEADER_MIME_TYPE: "image/jpeg", + }, + ), + ], +) +async def test_service_update_profile( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], +) -> None: + """Test the update profile service.""" + assert await async_setup_component(hass, "media_source", {}) + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + with ( + patch( + "homeassistant.components.camera.async_get_image", + return_value=camera.Image("image/jpeg", b"I play the sax\n"), + ), + patch( + "homeassistant.components.image.async_get_image", + return_value=image.Image(content_type="image/png", content=b"\x89PNG"), + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_PROFILE, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, **payload}, + blocking=True, + return_response=True, + ) + + mock_mastodon_client.account_update_credentials.assert_called_with(**kwargs) + + +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + (MastodonAPIError, "unable_to_update_profile"), + (MastodonUnauthorizedError, "auth_failed"), + ], +) +async def test_service_update_profile_exceptions( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: type[Exception], + translation_key: str, +) -> None: + """Test the update profile service exceptions.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_mastodon_client.account_update_credentials.side_effect = exception + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_PROFILE, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DISPLAY_NAME: "Test User", + }, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == translation_key + + +@pytest.mark.usefixtures("mock_mastodon_client") +async def test_service_update_profile_media_source_not_supported( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the update profile service with unsupported media source.""" + assert await async_setup_component(hass, "tts", {}) + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + with ( + patch( + "homeassistant.components.mastodon.services.async_resolve_media", + return_value=media_source.PlayMedia( + url="/api/tts_proxy/WDyphPCh3sAoO3koDY87ew.mp3", + mime_type="audio/mpeg", + path=None, + ), + ), + pytest.raises(HomeAssistantError) as err, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_PROFILE, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_AVATAR: { + "media_content_id": "media-source://tts/demo?message=Hello+world%21&language=en", + "media_content_type": "audio/mp3", + }, + }, + blocking=True, + return_response=True, + ) + assert err.value.translation_key == "media_source_not_supported" From db6d95273ce026e12a70101a8abe2d39b6d329b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 6 Apr 2026 11:05:02 +0200 Subject: [PATCH 0481/1707] Add microwave to some sensors' related appliance types (#167501) --- homeassistant/components/home_connect/sensor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 810d7ad356d102..177c58b8982f01 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -57,6 +57,7 @@ class HomeConnectSensorEntityDescription( "CookProcessor", "Dishwasher", "Dryer", + "Microwave", "Hood", "Oven", "Washer", @@ -198,7 +199,7 @@ class HomeConnectSensorEntityDescription( options=EVENT_OPTIONS, default_value="off", translation_key="program_aborted", - appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"), + appliance_types=("Dishwasher", "Microwave", "CleaningRobot", "CookProcessor"), ), HomeConnectSensorEntityDescription( key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED, @@ -211,6 +212,7 @@ class HomeConnectSensorEntityDescription( "Dishwasher", "Washer", "Dryer", + "Microwave", "WasherDryer", "CleaningRobot", "CookProcessor", From c243680113f6f99a189fc40934477d94747a1c62 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Apr 2026 11:05:33 +0200 Subject: [PATCH 0482/1707] Add coordinator update after press call in Portainer (#167497) --- homeassistant/components/portainer/button.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index c12c67bf81633d..6cf761149be9be 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -199,6 +199,8 @@ async def async_press(self) -> None: translation_key="timeout_connect_no_details", ) from err + await self.coordinator.async_request_refresh() + class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton): """Defines a Portainer endpoint button.""" From 893d9306d4bceaeb7666bbda04e6b81e0767a535 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Apr 2026 11:09:19 +0200 Subject: [PATCH 0483/1707] Refactor async_setup in Firefly III (#167496) --- homeassistant/components/firefly_iii/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/firefly_iii/coordinator.py b/homeassistant/components/firefly_iii/coordinator.py index 6b67a657ffd876..eab5d82adefc62 100644 --- a/homeassistant/components/firefly_iii/coordinator.py +++ b/homeassistant/components/firefly_iii/coordinator.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -79,13 +79,13 @@ async def _async_setup(self) -> None: translation_placeholders={"error": repr(err)}, ) from err except FireflyConnectionError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", translation_placeholders={"error": repr(err)}, ) from err except FireflyTimeoutError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", translation_placeholders={"error": repr(err)}, From 459fc436258e355f94f432a803c6cc2dec59db54 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 6 Apr 2026 11:18:42 +0200 Subject: [PATCH 0484/1707] Migrate wifi switch unique_id for Fritz (#166751) --- homeassistant/components/fritz/const.py | 1 - homeassistant/components/fritz/strings.json | 12 ++ homeassistant/components/fritz/switch.py | 135 ++++++++++++---- tests/components/fritz/conftest.py | 9 ++ .../fritz/snapshots/test_switch.ambr | 144 ++++++++--------- tests/components/fritz/test_switch.py | 146 +++++++++++++++++- 6 files changed, 339 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 604d3f94bf9896..032efb3f4ae9cd 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -80,6 +80,5 @@ class MeshRoles(StrEnum): FRITZ_AUTH_EXCEPTIONS = (FritzAuthorizationError, FritzSecurityError) -WIFI_STANDARD = {1: "2.4Ghz", 2: "5Ghz", 3: "5Ghz", 4: "Guest"} CONNECTION_TYPE_LAN = "LAN" diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 73bf2cbffbfcb7..22cdd12bd20fe5 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -169,6 +169,18 @@ "switch": { "internet_access": { "name": "Internet access" + }, + "wi_fi_guest": { + "name": "Guest" + }, + "wi_fi_main_2_4ghz": { + "name": "Main 2.4 GHz" + }, + "wi_fi_main_5ghz": { + "name": "Main 5 GHz" + }, + "wi_fi_main_5ghz_high_6ghz": { + "name": "Main 5 GHz High / 6 GHz" } } }, diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 61255e27a4dfe5..dd91c1a966ba5d 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -9,6 +9,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -22,8 +23,8 @@ SWITCH_TYPE_PORTFORWARD, SWITCH_TYPE_PROFILE, SWITCH_TYPE_WIFINETWORK, - WIFI_STANDARD, MeshRoles, + Platform, ) from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzBoxBaseEntity @@ -35,6 +36,101 @@ # Set a sane value to avoid too many updates PARALLEL_UPDATES = 5 +WIFI_STANDARD = {1: "2.4Ghz", 2: "5Ghz", 3: "5Ghz", 4: "Guest"} + +WIFI_BAND = { + 0: {"band": "2.4Ghz"}, + 1: {"band": "5Ghz"}, + 3: {"band": "5Ghz High / 6Ghz"}, +} + + +def _wifi_naming( + network_info: dict[str, Any], wifi_index: int, wifi_count: int +) -> str | None: + """Return a friendly name for a Wi-Fi network.""" + + if wifi_index == 2 and wifi_count == 4: + # In case of 4 Wi-Fi networks, the 2nd one is used for internal communication + # between mesh devices and should not be named like the others to avoid confusion + return None + + if (wifi_index + 1) == wifi_count: + # Last Wi-Fi network in the guest network, both bands available + return "Guest" + + # Cast to correct type for type checker + if (result := WIFI_BAND.get(wifi_index)) is not None: + return f"Main {result['band']}" + + return None + + +async def _get_wifi_networks_list(avm_wrapper: AvmWrapper) -> dict[int, dict[str, Any]]: + """Get a list of wifi networks with friendly names.""" + wifi_count = len( + [ + s + for s in avm_wrapper.connection.services + if s.startswith("WLANConfiguration") + ] + ) + _LOGGER.debug("WiFi networks count: %s", wifi_count) + networks: dict[int, dict[str, Any]] = {} + for i in range(1, wifi_count + 1): + network_info = await avm_wrapper.async_get_wlan_configuration(i) + if (switch_name := _wifi_naming(network_info, i - 1, wifi_count)) is None: + continue + networks[i] = network_info + networks[i]["switch_name"] = switch_name + + _LOGGER.debug("WiFi networks list: %s", networks) + return networks + + +async def _migrate_to_new_unique_id( + hass: HomeAssistant, avm_wrapper: AvmWrapper +) -> None: + """Migrate old unique ids to new unique ids.""" + + _LOGGER.debug("Migrating Wi-Fi switches") + entity_registry = er.async_get(hass) + + networks = await _get_wifi_networks_list(avm_wrapper) + for index, network in networks.items(): + description = f"Wi-Fi {network['NewSSID']}" + if ( + len( + [ + j + for j, n in networks.items() + if slugify(n["NewSSID"]) == slugify(network["NewSSID"]) + ] + ) + > 1 + ): + description += f" ({WIFI_STANDARD[index]})" + + old_unique_id = f"{avm_wrapper.unique_id}-{slugify(description)}" + new_unique_id = f"{avm_wrapper.unique_id}-wi_fi_{slugify(_wifi_naming(network, index - 1, len(networks)))}" + + entity_id = entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, old_unique_id + ) + + if entity_id is not None: + entity_registry.async_update_entity( + entity_id, + new_unique_id=new_unique_id, + ) + _LOGGER.debug( + "Migrating Wi-FI switch unique_id from [%s] to [%s]", + old_unique_id, + new_unique_id, + ) + + _LOGGER.debug("Migration completed") + async def _async_deflection_entities_list( avm_wrapper: AvmWrapper, device_friendly_name: str @@ -125,35 +221,7 @@ async def _async_wifi_entities_list( # # https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/wlanconfigSCPD.pdf # - wifi_count = len( - [ - s - for s in avm_wrapper.connection.services - if s.startswith("WLANConfiguration") - ] - ) - _LOGGER.debug("WiFi networks count: %s", wifi_count) - networks: dict[int, dict[str, Any]] = {} - for i in range(1, wifi_count + 1): - network_info = await avm_wrapper.async_get_wlan_configuration(i) - # Devices with 4 WLAN services, use the 2nd for internal communications - if not (wifi_count == 4 and i == 2): - networks[i] = network_info - for i, network in networks.copy().items(): - networks[i]["switch_name"] = network["NewSSID"] - if ( - len( - [ - j - for j, n in networks.items() - if slugify(n["NewSSID"]) == slugify(network["NewSSID"]) - ] - ) - > 1 - ): - networks[i]["switch_name"] += f" ({WIFI_STANDARD[i]})" - - _LOGGER.debug("WiFi networks list: %s", networks) + networks = await _get_wifi_networks_list(avm_wrapper) return [ FritzBoxWifiSwitch(avm_wrapper, device_friendly_name, index, data) for index, data in networks.items() @@ -225,6 +293,8 @@ async def async_setup_entry( local_ip = await async_get_source_ip(avm_wrapper.hass, target_ip=avm_wrapper.host) + await _migrate_to_new_unique_id(hass, avm_wrapper) + entities_list = await async_all_entities_list( avm_wrapper, entry.title, @@ -554,8 +624,11 @@ def __init__( ) self._network_num = network_num + description = f"Wi-Fi {network_data['switch_name']}" + self._attr_translation_key = slugify(description) + switch_info = SwitchInfo( - description=f"Wi-Fi {network_data['switch_name']}", + description=description, friendly_name=device_friendly_name, icon="mdi:wifi", type=SWITCH_TYPE_WIFINETWORK, diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index 5d32c4705e773c..65abfc3a4a0af3 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -106,6 +106,15 @@ def call_action(self, service: str, action: str, **kwargs: Any) -> Any: return action_data +def wifi_services_with_ssids(ssid_1: str, ssid_2: str) -> dict[str, dict[str, Any]]: + """Return Fritz services with overridden Wi-Fi SSIDs.""" + services = deepcopy(MOCK_FB_SERVICES) + for index, ssid in enumerate((ssid_1, ssid_2), start=1): + services[f"WLANConfiguration{index}"]["GetInfo"]["NewSSID"] = ssid + services[f"WLANConfiguration{index}"]["GetSSID"]["NewSSID"] = ssid + return services + + @pytest.fixture(name="fc_data") def fc_data_mock() -> dict[str, dict[str, Any]]: """Fixture for default fc_data.""" diff --git a/tests/components/fritz/snapshots/test_switch.ambr b/tests/components/fritz/snapshots/test_switch.ambr index 8789576d970c79..9002c780243ad2 100644 --- a/tests/components/fritz/snapshots/test_switch.ambr +++ b/tests/components/fritz/snapshots/test_switch.ambr @@ -101,7 +101,7 @@ 'state': 'on', }) # --- -# name: test_switch_setup[fc_data0][switch.mock_title_wi_fi_wifi_2_4ghz-entry] +# name: test_switch_setup[fc_data0][switch.mock_title_wi_fi_guest-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -115,7 +115,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.mock_title_wi_fi_wifi_2_4ghz', + 'entity_id': 'switch.mock_title_wi_fi_guest', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -123,36 +123,36 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'object_id_base': 'Mock Title Wi-Fi Guest', 'options': dict({ }), 'original_device_class': None, 'original_icon': 'mdi:wifi', - 'original_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'original_name': 'Mock Title Wi-Fi Guest', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_2_4ghz', + 'translation_key': 'wi_fi_guest', + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guest', 'unit_of_measurement': None, }) # --- -# name: test_switch_setup[fc_data0][switch.mock_title_wi_fi_wifi_2_4ghz-state] +# name: test_switch_setup[fc_data0][switch.mock_title_wi_fi_guest-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'friendly_name': 'Mock Title Wi-Fi Guest', 'icon': 'mdi:wifi', }), 'context': , - 'entity_id': 'switch.mock_title_wi_fi_wifi_2_4ghz', + 'entity_id': 'switch.mock_title_wi_fi_guest', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_switch_setup[fc_data0][switch.mock_title_wi_fi_wifi_5ghz-entry] +# name: test_switch_setup[fc_data0][switch.mock_title_wi_fi_main_2_4ghz-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -166,7 +166,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.mock_title_wi_fi_wifi_5ghz', + 'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -174,29 +174,29 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Mock Title Wi-Fi WiFi (5Ghz)', + 'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz', 'options': dict({ }), 'original_device_class': None, 'original_icon': 'mdi:wifi', - 'original_name': 'Mock Title Wi-Fi WiFi (5Ghz)', + 'original_name': 'Mock Title Wi-Fi Main 2.4Ghz', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_5ghz', + 'translation_key': 'wi_fi_main_2_4ghz', + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_main_2_4ghz', 'unit_of_measurement': None, }) # --- -# name: test_switch_setup[fc_data0][switch.mock_title_wi_fi_wifi_5ghz-state] +# name: test_switch_setup[fc_data0][switch.mock_title_wi_fi_main_2_4ghz-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Wi-Fi WiFi (5Ghz)', + 'friendly_name': 'Mock Title Wi-Fi Main 2.4Ghz', 'icon': 'mdi:wifi', }), 'context': , - 'entity_id': 'switch.mock_title_wi_fi_wifi_5ghz', + 'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz', 'last_changed': , 'last_reported': , 'last_updated': , @@ -355,7 +355,7 @@ 'state': 'on', }) # --- -# name: test_switch_setup[fc_data1][switch.mock_title_wi_fi_wifi-entry] +# name: test_switch_setup[fc_data1][switch.mock_title_wi_fi_guest-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -369,7 +369,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.mock_title_wi_fi_wifi', + 'entity_id': 'switch.mock_title_wi_fi_guest', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -377,36 +377,36 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Mock Title Wi-Fi WiFi', + 'object_id_base': 'Mock Title Wi-Fi Guest', 'options': dict({ }), 'original_device_class': None, 'original_icon': 'mdi:wifi', - 'original_name': 'Mock Title Wi-Fi WiFi', + 'original_name': 'Mock Title Wi-Fi Guest', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi', + 'translation_key': 'wi_fi_guest', + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guest', 'unit_of_measurement': None, }) # --- -# name: test_switch_setup[fc_data1][switch.mock_title_wi_fi_wifi-state] +# name: test_switch_setup[fc_data1][switch.mock_title_wi_fi_guest-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Wi-Fi WiFi', + 'friendly_name': 'Mock Title Wi-Fi Guest', 'icon': 'mdi:wifi', }), 'context': , - 'entity_id': 'switch.mock_title_wi_fi_wifi', + 'entity_id': 'switch.mock_title_wi_fi_guest', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_switch_setup[fc_data1][switch.mock_title_wi_fi_wifi2-entry] +# name: test_switch_setup[fc_data1][switch.mock_title_wi_fi_main_2_4ghz-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -420,7 +420,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.mock_title_wi_fi_wifi2', + 'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -428,29 +428,29 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Mock Title Wi-Fi WiFi2', + 'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz', 'options': dict({ }), 'original_device_class': None, 'original_icon': 'mdi:wifi', - 'original_name': 'Mock Title Wi-Fi WiFi2', + 'original_name': 'Mock Title Wi-Fi Main 2.4Ghz', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi2', + 'translation_key': 'wi_fi_main_2_4ghz', + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_main_2_4ghz', 'unit_of_measurement': None, }) # --- -# name: test_switch_setup[fc_data1][switch.mock_title_wi_fi_wifi2-state] +# name: test_switch_setup[fc_data1][switch.mock_title_wi_fi_main_2_4ghz-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Wi-Fi WiFi2', + 'friendly_name': 'Mock Title Wi-Fi Main 2.4Ghz', 'icon': 'mdi:wifi', }), 'context': , - 'entity_id': 'switch.mock_title_wi_fi_wifi2', + 'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz', 'last_changed': , 'last_reported': , 'last_updated': , @@ -609,7 +609,7 @@ 'state': 'on', }) # --- -# name: test_switch_setup[fc_data2][switch.mock_title_wi_fi_wifi_2_4ghz-entry] +# name: test_switch_setup[fc_data2][switch.mock_title_wi_fi_guest-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -623,7 +623,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.mock_title_wi_fi_wifi_2_4ghz', + 'entity_id': 'switch.mock_title_wi_fi_guest', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -631,36 +631,36 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'object_id_base': 'Mock Title Wi-Fi Guest', 'options': dict({ }), 'original_device_class': None, 'original_icon': 'mdi:wifi', - 'original_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'original_name': 'Mock Title Wi-Fi Guest', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_2_4ghz', + 'translation_key': 'wi_fi_guest', + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guest', 'unit_of_measurement': None, }) # --- -# name: test_switch_setup[fc_data2][switch.mock_title_wi_fi_wifi_2_4ghz-state] +# name: test_switch_setup[fc_data2][switch.mock_title_wi_fi_guest-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'friendly_name': 'Mock Title Wi-Fi Guest', 'icon': 'mdi:wifi', }), 'context': , - 'entity_id': 'switch.mock_title_wi_fi_wifi_2_4ghz', + 'entity_id': 'switch.mock_title_wi_fi_guest', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_switch_setup[fc_data2][switch.mock_title_wi_fi_wifi_5ghz-entry] +# name: test_switch_setup[fc_data2][switch.mock_title_wi_fi_main_2_4ghz-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -674,7 +674,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.mock_title_wi_fi_wifi_5ghz', + 'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -682,29 +682,29 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Mock Title Wi-Fi WiFi+ (5Ghz)', + 'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz', 'options': dict({ }), 'original_device_class': None, 'original_icon': 'mdi:wifi', - 'original_name': 'Mock Title Wi-Fi WiFi+ (5Ghz)', + 'original_name': 'Mock Title Wi-Fi Main 2.4Ghz', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_5ghz', + 'translation_key': 'wi_fi_main_2_4ghz', + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_main_2_4ghz', 'unit_of_measurement': None, }) # --- -# name: test_switch_setup[fc_data2][switch.mock_title_wi_fi_wifi_5ghz-state] +# name: test_switch_setup[fc_data2][switch.mock_title_wi_fi_main_2_4ghz-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Wi-Fi WiFi+ (5Ghz)', + 'friendly_name': 'Mock Title Wi-Fi Main 2.4Ghz', 'icon': 'mdi:wifi', }), 'context': , - 'entity_id': 'switch.mock_title_wi_fi_wifi_5ghz', + 'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz', 'last_changed': , 'last_reported': , 'last_updated': , @@ -920,7 +920,7 @@ 'state': 'on', }) # --- -# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_guestwifi-entry] +# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_guest-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -934,7 +934,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.mock_title_wi_fi_guestwifi', + 'entity_id': 'switch.mock_title_wi_fi_guest', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -942,36 +942,36 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Mock Title Wi-Fi GuestWifi', + 'object_id_base': 'Mock Title Wi-Fi Guest', 'options': dict({ }), 'original_device_class': None, 'original_icon': 'mdi:wifi', - 'original_name': 'Mock Title Wi-Fi GuestWifi', + 'original_name': 'Mock Title Wi-Fi Guest', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guestwifi', + 'translation_key': 'wi_fi_guest', + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guest', 'unit_of_measurement': None, }) # --- -# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_guestwifi-state] +# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_guest-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Wi-Fi GuestWifi', + 'friendly_name': 'Mock Title Wi-Fi Guest', 'icon': 'mdi:wifi', }), 'context': , - 'entity_id': 'switch.mock_title_wi_fi_guestwifi', + 'entity_id': 'switch.mock_title_wi_fi_guest', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_mywifi-entry] +# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_main_2_4ghz-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -985,7 +985,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.mock_title_wi_fi_mywifi', + 'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz', 'has_entity_name': False, 'hidden_by': None, 'icon': None, @@ -993,29 +993,29 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Mock Title Wi-Fi MyWifi', + 'object_id_base': 'Mock Title Wi-Fi Main 2.4Ghz', 'options': dict({ }), 'original_device_class': None, 'original_icon': 'mdi:wifi', - 'original_name': 'Mock Title Wi-Fi MyWifi', + 'original_name': 'Mock Title Wi-Fi Main 2.4Ghz', 'platform': 'fritz', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_mywifi', + 'translation_key': 'wi_fi_main_2_4ghz', + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_main_2_4ghz', 'unit_of_measurement': None, }) # --- -# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_mywifi-state] +# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_main_2_4ghz-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Wi-Fi MyWifi', + 'friendly_name': 'Mock Title Wi-Fi Main 2.4Ghz', 'icon': 'mdi:wifi', }), 'context': , - 'entity_id': 'switch.mock_title_wi_fi_mywifi', + 'entity_id': 'switch.mock_title_wi_fi_main_2_4ghz', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/fritz/test_switch.py b/tests/components/fritz/test_switch.py index df32344ee04fcc..895b738451a8cc 100644 --- a/tests/components/fritz/test_switch.py +++ b/tests/components/fritz/test_switch.py @@ -3,13 +3,14 @@ from __future__ import annotations from copy import deepcopy -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from fritzconnection.core.exceptions import FritzActionError from fritzconnection.lib.fritzstatus import DefaultConnectionService import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.fritz import switch as fritz_switch from homeassistant.components.fritz.const import DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, @@ -25,13 +26,16 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.util import slugify -from .conftest import FritzConnectionMock +from .conftest import FritzConnectionMock, wifi_services_with_ssids from .const import ( MOCK_CALL_DEFLECTION_DATA, MOCK_FB_SERVICES, MOCK_HOST_ATTRIBUTES_DATA, + MOCK_MESH_MASTER_MAC, MOCK_USER_DATA, ) @@ -397,7 +401,7 @@ async def test_switch_device_no_ip_address( STATE_ON, ), ( - "switch.mock_title_wi_fi_mywifi", + "switch.mock_title_wi_fi_guest", "async_set_wlan_configuration", STATE_ON, ), @@ -455,3 +459,137 @@ async def test_switch_turn_on_off( assert (state := hass.states.get(entity_id)) assert state.state == state_value + + +@pytest.mark.parametrize( + ("ssid_1", "ssid_2", "old_descriptions", "new_identifiers"), + [ + ( + "Main WiFi / +", + "Guest WiFi / +", + [ + "Wi-Fi Main WiFi / +", + "Wi-Fi Guest WiFi / +", + ], + ["main_2_4ghz", "guest"], + ), + ( + "My WiFi / +", + "My WiFi / +", + [ + "Wi-Fi My WiFi / + (2.4Ghz)", + "Wi-Fi My WiFi / + (5Ghz)", + ], + ["main_2_4ghz", "guest"], + ), + ], +) +async def test_migrate_to_new_unique_id( + hass: HomeAssistant, + fc_class_mock, + fh_class_mock, + fs_class_mock, + entity_registry: EntityRegistry, + device_registry: dr.DeviceRegistry, + ssid_1: str, + ssid_2: str, + old_descriptions: list[str], + new_identifiers: list[str], +) -> None: + """Test migrate from old unique ids to new unique ids.""" + + MOCK_UNIQUE_ID = "1234567890" + + fc_class_mock.return_value.override_services( + wifi_services_with_ssids(ssid_1, ssid_2) + ) + + entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_USER_DATA, unique_id=MOCK_UNIQUE_ID + ) + entry.add_to_hass(hass) + + entity_ids: list[str] = [] + old_unique_ids: list[str] = [] + new_unique_ids: list[str] = [] + for old_description, new_identifier in zip( + old_descriptions, new_identifiers, strict=True + ): + old_unique_id = f"{MOCK_MESH_MASTER_MAC}-{slugify(old_description)}" + new_unique_id = f"{MOCK_MESH_MASTER_MAC}-wi_fi_{new_identifier}" + old_unique_ids.append(old_unique_id) + new_unique_ids.append(new_unique_id) + entity_ids.append(f"switch.fritz_{slugify(old_unique_id)}") + + entity_registry.async_get_or_create( + disabled_by=None, + domain=SWITCH_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, MOCK_UNIQUE_ID)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, MOCK_MESH_MASTER_MAC), + }, + ) + await hass.async_block_till_done() + + for entity_id, old_unique_id in zip(entity_ids, old_unique_ids, strict=True): + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.unique_id == old_unique_id + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + for entity_id, new_unique_id in zip(entity_ids, new_unique_ids, strict=True): + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.unique_id == new_unique_id + + +async def test_wifi_naming_internal_comm_and_skipped() -> None: + """Test skip internal Wi-Fi network.""" + # Prepare AvmWrapper mock with 4 Wi-Fi networks + avm_wrapper = MagicMock() + avm_wrapper.connection.services = [ + "WLANConfiguration1", + "WLANConfiguration2", + "WLANConfiguration3", + "WLANConfiguration4", + ] + # The 3rd network (index 2) should be skipped + wifi_configs = [ + {"NewSSID": "wifi1"}, + {"NewSSID": "wifi2"}, + {"NewSSID": "wifi3"}, + {"NewSSID": "wifi4"}, + ] + avm_wrapper.async_get_wlan_configuration = AsyncMock(side_effect=wifi_configs) + + networks = await fritz_switch._get_wifi_networks_list(avm_wrapper) + # The 3rd network (index 2) should be skipped + assert 3 not in networks # 1-based index, so 3 is the 3rd + # The rest should be present + assert set(networks.keys()) == {1, 2, 4} + + +@pytest.mark.parametrize( + ("wifi_index", "wifi_count", "expected_name"), + [ + (0, 2, "Main 2.4Ghz"), + (1, 3, "Main 5Ghz"), + (1, 2, "Guest"), + (2, 4, None), + (2, 5, None), + ], +) +def test_wifi_naming_helper( + wifi_index: int, wifi_count: int, expected_name: str | None +) -> None: + """Test Wi-Fi naming helper covers supported and fallback branches.""" + assert fritz_switch._wifi_naming({}, wifi_index, wifi_count) == expected_name From ee4c941610ec79bbdfd17e781a8ff2e6f463c8bd Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 6 Apr 2026 11:24:34 +0200 Subject: [PATCH 0485/1707] Update IQS to gold for Fritz (#167358) --- homeassistant/components/fritz/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 8688eddbdab708..96f73918085d66 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "quality_scale": "silver", + "quality_scale": "gold", "requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"], "ssdp": [ { From 15ddce74a7ce78dfb7a0ee6b7694dfa736a805dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:46:32 +0200 Subject: [PATCH 0486/1707] Bump github/codeql-action from 4.32.6 to 4.35.1 (#167495) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b0d1025642ed0c..e931e77c86eb5d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: category: "/language:python" From 1a7465dd72c260a1d19af7e29fb9bc2d3b173f74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:51:59 +0200 Subject: [PATCH 0487/1707] Bump home-assistant/actions from 5f5b077d63a1e4c53019231409a0c4d791fb74e5 to 5752577ea7cc5aefb064b0b21432f18fe4d6ba90 (#167494) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 7e95120659bc04..926b478e10b204 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -49,7 +49,7 @@ jobs: - name: Get information id: info - uses: home-assistant/actions/helpers/info@5f5b077d63a1e4c53019231409a0c4d791fb74e5 # zizmor: ignore[unpinned-uses] + uses: home-assistant/actions/helpers/info@5752577ea7cc5aefb064b0b21432f18fe4d6ba90 # zizmor: ignore[unpinned-uses] - name: Get version id: version From e70514f540264f67f0c153cb96bb9f44eb6e23f6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 6 Apr 2026 02:53:27 -0700 Subject: [PATCH 0488/1707] Bump pyrainbird to 6.3.0 (#167493) --- homeassistant/components/rainbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 9563d9b7268926..b8a77b87fafdb9 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==6.1.1"] + "requirements": ["pyrainbird==6.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 93d5d0af719d20..4ced649071671a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2424,7 +2424,7 @@ pyqwikswitch==0.93 pyrail==0.4.1 # homeassistant.components.rainbird -pyrainbird==6.1.1 +pyrainbird==6.3.0 # homeassistant.components.playstation_network pyrate-limiter==4.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f5c9c490d1d45..f98b03d8bae2cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2077,7 +2077,7 @@ pyqwikswitch==0.93 pyrail==0.4.1 # homeassistant.components.rainbird -pyrainbird==6.1.1 +pyrainbird==6.3.0 # homeassistant.components.playstation_network pyrate-limiter==4.1.0 From 11fac8ee4879ed104dde96dce76f0437de6eda25 Mon Sep 17 00:00:00 2001 From: Chase <43818313+ab3lson@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:04:32 -0400 Subject: [PATCH 0489/1707] OpenRouter: Update quality scale (#166921) --- .../components/open_router/quality_scale.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/open_router/quality_scale.yaml b/homeassistant/components/open_router/quality_scale.yaml index 9b71a29dc6b71d..5ac803dfa50832 100644 --- a/homeassistant/components/open_router/quality_scale.yaml +++ b/homeassistant/components/open_router/quality_scale.yaml @@ -45,7 +45,7 @@ rules: comment: the integration only integrates state-less entities parallel-updates: todo reauthentication-flow: todo - test-coverage: todo + test-coverage: done # Gold devices: done @@ -63,8 +63,12 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo - entity-category: todo + dynamic-devices: + status: exempt + comment: devices are created via subentries, not discovered dynamically + entity-category: + status: exempt + comment: conversation and AI task entities do not use entity categories entity-device-class: status: exempt comment: no suitable device class for the conversation entity From 310af5a31a75cd201018c22697a1535ea5f7381c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 6 Apr 2026 03:05:06 -0700 Subject: [PATCH 0490/1707] Update roborock services to raise ServiceNotSupported for new devices that don't yet support it (#167470) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/roborock/vacuum.py | 30 +++++++- tests/components/roborock/test_vacuum.py | 82 ++++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 623644379a97d9..68259aa15d7ec7 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -19,7 +19,11 @@ VacuumEntityFeature, ) from homeassistant.core import HomeAssistant, ServiceResponse, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotSupported, + ServiceValidationError, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -484,6 +488,18 @@ async def async_send_command( }, ) from err + async def get_maps(self) -> ServiceResponse: + """Get map information such as map id and room ids.""" + raise ServiceNotSupported(DOMAIN, "get_maps", self.entity_id) + + async def get_vacuum_current_position(self) -> ServiceResponse: + """Get the current position of the vacuum from the map.""" + raise ServiceNotSupported(DOMAIN, "get_vacuum_current_position", self.entity_id) + + async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: + """Set the vacuum to go to a specific position.""" + raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id) + class RoborockQ10Vacuum(RoborockCoordinatedEntityB01Q10, StateVacuumEntity): """Representation of a Roborock Q10 vacuum.""" @@ -654,3 +670,15 @@ async def async_send_command( "command": command, }, ) from err + + async def get_maps(self) -> ServiceResponse: + """Get map information such as map id and room ids.""" + raise ServiceNotSupported(DOMAIN, "get_maps", self.entity_id) + + async def get_vacuum_current_position(self) -> ServiceResponse: + """Get the current position of the vacuum from the map.""" + raise ServiceNotSupported(DOMAIN, "get_vacuum_current_position", self.entity_id) + + async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: + """Set the vacuum to go to a specific position.""" + raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id) diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 8cc32b0eb60eda..953c390b8e148c 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -34,7 +34,11 @@ ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotSupported, + ServiceValidationError, +) from homeassistant.helpers import ( device_registry as dr, entity_registry as er, @@ -217,6 +221,31 @@ async def test_get_maps( assert response == snapshot +@pytest.mark.parametrize( + "entity_id", + [ + Q7_ENTITY_ID, + Q10_ENTITY_ID, + ], +) +async def test_get_maps_not_supported( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that unsupported vacuums raise ServiceNotSupported for get_maps.""" + with pytest.raises( + ServiceNotSupported, match="does not support action roborock.get_maps" + ): + await hass.services.async_call( + DOMAIN, + GET_MAPS_SERVICE_NAME, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + return_response=True, + ) + + async def test_goto( hass: HomeAssistant, setup_entry: MockConfigEntry, @@ -239,6 +268,31 @@ async def test_goto( ) +@pytest.mark.parametrize( + "entity_id", + [ + Q7_ENTITY_ID, + Q10_ENTITY_ID, + ], +) +async def test_goto_not_supported( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that unsupported vacuums raise ServiceNotSupported for goto.""" + with pytest.raises( + ServiceNotSupported, + match="does not support action roborock.set_vacuum_goto_position", + ): + await hass.services.async_call( + DOMAIN, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: entity_id, "x": 25500, "y": 25500}, + blocking=True, + ) + + async def test_get_current_position( hass: HomeAssistant, setup_entry: MockConfigEntry, @@ -305,6 +359,32 @@ async def test_get_current_position_no_robot_position( ) +@pytest.mark.parametrize( + "entity_id", + [ + Q7_ENTITY_ID, + Q10_ENTITY_ID, + ], +) +async def test_get_current_position_not_supported( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that the current-position service raises ServiceNotSupported.""" + with pytest.raises( + ServiceNotSupported, + match="does not support action roborock.get_vacuum_current_position", + ): + await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + return_response=True, + ) + + async def test_get_segments( hass: HomeAssistant, setup_entry: MockConfigEntry, From 4ad2f752a3382c68354589a23103568134ae739b Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Mon, 6 Apr 2026 06:06:03 -0400 Subject: [PATCH 0491/1707] Fix flaky fire_callbacks tests in casper_glow (#167418) --- tests/components/casper_glow/conftest.py | 8 ++++--- .../casper_glow/test_binary_sensor.py | 22 +++++++++---------- tests/components/casper_glow/test_light.py | 16 +++++++------- tests/components/casper_glow/test_select.py | 18 +++++++-------- tests/components/casper_glow/test_sensor.py | 6 ++--- 5 files changed, 36 insertions(+), 34 deletions(-) diff --git a/tests/components/casper_glow/conftest.py b/tests/components/casper_glow/conftest.py index a9e5bbf006c9a5..5aa80d1b1bcef3 100644 --- a/tests/components/casper_glow/conftest.py +++ b/tests/components/casper_glow/conftest.py @@ -1,6 +1,6 @@ """Casper Glow session fixtures.""" -from collections.abc import Callable, Generator +from collections.abc import Awaitable, Callable, Generator from unittest.mock import MagicMock, patch from pycasperglow import GlowState @@ -53,15 +53,17 @@ def mock_casper_glow() -> Generator[MagicMock]: @pytest.fixture def fire_callbacks( + hass: HomeAssistant, mock_casper_glow: MagicMock, -) -> Callable[[GlowState], None]: +) -> Callable[[GlowState], Awaitable[None]]: """Return a helper that fires all registered device callbacks with a given state.""" - def _fire(state: GlowState) -> None: + async def _fire(state: GlowState) -> None: for cb in ( call[0][0] for call in mock_casper_glow.register_callback.call_args_list ): cb(state) + await hass.async_block_till_done() return _fire diff --git a/tests/components/casper_glow/test_binary_sensor.py b/tests/components/casper_glow/test_binary_sensor.py index cca51cb965997c..c4bd17be276df0 100644 --- a/tests/components/casper_glow/test_binary_sensor.py +++ b/tests/components/casper_glow/test_binary_sensor.py @@ -1,6 +1,6 @@ """Test the Casper Glow binary sensor platform.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch from pycasperglow import GlowState @@ -43,7 +43,7 @@ async def test_paused_state_update( hass: HomeAssistant, mock_casper_glow: MagicMock, mock_config_entry: MockConfigEntry, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], is_paused: bool, expected_state: str, ) -> None: @@ -53,7 +53,7 @@ async def test_paused_state_update( ): await setup_integration(hass, mock_config_entry) - fire_callbacks(GlowState(is_paused=is_paused)) + await fire_callbacks(GlowState(is_paused=is_paused)) state = hass.states.get(PAUSED_ENTITY_ID) assert state is not None assert state.state == expected_state @@ -63,7 +63,7 @@ async def test_paused_ignores_none_state( hass: HomeAssistant, mock_casper_glow: MagicMock, mock_config_entry: MockConfigEntry, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test that a callback with is_paused=None does not overwrite the state.""" with patch( @@ -72,13 +72,13 @@ async def test_paused_ignores_none_state( await setup_integration(hass, mock_config_entry) # Set a known value first - fire_callbacks(GlowState(is_paused=True)) + await fire_callbacks(GlowState(is_paused=True)) state = hass.states.get(PAUSED_ENTITY_ID) assert state is not None assert state.state == STATE_ON # Callback with no is_paused data — state should remain unchanged - fire_callbacks(GlowState(is_on=True)) + await fire_callbacks(GlowState(is_on=True)) state = hass.states.get(PAUSED_ENTITY_ID) assert state is not None assert state.state == STATE_ON @@ -93,7 +93,7 @@ async def test_charging_state_update( hass: HomeAssistant, mock_casper_glow: MagicMock, mock_config_entry: MockConfigEntry, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], is_charging: bool, expected_state: str, ) -> None: @@ -103,7 +103,7 @@ async def test_charging_state_update( ): await setup_integration(hass, mock_config_entry) - fire_callbacks(GlowState(is_charging=is_charging)) + await fire_callbacks(GlowState(is_charging=is_charging)) state = hass.states.get(CHARGING_ENTITY_ID) assert state is not None assert state.state == expected_state @@ -113,7 +113,7 @@ async def test_charging_ignores_none_state( hass: HomeAssistant, mock_casper_glow: MagicMock, mock_config_entry: MockConfigEntry, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test that a callback with is_charging=None does not overwrite the state.""" with patch( @@ -122,13 +122,13 @@ async def test_charging_ignores_none_state( await setup_integration(hass, mock_config_entry) # Set a known value first - fire_callbacks(GlowState(is_charging=True)) + await fire_callbacks(GlowState(is_charging=True)) state = hass.states.get(CHARGING_ENTITY_ID) assert state is not None assert state.state == STATE_ON # Callback with no is_charging data — state should remain unchanged - fire_callbacks(GlowState(is_on=True)) + await fire_callbacks(GlowState(is_on=True)) state = hass.states.get(CHARGING_ENTITY_ID) assert state is not None assert state.state == STATE_ON diff --git a/tests/components/casper_glow/test_light.py b/tests/components/casper_glow/test_light.py index 7375d2ed7f214d..8cadb4caac3c6f 100644 --- a/tests/components/casper_glow/test_light.py +++ b/tests/components/casper_glow/test_light.py @@ -1,6 +1,6 @@ """Test the Casper Glow light platform.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch from pycasperglow import CasperGlowError, GlowState @@ -83,19 +83,19 @@ async def test_turn_off( async def test_state_update_via_callback( hass: HomeAssistant, config_entry: MockConfigEntry, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test that the entity updates state when the device fires a callback.""" state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_UNKNOWN - fire_callbacks(GlowState(is_on=True)) + await fire_callbacks(GlowState(is_on=True)) state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_ON - fire_callbacks(GlowState(is_on=False)) + await fire_callbacks(GlowState(is_on=False)) state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_OFF @@ -149,10 +149,10 @@ async def test_brightness_snap_to_nearest( async def test_brightness_update_via_callback( hass: HomeAssistant, config_entry: MockConfigEntry, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test that brightness updates via device callback.""" - fire_callbacks(GlowState(is_on=True, brightness_level=80)) + await fire_callbacks(GlowState(is_on=True, brightness_level=80)) state = hass.states.get(ENTITY_ID) assert state is not None @@ -196,7 +196,7 @@ async def test_state_update_via_callback_after_command_failure( hass: HomeAssistant, config_entry: MockConfigEntry, mock_casper_glow: MagicMock, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test that device callbacks correctly update state even after a command failure.""" mock_casper_glow.turn_on.side_effect = CasperGlowError("Connection failed") @@ -215,7 +215,7 @@ async def test_state_update_via_callback_after_command_failure( assert state.state == STATE_UNKNOWN # Device sends a push state update — entity reflects true device state - fire_callbacks(GlowState(is_on=True)) + await fire_callbacks(GlowState(is_on=True)) state = hass.states.get(ENTITY_ID) assert state is not None diff --git a/tests/components/casper_glow/test_select.py b/tests/components/casper_glow/test_select.py index 5ca1071a49c101..2ed72541435ce6 100644 --- a/tests/components/casper_glow/test_select.py +++ b/tests/components/casper_glow/test_select.py @@ -1,6 +1,6 @@ """Test the Casper Glow select platform.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch from pycasperglow import CasperGlowError, GlowState @@ -44,14 +44,14 @@ async def test_entities( async def test_select_state_from_callback( hass: HomeAssistant, config_entry: MockConfigEntry, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test that the select entity shows dimming time reported by device callback.""" state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_UNKNOWN - fire_callbacks( + await fire_callbacks( GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[2])) ) @@ -64,7 +64,7 @@ async def test_select_option( hass: HomeAssistant, config_entry: MockConfigEntry, mock_casper_glow: MagicMock, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test selecting a dimming time option.""" await hass.services.async_call( @@ -82,7 +82,7 @@ async def test_select_option( assert state.state == DIMMING_TIME_OPTIONS[1] # A subsequent device callback must not overwrite the user's selection. - fire_callbacks( + await fire_callbacks( GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[0])) ) @@ -118,7 +118,7 @@ async def test_select_state_update_via_callback_after_command_failure( hass: HomeAssistant, config_entry: MockConfigEntry, mock_casper_glow: MagicMock, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test that device callbacks correctly update state even after a command failure.""" mock_casper_glow.set_brightness_and_dimming_time.side_effect = CasperGlowError( @@ -138,7 +138,7 @@ async def test_select_state_update_via_callback_after_command_failure( assert state.state == STATE_UNKNOWN # Device sends a push state update — entity reflects true state - fire_callbacks( + await fire_callbacks( GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[1])) ) @@ -150,10 +150,10 @@ async def test_select_state_update_via_callback_after_command_failure( async def test_select_ignores_remaining_time_updates( hass: HomeAssistant, config_entry: MockConfigEntry, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test that callbacks with only remaining time do not change the select state.""" - fire_callbacks(GlowState(dimming_time_remaining_ms=2_640_000)) + await fire_callbacks(GlowState(dimming_time_remaining_ms=2_640_000)) state = hass.states.get(ENTITY_ID) assert state is not None diff --git a/tests/components/casper_glow/test_sensor.py b/tests/components/casper_glow/test_sensor.py index 329e2440e368f6..c6eb68512a96fb 100644 --- a/tests/components/casper_glow/test_sensor.py +++ b/tests/components/casper_glow/test_sensor.py @@ -1,6 +1,6 @@ """Test the Casper Glow sensor platform.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch from pycasperglow import BatteryLevel, GlowState @@ -59,13 +59,13 @@ async def test_battery_state_updated_via_callback( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_casper_glow: MagicMock, - fire_callbacks: Callable[[GlowState], None], + fire_callbacks: Callable[[GlowState], Awaitable[None]], ) -> None: """Test battery sensor updates when a device callback fires.""" with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) - fire_callbacks(GlowState(battery_level=BatteryLevel.PCT_50)) + await fire_callbacks(GlowState(battery_level=BatteryLevel.PCT_50)) state = hass.states.get(BATTERY_ENTITY_ID) assert state is not None From 0eaa8d38db1babf7cf5d211b90c33e87f766020e Mon Sep 17 00:00:00 2001 From: Andrea Turri Date: Mon, 6 Apr 2026 12:08:29 +0200 Subject: [PATCH 0492/1707] Miele - fix core temperature reading (#167476) --- homeassistant/components/miele/const.py | 4 + homeassistant/components/miele/sensor.py | 16 ++- tests/components/miele/test_sensor.py | 127 ++++++++++++++++++++++- 3 files changed, 141 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 52d728ef9db966..96794aa1edb210 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -19,9 +19,13 @@ LIGHT_ON = 1 LIGHT_OFF = 2 +# API "no reading" sentinels. Most temperatures use centidegrees (-32768 -> -327.68 °C). +# Some devices report the int16 minimum already in degrees after scaling (-3276800 raw -> -32768 °C). DISABLED_TEMP_ENTITIES = ( -32768 / 100, -32766 / 100, + -32768.0, + -32766.0, ) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 9802000e8c42d4..a723763ea35669 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -93,7 +93,14 @@ def _convert_temperature( """Convert temperature object to readable value.""" if index >= len(value_list): return None - raw_value = cast(int, value_list[index].temperature) / 100.0 + raw = value_list[index].temperature + if raw is None: + return None + try: + raw_centi = int(raw) + except TypeError, ValueError: + return None + raw_value = raw_centi / 100.0 if raw_value in DISABLED_TEMP_ENTITIES: return None return raw_value @@ -639,6 +646,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]: MieleAppliance.OVEN, MieleAppliance.OVEN_MICROWAVE, MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MK2, ), description=MieleSensorDescription( key="state_core_temperature", @@ -840,9 +848,9 @@ def _is_sensor_enabled( and definition.description.value_fn(device) is None and definition.description.zone != 1 ): - # all appliances supporting temperature have at least zone 1, for other zones - # don't create entity if API signals that datapoint is disabled, unless the sensor - # already appeared in the past (= it provided a valid value) + # Optional temperature datapoints (extra fridge zones, oven food probe): only + # create the entity after the API first reports a valid reading, then keep it + # so state can return to unknown when the datapoint is inactive. return _is_entity_registered(unique_id) if ( definition.description.key == "state_plate_step" diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index d6b5106eccbf9b..45568c0d2184fb 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -4,11 +4,12 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from pymiele import MieleDevices +from pymiele import MieleDevices, MieleTemperature import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.miele.const import DOMAIN +from homeassistant.components.miele.sensor import _convert_temperature from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -96,7 +97,7 @@ async def test_oven_temperatures_scenario( ) -> None: """Parametrized test for verifying temperature sensors for oven devices.""" - # Initial state when the oven is and created for the first time - don't know if it supports core temperature (probe) + # Initial state when the oven is created for the first time — no core probe entities yet check_sensor_state(hass, "sensor.oven_temperature", "unknown", 0) check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 0) check_sensor_state(hass, "sensor.oven_core_temperature", None, 0) @@ -206,6 +207,95 @@ def check_sensor_state( ) +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_oven_core_probe_sensors_unknown_when_inactive( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Oven food-probe (core) sensors must not expose API inactive sentinels as temperatures. + + Miele uses raw value -32768 (centidegrees) when the probe is not in use. After the + probe has reported a valid reading once, those entities must stay in the UI but + their state must be unknown—not a bogus numeric temperature. + """ + core_temp = "sensor.oven_core_temperature" + core_target = "sensor.oven_core_target_temperature" + + assert hass.states.get(core_temp) is None + assert hass.states.get(core_target) is None + + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0]["value_raw"] = 3000 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = 30.0 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(core_temp) is not None + assert hass.states.get(core_temp).state == "22.0" + assert hass.states.get(core_target) is not None + assert hass.states.get(core_target).state == "30.0" + + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_raw" + ] = -32768 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = None + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(core_temp).state == STATE_UNKNOWN + assert hass.states.get(core_target).state == STATE_UNKNOWN + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_oven_core_probe_unknown_when_inactive_raw_scaled( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Some ovens report int16-min as centidegrees (-32768 -> -327.68 °C); others as -3276800 raw (-32768 °C). + + Both must map to unknown, not a numeric sensor state. + """ + core_temp = "sensor.oven_core_temperature" + + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(core_temp) is not None + assert hass.states.get(core_temp).state == "22.0" + + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = -3276800 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(core_temp).state == STATE_UNKNOWN + + @pytest.mark.parametrize("load_device_file", ["oven.json"]) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) async def test_temperature_sensor_registry_lookup( @@ -747,3 +837,36 @@ async def test_elapsed_time_sensor_restored( state = hass.states.get(entity_id_abs) assert state is not None assert state.state == "2025-05-31T14:15:00+00:00" + + +def _core_temperature_entry(value_raw: object | None) -> MieleTemperature: + """Build a MieleTemperature like the API returns for core/zone readings.""" + return MieleTemperature({"value_raw": value_raw}) + + +@pytest.mark.parametrize( + ("entries", "index", "expected"), + [ + ([], 0, None), + ([_core_temperature_entry(2200)], 1, None), + ([_core_temperature_entry(None)], 0, None), + ([_core_temperature_entry(-32768)], 0, None), + ([_core_temperature_entry(-32766)], 0, None), + ([_core_temperature_entry(-3276800)], 0, None), + ([_core_temperature_entry(-3276600)], 0, None), + ([_core_temperature_entry(2150)], 0, 21.5), + ], +) +def test_convert_temperature( + entries: list[MieleTemperature], + index: int, + expected: float | None, +) -> None: + """Cover _convert_temperature branches (sentinels, scaling, bounds, valid values).""" + assert _convert_temperature(entries, index) == expected + + +def test_convert_temperature_invalid_raw_types() -> None: + """int() must not raise: bad API payloads become unknown.""" + assert _convert_temperature([_core_temperature_entry("n/a")], 0) is None + assert _convert_temperature([_core_temperature_entry([1])], 0) is None From 616a0f204cce0448694695e36a3b499f5627df05 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 6 Apr 2026 03:10:29 -0700 Subject: [PATCH 0493/1707] Migrate Fitbit user profile fetching to use fitbit-web-api (#167480) --- homeassistant/components/fitbit/api.py | 24 +++++++++---------- .../components/fitbit/config_flow.py | 4 +++- homeassistant/components/fitbit/model.py | 14 ----------- homeassistant/components/fitbit/sensor.py | 3 +++ tests/components/fitbit/conftest.py | 9 +++---- tests/components/fitbit/test_config_flow.py | 8 +++---- tests/components/fitbit/test_sensor.py | 3 --- 7 files changed, 25 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index b04310e57063c4..8d44a0b686e6bc 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -7,13 +7,14 @@ from fitbit import Fitbit from fitbit.exceptions import HTTPException, HTTPUnauthorized -from fitbit_web_api import ApiClient, Configuration, DevicesApi +from fitbit_web_api import ApiClient, Configuration, DevicesApi, UserApi from fitbit_web_api.exceptions import ( ApiException, OpenApiException, UnauthorizedException, ) from fitbit_web_api.models.device import Device +from fitbit_web_api.models.user import User from requests.exceptions import ConnectionError as RequestsConnectionError from homeassistant.const import CONF_ACCESS_TOKEN @@ -24,7 +25,6 @@ from .const import FitbitUnitSystem from .exceptions import FitbitApiException, FitbitAuthException -from .model import FitbitProfile _LOGGER = logging.getLogger(__name__) @@ -46,7 +46,7 @@ def __init__( ) -> None: """Initialize Fitbit auth.""" self._hass = hass - self._profile: FitbitProfile | None = None + self._profile: User | None = None self._unit_system = unit_system @abstractmethod @@ -74,18 +74,16 @@ async def _async_get_fitbit_web_api(self) -> ApiClient: configuration.access_token = token[CONF_ACCESS_TOKEN] return await self._hass.async_add_executor_job(ApiClient, configuration) - async def async_get_user_profile(self) -> FitbitProfile: + async def async_get_user_profile(self) -> User: """Return the user profile from the API.""" if self._profile is None: - client = await self._async_get_client() - response: dict[str, Any] = await self._run(client.user_profile_get) - _LOGGER.debug("user_profile_get=%s", response) - profile = response["user"] - self._profile = FitbitProfile( - encoded_id=profile["encodedId"], - display_name=profile["displayName"], - locale=profile.get("locale"), - ) + client = await self._async_get_fitbit_web_api() + api = UserApi(client) + api_response = await self._run_async(api.get_profile) + if not api_response.user: + raise FitbitApiException("No user profile returned from fitbit API") + _LOGGER.debug("user_profile_get=%s", api_response.to_dict()) + self._profile = api_response.user return self._profile async def async_get_unit_system(self) -> FitbitUnitSystem: diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index d5b33a731e3240..86794f5a963d5c 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -85,4 +85,6 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=profile.display_name, data=data) + return self.async_create_entry( + title=profile.display_name or "Fitbit", data=data + ) diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py index c1752616b2f27f..83cc47d21b0162 100644 --- a/homeassistant/components/fitbit/model.py +++ b/homeassistant/components/fitbit/model.py @@ -7,20 +7,6 @@ from .const import CONF_CLOCK_FORMAT, CONF_MONITORED_RESOURCES, FitbitScope -@dataclass -class FitbitProfile: - """User profile from the Fitbit API response.""" - - encoded_id: str - """The ID representing the Fitbit user.""" - - display_name: str - """The name shown when the user's friends look at their Fitbit profile.""" - - locale: str | None - """The locale defined in the user's Fitbit account settings.""" - - @dataclass class FitbitConfig: """Information from the fitbit ConfigEntry data.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index d8025225df522b..a33610ac84a6c7 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -25,6 +25,7 @@ UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level @@ -536,6 +537,8 @@ async def async_setup_entry( # These are run serially to reuse the cached user profile, not gathered # to avoid two racing requests. user_profile = await api.async_get_user_profile() + if user_profile.encoded_id is None: + raise ConfigEntryNotReady("Could not get user profile") unit_system = await api.async_get_unit_system() fitbit_config = config_from_entry_data(entry.data) diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 4740714e08e05a..7ecd254e55dcde 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -191,12 +191,13 @@ def mock_profile_response( @pytest.fixture(name="profile", autouse=True) -def mock_profile(requests_mock: Mocker, profile_response: dict[str, Any]) -> None: +def mock_profile( + aioclient_mock: AiohttpClientMocker, profile_response: dict[str, Any] +) -> None: """Fixture to setup fake requests made to Fitbit API during config flow.""" - requests_mock.register_uri( - "GET", + aioclient_mock.get( PROFILE_API_URL, - status_code=HTTPStatus.OK, + status=HTTPStatus.OK, json=profile_response, ) diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index 70c54cd2657b59..4df3d12ffe1f38 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest -from requests_mock.mocker import Mocker from homeassistant import config_entries from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN @@ -159,7 +158,6 @@ async def test_api_failure( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - requests_mock: Mocker, setup_credentials: None, http_status: HTTPStatus, json: Any, @@ -189,15 +187,15 @@ async def test_api_failure( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" + aioclient_mock.clear_requests() aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) - requests_mock.register_uri( - "GET", + aioclient_mock.get( PROFILE_API_URL, - status_code=http_status, + status=http_status, json=json, ) diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index e11d295f746dae..f947fbe033126f 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -740,7 +740,6 @@ async def test_device_battery_level_update_failed( aioclient_mock: AiohttpClientMocker, ) -> None: """Test API failure for a battery level sensor for devices.""" - aioclient_mock.clear_requests() aioclient_mock.get( DEVICES_API_URL, json=[DEVICE_RESPONSE_CHARGE_2], @@ -790,8 +789,6 @@ async def test_device_battery_level_reauth_required( aioclient_mock: AiohttpClientMocker, ) -> None: """Test API failure requires reauth.""" - - aioclient_mock.clear_requests() aioclient_mock.get( DEVICES_API_URL, json=[DEVICE_RESPONSE_CHARGE_2], From ba26d119f7774d1a868fc0346fc90cad038d16d1 Mon Sep 17 00:00:00 2001 From: Nick Haghiri <59633028+ElCruncharino@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:11:25 -0400 Subject: [PATCH 0494/1707] Bump b2sdk to 2.10.4 (#167481) --- homeassistant/components/backblaze_b2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backblaze_b2/manifest.json b/homeassistant/components/backblaze_b2/manifest.json index 71eed534584a6a..13d3521519b863 100644 --- a/homeassistant/components/backblaze_b2/manifest.json +++ b/homeassistant/components/backblaze_b2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["b2sdk"], "quality_scale": "bronze", - "requirements": ["b2sdk==2.10.1"] + "requirements": ["b2sdk==2.10.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4ced649071671a..31070dc26bd515 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -617,7 +617,7 @@ azure-servicebus==7.10.0 azure-storage-blob==12.24.0 # homeassistant.components.backblaze_b2 -b2sdk==2.10.1 +b2sdk==2.10.4 # homeassistant.components.holiday babel==2.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f98b03d8bae2cb..d95e983579d8a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -566,7 +566,7 @@ azure-kusto-ingest==4.5.1 azure-storage-blob==12.24.0 # homeassistant.components.backblaze_b2 -b2sdk==2.10.1 +b2sdk==2.10.4 # homeassistant.components.holiday babel==2.15.0 From 0674de2ce4039b827c766ee919f7199d033574b3 Mon Sep 17 00:00:00 2001 From: Nick Haghiri <59633028+ElCruncharino@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:12:55 -0400 Subject: [PATCH 0495/1707] Handle BadRequest exception in Backblaze B2 config flow and setup (#167482) --- homeassistant/components/backblaze_b2/__init__.py | 6 ++++++ .../components/backblaze_b2/config_flow.py | 8 ++++++++ .../components/backblaze_b2/strings.json | 4 ++++ tests/components/backblaze_b2/test_config_flow.py | 15 +++++++++++++++ tests/components/backblaze_b2/test_init.py | 1 + 5 files changed, 34 insertions(+) diff --git a/homeassistant/components/backblaze_b2/__init__.py b/homeassistant/components/backblaze_b2/__init__.py index 3a8d53f5b2a5eb..a2767d2f0afa28 100644 --- a/homeassistant/components/backblaze_b2/__init__.py +++ b/homeassistant/components/backblaze_b2/__init__.py @@ -74,6 +74,12 @@ def _authorize_and_get_bucket_sync() -> Bucket: translation_domain=DOMAIN, translation_key="invalid_bucket_name", ) from err + except exception.BadRequest as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="bad_request", + translation_placeholders={"error_message": str(err)}, + ) from err except ( exception.B2ConnectionError, exception.B2RequestTimeout, diff --git a/homeassistant/components/backblaze_b2/config_flow.py b/homeassistant/components/backblaze_b2/config_flow.py index 45acf01c874921..fc718505bd1800 100644 --- a/homeassistant/components/backblaze_b2/config_flow.py +++ b/homeassistant/components/backblaze_b2/config_flow.py @@ -174,6 +174,14 @@ def _authorize_and_get_bucket_sync() -> None: "Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET] ) errors[CONF_BUCKET] = "invalid_bucket_name" + except exception.BadRequest as err: + _LOGGER.error( + "Backblaze B2 API rejected the request for Key ID '%s': %s", + user_input[CONF_KEY_ID], + err, + ) + errors["base"] = "bad_request" + placeholders["error_message"] = str(err) except ( exception.B2ConnectionError, exception.B2RequestTimeout, diff --git a/homeassistant/components/backblaze_b2/strings.json b/homeassistant/components/backblaze_b2/strings.json index 15bc4a998d2a2a..ce8944a7375e97 100644 --- a/homeassistant/components/backblaze_b2/strings.json +++ b/homeassistant/components/backblaze_b2/strings.json @@ -6,6 +6,7 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { + "bad_request": "The Backblaze B2 API rejected the request: {error_message}", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]", "invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]", @@ -60,6 +61,9 @@ } }, "exceptions": { + "bad_request": { + "message": "The Backblaze B2 API rejected the request: {error_message}" + }, "cannot_connect": { "message": "Cannot connect to endpoint" }, diff --git a/tests/components/backblaze_b2/test_config_flow.py b/tests/components/backblaze_b2/test_config_flow.py index 2699576a6e2ba3..86841deb6a1fbc 100644 --- a/tests/components/backblaze_b2/test_config_flow.py +++ b/tests/components/backblaze_b2/test_config_flow.py @@ -177,6 +177,16 @@ async def test_already_configured( "cannot_connect", "base", ), + ( + "bad_request", + { + "patch": "b2sdk.v2.RawSimulator.authorize_account", + "exception": exception.BadRequest, + "args": ["test", "bad_request"], + }, + "bad_request", + "base", + ), ( "unknown_error", { @@ -252,6 +262,11 @@ async def test_config_flow_errors( "brand_name": "Backblaze B2", "allowed_prefix": "test/", } + elif error_type == "bad_request": + assert result.get("description_placeholders") == { + "brand_name": "Backblaze B2", + "error_message": "test (bad_request)", + } @pytest.mark.parametrize( diff --git a/tests/components/backblaze_b2/test_init.py b/tests/components/backblaze_b2/test_init.py index 5333643814750c..2a15e0bd5c4c50 100644 --- a/tests/components/backblaze_b2/test_init.py +++ b/tests/components/backblaze_b2/test_init.py @@ -57,6 +57,7 @@ async def test_setup_entry_invalid_auth( (exception.RestrictedBucket("testBucket"), ConfigEntryState.SETUP_RETRY), (exception.NonExistentBucket(), ConfigEntryState.SETUP_RETRY), (exception.ConnectionReset(), ConfigEntryState.SETUP_RETRY), + (exception.BadRequest("test", "bad_request"), ConfigEntryState.SETUP_RETRY), (exception.MissingAccountData("key"), ConfigEntryState.SETUP_ERROR), ], ) From 7c1abd993d7b2cb4297291ba06e6ddd1b7da6767 Mon Sep 17 00:00:00 2001 From: Cameron Jones <9124285+frostyrose@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:23:49 -0400 Subject: [PATCH 0496/1707] Add notify_on_use param to Schlage add_code service (#167402) --- homeassistant/components/schlage/__init__.py | 1 + homeassistant/components/schlage/lock.py | 4 +- .../components/schlage/services.yaml | 5 ++ homeassistant/components/schlage/strings.json | 4 ++ tests/components/schlage/test_lock.py | 48 +++++++++++++++++++ 5 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index ed995d4aa3d4a7..c9dcf2d09afe51 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -37,6 +37,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: schema={ vol.Required("name"): cv.string, vol.Required("code"): cv.matches_regex(r"^\d{4,8}$"), + vol.Optional("notify_on_use", default=True): cv.boolean, }, func=SERVICE_ADD_CODE, ) diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index 739e5a0b1d70c1..a7699d9004c72e 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -113,14 +113,14 @@ async def _async_fetch_access_codes(self) -> dict[str, AccessCode] | None: ) from ex return self._lock.access_codes - async def add_code(self, name: str, code: str) -> None: + async def add_code(self, name: str, code: str, notify_on_use: bool = True) -> None: """Add a lock code.""" codes = await self._async_fetch_access_codes() self._validate_code_name(codes, name) self._validate_code_value(codes, code) - access_code = AccessCode(name=name, code=code) + access_code = AccessCode(name=name, code=code, notify_on_use=notify_on_use) try: await self.hass.async_add_executor_job( self._lock.add_access_code, access_code diff --git a/homeassistant/components/schlage/services.yaml b/homeassistant/components/schlage/services.yaml index 97412251ea43c4..e248e8c6e8d2ad 100644 --- a/homeassistant/components/schlage/services.yaml +++ b/homeassistant/components/schlage/services.yaml @@ -23,6 +23,11 @@ add_code: text: multiline: false type: password + notify_on_use: + required: false + default: true + selector: + boolean: delete_code: target: diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 48f0232eb751af..0e19b66cf82036 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -83,6 +83,10 @@ "name": { "description": "Name for PIN code. Must be case insensitively unique to the lock.", "name": "PIN name" + }, + "notify_on_use": { + "description": "Whether the native Schlage notification should be sent when this PIN is used.", + "name": "Notify when PIN is used" } }, "name": "Add PIN code" diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 378b49bfb6b26c..6249cb5e94d0ca 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -97,10 +97,52 @@ async def test_changed_by( assert lock_device.attributes.get("changed_by") == "access code - foo" +@pytest.mark.parametrize( + "notify_on_use", + [ + True, + False, + ], + ids=["notify-true", "notify-false"], +) async def test_add_code_service( hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: MockSchlageConfigEntry, + notify_on_use: bool, +) -> None: + """Test add_code service.""" + # Mock access_codes as empty initially + mock_lock.access_codes = {} + mock_lock.add_access_code = Mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CODE, + service_data={ + "entity_id": "lock.vault_door", + "name": "test_user", + "code": "1234", + "notify_on_use": notify_on_use, + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Verify add_access_code was called with correct AccessCode + mock_lock.refresh_access_codes.assert_called_once() + mock_lock.add_access_code.assert_called_once() + call_args = mock_lock.add_access_code.call_args[0][0] + assert isinstance(call_args, AccessCode) + assert call_args.name == "test_user" + assert call_args.code == "1234" + assert call_args.notify_on_use == notify_on_use + + +async def test_add_code_service_default_notify_on_use_value( + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: MockSchlageConfigEntry, ) -> None: """Test add_code service.""" # Mock access_codes as empty initially @@ -126,6 +168,7 @@ async def test_add_code_service( assert isinstance(call_args, AccessCode) assert call_args.name == "test_user" assert call_args.code == "1234" + assert call_args.notify_on_use @pytest.mark.parametrize( @@ -155,6 +198,7 @@ async def test_add_code_service_invalid_code( "entity_id": "lock.vault_door", "name": "test_user", "code": code, + "notify_on_use": False, }, blocking=True, ) @@ -184,6 +228,7 @@ async def test_add_code_service_duplicate_name( "entity_id": "lock.vault_door", "name": "test_user", "code": "1234", + "notify_on_use": False, }, blocking=True, ) @@ -215,6 +260,7 @@ async def test_add_code_service_duplicate_code( "entity_id": "lock.vault_door", "name": "test_user", "code": "1234", + "notify_on_use": False, }, blocking=True, ) @@ -438,6 +484,7 @@ async def test_add_code_service_refresh_error( "entity_id": "lock.vault_door", "name": "test_user", "code": "1234", + "notify_on_use": False, }, blocking=True, ) @@ -461,6 +508,7 @@ async def test_add_code_service_api_error( "entity_id": "lock.vault_door", "name": "test_user", "code": "1234", + "notify_on_use": False, }, blocking=True, ) From 05aaf8745dc7f6e55f9d85b965d5c436cb46a9ed Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 6 Apr 2026 13:24:07 +0300 Subject: [PATCH 0497/1707] Add Tool search tool to Anthropic (#167484) --- .../components/anthropic/config_flow.py | 12 + homeassistant/components/anthropic/const.py | 7 + homeassistant/components/anthropic/entity.py | 44 ++- .../components/anthropic/strings.json | 4 + tests/components/anthropic/__init__.py | 24 ++ .../snapshots/test_conversation.ambr | 335 ++++++++++++++++++ .../components/anthropic/test_config_flow.py | 11 + .../components/anthropic/test_conversation.py | 263 ++++++++++++++ 8 files changed, 699 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index b5e1ef5c3b26ff..8b91bd4cc42576 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -53,6 +53,7 @@ CONF_TEMPERATURE, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, + CONF_TOOL_SEARCH, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -66,6 +67,7 @@ DOMAIN, NON_ADAPTIVE_THINKING_MODELS, NON_THINKING_MODELS, + TOOL_SEARCH_UNSUPPORTED_MODELS, WEB_SEARCH_UNSUPPORTED_MODELS, PromptCaching, ) @@ -466,6 +468,16 @@ async def async_step_model( self.options.pop(CONF_WEB_SEARCH_COUNTRY, None) self.options.pop(CONF_WEB_SEARCH_TIMEZONE, None) + if not model.startswith(tuple(TOOL_SEARCH_UNSUPPORTED_MODELS)): + step_schema[ + vol.Optional( + CONF_TOOL_SEARCH, + default=DEFAULT[CONF_TOOL_SEARCH], + ) + ] = bool + else: + self.options.pop(CONF_TOOL_SEARCH, None) + if not step_schema: user_input = {} diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index bed1f5530e34e4..6f47704db4b51e 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -18,6 +18,7 @@ CONF_TEMPERATURE = "temperature" CONF_THINKING_BUDGET = "thinking_budget" CONF_THINKING_EFFORT = "thinking_effort" +CONF_TOOL_SEARCH = "tool_search" CONF_WEB_SEARCH = "web_search" CONF_WEB_SEARCH_USER_LOCATION = "user_location" CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses" @@ -43,6 +44,7 @@ class PromptCaching(StrEnum): CONF_TEMPERATURE: 1.0, CONF_THINKING_BUDGET: 0, CONF_THINKING_EFFORT: "low", + CONF_TOOL_SEARCH: False, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_MAX_USES: 5, @@ -93,6 +95,11 @@ class PromptCaching(StrEnum): "claude-3-haiku", ] +TOOL_SEARCH_UNSUPPORTED_MODELS = [ + "claude-3", + "claude-haiku", +] + DEPRECATED_MODELS = [ "claude-3", ] diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index bc88cc58c1b49d..a2b9cfaa697d4b 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -58,6 +58,8 @@ ToolChoiceAutoParam, ToolChoiceToolParam, ToolParam, + ToolSearchToolBm25_20251119Param, + ToolSearchToolResultBlock, ToolUnionParam, ToolUseBlock, ToolUseBlockParam, @@ -74,6 +76,9 @@ from anthropic.types.text_editor_code_execution_tool_result_block_param import ( Content as TextEditorCodeExecutionToolResultBlockParamContentParam, ) +from anthropic.types.tool_search_tool_result_block_param import ( + Content as ToolSearchToolResultBlockParamContentParam, +) import voluptuous as vol from voluptuous_openapi import convert @@ -95,6 +100,7 @@ CONF_TEMPERATURE, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, + CONF_TOOL_SEARCH, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -204,7 +210,7 @@ def delete_empty(self) -> None: ] -def _convert_content( +def _convert_content( # noqa: C901 chat_content: Iterable[conversation.Content], ) -> tuple[list[MessageParam], str | None]: """Transform HA chat_log content into Anthropic API format.""" @@ -257,6 +263,15 @@ def _convert_content( content.tool_result, ), } + elif content.tool_name == "tool_search": + tool_result_block = { + "type": "tool_search_tool_result", + "tool_use_id": content.tool_call_id, + "content": cast( + ToolSearchToolResultBlockParamContentParam, + content.tool_result, + ), + } else: tool_result_block = { "type": "tool_result", @@ -387,6 +402,7 @@ def _convert_content( "code_execution", "bash_code_execution", "text_editor_code_execution", + "tool_search_tool_bm25", ], tool_call.tool_name, ), @@ -399,6 +415,7 @@ def _convert_content( "code_execution", "bash_code_execution", "text_editor_code_execution", + "tool_search_tool_bm25", ] else ToolUseBlockParam( type="tool_use", @@ -560,6 +577,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have CodeExecutionToolResultBlock, BashCodeExecutionToolResultBlock, TextEditorCodeExecutionToolResultBlock, + ToolSearchToolResultBlock, ), ): if content_details: @@ -690,6 +708,14 @@ async def _async_handle_chat_log( # noqa: C901 """Generate an answer for the chat log.""" options = self.subentry.data + preloaded_tools = [ + "HassTurnOn", + "HassTurnOff", + "GetLiveContext", + "code_execution", + "web_search", + ] + system = chat_log.content[0] if not isinstance(system, conversation.SystemContent): raise HomeAssistantError( @@ -884,8 +910,23 @@ async def _async_handle_chat_log( # noqa: C901 ), ) ) + preloaded_tools.append(structure_name) if tools: + if ( + options.get(CONF_TOOL_SEARCH, DEFAULT[CONF_TOOL_SEARCH]) + and len(tools) > len(preloaded_tools) + 1 + ): + for tool in tools: + if not tool["name"].endswith(tuple(preloaded_tools)): + tool["defer_loading"] = True + tools.append( + ToolSearchToolBm25_20251119Param( + type="tool_search_tool_bm25_20251119", + name="tool_search_tool_bm25", + ) + ) + model_args["tools"] = tools coordinator = self.entry.runtime_data @@ -929,6 +970,7 @@ async def _async_handle_chat_log( # noqa: C901 except anthropic.AnthropicError as err: # Non-connection error, mark connection as healthy coordinator.async_set_updated_data(None) + LOGGER.error("Error while talking to Anthropic: %s", err) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="api_error", diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 12ec2115ae43b8..7978cc768b7690 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -74,6 +74,7 @@ "code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data::code_execution%]", "thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]", "thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]", + "tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]", "user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]", "web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]", "web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]" @@ -82,6 +83,7 @@ "code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::code_execution%]", "thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]", "thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]", + "tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]", "user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]", "web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]", "web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]" @@ -136,6 +138,7 @@ "code_execution": "Code execution", "thinking_budget": "Thinking budget", "thinking_effort": "Thinking effort", + "tool_search": "Enable tool search tool", "user_location": "Include home location", "web_search": "Enable web search", "web_search_max_uses": "Maximum web searches" @@ -144,6 +147,7 @@ "code_execution": "Allow the model to execute code in a secure sandbox environment, enabling it to analyze data and perform complex calculations.", "thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.", "thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency", + "tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context", "user_location": "Localize search results based on home location", "web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff", "web_search_max_uses": "Limit the number of searches performed per response" diff --git a/tests/components/anthropic/__init__.py b/tests/components/anthropic/__init__.py index 5cda33064129ae..c9c0ff7747de9a 100644 --- a/tests/components/anthropic/__init__.py +++ b/tests/components/anthropic/__init__.py @@ -26,6 +26,7 @@ TextEditorCodeExecutionToolResultBlock, ThinkingBlock, ThinkingDelta, + ToolSearchToolResultBlock, ToolUseBlock, WebSearchResultBlock, WebSearchToolResultBlock, @@ -35,6 +36,9 @@ from anthropic.types.text_editor_code_execution_tool_result_block import ( Content as TextEditorCodeExecutionToolResultBlockContent, ) +from anthropic.types.tool_search_tool_result_block import ( + Content as ToolSearchToolResultBlockContent, +) def create_content_block( @@ -273,3 +277,23 @@ def create_text_editor_code_execution_result_block( ), RawContentBlockStopEvent(index=index, type="content_block_stop"), ] + + +def create_tool_search_result_block( + index: int, + id: str, + results: ToolSearchToolResultBlockContent, +) -> list[RawMessageStreamEvent]: + """Create a server tool result block for tool search results.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=ToolSearchToolResultBlock( + type="tool_search_tool_result", + tool_use_id=id, + content=results, + ), + index=index, + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 581a3ea73c649e..963a631ac73fb2 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -773,6 +773,92 @@ }), ]) # --- +# name: test_history_conversion[content7] + list([ + dict({ + 'content': 'Set humidity to 50%', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'EuQBClkIDBE=', + 'thinking': 'Let me search for a tool to set humidity.', + 'type': 'thinking', + }), + dict({ + 'id': 'srvtoolu_015vXmtZNASLa7n9RsoDfcBC', + 'input': dict({ + 'query': 'set humidity humidifier', + }), + 'name': 'tool_search_tool_bm25', + 'type': 'server_tool_use', + }), + dict({ + 'content': dict({ + 'tool_references': list([ + dict({ + 'tool_name': 'HassHumidifierSetpoint', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassHumidifierMode', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassClimateSetTemperature', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassFanSetSpeed', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassSetVolume', + 'type': 'tool_reference', + }), + ]), + 'type': 'tool_search_tool_search_result', + }), + 'tool_use_id': 'srvtoolu_015vXmtZNASLa7n9RsoDfcBC', + 'type': 'tool_search_tool_result', + }), + dict({ + 'id': 'toolu_01KNRWb3ZFufCa7WXtzCakhc', + 'input': dict({ + 'humidity': 50, + 'name': 'Hygrostat', + }), + 'name': 'HassHumidifierSetpoint', + 'type': 'tool_use', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': list([ + dict({ + 'content': '{"speech":{"plain":{"speech":"The Hygrostat is set to 50%","extra_data":null}},"response_type":"action_done","data":{"success":[],"failed":[]}}', + 'tool_use_id': 'toolu_01KNRWb3ZFufCa7WXtzCakhc', + 'type': 'tool_result', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': 'The Hygrostat humidity has been set to **50%**. ✅', + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': 'Yes, I am sure!', + 'role': 'assistant', + }), + ]) +# --- # name: test_redacted_thinking list([ dict({ @@ -1234,6 +1320,255 @@ }), ]) # --- +# name: test_tool_search + list([ + dict({ + 'attachments': None, + 'content': 'Can you set humidifier setpoint?', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'Sure, let me check that for you!', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + ]), + 'container': None, + 'redacted_thinking': None, + 'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + }), + 'role': 'assistant', + 'thinking_content': 'I will fetch the available tools', + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'srvtoolu_12345ABC', + 'tool_args': dict({ + 'limit': 5, + 'query': 'set humidifier humidity', + }), + 'tool_name': 'tool_search_tool_bm25', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'srvtoolu_12345ABC', + 'tool_name': 'tool_search', + 'tool_result': dict({ + 'tool_references': list([ + dict({ + 'tool_name': 'HassHumidifierSetpoint', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassHumidifierMode', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassClimateSetTemperature', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassFanSetSpeed', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassSetVolume', + 'type': 'tool_reference', + }), + ]), + 'type': 'tool_search_tool_search_result', + }), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'Yes, I can!', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + ]), + 'container': None, + 'redacted_thinking': None, + 'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + }), + 'role': 'assistant', + 'thinking_content': "Great! All clear, let's reply to the user!", + 'tool_calls': None, + }), + ]) +# --- +# name: test_tool_search.1 + list([ + dict({ + 'content': 'Can you set humidifier setpoint?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': 'I will fetch the available tools', + 'type': 'thinking', + }), + dict({ + 'text': 'Sure, let me check that for you!', + 'type': 'text', + }), + dict({ + 'id': 'srvtoolu_12345ABC', + 'input': dict({ + 'limit': 5, + 'query': 'set humidifier humidity', + }), + 'name': 'tool_search_tool_bm25', + 'type': 'server_tool_use', + }), + dict({ + 'content': dict({ + 'tool_references': list([ + dict({ + 'tool_name': 'HassHumidifierSetpoint', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassHumidifierMode', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassClimateSetTemperature', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassFanSetSpeed', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassSetVolume', + 'type': 'tool_reference', + }), + ]), + 'type': 'tool_search_tool_search_result', + }), + 'tool_use_id': 'srvtoolu_12345ABC', + 'type': 'tool_search_tool_result', + }), + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': "Great! All clear, let's reply to the user!", + 'type': 'thinking', + }), + dict({ + 'text': 'Yes, I can!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_tool_search_error + list([ + dict({ + 'attachments': None, + 'content': 'Do you have a tool to launch a rocket to the moon?', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'Sure, let me check that for you!', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': dict({ + 'citation_details': list([ + ]), + 'container': None, + 'redacted_thinking': None, + 'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + }), + 'role': 'assistant', + 'thinking_content': 'I will fetch the available tools', + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'srvtoolu_12345ABC', + 'tool_args': dict({ + 'limit': 5, + 'query': 'set humidifier humidity', + }), + 'tool_name': 'tool_search_tool_bm25', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'srvtoolu_12345ABC', + 'tool_name': 'tool_search', + 'tool_result': dict({ + 'error_code': 'too_many_requests', + 'type': 'tool_search_tool_result_error', + }), + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'I am unable to perform the tool search at this time.', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_tool_search_error.1 + list([ + dict({ + 'content': 'Do you have a tool to launch a rocket to the moon?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': 'I will fetch the available tools', + 'type': 'thinking', + }), + dict({ + 'text': 'Sure, let me check that for you!', + 'type': 'text', + }), + dict({ + 'id': 'srvtoolu_12345ABC', + 'input': dict({ + 'limit': 5, + 'query': 'set humidifier humidity', + }), + 'name': 'tool_search_tool_bm25', + 'type': 'server_tool_use', + }), + dict({ + 'content': dict({ + 'error_code': 'too_many_requests', + 'type': 'tool_search_tool_result_error', + }), + 'tool_use_id': 'srvtoolu_12345ABC', + 'type': 'tool_search_tool_result', + }), + dict({ + 'text': 'I am unable to perform the tool search at this time.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- # name: test_unknown_hass_api dict({ 'continue_conversation': False, diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 8779a569e09c31..05611f07372267 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -30,6 +30,7 @@ CONF_TEMPERATURE, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, + CONF_TOOL_SEARCH, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -331,6 +332,7 @@ async def test_subentry_web_search_user_location( "temperature": 1.0, "thinking_budget": 0, "timezone": "America/Los_Angeles", + "tool_search": False, "user_location": True, "web_search": True, "web_search_max_uses": 5, @@ -451,6 +453,7 @@ async def test_model_list_error( CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_PROMPT: "bla", CONF_PROMPT_CACHING: "prompt", + CONF_TOOL_SEARCH: False, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_MAX_USES: 4, CONF_WEB_SEARCH_USER_LOCATION: True, @@ -497,6 +500,7 @@ async def test_model_list_error( CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_PROMPT: "bla", CONF_PROMPT_CACHING: "off", + CONF_TOOL_SEARCH: False, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -518,6 +522,7 @@ async def test_model_list_error( CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_TOOL_SEARCH: True, CONF_CODE_EXECUTION: False, CONF_THINKING_BUDGET: 2048, }, @@ -530,6 +535,7 @@ async def test_model_list_error( CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], CONF_THINKING_BUDGET: 2048, + CONF_TOOL_SEARCH: True, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -542,6 +548,7 @@ async def test_model_list_error( CONF_CHAT_MODEL: "claude-opus-4-6", CONF_PROMPT: "bla", CONF_PROMPT_CACHING: "automatic", + CONF_TOOL_SEARCH: True, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -563,6 +570,7 @@ async def test_model_list_error( CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_TOOL_SEARCH: False, CONF_CODE_EXECUTION: True, CONF_THINKING_EFFORT: "medium", }, @@ -575,6 +583,7 @@ async def test_model_list_error( CONF_CHAT_MODEL: "claude-opus-4-6", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], CONF_THINKING_EFFORT: "medium", + CONF_TOOL_SEARCH: False, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -621,6 +630,7 @@ async def test_model_list_error( CONF_CHAT_MODEL: DEFAULT[CONF_CHAT_MODEL], CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], CONF_THINKING_BUDGET: DEFAULT[CONF_THINKING_BUDGET], + CONF_TOOL_SEARCH: True, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -801,6 +811,7 @@ async def test_creating_ai_task_subentry_advanced( CONF_CHAT_MODEL: "claude-sonnet-4-5", CONF_MAX_TOKENS: 200, CONF_TEMPERATURE: 0.5, + CONF_TOOL_SEARCH: False, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 3addbd8701994d..de617438dfefd2 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -16,6 +16,8 @@ TextEditorCodeExecutionStrReplaceResultBlock, TextEditorCodeExecutionToolResultError, TextEditorCodeExecutionViewResultBlock, + ToolSearchToolResultError, + ToolSearchToolSearchResultBlock, Usage, WebSearchResultBlock, WebSearchToolResultError, @@ -36,6 +38,7 @@ CONF_PROMPT_CACHING, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, + CONF_TOOL_SEARCH, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -60,6 +63,7 @@ create_server_tool_use_block, create_text_editor_code_execution_result_block, create_thinking_block, + create_tool_search_result_block, create_tool_use_block, create_web_search_result_block, ) @@ -467,6 +471,7 @@ async def test_assist_api_tools_conversion( "vacuum", "cover", "weather", + "demo", ): assert await async_setup_component(hass, component, {}) @@ -1502,6 +1507,194 @@ async def test_text_editor_code_execution( assert mock_create_stream.call_args.kwargs["messages"] == snapshot +@freeze_time("2025-10-31 12:00:00") +async def test_tool_search( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test tool search.""" + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "demo", {}) + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_CHAT_MODEL: "claude-sonnet-4-6", + CONF_TOOL_SEARCH: True, + }, + ) + + tool_search_result = ToolSearchToolSearchResultBlock( + type="tool_search_tool_search_result", + tool_references=[ + { + "type": "tool_reference", + "tool_name": "HassHumidifierSetpoint", + }, + { + "type": "tool_reference", + "tool_name": "HassHumidifierMode", + }, + { + "type": "tool_reference", + "tool_name": "HassClimateSetTemperature", + }, + { + "type": "tool_reference", + "tool_name": "HassFanSetSpeed", + }, + { + "type": "tool_reference", + "tool_name": "HassSetVolume", + }, + ], + ) + + mock_create_stream.return_value = [ + ( + *create_thinking_block( + 0, + ["I will fetch the available", " tools"], + ), + *create_content_block( + 1, + ["Sure, let me check that for you!"], + ), + *create_server_tool_use_block( + 2, + "srvtoolu_12345ABC", + "tool_search_tool_bm25", + [ + '{"query": "s', + "et humidi", + "fier hum", + 'idity"', + ', "limit"', + ": 5}", + ], + ), + *create_tool_search_result_block( + 3, "srvtoolu_12345ABC", tool_search_result + ), + *create_thinking_block( + 4, + ["Great! All clear, let's reply to the user!"], + ), + *create_content_block( + 5, + ["Yes, I can!"], + ), + ) + ] + + result = await conversation.async_converse( + hass, + "Can you set humidifier setpoint?", + None, + Context(), + agent_id="conversation.claude_conversation", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot + assert mock_create_stream.call_args.kwargs["messages"] == snapshot + + tools = mock_create_stream.call_args.kwargs["tools"] + assert { + "type": "tool_search_tool_bm25_20251119", + "name": "tool_search_tool_bm25", + } in tools + for tool in tools: + if tool["name"] in ( + "HassTurnOn", + "HassTurnOff", + "GetLiveContext", + "tool_search_tool_bm25", + ): + assert "defer_loading" not in tool + else: + assert tool["defer_loading"] is True + + +@freeze_time("2025-10-31 12:00:00") +async def test_tool_search_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test tool_search error.""" + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_CHAT_MODEL: "claude-sonnet-4-6", + CONF_TOOL_SEARCH: True, + }, + ) + + tool_search_result = ToolSearchToolResultError( + type="tool_search_tool_result_error", + error_code="too_many_requests", + ) + mock_create_stream.return_value = [ + ( + *create_thinking_block( + 0, + ["I will fetch the available", " tools"], + ), + *create_content_block( + 1, + ["Sure, let me check that for you!"], + ), + *create_server_tool_use_block( + 2, + "srvtoolu_12345ABC", + "tool_search_tool_bm25", + [ + '{"query": "s', + "et humidi", + "fier hum", + 'idity"', + ', "limit"', + ": 5}", + ], + ), + *create_tool_search_result_block( + 3, "srvtoolu_12345ABC", tool_search_result + ), + *create_content_block( + 4, + ["I am unable to perform the tool search at this time."], + ), + ) + ] + + result = await conversation.async_converse( + hass, + "Do you have a tool to launch a rocket to the moon?", + None, + Context(), + agent_id="conversation.claude_conversation", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + # Don't test the prompt because it's not deterministic + assert chat_log.content[1:] == snapshot + assert mock_create_stream.call_args.kwargs["messages"] == snapshot + + async def test_container_reused( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, @@ -1747,6 +1940,76 @@ async def test_container_reused( content="It is currently 2:30 PM.", ), ], + [ + conversation.chat_log.SystemContent( + "You are a voice assistant for Home Assistant." + ), + conversation.chat_log.UserContent("Set humidity to 50%"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", + thinking_content="Let me search for a tool to set humidity.", + tool_calls=[ + llm.ToolInput( + tool_name="tool_search_tool_bm25", + tool_args={"query": "set humidity humidifier"}, + id="srvtoolu_015vXmtZNASLa7n9RsoDfcBC", + external=True, + ) + ], + native=ContentDetails(thinking_signature="EuQBClkIDBE="), + ), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude_conversation", + tool_call_id="srvtoolu_015vXmtZNASLa7n9RsoDfcBC", + tool_name="tool_search", + tool_result={ + "tool_references": [ + { + "tool_name": "HassHumidifierSetpoint", + "type": "tool_reference", + }, + {"tool_name": "HassHumidifierMode", "type": "tool_reference"}, + { + "tool_name": "HassClimateSetTemperature", + "type": "tool_reference", + }, + {"tool_name": "HassFanSetSpeed", "type": "tool_reference"}, + {"tool_name": "HassSetVolume", "type": "tool_reference"}, + ], + "type": "tool_search_tool_search_result", + }, + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", + tool_calls=[ + llm.ToolInput( + tool_name="HassHumidifierSetpoint", + tool_args={"name": "Hygrostat", "humidity": 50}, + id="toolu_01KNRWb3ZFufCa7WXtzCakhc", + external=False, + ) + ], + ), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude_conversation", + tool_call_id="toolu_01KNRWb3ZFufCa7WXtzCakhc", + tool_name="HassHumidifierSetpoint", + tool_result={ + "speech": { + "plain": { + "speech": "The Hygrostat is set to 50%", + "extra_data": None, + } + }, + "response_type": "action_done", + "data": {"success": [], "failed": []}, + }, + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", + content="The Hygrostat humidity has been set to **50%**. ✅", + ), + ], ], ) async def test_history_conversion( From 4849dc0eb9b34ea7ffe9f850d3661e9e92118d85 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Mon, 6 Apr 2026 03:28:16 -0700 Subject: [PATCH 0498/1707] Fix modem_callerid test_setup_entry mock (#167461) --- tests/components/modem_callerid/test_init.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/tests/components/modem_callerid/test_init.py b/tests/components/modem_callerid/test_init.py index e12850f763ddbf..0e76fe23e01b64 100644 --- a/tests/components/modem_callerid/test_init.py +++ b/tests/components/modem_callerid/test_init.py @@ -1,7 +1,5 @@ """Test Modem Caller ID integration.""" -from unittest.mock import patch - from phone_modem import exceptions from homeassistant.components.modem_callerid.const import DOMAIN @@ -21,14 +19,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: data={CONF_DEVICE: com_port().device}, ) entry.add_to_hass(hass) - with ( - patch("aioserial.AioSerial", autospec=True), - patch( - "homeassistant.components.modem_callerid.PhoneModem._get_response", - return_value="OK", - ), - patch("phone_modem.PhoneModem._modem_sm"), - ): + with patch_init_modem(): await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.LOADED From dfaed39a01d02f681a73c2ecf2937caf85106fcb Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Mon, 6 Apr 2026 11:28:35 +0100 Subject: [PATCH 0499/1707] Fix handling of missing period statistics in Anglian Water coordinator (#167427) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/anglian_water/coordinator.py | 39 ++++++-- .../anglian_water/test_coordinator.py | 92 ++++++++++++++++++- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/anglian_water/coordinator.py b/homeassistant/components/anglian_water/coordinator.py index 81c845420a634e..7c2308148b6cb1 100644 --- a/homeassistant/components/anglian_water/coordinator.py +++ b/homeassistant/components/anglian_water/coordinator.py @@ -92,6 +92,7 @@ async def _insert_statistics(self) -> None: _LOGGER.debug("Updating statistics for the first time") usage_sum = 0.0 last_stats_time = None + allow_update_last_stored_hour = False else: if not meter.readings or len(meter.readings) == 0: _LOGGER.debug("No recent usage statistics found, skipping update") @@ -107,6 +108,7 @@ async def _insert_statistics(self) -> None: continue start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) _LOGGER.debug("Getting statistics at %s", start) + stats: dict[str, list[Any]] = {} for end in (start + timedelta(seconds=1), None): stats = await get_instance(self.hass).async_add_executor_job( statistics_during_period, @@ -127,15 +129,28 @@ async def _insert_statistics(self) -> None: "Not found, trying to find oldest statistic after %s", start, ) - assert stats - def _safe_get_sum(records: list[Any]) -> float: - if records and "sum" in records[0]: - return float(records[0]["sum"]) - return 0.0 - - usage_sum = _safe_get_sum(stats.get(usage_statistic_id, [])) - last_stats_time = stats[usage_statistic_id][0]["start"] + if not stats or not stats.get(usage_statistic_id): + _LOGGER.debug( + "Could not find existing statistics during period lookup for %s, " + "falling back to last stored statistic", + usage_statistic_id, + ) + allow_update_last_stored_hour = True + last_records = last_stat[usage_statistic_id] + usage_sum = float(last_records[0].get("sum") or 0.0) + last_stats_time = last_records[0]["start"] + else: + allow_update_last_stored_hour = False + records = stats[usage_statistic_id] + + def _safe_get_sum(records: list[Any]) -> float: + if records and "sum" in records[0]: + return float(records[0]["sum"]) + return 0.0 + + usage_sum = _safe_get_sum(records) + last_stats_time = records[0]["start"] usage_statistics = [] @@ -148,7 +163,13 @@ def _safe_get_sum(records: list[Any]) -> float: ) continue start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) - if last_stats_time is not None and start.timestamp() <= last_stats_time: + if last_stats_time is not None and ( + start.timestamp() < last_stats_time + or ( + start.timestamp() == last_stats_time + and not allow_update_last_stored_hour + ) + ): continue usage_state = max(0, read["consumption"] / 1000) usage_sum = max(0, read["read"]) diff --git a/tests/components/anglian_water/test_coordinator.py b/tests/components/anglian_water/test_coordinator.py index 1072b5312188d8..45d03a11f31522 100644 --- a/tests/components/anglian_water/test_coordinator.py +++ b/tests/components/anglian_water/test_coordinator.py @@ -1,6 +1,7 @@ """Tests for the Anglian Water coordinator.""" -from unittest.mock import AsyncMock +from datetime import timedelta +from unittest.mock import AsyncMock, patch from pyanglianwater.meter import SmartMeter import pytest @@ -162,3 +163,92 @@ async def test_coordinator_invalid_readings( "Could not parse read_at time also-invalid-date, skipping reading" in caplog.text ) + + +async def test_coordinator_subsequent_run_missing_period_statistics( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smart_meter: SmartMeter, + mock_anglian_water_client: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the coordinator handles missing period lookup statistics.""" + coordinator = AnglianWaterUpdateCoordinator( + hass, mock_anglian_water_client, mock_config_entry + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Correct the latest already-stored reading. Fallback should still update + # this hour instead of skipping it. + mock_smart_meter.readings[-1] = { + "read_at": "2024-06-01T14:00:00", + "consumption": 35, + "read": 70, + } + + # Add a new later reading to ensure fallback also accepts newer entries. + mock_smart_meter.readings.append( + {"read_at": "2024-06-01T15:00:00", "consumption": 20, "read": 90} + ) + + with patch( + "homeassistant.components.anglian_water.coordinator.statistics_during_period", + return_value={}, + ): + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + assert "Could not find existing statistics during period lookup" in caplog.text + + statistic_id = f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage" + stats = await hass.async_add_executor_job( + get_last_statistics, hass, 1, statistic_id, True, {"sum"} + ) + assert stats[statistic_id][0]["sum"] >= 70 + + parsed_read_at = dt_util.parse_datetime("2024-06-01T14:00:00") + assert parsed_read_at is not None + corrected_start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) + + corrected_stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + corrected_start, + corrected_start + timedelta(seconds=1), + { + statistic_id, + }, + "hour", + None, + {"sum"}, + ) + assert corrected_stats[statistic_id][0]["sum"] == 70 + + +async def test_coordinator_period_statistics_without_sum( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_anglian_water_client: AsyncMock, +) -> None: + """Test period lookup records without sum are handled safely.""" + coordinator = AnglianWaterUpdateCoordinator( + hass, mock_anglian_water_client, mock_config_entry + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + statistic_id = f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage" + with patch( + "homeassistant.components.anglian_water.coordinator.statistics_during_period", + return_value={statistic_id: [{"start": 0.0}]}, + ): + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + stats = await hass.async_add_executor_job( + get_last_statistics, hass, 1, statistic_id, True, {"sum"} + ) + assert stats[statistic_id] From b8d8b1cfa81fb6e8324c711273b631e4b9d15eb1 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Mon, 6 Apr 2026 03:29:27 -0700 Subject: [PATCH 0500/1707] Fix nzbget positional argument mismatch in NZBGetAPI calls (#167456) --- homeassistant/components/nzbget/config_flow.py | 12 ++++++------ homeassistant/components/nzbget/coordinator.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index a99d3d3f328b0e..c13333f7a94bdc 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -30,12 +30,12 @@ def _validate_input(data: dict[str, Any]) -> None: Data has the keys from DATA_SCHEMA with values provided by the user. """ nzbget_api = NZBGetAPI( - data[CONF_HOST], - data.get(CONF_USERNAME), - data.get(CONF_PASSWORD), - data[CONF_SSL], - data[CONF_VERIFY_SSL], - data[CONF_PORT], + host=data[CONF_HOST], + username=data.get(CONF_USERNAME), + password=data.get(CONF_PASSWORD), + secure=data[CONF_SSL], + verify_certificate=data[CONF_VERIFY_SSL], + port=data[CONF_PORT], ) nzbget_api.version() diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index 1fdad398d576b1..855ff532e72d7a 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -38,12 +38,12 @@ def __init__( ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( - config_entry.data[CONF_HOST], - config_entry.data.get(CONF_USERNAME), - config_entry.data.get(CONF_PASSWORD), - config_entry.data[CONF_SSL], - config_entry.data[CONF_VERIFY_SSL], - config_entry.data[CONF_PORT], + host=config_entry.data[CONF_HOST], + username=config_entry.data.get(CONF_USERNAME), + password=config_entry.data.get(CONF_PASSWORD), + secure=config_entry.data[CONF_SSL], + verify_certificate=config_entry.data[CONF_VERIFY_SSL], + port=config_entry.data[CONF_PORT], ) self._completed_downloads_init = False From b1d81536ce0a6f2a03c546f3622e52ad3f32f3f3 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Mon, 6 Apr 2026 06:30:55 -0400 Subject: [PATCH 0501/1707] Bump jvcprojector dependency to pyjvcprojector 2.0.5 (#167450) --- homeassistant/components/jvc_projector/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index c2b1243a993c5d..389b9ff2b55a97 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==2.0.3"] + "requirements": ["pyjvcprojector==2.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 31070dc26bd515..1e142193a96880 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2206,7 +2206,7 @@ pyitachip2ir==0.0.7 pyituran==0.1.5 # homeassistant.components.jvc_projector -pyjvcprojector==2.0.3 +pyjvcprojector==2.0.5 # homeassistant.components.kaleidescape pykaleidescape==1.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d95e983579d8a4..5bfe0f09f9610d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ pyisy==3.4.1 pyituran==0.1.5 # homeassistant.components.jvc_projector -pyjvcprojector==2.0.3 +pyjvcprojector==2.0.5 # homeassistant.components.kaleidescape pykaleidescape==1.1.5 From 74770f0b33a65f9c7b439fdf44477c5c419a806d Mon Sep 17 00:00:00 2001 From: cdheiser <10488026+cdheiser@users.noreply.github.com> Date: Mon, 6 Apr 2026 03:31:10 -0700 Subject: [PATCH 0502/1707] Bump pylutron to 0.4.1 (#167324) --- homeassistant/components/lutron/__init__.py | 19 +++++++++++++--- homeassistant/components/lutron/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lutron/test_init.py | 22 +++++++++++++++++++ 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 0a15d5a20f8fa1..86c84ae23b5c0d 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -4,11 +4,20 @@ import logging from typing import Any, cast -from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output +from pylutron import ( + Button, + Keypad, + Led, + Lutron, + LutronException, + OccupancyGroup, + Output, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN @@ -57,8 +66,12 @@ async def async_setup_entry( pwd = config_entry.data[CONF_PASSWORD] lutron_client = Lutron(host, uid, pwd) - await hass.async_add_executor_job(lutron_client.load_xml_db) - lutron_client.connect() + try: + await hass.async_add_executor_job(lutron_client.load_xml_db) + lutron_client.connect() + except LutronException as ex: + raise ConfigEntryNotReady(f"Failed to connect to Lutron repeater: {ex}") from ex + _LOGGER.debug("Connected to main repeater at %s", host) entity_registry = er.async_get(hass) diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index e40203a6ccafed..b08676082cba0f 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.4.0"], + "requirements": ["pylutron==0.4.1"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 1e142193a96880..74b029e908ce78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2263,7 +2263,7 @@ pylitterbot==2025.2.0 pylutron-caseta==0.27.0 # homeassistant.components.lutron -pylutron==0.4.0 +pylutron==0.4.1 # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5bfe0f09f9610d..a12cb5ba2ddaa3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1940,7 +1940,7 @@ pylitterbot==2025.2.0 pylutron-caseta==0.27.0 # homeassistant.components.lutron -pylutron==0.4.0 +pylutron==0.4.1 # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/tests/components/lutron/test_init.py b/tests/components/lutron/test_init.py index da7148218a7e51..26d419aa35b5a4 100644 --- a/tests/components/lutron/test_init.py +++ b/tests/components/lutron/test_init.py @@ -3,7 +3,11 @@ from typing import Any, cast from unittest.mock import MagicMock +from pylutron import LutronException +import pytest + from homeassistant.components.lutron.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -45,6 +49,24 @@ async def test_unload_entry( await hass.async_block_till_done() +@pytest.mark.parametrize("method", ["load_xml_db", "connect"]) +async def test_setup_entry_not_ready( + hass: HomeAssistant, + mock_lutron: MagicMock, + mock_config_entry: MockConfigEntry, + method: str, +) -> None: + """Test setting up the integration when Lutron repeater is not ready.""" + mock_config_entry.add_to_hass(hass) + + getattr(mock_lutron, method).side_effect = LutronException(f"{method} failed") + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_unique_id_migration( hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry ) -> None: From 67243a5044291e148c7ec64799c3124c07cfd693 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 6 Apr 2026 20:31:31 +1000 Subject: [PATCH 0503/1707] Revert "Add Remote platform to SMLIGHT Integration (#166728)" (#167424) --- homeassistant/components/smlight/__init__.py | 1 - homeassistant/components/smlight/remote.py | 70 -------- homeassistant/components/smlight/strings.json | 8 - tests/components/smlight/test_remote.py | 157 ------------------ 4 files changed, 236 deletions(-) delete mode 100644 homeassistant/components/smlight/remote.py delete mode 100644 tests/components/smlight/test_remote.py diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index b815b4d74a28cf..a6d7bbd14ea305 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -19,7 +19,6 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, - Platform.REMOTE, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/smlight/remote.py b/homeassistant/components/smlight/remote.py deleted file mode 100644 index 4976c7688f24dc..00000000000000 --- a/homeassistant/components/smlight/remote.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Remote platform for SLZB-Ultima.""" - -import asyncio -from collections.abc import Iterable -from typing import Any - -from pysmlight.exceptions import SmlightError -from pysmlight.models import IRPayload - -from homeassistant.components.remote import ( - ATTR_DELAY_SECS, - ATTR_NUM_REPEATS, - DEFAULT_DELAY_SECS, - DEFAULT_NUM_REPEATS, - RemoteEntity, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN -from .coordinator import SmConfigEntry, SmDataUpdateCoordinator -from .entity import SmEntity - -PARALLEL_UPDATES = 1 - - -async def async_setup_entry( - hass: HomeAssistant, - entry: SmConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Initialize remote for SLZB-Ultima device.""" - coordinator = entry.runtime_data.data - - if coordinator.data.info.has_peripherals: - async_add_entities([SmRemoteEntity(coordinator)]) - - -class SmRemoteEntity(SmEntity, RemoteEntity): - """Representation of a SLZB-Ultima remote.""" - - _attr_translation_key = "remote" - _attr_is_on = True - - def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: - """Initialize the SLZB-Ultima remote.""" - super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.unique_id}-remote" - - async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: - """Send a sequence of commands to a device.""" - num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS) - delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) - - for _ in range(num_repeats): - for cmd in command: - try: - await self.coordinator.async_execute_command( - self.coordinator.client.actions.send_ir_code, - IRPayload(code=cmd), - ) - except SmlightError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="send_ir_code_failed", - translation_placeholders={"error": str(err)}, - ) from err - - await asyncio.sleep(delay_secs) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 10310d4c6ef406..6fbac239207976 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -84,11 +84,6 @@ "name": "Ambilight" } }, - "remote": { - "remote": { - "name": "IR Remote" - } - }, "sensor": { "core_temperature": { "name": "Core chip temp" @@ -164,9 +159,6 @@ }, "firmware_update_failed": { "message": "Firmware update failed for {device_name}." - }, - "send_ir_code_failed": { - "message": "Failed to send IR code: {error}." } }, "issues": { diff --git a/tests/components/smlight/test_remote.py b/tests/components/smlight/test_remote.py deleted file mode 100644 index 26382f0833726a..00000000000000 --- a/tests/components/smlight/test_remote.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for SLZB-Ultima remote entity.""" - -from unittest.mock import MagicMock, patch - -from pysmlight import Info -from pysmlight.exceptions import SmlightError -from pysmlight.models import IRPayload -import pytest - -from homeassistant.components.remote import ( - ATTR_COMMAND, - ATTR_DELAY_SECS, - ATTR_NUM_REPEATS, - DOMAIN as REMOTE_DOMAIN, - SERVICE_SEND_COMMAND, -) -from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError - -from .conftest import setup_integration - -from tests.common import MockConfigEntry - - -@pytest.fixture -def platforms() -> list[Platform]: - """Platforms, which should be loaded during the test.""" - return [Platform.REMOTE] - - -MOCK_ULTIMA = Info( - MAC="AA:BB:CC:DD:EE:FF", - model="SLZB-Ultima3", -) - - -async def test_remote_setup_ultima( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, -) -> None: - """Test remote entity is created for Ultima devices.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA - await setup_integration(hass, mock_config_entry) - - state = hass.states.get("remote.mock_title_ir_remote") - assert state is not None - - -async def test_remote_not_created_non_ultima( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, -) -> None: - """Test remote entity is not created for non-Ultima devices.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info( - MAC="AA:BB:CC:DD:EE:FF", - model="SLZB-MR1", - ) - await setup_integration(hass, mock_config_entry) - - state = hass.states.get("remote.mock_title_ir_remote") - assert state is None - - -async def test_remote_send_command( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, -) -> None: - """Test sending IR command.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA - await setup_integration(hass, mock_config_entry) - - entity_id = "remote.mock_title_ir_remote" - state = hass.states.get(entity_id) - assert state is not None - - await hass.services.async_call( - REMOTE_DOMAIN, - SERVICE_SEND_COMMAND, - { - ATTR_ENTITY_ID: entity_id, - ATTR_COMMAND: ["my_code", "another_code"], - ATTR_DELAY_SECS: 0, - }, - blocking=True, - ) - - assert mock_smlight_client.actions.send_ir_code.call_count == 2 - mock_smlight_client.actions.send_ir_code.assert_any_call(IRPayload(code="my_code")) - mock_smlight_client.actions.send_ir_code.assert_any_call( - IRPayload(code="another_code") - ) - - -async def test_remote_send_command_error( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, -) -> None: - """Test connection error handling.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA - await setup_integration(hass, mock_config_entry) - - entity_id = "remote.mock_title_ir_remote" - state = hass.states.get(entity_id) - assert state is not None - - mock_smlight_client.actions.send_ir_code.side_effect = SmlightError("Failed") - - with pytest.raises(HomeAssistantError) as exc_info: - await hass.services.async_call( - REMOTE_DOMAIN, - SERVICE_SEND_COMMAND, - {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: ["my_code"]}, - blocking=True, - ) - assert exc_info.value.translation_key == "send_ir_code_failed" - - -@patch("homeassistant.components.smlight.remote.asyncio.sleep") -async def test_remote_send_command_repeats( - mock_sleep: MagicMock, - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, -) -> None: - """Test sending IR command with repeats and delay.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA - await setup_integration(hass, mock_config_entry) - - entity_id = "remote.mock_title_ir_remote" - state = hass.states.get(entity_id) - assert state is not None - - await hass.services.async_call( - REMOTE_DOMAIN, - SERVICE_SEND_COMMAND, - { - ATTR_ENTITY_ID: entity_id, - ATTR_COMMAND: ["my_code", "another_code"], - ATTR_NUM_REPEATS: 2, - ATTR_DELAY_SECS: 0.5, - }, - blocking=True, - ) - - assert mock_smlight_client.actions.send_ir_code.call_count == 4 - assert mock_sleep.call_count == 5 - mock_sleep.assert_called_with(0.5) From 34dc52c73215b178adadde862e3d3b9e32c35923 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Apr 2026 12:33:33 +0200 Subject: [PATCH 0504/1707] Add better pause behaviour to Portainer (#167453) --- homeassistant/components/portainer/const.py | 1 + homeassistant/components/portainer/coordinator.py | 3 ++- homeassistant/components/portainer/switch.py | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/const.py b/homeassistant/components/portainer/const.py index 8c1f1fa9d094a1..356f9eb30d7b89 100644 --- a/homeassistant/components/portainer/const.py +++ b/homeassistant/components/portainer/const.py @@ -19,6 +19,7 @@ class ContainerState(StrEnum): """Portainer container state.""" RUNNING = "running" + PAUSED = "paused" class StackStatus(IntEnum): diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index 179f8a84610b8b..84a8fb069ad594 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -216,7 +216,8 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: running_containers = [ container for container in containers - if container.state == ContainerState.RUNNING + if container.state + in (ContainerState.RUNNING, ContainerState.PAUSED) ] if running_containers: container_stats = dict( diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index 478c991f513a26..3d8a661845df19 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry -from .const import DOMAIN, StackStatus +from .const import DOMAIN, ContainerState, StackStatus from .coordinator import ( PortainerContainerData, PortainerCoordinator, @@ -88,7 +88,9 @@ async def _perform_action( key="container", translation_key="container", device_class=SwitchDeviceClass.SWITCH, - is_on_fn=lambda data: data.container.state == "running", + is_on_fn=lambda data: ( + data.container.state in (ContainerState.RUNNING, ContainerState.PAUSED) + ), turn_on_fn=lambda portainer: portainer.start_container, turn_off_fn=lambda portainer: portainer.stop_container, ), From 9ff97ecd7b20c14b186ea1640c2625f60d10c1a4 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:35:32 -0400 Subject: [PATCH 0505/1707] Remove if statements in Sonos Media Player Tests (#167210) --- tests/components/sonos/test_media_player.py | 31 +++++++++------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 084cf4174e40aa..b00fe06e193304 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -165,7 +165,7 @@ async def test_entity_basic( "clear_queue": 1, "position": None, "play": 1, - "play_pos": 0, + "expected_play_args": [0], }, ), ( @@ -178,7 +178,6 @@ async def test_entity_basic( "clear_queue": 0, "position": None, "play": 0, - "play_pos": 0, }, ), ( @@ -191,7 +190,6 @@ async def test_entity_basic( "clear_queue": 0, "position": 1, "play": 0, - "play_pos": 0, }, ), ( @@ -204,7 +202,7 @@ async def test_entity_basic( "clear_queue": 0, "position": 1, "play": 1, - "play_pos": 9, + "expected_play_args": [9], }, ), ( @@ -217,7 +215,7 @@ async def test_entity_basic( "clear_queue": 1, "position": None, "play": 1, - "play_pos": 0, + "expected_play_args": [0], }, ), ( @@ -230,7 +228,7 @@ async def test_entity_basic( "clear_queue": 1, "position": None, "play": 1, - "play_pos": 0, + "expected_play_args": [0], }, ), ], @@ -266,23 +264,20 @@ async def test_play_media_library( sock_mock.add_to_queue.call_args_list[0].args[0].item_id == test_result["item_id"] ) - if test_result["position"] is not None: - assert ( - sock_mock.add_to_queue.call_args_list[0].kwargs["position"] - == test_result["position"] - ) - else: - assert "position" not in sock_mock.add_to_queue.call_args_list[0].kwargs + assert ( + sock_mock.add_to_queue.call_args_list[0].kwargs.get("position") + == test_result["position"] + ) + assert ( sock_mock.add_to_queue.call_args_list[0].kwargs["timeout"] == LONG_SERVICE_TIMEOUT ) assert sock_mock.play_from_queue.call_count == test_result["play"] - if test_result["play"] != 0: - assert ( - sock_mock.play_from_queue.call_args_list[0].args[0] - == test_result["play_pos"] - ) + actual_play_args = [ + call.args[0] for call in sock_mock.play_from_queue.call_args_list + ] + assert actual_play_args == test_result.get("expected_play_args", []) @pytest.mark.parametrize( From 6d80b3769a38955d5f1c262d805b2d7fd5d7fcb9 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 6 Apr 2026 13:35:44 +0300 Subject: [PATCH 0506/1707] Promote Anthropic to Silver (#167361) --- homeassistant/components/anthropic/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 7ed34c517d1248..caf54b71729954 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["anthropic==0.83.0"] } From 50d9109e5f151b8973fc09e65664ca47f4bd6fa4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 12:37:29 +0200 Subject: [PATCH 0507/1707] Improve Google Calendar action naming consistency (#167143) --- homeassistant/components/google/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 91fd097ef0d3b3..647c108274d5c5 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -111,7 +111,7 @@ "name": "Add event" }, "create_event": { - "description": "Adds a new calendar event.", + "description": "Adds a new event to a Google calendar.", "fields": { "description": { "description": "[%key:component::google::services::add_event::fields::description::description%]", @@ -146,7 +146,7 @@ "name": "Summary" } }, - "name": "Create event" + "name": "Create event in Google Calendar" } } } From 7bea4a53e2adac5953158e6930e41a70fa7d00b4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 12:37:37 +0200 Subject: [PATCH 0508/1707] Improve `google_photos` action naming consistency (#167146) --- homeassistant/components/google_photos/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index bb041da4a6330f..5295dd6690e70c 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -94,7 +94,7 @@ "name": "Filename" } }, - "name": "Upload media" + "name": "Upload media to Google Photos" } } } From 3c7c0091f26670d9ee3b81764c92823870326be3 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 6 Apr 2026 12:38:27 +0200 Subject: [PATCH 0509/1707] Fix setup without dhw (#167423) --- .../components/bsblan/coordinator.py | 31 +++++++++++++------ .../components/bsblan/diagnostics.py | 4 ++- .../components/bsblan/water_heater.py | 24 +++++++++----- tests/components/bsblan/test_init.py | 24 ++++++++++++++ 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index e1869d5f772e94..a2805aa5ff13d8 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -10,6 +10,7 @@ BSBLAN, BSBLANAuthError, BSBLANConnectionError, + BSBLANError, HotWaterConfig, HotWaterSchedule, HotWaterState, @@ -50,7 +51,7 @@ class BSBLanFastData: state: State sensor: Sensor - dhw: HotWaterState + dhw: HotWaterState | None = None @dataclass @@ -111,7 +112,6 @@ async def _async_update_data(self) -> BSBLanFastData: # This reduces response time significantly (~0.2s per parameter) state = await self.client.state(include=STATE_INCLUDE) sensor = await self.client.sensor(include=SENSOR_INCLUDE) - dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE) except BSBLANAuthError as err: raise ConfigEntryAuthFailed( @@ -126,6 +126,19 @@ async def _async_update_data(self) -> BSBLanFastData: translation_placeholders={"host": host}, ) from err + # Fetch DHW state separately - device may not support hot water + dhw: HotWaterState | None = None + try: + dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE) + except BSBLANError: + # Preserve last known DHW state if available (entity may depend on it) + if self.data: + dhw = self.data.dhw + LOGGER.debug( + "DHW (Domestic Hot Water) state not available on device at %s", + self.config_entry.data[CONF_HOST], + ) + return BSBLanFastData( state=state, sensor=sensor, @@ -159,13 +172,6 @@ async def _async_update_data(self) -> BSBLanSlowData: dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE) dhw_schedule = await self.client.hot_water_schedule() - except AttributeError: - # Device does not support DHW functionality - LOGGER.debug( - "DHW (Domestic Hot Water) not available on device at %s", - self.config_entry.data[CONF_HOST], - ) - return BSBLanSlowData() except (BSBLANConnectionError, BSBLANAuthError) as err: # If config update fails, keep existing data LOGGER.debug( @@ -177,6 +183,13 @@ async def _async_update_data(self) -> BSBLanSlowData: return self.data # First fetch failed, return empty data return BSBLanSlowData() + except BSBLANError, AttributeError: + # Device does not support DHW functionality + LOGGER.debug( + "DHW (Domestic Hot Water) not available on device at %s", + self.config_entry.data[CONF_HOST], + ) + return BSBLanSlowData() return BSBLanSlowData( dhw_config=dhw_config, diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py index 55dedead85192b..324e2fc1497cd5 100644 --- a/homeassistant/components/bsblan/diagnostics.py +++ b/homeassistant/components/bsblan/diagnostics.py @@ -22,7 +22,9 @@ async def async_get_config_entry_diagnostics( "fast_coordinator_data": { "state": data.fast_coordinator.data.state.model_dump(), "sensor": data.fast_coordinator.data.sensor.model_dump(), - "dhw": data.fast_coordinator.data.dhw.model_dump(), + "dhw": data.fast_coordinator.data.dhw.model_dump() + if data.fast_coordinator.data.dhw + else None, }, "static": data.static.model_dump() if data.static is not None else None, } diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index ec8d01b9c710df..c91a9518f7b9a2 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -4,7 +4,7 @@ from typing import Any -from bsblan import BSBLANError, SetHotWaterParam +from bsblan import BSBLANError, HotWaterState, SetHotWaterParam from homeassistant.components.water_heater import ( STATE_ECO, @@ -46,8 +46,10 @@ async def async_setup_entry( data = entry.runtime_data # Only create water heater entity if DHW (Domestic Hot Water) is available - # Check if we have any DHW-related data indicating water heater support dhw_data = data.fast_coordinator.data.dhw + if dhw_data is None: + # Device does not support DHW, skip water heater setup + return if ( dhw_data.operating_mode is None and dhw_data.nominal_setpoint is None @@ -107,11 +109,21 @@ def __init__(self, data: BSBLanData) -> None: else: self._attr_max_temp = 65.0 # Default maximum + @property + def _dhw(self) -> HotWaterState: + """Return DHW state data. + + This entity is only created when DHW data is available. + """ + dhw = self.coordinator.data.dhw + assert dhw is not None + return dhw + @property def current_operation(self) -> str | None: """Return current operation.""" if ( - operating_mode := self.coordinator.data.dhw.operating_mode + operating_mode := self._dhw.operating_mode ) is None or operating_mode.value is None: return None return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value) @@ -119,16 +131,14 @@ def current_operation(self) -> str | None: @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if ( - current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature - ) is None: + if (current_temp := self._dhw.dhw_actual_value_top_temperature) is None: return None return current_temp.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None: + if (target_temp := self._dhw.nominal_setpoint) is None: return None return target_temp.value diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index cced08a3daa947..a6f673502bb247 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -201,6 +201,30 @@ async def test_config_entry_timeout_error( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_coordinator_fast_no_dhw_support( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, +) -> None: + """Test fast coordinator when device does not support DHW.""" + mock_bsblan.hot_water_state.side_effect = BSBLANError( + "None of the requested parameters are valid for this section" + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Integration should still load even if DHW is not supported + assert mock_config_entry.state is ConfigEntryState.LOADED + + # DHW data should be None in the fast coordinator + assert mock_config_entry.runtime_data.fast_coordinator.data.dhw is None + + # Water heater entity should not be created + assert hass.states.get("water_heater.bsb_lan") is None + + async def test_coordinator_slow_no_dhw_support( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 8b9ba690f158bc663998a3440d4fba919ecc705d Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 6 Apr 2026 12:38:55 +0200 Subject: [PATCH 0510/1707] Include port in BSB-LAN configuration URL when non-default (#166480) --- homeassistant/components/bsblan/entity.py | 10 +++-- tests/components/bsblan/test_init.py | 50 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index e95873ac85d996..536551fe6d026e 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -2,6 +2,9 @@ from __future__ import annotations +from yarl import URL + +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -10,7 +13,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BSBLanData -from .const import DOMAIN +from .const import DEFAULT_PORT, DOMAIN from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator @@ -22,7 +25,8 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]): def __init__(self, coordinator: _T, data: BSBLanData) -> None: """Initialize BSBLan entity with device info.""" super().__init__(coordinator) - host = coordinator.config_entry.data["host"] + host = coordinator.config_entry.data[CONF_HOST] + port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT) mac = data.device.MAC self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mac)}, @@ -44,7 +48,7 @@ def __init__(self, coordinator: _T, data: BSBLanData) -> None: else None ), sw_version=data.device.version, - configuration_url=f"http://{host}", + configuration_url=str(URL.build(scheme="http", host=host, port=port)), ) diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index a6f673502bb247..bc847031a02b23 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -6,8 +6,11 @@ from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry, async_fire_time_changed @@ -245,3 +248,50 @@ async def test_coordinator_slow_no_dhw_support( # Verify slow coordinator handled the AttributeError gracefully assert mock_bsblan.hot_water_config.called + + +async def test_configuration_url_default_port( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, +) -> None: + """Test configuration_url omits port 80 (HTTP default).""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "00:80:41:19:69:90")} + ) + assert device is not None + assert device.configuration_url == "http://127.0.0.1" + + +async def test_configuration_url_non_default_port( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_bsblan: MagicMock, +) -> None: + """Test configuration_url includes port when it differs from the default.""" + config_entry = MockConfigEntry( + title="BSBLAN Setup", + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 8080, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "00:80:41:19:69:90")} + ) + assert device is not None + assert device.configuration_url == "http://192.168.1.100:8080" From bf123237825031a72551b173abdf42de0a290bf7 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Mon, 6 Apr 2026 13:39:49 +0300 Subject: [PATCH 0511/1707] Use dedicated session for seventeentrack to preserve login cookies (#167394) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/seventeentrack/__init__.py | 4 ++-- homeassistant/components/seventeentrack/config_flow.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index 90fe9f325fae67..afb538c6b3257e 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -31,7 +31,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up 17Track from a config entry.""" - session = async_get_clientsession(hass) + session = async_create_clientsession(hass) client = SeventeenTrackClient(session=session) try: diff --git a/homeassistant/components/seventeentrack/config_flow.py b/homeassistant/components/seventeentrack/config_flow.py index f4f3b3e82ae7fc..58cffbb1303b8f 100644 --- a/homeassistant/components/seventeentrack/config_flow.py +++ b/homeassistant/components/seventeentrack/config_flow.py @@ -99,5 +99,5 @@ async def async_step_user( @callback def _get_client(self): - session = aiohttp_client.async_get_clientsession(self.hass) + session = aiohttp_client.async_create_clientsession(self.hass) return SeventeenTrackClient(session=session) From 6212d548b8d36836dc0086d58361717c077656cd Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 6 Apr 2026 12:43:57 +0200 Subject: [PATCH 0512/1707] Bump axis to v68 to improve MQTT event resiliance (#167373) --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/axis/conftest.py | 14 ++++++----- tests/components/axis/test_hub.py | 28 +++++++++++++++++++++ 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 072d0378ec0305..ed446f6c72ada1 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==67"], + "requirements": ["axis==68"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/requirements_all.txt b/requirements_all.txt index 74b029e908ce78..024d09e6ca7d13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -596,7 +596,7 @@ avea==1.6.1 # avion==0.10 # homeassistant.components.axis -axis==67 +axis==68 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a12cb5ba2ddaa3..03fe746e9a07eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -548,7 +548,7 @@ autoskope_client==1.4.1 av==16.0.1 # homeassistant.components.axis -axis==67 +axis==68 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 8a452109d1db51..0684c9a23e35ef 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -68,8 +68,8 @@ def __call__( class _RtspClientMock(Protocol): - async def __call__( - self, data: dict[str, Any] | None = None, state: str = "" + def __call__( + self, data: bytes | None = None, state: Signal | None = None ) -> None: ... @@ -337,14 +337,16 @@ def stop_stream() -> None: rtsp_client_mock.return_value.stop = stop_stream - def make_rtsp_call(data: dict[str, Any] | None = None, state: str = "") -> None: + def make_rtsp_call( + data: bytes | None = None, state: Signal | None = None + ) -> None: """Generate a RTSP call.""" axis_streammanager_session_callback = rtsp_client_mock.call_args[0][4] - if data: - rtsp_client_mock.return_value.rtp.data = data + if data is not None: + rtsp_client_mock.return_value.data = data axis_streammanager_session_callback(signal=Signal.DATA) - elif state: + elif state is not None: axis_streammanager_session_callback(signal=state) else: raise NotImplementedError diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index 2d963cf56fbe49..7186ada3ce834e 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -2,6 +2,7 @@ from collections.abc import Callable from ipaddress import ip_address +import logging from types import MappingProxyType from typing import Any from unittest import mock @@ -73,6 +74,33 @@ async def test_device_support_mqtt( assert pir.name == f"{NAME} PIR 0" +@pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_MQTT]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_device_support_mqtt_without_required_event_keys( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + caplog: pytest.LogCaptureFixture, +) -> None: + """Ignore non-event MQTT payloads without raising callback exceptions.""" + caplog.set_level(logging.ERROR, logger="homeassistant.components.mqtt.client") + + mqtt_call = call(f"axis/{MAC}/#", mock.ANY, 0, "utf-8", ANY) + assert mqtt_call in mqtt_mock.async_subscribe.call_args_list + + topic = f"axis/{MAC}/device" + message = ( + b'{"timestamp": 1775115420, "time": "2026-04-02T09:37:00+0200", ' + b'"zone": "CEST", "ip": "1.2.3.4", "host": "hostname", ' + b'"temperature": {"temp_main": 23.5, "temp_cpu": 24.0}, ' + b'"power": {"pwr": 4.76, "pwr-avg": 3.88, "pwr-max": 9.13}}' + ) + + async_fire_mqtt_message(hass, topic, message) + await hass.async_block_till_done() + + assert "Exception in _mqtt_message" not in caplog.text + + @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_MQTT]) @pytest.mark.parametrize("mqtt_status_code", [401]) @pytest.mark.usefixtures("config_entry_setup") From 2216fcccc77dad0b7910c21f8b04e29515bf0e09 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:47:26 +0200 Subject: [PATCH 0513/1707] Handle access.logs.add events for UniFi Access G6 Pro Entry (#167362) Co-authored-by: RaHehl --- .../components/unifi_access/coordinator.py | 47 ++- tests/components/unifi_access/test_event.py | 386 ++++++++++++++++++ 2 files changed, 430 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index 480b5f81902200..0e33638c49ecee 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -27,6 +27,7 @@ InsightsAdd, LocationUpdateState, LocationUpdateV2, + LogAdd, SettingUpdate, ThumbnailInfo, V2LocationState, @@ -92,6 +93,7 @@ def __init__( ) self.client = client self._event_listeners: list[Callable[[DoorEvent], None]] = [] + self._device_to_door: dict[str, str] = {} @callback def async_subscribe_door_events( @@ -141,6 +143,7 @@ async def _async_setup(self) -> None: "access.data.v2.location.update": self._handle_v2_location_update, "access.hw.door_bell": self._handle_doorbell, "access.logs.insights.add": self._handle_insights_add, + "access.logs.add": self._handle_logs_add, "access.data.setting.update": self._handle_setting_update, } self.client.start_websocket( @@ -198,6 +201,13 @@ async def _async_update_data(self) -> UnifiAccessData: current_ids = {door.id for door in doors} | {self.config_entry.entry_id} self._remove_stale_devices(current_ids) + current_door_ids = {door.id for door in doors} + self._device_to_door = { + dev_id: door_id + for dev_id, door_id in self._device_to_door.items() + if door_id in current_door_ids + } + return UnifiAccessData( doors={door.id: door for door in doors}, emergency=emergency, @@ -268,9 +278,20 @@ async def _handle_location_update(self, msg: WebsocketMessage) -> None: async def _handle_v2_location_update(self, msg: WebsocketMessage) -> None: """Handle V2 location update messages.""" update = cast(V2LocationUpdate, msg) - self._process_door_update( - update.data.id, update.data.state, update.data.thumbnail - ) + door_id = update.data.id + + stale_device_ids = [ + device_id + for device_id, mapped_door_id in self._device_to_door.items() + if mapped_door_id == door_id + ] + for device_id in stale_device_ids: + del self._device_to_door[device_id] + + for device_id in update.data.device_ids: + self._device_to_door[device_id] = door_id + + self._process_door_update(door_id, update.data.state, update.data.thumbnail) def _process_door_update( self, @@ -396,6 +417,26 @@ async def _handle_insights_add(self, msg: WebsocketMessage) -> None: if door.id: self._dispatch_door_event(door.id, "access", event_type, attrs) + async def _handle_logs_add(self, msg: WebsocketMessage) -> None: + """Handle access log events (entry/exit via access.logs.add).""" + log = cast(LogAdd, msg) + source = log.data.source + device_target = source.device_config + if device_target is None or device_target.id not in self._device_to_door: + return + door_id = self._device_to_door[device_target.id] + event_type = ( + "access_granted" if source.event.result == "ACCESS" else "access_denied" + ) + attrs: dict[str, Any] = {} + if source.actor.display_name: + attrs["actor"] = source.actor.display_name + if source.authentication.credential_provider: + attrs["authentication"] = source.authentication.credential_provider + if source.event.result: + attrs["result"] = source.event.result + self._dispatch_door_event(door_id, "access", event_type, attrs) + def get_lock_rule_status(self, door_id: str) -> DoorLockRuleStatus | None: """Return the current lock rule status for a door.""" return self.data.door_lock_rules.get(door_id) diff --git a/tests/components/unifi_access/test_event.py b/tests/components/unifi_access/test_event.py index 07d35ce4e42681..72e7957c2829bf 100644 --- a/tests/components/unifi_access/test_event.py +++ b/tests/components/unifi_access/test_event.py @@ -14,6 +14,15 @@ InsightsAddData, InsightsMetadata, InsightsMetadataEntry, + LogActor, + LogAdd, + LogAddData, + LogAuthentication, + LogEvent, + LogSource, + LogTarget, + V2LocationUpdate, + V2LocationUpdateData, WebsocketMessage, ) @@ -391,3 +400,380 @@ async def test_unload_entry_removes_listeners( state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY) assert state is not None assert state.state == "unavailable" + + +async def _populate_device_mapping( + handlers: dict[str, Callable[[WebsocketMessage], Awaitable[None]]], +) -> None: + """Send a V2 location update to populate the device-to-door mapping.""" + location_msg = V2LocationUpdate( + event="access.data.v2.location.update", + data=V2LocationUpdateData( + id="door-001", + location_type="door", + name="Front Door", + device_ids=["hub-device-001", "camera-device-001"], + ), + ) + await handlers["access.data.v2.location.update"](location_msg) + + +@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00") +async def test_logs_add_access_granted( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test access.logs.add event dispatches access_granted.""" + handlers = _get_ws_handlers(mock_client) + await _populate_device_mapping(handlers) + + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="hub-device-001", + display_name="UA Hub Door", + ), + ], + actor=LogActor(display_name="John Doe"), + event=LogEvent(result="ACCESS"), + authentication=LogAuthentication(credential_provider="NFC"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.attributes["event_type"] == "access_granted" + assert state.attributes["actor"] == "John Doe" + assert state.attributes["authentication"] == "NFC" + assert state.attributes["result"] == "ACCESS" + assert state.state == "2025-01-01T00:00:00.000+00:00" + + +@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00") +async def test_logs_add_access_denied( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test access.logs.add event dispatches access_denied for non-ACCESS result.""" + handlers = _get_ws_handlers(mock_client) + await _populate_device_mapping(handlers) + + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="hub-device-001", + display_name="UA Hub Door", + ), + ], + event=LogEvent(result="BLOCKED"), + authentication=LogAuthentication(credential_provider="PIN_CODE"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.attributes["event_type"] == "access_denied" + assert state.attributes["result"] == "BLOCKED" + assert state.state == "2025-01-01T00:00:00.000+00:00" + + +async def test_logs_add_unknown_device_ignored( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test access.logs.add event with unknown device ID is ignored.""" + handlers = _get_ws_handlers(mock_client) + await _populate_device_mapping(handlers) + + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="unknown-device", + display_name="Unknown Hub", + ), + ], + event=LogEvent(result="ACCESS"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.state == "unknown" + + +async def test_logs_add_without_location_update_ignored( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test access.logs.add event is ignored when no device mapping exists.""" + handlers = _get_ws_handlers(mock_client) + + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="hub-device-001", + display_name="UA Hub Door", + ), + ], + event=LogEvent(result="ACCESS"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.state == "unknown" + + +@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00") +async def test_logs_add_empty_result_dispatches_access_denied( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test access.logs.add event with empty result dispatches access_denied.""" + handlers = _get_ws_handlers(mock_client) + await _populate_device_mapping(handlers) + + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="hub-device-001", + display_name="UA Hub Door", + ), + ], + event=LogEvent(result=""), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.attributes["event_type"] == "access_denied" + assert "result" not in state.attributes + assert state.state == "2025-01-01T00:00:00.000+00:00" + + +async def test_logs_add_no_device_config_target_ignored( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test access.logs.add event without device_config target is ignored.""" + handlers = _get_ws_handlers(mock_client) + await _populate_device_mapping(handlers) + + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="user", + id="some-user-id", + display_name="Some User", + ), + ], + event=LogEvent(result="ACCESS"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.state == "unknown" + + +async def test_logs_add_empty_targets_ignored( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test access.logs.add event with empty targets list is ignored.""" + handlers = _get_ws_handlers(mock_client) + await _populate_device_mapping(handlers) + + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[], + event=LogEvent(result="ACCESS"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.state == "unknown" + + +@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00") +async def test_logs_add_stale_device_mapping_cleared( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test stale device mappings are cleared when a door's devices change.""" + handlers = _get_ws_handlers(mock_client) + await _populate_device_mapping(handlers) + + # Reassign door-001 to only have camera-device-001 (hub-device-001 removed) + reassign_msg = V2LocationUpdate( + event="access.data.v2.location.update", + data=V2LocationUpdateData( + id="door-001", + location_type="door", + name="Front Door", + device_ids=["camera-device-001"], + ), + ) + await handlers["access.data.v2.location.update"](reassign_msg) + + # hub-device-001 should no longer resolve to door-001 + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="hub-device-001", + display_name="UA Hub Door", + ), + ], + actor=LogActor(display_name="John Doe"), + event=LogEvent(result="ACCESS"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.state == "unknown" + + # camera-device-001 should still resolve to door-001 + camera_log = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="camera-device-001", + display_name="Camera", + ), + ], + actor=LogActor(display_name="Jane Doe"), + event=LogEvent(result="ACCESS"), + ), + ), + ) + + await handlers["access.logs.add"](camera_log) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.attributes["event_type"] == "access_granted" + assert state.state == "2025-01-01T00:00:00.000+00:00" + + +@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00") +async def test_logs_add_device_mapping_pruned_on_refresh( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test device-to-door mappings are pruned when a door is removed on refresh.""" + handlers = _get_ws_handlers(mock_client) + await _populate_device_mapping(handlers) + + # Simulate door-001 being removed from the hub + mock_client.get_doors.return_value = [ + door for door in mock_client.get_doors.return_value if door.id != "door-001" + ] + + # Trigger refresh via WebSocket reconnect + on_disconnect = mock_client.start_websocket.call_args[1]["on_disconnect"] + on_connect = mock_client.start_websocket.call_args[1]["on_connect"] + on_disconnect() + await hass.async_block_till_done() + on_connect() + await hass.async_block_till_done() + + # hub-device-001 mapping should have been pruned; + # sending a log event for it must not raise an error + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="hub-device-001", + display_name="UA Hub Door", + ), + ], + actor=LogActor(display_name="John Doe"), + event=LogEvent(result="ACCESS"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + # door-001 entity was removed when the door disappeared + assert hass.states.get(FRONT_DOOR_ACCESS_ENTITY) is None From ddc00f6924ccb331d0c0f56601b31618643a529e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Apr 2026 12:48:15 +0200 Subject: [PATCH 0514/1707] Extract functional utility template functions into a functional Jinja2 extension (#167357) --- homeassistant/helpers/template/__init__.py | 179 +----- .../helpers/template/extensions/__init__.py | 2 + .../helpers/template/extensions/functional.py | 247 +++++++++ .../snapshots/test_functional.ambr} | 0 .../template/extensions/test_functional.py | 521 ++++++++++++++++++ tests/helpers/template/test_init.py | 506 ----------------- 6 files changed, 773 insertions(+), 682 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/functional.py rename tests/helpers/template/{snapshots/test_init.ambr => extensions/snapshots/test_functional.ambr} (100%) create mode 100644 tests/helpers/template/extensions/test_functional.py diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 4ef3502002c69a..728f11bc365832 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -6,15 +6,12 @@ import asyncio import collections.abc from collections.abc import Callable, Generator, Iterable -from copy import deepcopy from datetime import datetime, timedelta from enum import Enum from functools import cache, lru_cache, partial, wraps import logging import math -from operator import contains import pathlib -import random import re import sys from types import CodeType @@ -45,7 +42,6 @@ from homeassistant.core import ( Context, HomeAssistant, - ServiceResponse, State, callback, valid_domain, @@ -1460,164 +1456,11 @@ def add(value, amount, default=_SENTINEL): return default -def apply(value, fn, *args, **kwargs): - """Call the given callable with the provided arguments and keyword arguments.""" - return fn(value, *args, **kwargs) - - -def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: - """Turn a macro with a 'returns' keyword argument into a function that returns what that argument is called with.""" - - def wrapper(*args, **kwargs): - return_value = None - - def returns(value): - nonlocal return_value - return_value = value - return value - - # Call the callable with the value and other args - macro(*args, **kwargs, returns=returns) - return return_value - - # Remove "macro_" from the macro's name to avoid confusion in the wrapper's name - trimmed_name = macro.name.removeprefix("macro_") - - wrapper.__name__ = trimmed_name - wrapper.__qualname__ = trimmed_name - return wrapper - - def version(value): """Filter and function to get version object of the value.""" return AwesomeVersion(value) -def merge_response(value: ServiceResponse) -> list[Any]: - """Merge action responses into single list. - - Checks that the input is a correct service response: - { - "entity_id": {str: dict[str, Any]}, - } - If response is a single list, it will extend the list with the items - and add the entity_id and value_key to each dictionary for reference. - If response is a dictionary or multiple lists, - it will append the dictionary/lists to the list - and add the entity_id to each dictionary for reference. - """ - if not isinstance(value, dict): - raise TypeError("Response is not a dictionary") - if not value: - # Bail out early if response is an empty dictionary - return [] - - is_single_list = False - response_items: list = [] - input_service_response = deepcopy(value) - for entity_id, entity_response in input_service_response.items(): # pylint: disable=too-many-nested-blocks - if not isinstance(entity_response, dict): - raise TypeError("Response is not a dictionary") - for value_key, type_response in entity_response.items(): - if len(entity_response) == 1 and isinstance(type_response, list): - # Provides special handling for responses such as calendar events - # and weather forecasts where the response contains a single list with multiple - # dictionaries inside. - is_single_list = True - for dict_in_list in type_response: - if isinstance(dict_in_list, dict): - if ATTR_ENTITY_ID in dict_in_list: - raise ValueError( - f"Response dictionary already contains key '{ATTR_ENTITY_ID}'" - ) - dict_in_list[ATTR_ENTITY_ID] = entity_id - dict_in_list["value_key"] = value_key - response_items.extend(type_response) - else: - # Break the loop if not a single list as the logic is then managed in the outer loop - # which handles both dictionaries and in the case of multiple lists. - break - - if not is_single_list: - _response = entity_response.copy() - if ATTR_ENTITY_ID in _response: - raise ValueError( - f"Response dictionary already contains key '{ATTR_ENTITY_ID}'" - ) - _response[ATTR_ENTITY_ID] = entity_id - response_items.append(_response) - - return response_items - - -def fail_when_undefined(value): - """Filter to force a failure when the value is undefined.""" - if isinstance(value, jinja2.Undefined): - value() - return value - - -@pass_context -def random_every_time(context, values): - """Choose a random value. - - Unlike Jinja's random filter, - this is context-dependent to avoid caching the chosen value. - """ - return random.choice(values) - - -def iif( - value: Any, if_true: Any = True, if_false: Any = False, if_none: Any = _SENTINEL -) -> Any: - """Immediate if function/filter that allow for common if/else constructs. - - https://en.wikipedia.org/wiki/IIf - - Examples: - {{ is_state("device_tracker.frenck", "home") | iif("yes", "no") }} - {{ iif(1==2, "yes", "no") }} - {{ (1 == 1) | iif("yes", "no") }} - - """ - if value is None and if_none is not _SENTINEL: - return if_none - if bool(value): - return if_true - return if_false - - -def typeof(value: Any) -> Any: - """Return the type of value passed to debug types.""" - return value.__class__.__name__ - - -def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: - """Combine multiple dictionaries into one.""" - if not args: - raise TypeError("combine expected at least 1 argument, got 0") - - result: dict[Any, Any] = {} - for arg in args: - if not isinstance(arg, dict): - raise TypeError(f"combine expected a dict, got {type(arg).__name__}") - - if recursive: - for key, value in arg.items(): - if ( - key in result - and isinstance(result[key], dict) - and isinstance(value, dict) - ): - result[key] = combine(result[key], value, recursive=True) - else: - result[key] = value - else: - result |= arg - - return result - - def make_logging_undefined( strict: bool | None, log_fn: Callable[[int, str], None] | None ) -> type[jinja2.Undefined]: @@ -1754,6 +1597,9 @@ def __init__( ) self.add_extension("homeassistant.helpers.template.extensions.DeviceExtension") self.add_extension("homeassistant.helpers.template.extensions.FloorExtension") + self.add_extension( + "homeassistant.helpers.template.extensions.FunctionalExtension" + ) self.add_extension("homeassistant.helpers.template.extensions.IssuesExtension") self.add_extension("homeassistant.helpers.template.extensions.LabelExtension") self.add_extension("homeassistant.helpers.template.extensions.MathExtension") @@ -1766,32 +1612,13 @@ def __init__( "homeassistant.helpers.template.extensions.TypeCastExtension" ) - self.globals["apply"] = apply - self.globals["as_function"] = as_function - self.globals["combine"] = combine - self.globals["iif"] = iif - self.globals["merge_response"] = merge_response - self.globals["typeof"] = typeof self.globals["version"] = version - self.globals["zip"] = zip self.filters["add"] = add - self.filters["apply"] = apply - self.filters["as_function"] = as_function - self.filters["combine"] = combine - self.filters["contains"] = contains - self.filters["iif"] = iif - self.filters["is_defined"] = fail_when_undefined self.filters["multiply"] = multiply - self.filters["ord"] = ord - self.filters["random"] = random_every_time self.filters["round"] = forgiving_round - self.filters["typeof"] = typeof self.filters["version"] = version - self.tests["apply"] = apply - self.tests["contains"] = contains - if hass is None: return diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 47c4ae648dca25..c2c9755d06b989 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -7,6 +7,7 @@ from .datetime import DateTimeExtension from .devices import DeviceExtension from .floors import FloorExtension +from .functional import FunctionalExtension from .issues import IssuesExtension from .labels import LabelExtension from .math import MathExtension @@ -23,6 +24,7 @@ "DateTimeExtension", "DeviceExtension", "FloorExtension", + "FunctionalExtension", "IssuesExtension", "LabelExtension", "MathExtension", diff --git a/homeassistant/helpers/template/extensions/functional.py b/homeassistant/helpers/template/extensions/functional.py new file mode 100644 index 00000000000000..2f65d4263eb925 --- /dev/null +++ b/homeassistant/helpers/template/extensions/functional.py @@ -0,0 +1,247 @@ +"""Functional utility functions for Home Assistant templates.""" + +from __future__ import annotations + +from collections.abc import Callable +from copy import deepcopy +from operator import contains +import random +from typing import TYPE_CHECKING, Any + +import jinja2 +from jinja2 import pass_context + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import ServiceResponse + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + +_SENTINEL = object() + + +class FunctionalExtension(BaseTemplateExtension): + """Jinja2 extension for functional utility functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the functional extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "apply", + self.apply, + as_global=True, + as_filter=True, + as_test=True, + ), + TemplateFunction( + "as_function", + self.as_function, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "iif", + self.iif, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "merge_response", + self.merge_response, + as_global=True, + ), + TemplateFunction( + "combine", + self.combine, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "typeof", + self.typeof, + as_global=True, + as_filter=True, + ), + TemplateFunction( + "is_defined", + self.fail_when_undefined, + as_filter=True, + ), + TemplateFunction( + "random", + _random_every_time, + as_filter=True, + ), + TemplateFunction( + "zip", + zip, + as_global=True, + ), + TemplateFunction( + "ord", + ord, + as_filter=True, + ), + TemplateFunction( + "contains", + contains, + as_filter=True, + as_test=True, + ), + ], + ) + + @staticmethod + def apply(value: Any, fn: Any, *args: Any, **kwargs: Any) -> Any: + """Call the given callable with the provided arguments and keyword arguments.""" + return fn(value, *args, **kwargs) + + @staticmethod + def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: + """Turn a macro with a 'returns' keyword argument into a function.""" + + def wrapper(*args: Any, **kwargs: Any) -> Any: + return_value = None + + def returns(value: Any) -> Any: + nonlocal return_value + return_value = value + return value + + # Call the callable with the value and other args + macro(*args, **kwargs, returns=returns) + return return_value + + # Remove "macro_" from the macro's name to avoid confusion + trimmed_name = macro.name.removeprefix("macro_") + + wrapper.__name__ = trimmed_name + wrapper.__qualname__ = trimmed_name + return wrapper + + @staticmethod + def iif( + value: Any, + if_true: Any = True, + if_false: Any = False, + if_none: Any = _SENTINEL, + ) -> Any: + """Immediate if function/filter that allow for common if/else constructs. + + https://en.wikipedia.org/wiki/IIf + + Examples: + {{ is_state("device_tracker.frenck", "home") | iif("yes", "no") }} + {{ iif(1==2, "yes", "no") }} + {{ (1 == 1) | iif("yes", "no") }} + """ + if value is None and if_none is not _SENTINEL: + return if_none + if bool(value): + return if_true + return if_false + + @staticmethod + def merge_response(value: ServiceResponse) -> list[Any]: + """Merge action responses into single list. + + Checks that the input is a correct service response: + { + "entity_id": {str: dict[str, Any]}, + } + If response is a single list, it will extend the list with the items + and add the entity_id and value_key to each dictionary for reference. + If response is a dictionary or multiple lists, + it will append the dictionary/lists to the list + and add the entity_id to each dictionary for reference. + """ + if not isinstance(value, dict): + raise TypeError("Response is not a dictionary") + if not value: + return [] + + is_single_list = False + response_items: list = [] + input_service_response = deepcopy(value) + for entity_id, entity_response in input_service_response.items(): # pylint: disable=too-many-nested-blocks + if not isinstance(entity_response, dict): + raise TypeError("Response is not a dictionary") + for value_key, type_response in entity_response.items(): + if len(entity_response) == 1 and isinstance(type_response, list): + is_single_list = True + for dict_in_list in type_response: + if isinstance(dict_in_list, dict): + if ATTR_ENTITY_ID in dict_in_list: + raise ValueError( + f"Response dictionary already contains key '{ATTR_ENTITY_ID}'" + ) + dict_in_list[ATTR_ENTITY_ID] = entity_id + dict_in_list["value_key"] = value_key + response_items.extend(type_response) + else: + break + + if not is_single_list: + _response = entity_response.copy() + if ATTR_ENTITY_ID in _response: + raise ValueError( + f"Response dictionary already contains key '{ATTR_ENTITY_ID}'" + ) + _response[ATTR_ENTITY_ID] = entity_id + response_items.append(_response) + + return response_items + + @staticmethod + def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: + """Combine multiple dictionaries into one.""" + if not args: + raise TypeError("combine expected at least 1 argument, got 0") + + result: dict[Any, Any] = {} + for arg in args: + if not isinstance(arg, dict): + raise TypeError(f"combine expected a dict, got {type(arg).__name__}") + + if recursive: + for key, value in arg.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = FunctionalExtension.combine( + result[key], value, recursive=True + ) + else: + result[key] = value + else: + result |= arg + + return result + + @staticmethod + def typeof(value: Any) -> Any: + """Return the type of value passed to debug types.""" + return value.__class__.__name__ + + @staticmethod + def fail_when_undefined(value: Any) -> Any: + """Filter to force a failure when the value is undefined.""" + if isinstance(value, jinja2.Undefined): + value() + return value + + +@pass_context +def _random_every_time(context: Any, values: Any) -> Any: + """Choose a random value. + + Unlike Jinja's random filter, + this is context-dependent to avoid caching the chosen value. + """ + return random.choice(values) diff --git a/tests/helpers/template/snapshots/test_init.ambr b/tests/helpers/template/extensions/snapshots/test_functional.ambr similarity index 100% rename from tests/helpers/template/snapshots/test_init.ambr rename to tests/helpers/template/extensions/snapshots/test_functional.ambr diff --git a/tests/helpers/template/extensions/test_functional.py b/tests/helpers/template/extensions/test_functional.py new file mode 100644 index 00000000000000..977d0b06846de9 --- /dev/null +++ b/tests/helpers/template/extensions/test_functional.py @@ -0,0 +1,521 @@ +"""Test functional utility functions for Home Assistant templates.""" + +from __future__ import annotations + +import random +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template + +from tests.helpers.template.helpers import render + + +def test_apply(hass: HomeAssistant) -> None: + """Test apply.""" + tpl = """ + {%- macro add_foo(arg) -%} + {{arg}}foo + {%- endmacro -%} + {{ ["a", "b", "c"] | map('apply', add_foo) | list }} + """ + assert render(hass, tpl) == ["afoo", "bfoo", "cfoo"] + + assert render( + hass, "{{ ['1', '2', '3', '4', '5'] | map('apply', int) | list }}" + ) == [1, 2, 3, 4, 5] + + +def test_apply_macro_with_arguments(hass: HomeAssistant) -> None: + """Test apply macro with positional, named, and mixed arguments.""" + # Test macro with positional arguments + tpl = """ + {%- macro add_numbers(a, b, c) -%} + {{ a + b + c }} + {%- endmacro -%} + {{ apply(5, add_numbers, 10, 15) }} + """ + assert render(hass, tpl) == 30 + + # Test macro with named arguments + tpl = """ + {%- macro greet(name, greeting="Hello") -%} + {{ greeting }}, {{ name }}! + {%- endmacro -%} + {{ apply("World", greet, greeting="Hi") }} + """ + assert render(hass, tpl) == "Hi, World!" + + # Test macro with mixed arguments + tpl = """ + {%- macro format_message(prefix, name, suffix="!") -%} + {{ prefix }} {{ name }}{{ suffix }} + {%- endmacro -%} + {{ apply("Welcome", format_message, "John", suffix="...") }} + """ + assert render(hass, tpl) == "Welcome John..." + + +def test_as_function(hass: HomeAssistant) -> None: + """Test as_function.""" + tpl = """ + {%- macro macro_double(num, returns) -%} + {%- do returns(num * 2) -%} + {%- endmacro -%} + {%- set double = macro_double | as_function -%} + {{ double(5) }} + """ + assert render(hass, tpl) == 10 + + +def test_as_function_no_arguments(hass: HomeAssistant) -> None: + """Test as_function with no arguments.""" + tpl = """ + {%- macro macro_get_hello(returns) -%} + {%- do returns("Hello") -%} + {%- endmacro -%} + {%- set get_hello = macro_get_hello | as_function -%} + {{ get_hello() }} + """ + assert render(hass, tpl) == "Hello" + + +def test_ord(hass: HomeAssistant) -> None: + """Test the ord filter.""" + assert render(hass, '{{ "d" | ord }}') == 100 + + +@patch.object(random, "choice") +def test_random_every_time(test_choice: MagicMock, hass: HomeAssistant) -> None: + """Ensure the random filter runs every time, not just once.""" + tpl = template.Template("{{ [1,2] | random }}", hass) + test_choice.return_value = "foo" + assert tpl.async_render() == "foo" + test_choice.return_value = "bar" + assert tpl.async_render() == "bar" + + +def test_render_with_possible_json_value_valid_with_is_defined( + hass: HomeAssistant, +) -> None: + """Render with possible JSON value with known JSON object.""" + tpl = template.Template("{{ value_json.hello|is_defined }}", hass) + assert tpl.async_render_with_possible_json_value('{"hello": "world"}') == "world" + + +def test_render_with_possible_json_value_undefined_json(hass: HomeAssistant) -> None: + """Render with possible JSON value with unknown JSON object.""" + tpl = template.Template("{{ value_json.bye|is_defined }}", hass) + assert ( + tpl.async_render_with_possible_json_value('{"hello": "world"}') + == '{"hello": "world"}' + ) + + +def test_render_with_possible_json_value_undefined_json_error_value( + hass: HomeAssistant, +) -> None: + """Render with possible JSON value with unknown JSON object.""" + tpl = template.Template("{{ value_json.bye|is_defined }}", hass) + assert tpl.async_render_with_possible_json_value('{"hello": "world"}', "") == "" + + +def test_iif(hass: HomeAssistant) -> None: + """Test the immediate if function/filter.""" + + result = render(hass, "{{ (1 == 1) | iif }}") + assert result is True + + result = render(hass, "{{ (1 == 2) | iif }}") + assert result is False + + result = render(hass, "{{ (1 == 1) | iif('yes') }}") + assert result == "yes" + + result = render(hass, "{{ (1 == 2) | iif('yes') }}") + assert result is False + + result = render(hass, "{{ (1 == 2) | iif('yes', 'no') }}") + assert result == "no" + + result = render(hass, "{{ not_exists | default(None) | iif('yes', 'no') }}") + assert result == "no" + + result = render( + hass, "{{ not_exists | default(None) | iif('yes', 'no', 'unknown') }}" + ) + assert result == "unknown" + + result = render(hass, "{{ iif(1 == 1) }}") + assert result is True + + result = render(hass, "{{ iif(1 == 2, 'yes', 'no') }}") + assert result == "no" + + +@pytest.mark.parametrize( + ("seq", "value", "expected"), + [ + ([0], 0, True), + ([1], 0, False), + ([False], 0, True), + ([True], 0, False), + ([0], [0], False), + (["toto", 1], "toto", True), + (["toto", 1], "tata", False), + ([], 0, False), + ([], None, False), + ], +) +def test_contains( + hass: HomeAssistant, seq: list, value: object, expected: bool +) -> None: + """Test contains.""" + assert ( + render(hass, "{{ seq | contains(value) }}", {"seq": seq, "value": value}) + == expected + ) + assert ( + render(hass, "{{ seq is contains(value) }}", {"seq": seq, "value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("service_response"), + [ + { + "calendar.sports": { + "events": [ + { + "start": "2024-02-27T17:00:00-06:00", + "end": "2024-02-27T18:00:00-06:00", + "summary": "Basketball vs. Rockets", + "description": "", + } + ] + }, + "calendar.local_furry_events": {"events": []}, + "calendar.yap_house_schedules": { + "events": [ + { + "start": "2024-02-26T08:00:00-06:00", + "end": "2024-02-26T09:00:00-06:00", + "summary": "Dr. Appt", + "description": "", + }, + { + "start": "2024-02-28T20:00:00-06:00", + "end": "2024-02-28T21:00:00-06:00", + "summary": "Bake a cake", + "description": "something good", + }, + ] + }, + }, + { + "binary_sensor.workday": {"workday": True}, + "binary_sensor.workday2": {"workday": False}, + }, + { + "weather.smhi_home": { + "forecast": [ + { + "datetime": "2024-03-31T16:00:00", + "condition": "cloudy", + "wind_bearing": 79, + "cloud_coverage": 100, + "temperature": 10, + "templow": 4, + "pressure": 998, + "wind_gust_speed": 21.6, + "wind_speed": 11.88, + "precipitation": 0.2, + "humidity": 87, + }, + { + "datetime": "2024-04-01T12:00:00", + "condition": "rainy", + "wind_bearing": 17, + "cloud_coverage": 100, + "temperature": 6, + "templow": 1, + "pressure": 999, + "wind_gust_speed": 20.52, + "wind_speed": 8.64, + "precipitation": 2.2, + "humidity": 88, + }, + { + "datetime": "2024-04-02T12:00:00", + "condition": "cloudy", + "wind_bearing": 17, + "cloud_coverage": 100, + "temperature": 0, + "templow": -3, + "pressure": 1003, + "wind_gust_speed": 57.24, + "wind_speed": 30.6, + "precipitation": 1.3, + "humidity": 71, + }, + ] + }, + "weather.forecast_home": { + "forecast": [ + { + "condition": "cloudy", + "precipitation_probability": 6.6, + "datetime": "2024-03-31T10:00:00+00:00", + "wind_bearing": 71.8, + "temperature": 10.9, + "templow": 6.5, + "wind_gust_speed": 24.1, + "wind_speed": 13.7, + "precipitation": 0, + "humidity": 71, + }, + { + "condition": "cloudy", + "precipitation_probability": 8, + "datetime": "2024-04-01T10:00:00+00:00", + "wind_bearing": 350.6, + "temperature": 10.2, + "templow": 3.4, + "wind_gust_speed": 38.2, + "wind_speed": 21.6, + "precipitation": 0, + "humidity": 79, + }, + { + "condition": "snowy", + "precipitation_probability": 67.4, + "datetime": "2024-04-02T10:00:00+00:00", + "wind_bearing": 24.5, + "temperature": 3, + "templow": 0, + "wind_gust_speed": 64.8, + "wind_speed": 37.4, + "precipitation": 2.3, + "humidity": 77, + }, + ] + }, + }, + { + "vacuum.deebot_n8_plus_1": { + "payloadType": "j", + "resp": { + "body": { + "msg": "ok", + } + }, + "header": { + "ver": "0.0.1", + }, + }, + "vacuum.deebot_n8_plus_2": { + "payloadType": "j", + "resp": { + "body": { + "msg": "ok", + } + }, + "header": { + "ver": "0.0.1", + }, + }, + }, + ], + ids=["calendar", "workday", "weather", "vacuum"], +) +async def test_merge_response( + hass: HomeAssistant, + service_response: dict, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter.""" + + _template = "{{ merge_response(" + str(service_response) + ") }}" + + assert service_response == snapshot(name="a_response") + assert render( + hass, + _template, + ) == snapshot(name="b_rendered") + + +async def test_merge_response_with_entity_id_in_response( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter with empty lists.""" + + service_response = { + "test.response": {"some_key": True, "entity_id": "test.response"}, + "test.response2": {"some_key": False, "entity_id": "test.response2"}, + } + _template = "{{ merge_response(" + str(service_response) + ") }}" + with pytest.raises( + TemplateError, + match="ValueError: Response dictionary already contains key 'entity_id'", + ): + render(hass, _template) + + service_response = { + "test.response": { + "happening": [ + { + "start": "2024-02-27T17:00:00-06:00", + "end": "2024-02-27T18:00:00-06:00", + "summary": "Magic day", + "entity_id": "test.response", + } + ] + } + } + _template = "{{ merge_response(" + str(service_response) + ") }}" + with pytest.raises( + TemplateError, + match="ValueError: Response dictionary already contains key 'entity_id'", + ): + render(hass, _template) + + +async def test_merge_response_with_empty_response( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter with empty lists.""" + + service_response = { + "calendar.sports": {"events": []}, + "calendar.local_furry_events": {"events": []}, + "calendar.yap_house_schedules": {"events": []}, + } + _template = "{{ merge_response(" + str(service_response) + ") }}" + assert service_response == snapshot(name="a_response") + assert render(hass, _template) == snapshot(name="b_rendered") + + +async def test_response_empty_dict( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter with empty dict.""" + + service_response = {} + _template = "{{ merge_response(" + str(service_response) + ") }}" + + result = render(hass, _template) + assert result == [] + + +async def test_response_incorrect_value( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter with incorrect response.""" + + service_response = "incorrect" + _template = "{{ merge_response(" + str(service_response) + ") }}" + with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): + render(hass, _template) + + +async def test_merge_response_with_incorrect_response(hass: HomeAssistant) -> None: + """Test the merge_response function/filter with empty response should raise.""" + + service_response = {"calendar.sports": []} + _template = "{{ merge_response(" + str(service_response) + ") }}" + with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): + render(hass, _template) + + service_response = { + "binary_sensor.workday": [], + } + _template = "{{ merge_response(" + str(service_response) + ") }}" + with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): + render(hass, _template) + + +async def test_merge_response_not_mutate_original_object( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test the merge_response does not mutate original service response value.""" + + value = '{"calendar.family": {"events": [{"summary": "An event"}]}' + _template = ( + "{% set calendar_response = " + value + "} %}" + "{{ merge_response(calendar_response) }}" + # We should be able to merge the same response again + # as the merge is working on a copy of the original object (response) + "{{ merge_response(calendar_response) }}" + ) + + assert render(hass, _template) + + +def test_typeof(hass: HomeAssistant) -> None: + """Test the typeof debug filter/function.""" + assert render(hass, "{{ True | typeof }}") == "bool" + assert render(hass, "{{ typeof(True) }}") == "bool" + + assert render(hass, "{{ [1, 2, 3] | typeof }}") == "list" + assert render(hass, "{{ typeof([1, 2, 3]) }}") == "list" + + assert render(hass, "{{ 1 | typeof }}") == "int" + assert render(hass, "{{ typeof(1) }}") == "int" + + assert render(hass, "{{ 1.1 | typeof }}") == "float" + assert render(hass, "{{ typeof(1.1) }}") == "float" + + assert render(hass, "{{ None | typeof }}") == "NoneType" + assert render(hass, "{{ typeof(None) }}") == "NoneType" + + assert render(hass, "{{ 'Home Assistant' | typeof }}") == "str" + assert render(hass, "{{ typeof('Home Assistant') }}") == "str" + + +def test_combine(hass: HomeAssistant) -> None: + """Test combine filter and function.""" + assert render(hass, "{{ {'a': 1, 'b': 2} | combine({'b': 3, 'c': 4}) }}") == { + "a": 1, + "b": 3, + "c": 4, + } + + assert render(hass, "{{ combine({'a': 1, 'b': 2}, {'b': 3, 'c': 4}) }}") == { + "a": 1, + "b": 3, + "c": 4, + } + + assert render( + hass, + "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", + ) == {"a": 1, "b": {"x": 1, "y": 2}, "c": 4} + + # Test that recursive=False does not merge nested dictionaries + assert render( + hass, + "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=False) }}", + ) == {"a": 1, "b": {"y": 2}, "c": 4} + + # Test that None values are handled correctly in recursive merge + assert render( + hass, + "{{ combine({'a': 1, 'b': none}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", + ) == {"a": 1, "b": {"y": 2}, "c": 4} + + with pytest.raises( + TemplateError, match="combine expected at least 1 argument, got 0" + ): + render(hass, "{{ combine() }}") + + with pytest.raises(TemplateError, match="combine expected a dict, got str"): + render(hass, "{{ {'a': 1} | combine('not a dict') }}") diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 01a6757af97a8c..15dd73126fa183 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -6,12 +6,10 @@ from datetime import datetime, timedelta import json import logging -import random from unittest.mock import patch from freezegun import freeze_time import pytest -from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant import config_entries @@ -376,90 +374,6 @@ def test_add(hass: HomeAssistant) -> None: assert render(hass, "{{ 'no_number' | add(10, default=1) }}") == 1 -def test_apply(hass: HomeAssistant) -> None: - """Test apply.""" - tpl = """ - {%- macro add_foo(arg) -%} - {{arg}}foo - {%- endmacro -%} - {{ ["a", "b", "c"] | map('apply', add_foo) | list }} - """ - assert render(hass, tpl) == ["afoo", "bfoo", "cfoo"] - - assert render( - hass, "{{ ['1', '2', '3', '4', '5'] | map('apply', int) | list }}" - ) == [1, 2, 3, 4, 5] - - -def test_apply_macro_with_arguments(hass: HomeAssistant) -> None: - """Test apply macro with positional, named, and mixed arguments.""" - # Test macro with positional arguments - tpl = """ - {%- macro add_numbers(a, b, c) -%} - {{ a + b + c }} - {%- endmacro -%} - {{ apply(5, add_numbers, 10, 15) }} - """ - assert render(hass, tpl) == 30 - - # Test macro with named arguments - tpl = """ - {%- macro greet(name, greeting="Hello") -%} - {{ greeting }}, {{ name }}! - {%- endmacro -%} - {{ apply("World", greet, greeting="Hi") }} - """ - assert render(hass, tpl) == "Hi, World!" - - # Test macro with mixed arguments - tpl = """ - {%- macro format_message(prefix, name, suffix="!") -%} - {{ prefix }} {{ name }}{{ suffix }} - {%- endmacro -%} - {{ apply("Welcome", format_message, "John", suffix="...") }} - """ - assert render(hass, tpl) == "Welcome John..." - - -def test_as_function(hass: HomeAssistant) -> None: - """Test as_function.""" - tpl = """ - {%- macro macro_double(num, returns) -%} - {%- do returns(num * 2) -%} - {%- endmacro -%} - {%- set double = macro_double | as_function -%} - {{ double(5) }} - """ - assert render(hass, tpl) == 10 - - -def test_as_function_no_arguments(hass: HomeAssistant) -> None: - """Test as_function with no arguments.""" - tpl = """ - {%- macro macro_get_hello(returns) -%} - {%- do returns("Hello") -%} - {%- endmacro -%} - {%- set get_hello = macro_get_hello | as_function -%} - {{ get_hello() }} - """ - assert render(hass, tpl) == "Hello" - - -def test_ord(hass: HomeAssistant) -> None: - """Test the ord filter.""" - assert render(hass, '{{ "d" | ord }}') == 100 - - -@patch.object(random, "choice") -def test_random_every_time(test_choice, hass: HomeAssistant) -> None: - """Ensure the random filter runs every time, not just once.""" - tpl = template.Template("{{ [1,2] | random }}", hass) - test_choice.return_value = "foo" - assert tpl.async_render() == "foo" - test_choice.return_value = "bar" - assert tpl.async_render() == "bar" - - def test_passing_vars_as_keywords(hass: HomeAssistant) -> None: """Test passing variables as keywords.""" assert render(hass, "{{ hello }}", hello=127) == 127 @@ -523,31 +437,6 @@ def test_render_with_possible_json_value_with_missing_json_value( assert tpl.async_render_with_possible_json_value('{"hello": "world"}') == "" -def test_render_with_possible_json_value_valid_with_is_defined( - hass: HomeAssistant, -) -> None: - """Render with possible JSON value with known JSON object.""" - tpl = template.Template("{{ value_json.hello|is_defined }}", hass) - assert tpl.async_render_with_possible_json_value('{"hello": "world"}') == "world" - - -def test_render_with_possible_json_value_undefined_json(hass: HomeAssistant) -> None: - """Render with possible JSON value with unknown JSON object.""" - tpl = template.Template("{{ value_json.bye|is_defined }}", hass) - assert ( - tpl.async_render_with_possible_json_value('{"hello": "world"}') - == '{"hello": "world"}' - ) - - -def test_render_with_possible_json_value_undefined_json_error_value( - hass: HomeAssistant, -) -> None: - """Render with possible JSON value with unknown JSON object.""" - tpl = template.Template("{{ value_json.bye|is_defined }}", hass) - assert tpl.async_render_with_possible_json_value('{"hello": "world"}', "") == "" - - def test_render_with_possible_json_value_non_string_value(hass: HomeAssistant) -> None: """Render with possible JSON value with non-string value.""" tpl = template.Template( @@ -2321,39 +2210,6 @@ def test_render_complex_handling_non_template_values(hass: HomeAssistant) -> Non ) == {True: 1, False: 2} -def test_iif(hass: HomeAssistant) -> None: - """Test the immediate if function/filter.""" - - result = render(hass, "{{ (1 == 1) | iif }}") - assert result is True - - result = render(hass, "{{ (1 == 2) | iif }}") - assert result is False - - result = render(hass, "{{ (1 == 1) | iif('yes') }}") - assert result == "yes" - - result = render(hass, "{{ (1 == 2) | iif('yes') }}") - assert result is False - - result = render(hass, "{{ (1 == 2) | iif('yes', 'no') }}") - assert result == "no" - - result = render(hass, "{{ not_exists | default(None) | iif('yes', 'no') }}") - assert result == "no" - - result = render( - hass, "{{ not_exists | default(None) | iif('yes', 'no', 'unknown') }}" - ) - assert result == "unknown" - - result = render(hass, "{{ iif(1 == 1) }}") - assert result is True - - result = render(hass, "{{ iif(1 == 2, 'yes', 'no') }}") - assert result == "no" - - @pytest.mark.usefixtures("hass") async def test_cache_garbage_collection() -> None: """Test caching a template.""" @@ -2733,32 +2589,6 @@ async def test_template_states_can_serialize(hass: HomeAssistant) -> None: assert json_dumps(template_state) == json_dumps(template_state) -@pytest.mark.parametrize( - ("seq", "value", "expected"), - [ - ([0], 0, True), - ([1], 0, False), - ([False], 0, True), - ([True], 0, False), - ([0], [0], False), - (["toto", 1], "toto", True), - (["toto", 1], "tata", False), - ([], 0, False), - ([], None, False), - ], -) -def test_contains(hass: HomeAssistant, seq, value, expected) -> None: - """Test contains.""" - assert ( - render(hass, "{{ seq | contains(value) }}", {"seq": seq, "value": value}) - == expected - ) - assert ( - render(hass, "{{ seq is contains(value) }}", {"seq": seq, "value": value}) - == expected - ) - - async def test_render_to_info_with_exception(hass: HomeAssistant) -> None: """Test info is still available if the template has an exception.""" hass.states.async_set("test_domain.object", "dog") @@ -2836,264 +2666,6 @@ def test_template_output_exceeds_maximum_size(hass: HomeAssistant) -> None: render(hass, "{{ 'a' * 1024 * 257 }}") -@pytest.mark.parametrize( - ("service_response"), - [ - { - "calendar.sports": { - "events": [ - { - "start": "2024-02-27T17:00:00-06:00", - "end": "2024-02-27T18:00:00-06:00", - "summary": "Basketball vs. Rockets", - "description": "", - } - ] - }, - "calendar.local_furry_events": {"events": []}, - "calendar.yap_house_schedules": { - "events": [ - { - "start": "2024-02-26T08:00:00-06:00", - "end": "2024-02-26T09:00:00-06:00", - "summary": "Dr. Appt", - "description": "", - }, - { - "start": "2024-02-28T20:00:00-06:00", - "end": "2024-02-28T21:00:00-06:00", - "summary": "Bake a cake", - "description": "something good", - }, - ] - }, - }, - { - "binary_sensor.workday": {"workday": True}, - "binary_sensor.workday2": {"workday": False}, - }, - { - "weather.smhi_home": { - "forecast": [ - { - "datetime": "2024-03-31T16:00:00", - "condition": "cloudy", - "wind_bearing": 79, - "cloud_coverage": 100, - "temperature": 10, - "templow": 4, - "pressure": 998, - "wind_gust_speed": 21.6, - "wind_speed": 11.88, - "precipitation": 0.2, - "humidity": 87, - }, - { - "datetime": "2024-04-01T12:00:00", - "condition": "rainy", - "wind_bearing": 17, - "cloud_coverage": 100, - "temperature": 6, - "templow": 1, - "pressure": 999, - "wind_gust_speed": 20.52, - "wind_speed": 8.64, - "precipitation": 2.2, - "humidity": 88, - }, - { - "datetime": "2024-04-02T12:00:00", - "condition": "cloudy", - "wind_bearing": 17, - "cloud_coverage": 100, - "temperature": 0, - "templow": -3, - "pressure": 1003, - "wind_gust_speed": 57.24, - "wind_speed": 30.6, - "precipitation": 1.3, - "humidity": 71, - }, - ] - }, - "weather.forecast_home": { - "forecast": [ - { - "condition": "cloudy", - "precipitation_probability": 6.6, - "datetime": "2024-03-31T10:00:00+00:00", - "wind_bearing": 71.8, - "temperature": 10.9, - "templow": 6.5, - "wind_gust_speed": 24.1, - "wind_speed": 13.7, - "precipitation": 0, - "humidity": 71, - }, - { - "condition": "cloudy", - "precipitation_probability": 8, - "datetime": "2024-04-01T10:00:00+00:00", - "wind_bearing": 350.6, - "temperature": 10.2, - "templow": 3.4, - "wind_gust_speed": 38.2, - "wind_speed": 21.6, - "precipitation": 0, - "humidity": 79, - }, - { - "condition": "snowy", - "precipitation_probability": 67.4, - "datetime": "2024-04-02T10:00:00+00:00", - "wind_bearing": 24.5, - "temperature": 3, - "templow": 0, - "wind_gust_speed": 64.8, - "wind_speed": 37.4, - "precipitation": 2.3, - "humidity": 77, - }, - ] - }, - }, - { - "vacuum.deebot_n8_plus_1": { - "payloadType": "j", - "resp": { - "body": { - "msg": "ok", - } - }, - "header": { - "ver": "0.0.1", - }, - }, - "vacuum.deebot_n8_plus_2": { - "payloadType": "j", - "resp": { - "body": { - "msg": "ok", - } - }, - "header": { - "ver": "0.0.1", - }, - }, - }, - ], - ids=["calendar", "workday", "weather", "vacuum"], -) -async def test_merge_response( - hass: HomeAssistant, - service_response: dict, - snapshot: SnapshotAssertion, -) -> None: - """Test the merge_response function/filter.""" - - _template = "{{ merge_response(" + str(service_response) + ") }}" - - assert service_response == snapshot(name="a_response") - assert render( - hass, - _template, - ) == snapshot(name="b_rendered") - - -async def test_merge_response_with_entity_id_in_response( - hass: HomeAssistant, - snapshot: SnapshotAssertion, -) -> None: - """Test the merge_response function/filter with empty lists.""" - - service_response = { - "test.response": {"some_key": True, "entity_id": "test.response"}, - "test.response2": {"some_key": False, "entity_id": "test.response2"}, - } - _template = "{{ merge_response(" + str(service_response) + ") }}" - with pytest.raises( - TemplateError, - match="ValueError: Response dictionary already contains key 'entity_id'", - ): - render(hass, _template) - - service_response = { - "test.response": { - "happening": [ - { - "start": "2024-02-27T17:00:00-06:00", - "end": "2024-02-27T18:00:00-06:00", - "summary": "Magic day", - "entity_id": "test.response", - } - ] - } - } - _template = "{{ merge_response(" + str(service_response) + ") }}" - with pytest.raises( - TemplateError, - match="ValueError: Response dictionary already contains key 'entity_id'", - ): - render(hass, _template) - - -async def test_merge_response_with_empty_response( - hass: HomeAssistant, - snapshot: SnapshotAssertion, -) -> None: - """Test the merge_response function/filter with empty lists.""" - - service_response = { - "calendar.sports": {"events": []}, - "calendar.local_furry_events": {"events": []}, - "calendar.yap_house_schedules": {"events": []}, - } - _template = "{{ merge_response(" + str(service_response) + ") }}" - assert service_response == snapshot(name="a_response") - assert render(hass, _template) == snapshot(name="b_rendered") - - -async def test_response_empty_dict( - hass: HomeAssistant, - snapshot: SnapshotAssertion, -) -> None: - """Test the merge_response function/filter with empty dict.""" - - service_response = {} - _template = "{{ merge_response(" + str(service_response) + ") }}" - - result = render(hass, _template) - assert result == [] - - -async def test_response_incorrect_value( - hass: HomeAssistant, - snapshot: SnapshotAssertion, -) -> None: - """Test the merge_response function/filter with incorrect response.""" - - service_response = "incorrect" - _template = "{{ merge_response(" + str(service_response) + ") }}" - with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): - render(hass, _template) - - -async def test_merge_response_with_incorrect_response(hass: HomeAssistant) -> None: - """Test the merge_response function/filter with empty response should raise.""" - - service_response = {"calendar.sports": []} - _template = "{{ merge_response(" + str(service_response) + ") }}" - with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): - render(hass, _template) - - service_response = { - "binary_sensor.workday": [], - } - _template = "{{ merge_response(" + str(service_response) + ") }}" - with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): - render(hass, _template) - - def test_warn_no_hass(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test deprecation warning when instantiating Template without hass.""" @@ -3109,81 +2681,3 @@ def test_warn_no_hass(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> template.Template("blah", hass) assert message not in caplog.text caplog.clear() - - -async def test_merge_response_not_mutate_original_object( - hass: HomeAssistant, snapshot: SnapshotAssertion -) -> None: - """Test the merge_response does not mutate original service response value.""" - - value = '{"calendar.family": {"events": [{"summary": "An event"}]}' - _template = ( - "{% set calendar_response = " + value + "} %}" - "{{ merge_response(calendar_response) }}" - # We should be able to merge the same response again - # as the merge is working on a copy of the original object (response) - "{{ merge_response(calendar_response) }}" - ) - - assert render(hass, _template) - - -def test_typeof(hass: HomeAssistant) -> None: - """Test the typeof debug filter/function.""" - assert render(hass, "{{ True | typeof }}") == "bool" - assert render(hass, "{{ typeof(True) }}") == "bool" - - assert render(hass, "{{ [1, 2, 3] | typeof }}") == "list" - assert render(hass, "{{ typeof([1, 2, 3]) }}") == "list" - - assert render(hass, "{{ 1 | typeof }}") == "int" - assert render(hass, "{{ typeof(1) }}") == "int" - - assert render(hass, "{{ 1.1 | typeof }}") == "float" - assert render(hass, "{{ typeof(1.1) }}") == "float" - - assert render(hass, "{{ None | typeof }}") == "NoneType" - assert render(hass, "{{ typeof(None) }}") == "NoneType" - - assert render(hass, "{{ 'Home Assistant' | typeof }}") == "str" - assert render(hass, "{{ typeof('Home Assistant') }}") == "str" - - -def test_combine(hass: HomeAssistant) -> None: - """Test combine filter and function.""" - assert render(hass, "{{ {'a': 1, 'b': 2} | combine({'b': 3, 'c': 4}) }}") == { - "a": 1, - "b": 3, - "c": 4, - } - - assert render(hass, "{{ combine({'a': 1, 'b': 2}, {'b': 3, 'c': 4}) }}") == { - "a": 1, - "b": 3, - "c": 4, - } - - assert render( - hass, - "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", - ) == {"a": 1, "b": {"x": 1, "y": 2}, "c": 4} - - # Test that recursive=False does not merge nested dictionaries - assert render( - hass, - "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=False) }}", - ) == {"a": 1, "b": {"y": 2}, "c": 4} - - # Test that None values are handled correctly in recursive merge - assert render( - hass, - "{{ combine({'a': 1, 'b': none}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", - ) == {"a": 1, "b": {"y": 2}, "c": 4} - - with pytest.raises( - TemplateError, match="combine expected at least 1 argument, got 0" - ): - render(hass, "{{ combine() }}") - - with pytest.raises(TemplateError, match="combine expected a dict, got str"): - render(hass, "{{ {'a': 1} | combine('not a dict') }}") From 85fa2415c110364f4be9dec0146ac93af46dfc48 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 12:49:23 +0200 Subject: [PATCH 0515/1707] Fix name of `shopping_list.add_item` action (#167352) --- homeassistant/components/shopping_list/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json index 925fffffb4b621..06ffc307b1a7d9 100644 --- a/homeassistant/components/shopping_list/strings.json +++ b/homeassistant/components/shopping_list/strings.json @@ -26,7 +26,7 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Add item" + "name": "Add shopping list item" }, "clear_completed_items": { "description": "Removes completed items from the shopping list.", From f1d309779ee40266f64a0ca5a92dff591363ba11 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 12:49:53 +0200 Subject: [PATCH 0516/1707] Improve Profiler action naming consistency (#167349) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/profiler/strings.json | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/profiler/strings.json b/homeassistant/components/profiler/strings.json index b95f6d83738d10..8a7c73b368d690 100644 --- a/homeassistant/components/profiler/strings.json +++ b/homeassistant/components/profiler/strings.json @@ -8,7 +8,7 @@ }, "services": { "dump_log_objects": { - "description": "Dumps the repr of all matching objects to the log.", + "description": "Lets the Profiler dump the repr of all matching objects to the log.", "fields": { "type": { "description": "The type of objects to dump to the log.", @@ -18,37 +18,37 @@ "name": "Dump log objects" }, "dump_sockets": { - "description": "Logs information about all currently used sockets.", + "description": "Lets the Profiler log information about all currently used sockets.", "name": "Dump used sockets" }, "log_current_tasks": { - "description": "Logs all the current asyncio tasks.", + "description": "Lets the Profiler log all the current asyncio tasks.", "name": "Log current asyncio tasks" }, "log_event_loop_scheduled": { - "description": "Logs what is scheduled in the event loop.", + "description": "Lets the Profiler log what is scheduled in the event loop.", "name": "Log event loop scheduled" }, "log_thread_frames": { - "description": "Logs the current frames for all threads.", + "description": "Lets the Profiler log the current frames for all threads.", "name": "Log thread frames" }, "lru_stats": { - "description": "Logs the stats of all lru caches.", + "description": "Lets the Profiler log the stats of all LRU caches.", "name": "Log LRU stats" }, "memory": { - "description": "Starts the Memory Profiler.", + "description": "Lets the Profiler create a memory profile for a specified number of seconds.", "fields": { "seconds": { - "description": "The number of seconds to run the memory profiler.", - "name": "Seconds" + "description": "[%key:component::profiler::services::start::fields::seconds::description%]", + "name": "[%key:component::profiler::services::start::fields::seconds::name%]" } }, - "name": "Memory" + "name": "Create memory profile" }, "set_asyncio_debug": { - "description": "Enable or disable asyncio debug.", + "description": "Lets the Profiler enable or disable asyncio debug.", "fields": { "enabled": { "description": "Whether to enable or disable asyncio debug.", @@ -58,17 +58,17 @@ "name": "Set asyncio debug" }, "start": { - "description": "Starts the Profiler.", + "description": "Lets the Profiler create a system profile for a specified number of seconds.", "fields": { "seconds": { - "description": "The number of seconds to run the profiler.", + "description": "The number of seconds to run the Profiler.", "name": "Seconds" } }, - "name": "[%key:common::action::start%]" + "name": "Create system profile" }, "start_log_object_sources": { - "description": "Starts logging sources of new objects in memory.", + "description": "Starts the Profiler logging sources of new objects in memory.", "fields": { "max_objects": { "description": "The maximum number of objects to log.", @@ -82,7 +82,7 @@ "name": "Start logging object sources" }, "start_log_objects": { - "description": "Starts logging growth of objects in memory.", + "description": "Starts the Profiler logging growth of objects in memory.", "fields": { "scan_interval": { "description": "The number of seconds between logging objects.", @@ -92,11 +92,11 @@ "name": "Start logging objects" }, "stop_log_object_sources": { - "description": "Stops logging sources of new objects in memory.", + "description": "Stops the Profiler logging sources of new objects in memory.", "name": "Stop logging object sources" }, "stop_log_objects": { - "description": "Stops logging growth of objects in memory.", + "description": "Stops the Profiler logging growth of objects in memory.", "name": "Stop logging objects" } } From d3ca5132fce2dd1d0bd56d78a37d4db4052ef508 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:51:42 +0200 Subject: [PATCH 0517/1707] Fix polling pause in Husqvarna Automower (#167397) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../husqvarna_automower/coordinator.py | 6 +-- .../husqvarna_automower/test_init.py | 44 ++----------------- 2 files changed, 6 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 1d1619762dfdfd..aa1682923c8835 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -184,10 +184,8 @@ async def client_listen( ) def _should_poll(self) -> bool: - """Return True if at least one mower is connected and at least one is not OFF.""" - return any(mower.metadata.connected for mower in self.data.values()) and any( - mower.mower.state != MowerStates.OFF for mower in self.data.values() - ) + """Return True if at least one mower is not OFF.""" + return any(mower.mower.state != MowerStates.OFF for mower in self.data.values()) async def _pong_watchdog(self) -> None: """Watchdog to check for pong messages.""" diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 14fa01fa938bc4..0e8c8fa99b5397 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -516,27 +516,12 @@ def fake_register_websocket_response( ) -@pytest.mark.parametrize( - ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), - [ - (True, MowerStates.OFF, False, MowerStates.OFF), # False - (False, MowerStates.PAUSED, False, MowerStates.OFF), # False - (False, MowerStates.OFF, True, MowerStates.OFF), # False - (False, MowerStates.OFF, False, MowerStates.PAUSED), # False - (True, MowerStates.OFF, True, MowerStates.OFF), # False - (False, MowerStates.OFF, False, MowerStates.OFF), # False - ], -) async def test_dynamic_polling( hass: HomeAssistant, mock_automower_client, mock_config_entry, freezer: FrozenDateTimeFactory, values: dict[str, MowerAttributes], - mower1_connected: bool, - mower1_state: MowerStates, - mower2_connected: bool, - mower2_state: MowerStates, ) -> None: """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. @@ -579,10 +564,8 @@ def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: assert mock_automower_client.get_status.call_count == 2 # websocket is still active, but mowers are inactive -> no polling required - poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected - poll_values[TEST_MOWER_ID].mower.state = mower1_state - poll_values["1234"].metadata.connected = mower2_connected - poll_values["1234"].mower.state = mower2_state + poll_values[TEST_MOWER_ID].mower.state = MowerStates.OFF + poll_values["1234"].mower.state = MowerStates.OFF mock_automower_client.get_status.return_value = poll_values freezer.tick(SCAN_INTERVAL) @@ -608,9 +591,7 @@ def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: # websocket is still active, and mowers are active -> polling required mock_automower_client.get_status.reset_mock() assert mock_automower_client.get_status.call_count == 0 - poll_values[TEST_MOWER_ID].metadata.connected = True poll_values[TEST_MOWER_ID].mower.state = MowerStates.PAUSED - poll_values["1234"].metadata.connected = False poll_values["1234"].mower.state = MowerStates.OFF websocket_values = deepcopy(poll_values) callback_holder["data_cb"](websocket_values) @@ -623,17 +604,6 @@ def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: assert mock_automower_client.get_status.call_count == 2 -@pytest.mark.parametrize( - ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), - [ - (True, MowerStates.OFF, False, MowerStates.OFF), # False - (False, MowerStates.PAUSED, False, MowerStates.OFF), # False - (False, MowerStates.OFF, True, MowerStates.OFF), # False - (False, MowerStates.OFF, False, MowerStates.PAUSED), # False - (True, MowerStates.OFF, True, MowerStates.OFF), # False - (False, MowerStates.OFF, False, MowerStates.OFF), # False - ], -) async def test_websocket_watchdog( hass: HomeAssistant, mock_automower_client, @@ -641,10 +611,6 @@ async def test_websocket_watchdog( freezer: FrozenDateTimeFactory, entity_registry: er.EntityRegistry, values: dict[str, MowerAttributes], - mower1_connected: bool, - mower1_state: MowerStates, - mower2_connected: bool, - mower2_state: MowerStates, ) -> None: """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. @@ -686,10 +652,8 @@ def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: assert mock_automower_client.get_status.call_count == 2 # websocket is still active, but mowers are inactive -> no polling required - poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected - poll_values[TEST_MOWER_ID].mower.state = mower1_state - poll_values["1234"].metadata.connected = mower2_connected - poll_values["1234"].mower.state = mower2_state + poll_values[TEST_MOWER_ID].mower.state = MowerStates.OFF + poll_values["1234"].mower.state = MowerStates.OFF mock_automower_client.get_status.return_value = poll_values freezer.tick(SCAN_INTERVAL) From ca5aa215d214b5758291cb011b7968538258d23b Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:57:12 +0300 Subject: [PATCH 0518/1707] Victron GX communication center integration (#156090) Co-authored-by: Martin Hjelmare Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- CODEOWNERS | 2 + homeassistant/brands/victron.json | 2 +- .../components/victron_gx/__init__.py | 61 + .../components/victron_gx/config_flow.py | 274 +++ homeassistant/components/victron_gx/const.py | 7 + homeassistant/components/victron_gx/entity.py | 70 + homeassistant/components/victron_gx/hub.py | 153 ++ .../components/victron_gx/manifest.json | 17 + .../components/victron_gx/quality_scale.yaml | 75 + homeassistant/components/victron_gx/sensor.py | 116 ++ .../components/victron_gx/strings.json | 1770 +++++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/ssdp.py | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/victron_gx/__init__.py | 1 + tests/components/victron_gx/conftest.py | 76 + tests/components/victron_gx/const.py | 7 + .../components/victron_gx/test_config_flow.py | 545 +++++ tests/components/victron_gx/test_init.py | 188 ++ tests/components/victron_gx/test_sensor.py | 106 + 22 files changed, 3488 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/victron_gx/__init__.py create mode 100644 homeassistant/components/victron_gx/config_flow.py create mode 100644 homeassistant/components/victron_gx/const.py create mode 100644 homeassistant/components/victron_gx/entity.py create mode 100644 homeassistant/components/victron_gx/hub.py create mode 100644 homeassistant/components/victron_gx/manifest.json create mode 100644 homeassistant/components/victron_gx/quality_scale.yaml create mode 100644 homeassistant/components/victron_gx/sensor.py create mode 100644 homeassistant/components/victron_gx/strings.json create mode 100644 tests/components/victron_gx/__init__.py create mode 100644 tests/components/victron_gx/conftest.py create mode 100644 tests/components/victron_gx/const.py create mode 100644 tests/components/victron_gx/test_config_flow.py create mode 100644 tests/components/victron_gx/test_init.py create mode 100644 tests/components/victron_gx/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1662d1b3df0cc9..ca1135832fe98c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1875,6 +1875,8 @@ CLAUDE.md @home-assistant/core /tests/components/vicare/ @CFenner /homeassistant/components/victron_ble/ @rajlaud /tests/components/victron_ble/ @rajlaud +/homeassistant/components/victron_gx/ @tomer-w +/tests/components/victron_gx/ @tomer-w /homeassistant/components/victron_remote_monitoring/ @AndyTempel /tests/components/victron_remote_monitoring/ @AndyTempel /homeassistant/components/vilfo/ @ManneW diff --git a/homeassistant/brands/victron.json b/homeassistant/brands/victron.json index e8508b389aa063..8d01e456b69d57 100644 --- a/homeassistant/brands/victron.json +++ b/homeassistant/brands/victron.json @@ -1,5 +1,5 @@ { "domain": "victron", "name": "Victron", - "integrations": ["victron_ble", "victron_remote_monitoring"] + "integrations": ["victron_gx", "victron_ble", "victron_remote_monitoring"] } diff --git a/homeassistant/components/victron_gx/__init__.py b/homeassistant/components/victron_gx/__init__.py new file mode 100644 index 00000000000000..96183fb56e42ed --- /dev/null +++ b/homeassistant/components/victron_gx/__init__.py @@ -0,0 +1,61 @@ +"""The victron_gx integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant + +from .hub import Hub, VictronGxConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: VictronGxConfigEntry) -> bool: + """Set up victron_gx from a config entry.""" + _LOGGER.debug("async_setup_entry called for entry: %s", entry.entry_id) + + hub = Hub(hass, entry) + entry.runtime_data = hub + + # All platforms should be set up before starting the hub + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + try: + await hub.start() + except Exception as err: + _LOGGER.error( + "Error starting hub for entry %s: %s", + entry.entry_id, + err, + exc_info=err, + ) + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + hub.unregister_all_new_metric_callbacks() + raise + + async def _async_stop(_: Event) -> None: + await hub.stop() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + ) + + _LOGGER.debug("async_setup_entry completed for entry: %s", entry.entry_id) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: VictronGxConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("async_unload_entry called for entry: %s", entry.entry_id) + hub: Hub | None = getattr(entry, "runtime_data", None) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok and hub is not None: + await hub.stop() + hub.unregister_all_new_metric_callbacks() + + return unload_ok diff --git a/homeassistant/components/victron_gx/config_flow.py b/homeassistant/components/victron_gx/config_flow.py new file mode 100644 index 00000000000000..04437e676a0ce4 --- /dev/null +++ b/homeassistant/components/victron_gx/config_flow.py @@ -0,0 +1,274 @@ +"""Config flow for the Victron GX integration.""" + +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urlparse + +from victron_mqtt import AuthenticationError, CannotConnectError, Hub as VictronVenusHub +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.helpers import selector +from homeassistant.helpers.redact import async_redact_data +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo + +from .const import CONF_INSTALLATION_ID, CONF_MODEL, CONF_SERIAL, DOMAIN + +DEFAULT_HOST = "venus.local" +DEFAULT_PORT = 1883 + +_LOGGER = logging.getLogger(__name__) + +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} + +ENTRY_TITLE_FORMAT = "Victron OS {installation_id} ({host}:{port})" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): selector.TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_USERNAME): selector.TextSelector(), + vol.Optional(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required(CONF_SSL, default=False): selector.BooleanSelector(), + } +) + +STEP_SSDP_AUTH_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME, default=""): selector.TextSelector(), + vol.Optional(CONF_PASSWORD, default=""): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_SSL, default=False): selector.BooleanSelector(), + } +) + + +async def validate_input(data: dict[str, Any]) -> str: + """Validate the user input allows us to connect. + + Data has the keys from SSDP values as well as user input. + + Returns the installation id upon success. + """ + _LOGGER.debug("Validating input: %s", async_redact_data(data, TO_REDACT)) + hub: VictronVenusHub | None = None + try: + hub = VictronVenusHub( + host=data[CONF_HOST], + port=int(data[CONF_PORT]), + username=data.get(CONF_USERNAME) or None, + password=data.get(CONF_PASSWORD) or None, + use_ssl=data.get(CONF_SSL, False), + installation_id=data.get(CONF_INSTALLATION_ID) or None, + serial=data.get(CONF_SERIAL) or None, + ) + + await hub.connect() + if hub.installation_id is None: + raise CannotConnectError("Victron hub did not provide an installation_id") + + return hub.installation_id + finally: + if hub is not None: + try: + await hub.disconnect() + except Exception: # noqa: BLE001 + _LOGGER.debug("Ignoring disconnect error during config validation") + + +class VictronGXConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for Victron GX devices.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self.hostname: str | None = None + self.serial: str | None = None + self.installation_id: str | None = None + self.friendly_name: str | None = None + self.model_name: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + _LOGGER.debug( + "User input received: %s", + async_redact_data(user_input, TO_REDACT), + ) + data = {**user_input, CONF_SERIAL: self.serial, CONF_MODEL: self.model_name} + + try: + installation_id = await validate_input(data) + _LOGGER.debug( + "Successfully connected to Victron device: %s", installation_id + ) + except AuthenticationError: + _LOGGER.debug( + "Authentication failed during initial setup", exc_info=True + ) + errors["base"] = "invalid_auth" + except CannotConnectError: + _LOGGER.debug("Cannot connect to Victron device", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error connecting to Victron device") + errors["base"] = "unknown" + else: + data[CONF_INSTALLATION_ID] = installation_id + unique_id = installation_id + await self.async_set_unique_id(unique_id) + + self._abort_if_unique_id_configured() + title = ENTRY_TITLE_FORMAT.format( + installation_id=installation_id, + host=data[CONF_HOST], + port=data[CONF_PORT], + ) + return self.async_create_entry(title=title, data=data) + + _LOGGER.debug("Showing form with errors: %s", errors) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle SSDP discovery.""" + self.hostname = str(urlparse(discovery_info.ssdp_location).hostname) + self.serial = discovery_info.upnp["serialNumber"] + self.installation_id = discovery_info.upnp["X_VrmPortalId"] + self.model_name = discovery_info.upnp["modelName"] + self.friendly_name = discovery_info.upnp["friendlyName"] + + await self.async_set_unique_id(self.installation_id) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = { + "name": self.friendly_name or self.hostname + } + + # Verify connectivity before showing the confirmation dialog + try: + ssdp_conf = { + CONF_HOST: self.hostname, + CONF_PORT: DEFAULT_PORT, + CONF_SERIAL: self.serial, + CONF_INSTALLATION_ID: self.installation_id, + } + await validate_input(ssdp_conf) + except AuthenticationError: + return await self.async_step_ssdp_auth() + except CannotConnectError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception( + "Unexpected error validating SSDP discovery for Victron GX" + ) + return self.async_abort(reason="unknown") + + return await self.async_step_ssdp_confirm() + + async def async_step_ssdp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm SSDP discovered device.""" + assert self.hostname is not None + assert self.installation_id is not None + + if user_input is not None: + return self.async_create_entry( + title=ENTRY_TITLE_FORMAT.format( + installation_id=self.installation_id, + host=self.hostname, + port=DEFAULT_PORT, + ), + data={ + CONF_HOST: self.hostname, + CONF_PORT: DEFAULT_PORT, + CONF_SERIAL: self.serial, + CONF_INSTALLATION_ID: self.installation_id, + CONF_MODEL: self.model_name, + }, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="ssdp_confirm", + description_placeholders={"name": self.friendly_name or self.hostname}, + ) + + async def async_step_ssdp_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle SSDP auth when credentials are required.""" + assert self.hostname is not None + assert self.installation_id is not None + + errors: dict[str, str] = {} + + if user_input is not None: + _LOGGER.debug( + "SSDP auth user input received: %s", + async_redact_data(user_input, TO_REDACT), + ) + data: dict[str, Any] = { + CONF_HOST: self.hostname, + CONF_PORT: DEFAULT_PORT, + CONF_SERIAL: self.serial, + CONF_INSTALLATION_ID: self.installation_id, + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_SSL: user_input.get(CONF_SSL), + } + + try: + await validate_input(data) + _LOGGER.debug("SSDP authentication successful") + except AuthenticationError: + _LOGGER.debug("Authentication failed during SSDP setup", exc_info=True) + errors["base"] = "invalid_auth" + except CannotConnectError: + _LOGGER.debug("Cannot connect during SSDP setup", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during SSDP setup") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=ENTRY_TITLE_FORMAT.format( + installation_id=self.installation_id, + host=self.hostname, + port=DEFAULT_PORT, + ), + data=data, + ) + + return self.async_show_form( + step_id="ssdp_auth", + data_schema=self.add_suggested_values_to_schema( + STEP_SSDP_AUTH_DATA_SCHEMA, user_input + ), + errors=errors, + description_placeholders={"host": self.hostname}, + ) diff --git a/homeassistant/components/victron_gx/const.py b/homeassistant/components/victron_gx/const.py new file mode 100644 index 00000000000000..bd63f86410fb4d --- /dev/null +++ b/homeassistant/components/victron_gx/const.py @@ -0,0 +1,7 @@ +"""Constants for the victron_gx integration.""" + +DOMAIN = "victron_gx" + +CONF_INSTALLATION_ID = "installation_id" +CONF_MODEL = "model" +CONF_SERIAL = "serial" diff --git a/homeassistant/components/victron_gx/entity.py b/homeassistant/components/victron_gx/entity.py new file mode 100644 index 00000000000000..321f371f51d199 --- /dev/null +++ b/homeassistant/components/victron_gx/entity.py @@ -0,0 +1,70 @@ +"""Base entity for entities in victron_gx integration.""" + +from abc import abstractmethod +from typing import Any + +from victron_mqtt import Device as VictronVenusDevice, Metric as VictronVenusMetric + +from homeassistant.const import EntityCategory +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +# Entities that should be marked as diagnostic +ENTITIES_CATEGORY_DIAGNOSTIC = ["system_heartbeat"] +# Entities that should be disabled by default +ENTITIES_DISABLE_BY_DEFAULT = ["system_heartbeat"] + + +class VictronBaseEntity(Entity): + """Implementation of a Victron GX base entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the entity.""" + self._device = device + self._metric = metric + self._attr_device_info = device_info + self._attr_unique_id = f"{installation_id}_{metric.unique_id}" + self._attr_suggested_display_precision = metric.precision + self._attr_translation_key = metric.generic_short_id.replace("{", "").replace( + "}", "" + ) + self._attr_translation_placeholders = metric.key_values + + self._attr_entity_category = ( + EntityCategory.DIAGNOSTIC + if metric.generic_short_id in ENTITIES_CATEGORY_DIAGNOSTIC + else None + ) + self._attr_entity_registry_enabled_default = ( + metric.generic_short_id not in ENTITIES_DISABLE_BY_DEFAULT + ) + + @callback + @abstractmethod + def _on_update_cb(self, value: Any) -> None: + """Handle the metric update. Must be implemented by subclasses.""" + + @callback + def _on_update(self, _: VictronVenusMetric, value: Any) -> None: + self._on_update_cb(value) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self._metric.on_update = self._on_update + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + # Unregister update callback + self._metric.on_update = None + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/victron_gx/hub.py b/homeassistant/components/victron_gx/hub.py new file mode 100644 index 00000000000000..3fbabcb5094b8b --- /dev/null +++ b/homeassistant/components/victron_gx/hub.py @@ -0,0 +1,153 @@ +"""Main Hub class.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from typing import TYPE_CHECKING + +from victron_mqtt import ( + AuthenticationError, + CannotConnectError, + Device as VictronVenusDevice, + Hub as VictronVenusHub, + Metric as VictronVenusMetric, + MetricKind, + OperationMode, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.redact import async_redact_data + +from .const import CONF_INSTALLATION_ID, CONF_MODEL, CONF_SERIAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL_SECONDS = 30 + +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} + +type VictronGxConfigEntry = ConfigEntry[Hub] + +NewMetricCallback = Callable[ + [VictronVenusDevice, VictronVenusMetric, DeviceInfo, str], None +] + + +class Hub: + """Victron MQTT Hub for managing communication and sensors.""" + + def __init__(self, hass: HomeAssistant, entry: VictronGxConfigEntry) -> None: + """Initialize Victron MQTT Hub. + + Args: + hass: Home Assistant instance + entry: ConfigEntry containing configuration + + """ + + _LOGGER.debug( + "Initializing hub. ConfigEntry: %s, data: %s", + entry, + async_redact_data({**entry.data, **entry.options}, TO_REDACT), + ) + config = {**entry.data, **entry.options} + self.hass = hass + self.host = config[CONF_HOST] + + self._hub = VictronVenusHub( + host=self.host, + port=config.get(CONF_PORT, 1883), + username=config.get(CONF_USERNAME) or None, + password=config.get(CONF_PASSWORD) or None, + use_ssl=config.get(CONF_SSL, False), + installation_id=config.get(CONF_INSTALLATION_ID) or None, + model_name=config.get(CONF_MODEL) or None, + serial=config.get(CONF_SERIAL) or None, + operation_mode=OperationMode.READ_ONLY, + update_frequency_seconds=UPDATE_INTERVAL_SECONDS, + ) + self._hub.on_new_metric = self._on_new_metric + self.new_metric_callbacks: dict[MetricKind, NewMetricCallback] = {} + + async def start(self) -> None: + """Start the Victron MQTT hub.""" + _LOGGER.info("Starting hub") + try: + await self._hub.connect() + except AuthenticationError as auth_error: + raise ConfigEntryAuthFailed( + f"Authentication failed for {self.host}: {auth_error}" + ) from auth_error + except CannotConnectError as connect_error: + raise ConfigEntryNotReady( + f"Cannot connect to the hub at {self.host}: {connect_error}" + ) from connect_error + + async def stop(self) -> None: + """Stop the Victron MQTT hub.""" + _LOGGER.info("Stopping hub") + try: + await self._hub.disconnect() + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "Ignoring error while disconnecting from hub %s during shutdown", + self.host, + exc_info=err, + ) + + def _on_new_metric( + self, + hub: VictronVenusHub, + device: VictronVenusDevice, + metric: VictronVenusMetric, + ) -> None: + _LOGGER.debug("New metric received. Device: %s, Metric: %s", device, metric) + if TYPE_CHECKING: + assert hub.installation_id is not None + device_info = Hub._map_device_info(device, hub.installation_id) + callback = self.new_metric_callbacks.get(metric.metric_kind) + if callback is not None: + callback(device, metric, device_info, hub.installation_id) + + @staticmethod + def _map_device_info( + device: VictronVenusDevice, installation_id: str + ) -> DeviceInfo: + device_info = DeviceInfo( + identifiers={(DOMAIN, f"{installation_id}_{device.unique_id}")}, + manufacturer=( + device.manufacturer + if device.manufacturer is not None + else "Victron Energy" + ), + name=device.name, + model=device.model, + serial_number=device.serial_number, + ) + # Don't set via_device for the GX device itself + if device.unique_id != "system_0": + device_info["via_device"] = (DOMAIN, f"{installation_id}_system_0") + return device_info + + def register_new_metric_callback( + self, kind: MetricKind, new_metric_callback: NewMetricCallback + ) -> None: + """Register a callback to handle a new specific metric kind.""" + _LOGGER.debug("Registering NewMetricCallback. kind: %s", kind) + self.new_metric_callbacks[kind] = new_metric_callback + + def unregister_all_new_metric_callbacks(self) -> None: + """Unregister all callbacks to handle new metrics for all metric kinds.""" + _LOGGER.debug("Unregistering NewMetricCallback") + self.new_metric_callbacks.clear() diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json new file mode 100644 index 00000000000000..1dfc01475d8ba9 --- /dev/null +++ b/homeassistant/components/victron_gx/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "victron_gx", + "name": "Victron GX", + "codeowners": ["@tomer-w"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/victron_gx", + "integration_type": "hub", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["victron-mqtt==2026.4.0"], + "ssdp": [ + { + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy" + } + ] +} diff --git a/homeassistant/components/victron_gx/quality_scale.yaml b/homeassistant/components/victron_gx/quality_scale.yaml new file mode 100644 index 00000000000000..af3275f1d21bf0 --- /dev/null +++ b/homeassistant/components/victron_gx/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not have actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not have actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: + status: exempt + comment: | + Not relevant. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Not relevant. + strict-typing: todo diff --git a/homeassistant/components/victron_gx/sensor.py b/homeassistant/components/victron_gx/sensor.py new file mode 100644 index 00000000000000..35a371fbe0478f --- /dev/null +++ b/homeassistant/components/victron_gx/sensor.py @@ -0,0 +1,116 @@ +"""Support for Victron GX sensors.""" + +from typing import Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + MetricNature, + MetricType, + VictronEnum, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +PARALLEL_UPDATES = 0 # There is no I/O in the entity itself. + +METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, SensorDeviceClass] = { + MetricType.POWER: SensorDeviceClass.POWER, + MetricType.APPARENT_POWER: SensorDeviceClass.APPARENT_POWER, + MetricType.ENERGY: SensorDeviceClass.ENERGY, + MetricType.VOLTAGE: SensorDeviceClass.VOLTAGE, + MetricType.CURRENT: SensorDeviceClass.CURRENT, + MetricType.FREQUENCY: SensorDeviceClass.FREQUENCY, + MetricType.ELECTRIC_STORAGE_PERCENTAGE: SensorDeviceClass.BATTERY, + MetricType.TEMPERATURE: SensorDeviceClass.TEMPERATURE, + MetricType.SPEED: SensorDeviceClass.SPEED, + MetricType.LIQUID_VOLUME: SensorDeviceClass.VOLUME_STORAGE, + MetricType.DURATION: SensorDeviceClass.DURATION, + MetricType.ENUM: SensorDeviceClass.ENUM, +} + +METRIC_NATURE_TO_STATE_CLASS: dict[MetricNature, SensorStateClass] = { + MetricNature.MEASUREMENT: SensorStateClass.MEASUREMENT, + MetricNature.TOTAL: SensorStateClass.TOTAL, + MetricNature.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX sensors from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new sensor metric discovery.""" + async_add_entities( + [ + VictronSensor( + device, + metric, + device_info, + installation_id, + ) + ] + ) + + hub.register_new_metric_callback(MetricKind.SENSOR, on_new_metric) + + +class VictronSensor(VictronBaseEntity, SensorEntity): + """Implementation of a Victron GX sensor.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, metric, device_info, installation_id) + self._attr_device_class = METRIC_TYPE_TO_DEVICE_CLASS.get(metric.metric_type) + # Enum sensors must not have a state class + if self._attr_device_class == SensorDeviceClass.ENUM: + self._attr_options = metric.enum_values + else: + self._attr_state_class = METRIC_NATURE_TO_STATE_CLASS.get( + metric.metric_nature + ) + # Only set native_unit_of_measurement when a device_class is present. + # Entities without a device_class get their display unit from + # the translation files instead. + if self._attr_device_class is not None: + self._attr_native_unit_of_measurement = metric.unit_of_measurement + self._attr_native_value = VictronSensor._normalize_value(metric.value) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_native_value = VictronSensor._normalize_value(value) + self.async_write_ha_state() + + @staticmethod + def _normalize_value(value: Any) -> Any: + """Normalize Victron enum values to their enum code.""" + if isinstance(value, VictronEnum): + return value.id + return value diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json new file mode 100644 index 00000000000000..093310b9d5fc05 --- /dev/null +++ b/homeassistant/components/victron_gx/strings.json @@ -0,0 +1,1770 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "ssdp_auth": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::victron_gx::config::step::user::data_description::password%]", + "ssl": "[%key:component::victron_gx::config::step::user::data_description::ssl%]", + "username": "[%key:component::victron_gx::config::step::user::data_description::username%]" + }, + "description": "Authentication is required to connect to {host}.", + "title": "Authenticate Victron GX" + }, + "ssdp_confirm": { + "description": "Do you want to set up the Victron GX device {name}?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "Hostname or IP address of Victron device, usually mDNS name like 'venus.local'", + "password": "Password for the Victron device, default is empty. This is not your VRM password.", + "port": "The MQTT port on the host. Normally it is 1883.", + "ssl": "Indicates whether to use SSL to connect to the Victron device. Normally it is disabled.", + "username": "Username for the MQTT server, default is empty. Not needed by Victron devices. This is only needed if you route your MQTT messages through a non-Victron server and it does require a username." + } + } + } + }, + "entity": { + "sensor": { + "acload_current": { + "name": "Load current" + }, + "acload_current_phase": { + "name": "Current on {phase}" + }, + "acload_energy_forward": { + "name": "Consumption" + }, + "acload_energy_forward_phase": { + "name": "Consumption on {phase}" + }, + "acload_frequency": { + "name": "Frequency" + }, + "acload_power": { + "name": "Power" + }, + "acload_power_phase": { + "name": "Power on {phase}" + }, + "acload_voltage": { + "name": "Voltage" + }, + "acload_voltage_phase": { + "name": "Voltage on {phase}" + }, + "acsystem_mode": { + "name": "Mode", + "state": { + "charger_only": "Charger only", + "inverter_only": "Inverter only", + "off": "Off", + "on": "On", + "passthrough": "Passthrough" + } + }, + "alternator_charge_current_limit": { + "name": "Charge current limit" + }, + "alternator_dc_current": { + "name": "DC output current" + }, + "alternator_dc_power": { + "name": "DC output power" + }, + "alternator_dc_voltage": { + "name": "DC output voltage" + }, + "alternator_input_current": { + "name": "Input current" + }, + "alternator_input_power": { + "name": "Input power" + }, + "alternator_input_voltage": { + "name": "Input voltage" + }, + "alternator_state": { + "name": "State", + "state": { + "absorption": "Absorption", + "auto_equalize": "Auto Equalize / Recondition", + "battery_safe": "Battery Safe", + "bulk": "Bulk", + "discharging": "Discharging", + "equalize": "Equalize", + "external_control": "External Control", + "fault": "Fault", + "float": "Float", + "inverting": "Inverting", + "low_power": "Low Power", + "off": "Off", + "passthrough": "Passthrough", + "power_assist": "Power Assist", + "power_supply": "Power Supply", + "recharging": "Recharging", + "repeated_absorption": "Repeated Absorption", + "scheduled_recharging": "Scheduled Recharging", + "starting_up": "Starting Up", + "storage": "Storage", + "sustain": "Sustain", + "sustain_alt": "Sustain Alt" + } + }, + "auxiliary_battery_voltage": { + "name": "Auxiliary battery voltage" + }, + "battery_automatic_syncs": { + "name": "Automatic syncs", + "unit_of_measurement": "syncs" + }, + "battery_average_discharge": { + "name": "Average discharge" + }, + "battery_capacity": { + "name": "Capacity", + "unit_of_measurement": "Ah" + }, + "battery_cell_cell_id_voltage": { + "name": "Cell {cell_id} voltage" + }, + "battery_cell_imbalance": { + "name": "Cell imbalance", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "battery_cell_voltage_deviation": { + "name": "Cell voltage deviation" + }, + "battery_charged_energy": { + "name": "Charged energy" + }, + "battery_consumed_amphours": { + "name": "Consumed amp-hours", + "unit_of_measurement": "Ah" + }, + "battery_cumulative_ah_drawn": { + "name": "Cumulative Ah drawn", + "unit_of_measurement": "Ah" + }, + "battery_current": { + "name": "DC bus current" + }, + "battery_deepest_discharge": { + "name": "Deepest discharge" + }, + "battery_discharged_energy": { + "name": "Discharged energy" + }, + "battery_high_charge_current": { + "name": "High charge current", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "battery_high_charge_temperature": { + "name": "High charge temperature", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "battery_high_discharge_current": { + "name": "High discharge current", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "battery_installed_capacity": { + "name": "Installed capacity", + "unit_of_measurement": "Ah" + }, + "battery_internal_failure": { + "name": "Internal failure", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "battery_last_discharge": { + "name": "Last discharge" + }, + "battery_low_cell_voltage": { + "name": "Low cell voltage", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "battery_low_charge_temperature": { + "name": "Low charge temperature", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "battery_max_cell_temperature": { + "name": "Maximum cell temperature" + }, + "battery_max_cell_voltage": { + "name": "Maximum cell voltage" + }, + "battery_max_charge_current": { + "name": "Maximum allowed charge current" + }, + "battery_max_charge_voltage": { + "name": "Maximum allowed charging voltage" + }, + "battery_max_discharge_current": { + "name": "Maximum allowed discharge current" + }, + "battery_max_temperature_cell_id": { + "name": "Maximum temperature cell ID" + }, + "battery_max_voltage_cell_id": { + "name": "Maximum voltage cell ID" + }, + "battery_maximum_voltage": { + "name": "Maximum voltage" + }, + "battery_mid_voltage": { + "name": "DC bus mid voltage" + }, + "battery_mid_voltage_deviation": { + "name": "DC bus mid voltage deviation", + "unit_of_measurement": "%" + }, + "battery_min_cell_temperature": { + "name": "Minimum cell temperature" + }, + "battery_min_cell_voltage": { + "name": "Minimum cell voltage" + }, + "battery_min_temperature_cell_id": { + "name": "Minimum temperature cell ID" + }, + "battery_min_voltage_cell_id": { + "name": "Minimum voltage cell ID" + }, + "battery_minimum_voltage": { + "name": "Minimum voltage" + }, + "battery_nr_modules_blocking_charge": { + "name": "Number of modules blocking charge", + "unit_of_measurement": "modules" + }, + "battery_nr_modules_blocking_discharge": { + "name": "Number of modules blocking discharge", + "unit_of_measurement": "modules" + }, + "battery_nr_modules_offline": { + "name": "Number of modules offline", + "unit_of_measurement": "modules" + }, + "battery_nr_modules_online": { + "name": "Number of modules online", + "unit_of_measurement": "modules" + }, + "battery_power": { + "name": "Power" + }, + "battery_soc": { + "name": "Charge" + }, + "battery_soh": { + "name": "State of health", + "unit_of_measurement": "%" + }, + "battery_temperature": { + "name": "Temperature" + }, + "battery_time_since_last_full_charge": { + "name": "Time since last full charge", + "unit_of_measurement": "seconds" + }, + "battery_time_to_go": { + "name": "Time to go" + }, + "battery_total_charge_cycles": { + "name": "Total charge cycles", + "unit_of_measurement": "cycles" + }, + "battery_voltage": { + "name": "DC bus voltage" + }, + "charge_mode": { + "name": "Charge mode" + }, + "charger_dc_current": { + "name": "DC output current" + }, + "charger_dc_voltage": { + "name": "DC output voltage" + }, + "charger_error_code": { + "name": "Error code", + "state": { + "battery_voltage_too_high": "Battery voltage too high", + "bms_connection_lost": "BMS connection lost", + "bulk_time_limit_exceeded": "Bulk time limit exceeded", + "charger_current_reversed": "Charger current reversed", + "charger_over_current": "Charger over current", + "charger_temperature_too_high": "Charger temperature too high", + "converter_issue": "Converter issue", + "current_sensor_issue": "Current sensor issue", + "factory_calibration_data_lost": "Factory calibration data lost", + "input_current_too_high": "Input current too high (solar panel)", + "input_shutdown_battery_voltage_too_high": "Input shutdown (battery voltage too high)", + "input_shutdown_reverse_current": "Input shutdown (reverse current)", + "input_voltage_too_high": "Input voltage too high (solar panel)", + "invalid_incompatible_firmware": "Invalid/incompatible firmware", + "lost_communication_with_device": "Lost communication with device", + "network_misconfigured": "Network misconfigured", + "no_error": "No error", + "synchronized_charging_config_issue": "Synchronized charging config issue", + "terminals_overheated": "Terminals overheated", + "user_settings_invalid": "User settings invalid" + } + }, + "charger_nr_of_outputs": { + "name": "Number of outputs", + "unit_of_measurement": "outputs" + }, + "charger_state": { + "name": "State", + "state": { + "absorption": "Absorption", + "auto_equalize": "Auto Equalize / Recondition", + "battery_safe": "Battery Safe", + "bulk": "Bulk", + "discharging": "Discharging", + "equalize": "Equalize", + "external_control": "External Control", + "fault": "Fault", + "float": "Float", + "inverting": "Inverting", + "low_power": "Low Power", + "off": "Off", + "passthrough": "Passthrough", + "power_assist": "Power Assist", + "power_supply": "Power Supply", + "recharging": "Recharging", + "repeated_absorption": "Repeated Absorption", + "scheduled_recharging": "Scheduled Recharging", + "starting_up": "Starting Up", + "storage": "Storage", + "sustain": "Sustain", + "sustain_alt": "Sustain Alt" + } + }, + "dcdc_dc_current": { + "name": "DC output current" + }, + "dcdc_dc_power": { + "name": "DC output power" + }, + "dcdc_dc_voltage": { + "name": "DC output voltage" + }, + "dcdc_input_current": { + "name": "Input current" + }, + "dcdc_input_power": { + "name": "Input power" + }, + "dcdc_input_voltage": { + "name": "Input voltage" + }, + "dcdc_state": { + "name": "State", + "state": { + "absorption": "Absorption", + "auto_equalize": "Auto Equalize / Recondition", + "battery_safe": "Battery Safe", + "bulk": "Bulk", + "discharging": "Discharging", + "equalize": "Equalize", + "external_control": "External Control", + "fault": "Fault", + "float": "Float", + "inverting": "Inverting", + "low_power": "Low Power", + "off": "Off", + "passthrough": "Passthrough", + "power_assist": "Power Assist", + "power_supply": "Power Supply", + "recharging": "Recharging", + "repeated_absorption": "Repeated Absorption", + "scheduled_recharging": "Scheduled Recharging", + "starting_up": "Starting Up", + "storage": "Storage", + "sustain": "Sustain", + "sustain_alt": "Sustain Alt" + } + }, + "dcload_current": { + "name": "Current" + }, + "dcload_power": { + "name": "Power" + }, + "dcload_voltage": { + "name": "Voltage" + }, + "dcsystem_aux_voltage": { + "name": "Auxiliary voltage" + }, + "dcsystem_current": { + "name": "Current" + }, + "dcsystem_power": { + "name": "Power" + }, + "dcsystem_voltage": { + "name": "Voltage" + }, + "digitalinput_alarm": { + "name": "Alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "digitalinput_input_state_raw": { + "name": "Raw state", + "state": { + "high_open": "High/Open", + "low_closed": "Low/Closed" + } + }, + "digitalinput_state": { + "name": "State", + "state": { + "alarm": "Alarm", + "closed": "Closed", + "high": "High", + "low": "Low", + "no": "No", + "off": "Off", + "ok": "Ok", + "on": "On", + "open": "Open", + "running": "Running", + "stopped": "Stopped", + "yes": "Yes" + } + }, + "digitalinput_type": { + "name": "Type", + "state": { + "bilge_alarm": "Bilge alarm", + "bilge_pump": "Bilge pump", + "burglar_alarm": "Burglar alarm", + "co2_alarm": "CO2 alarm", + "disabled": "Disabled", + "door_alarm": "Door alarm", + "fire_alarm": "Fire alarm", + "generator": "Generator", + "pulse_meter": "Pulse meter", + "smoke_alarm": "Smoke alarm", + "touch_input_control": "Touch input control" + } + }, + "evcharger_current": { + "name": "Current" + }, + "evcharger_max_set_current": { + "name": "Maximum set current" + }, + "evcharger_min_set_current": { + "name": "Minimum set current" + }, + "evcharger_mode": { + "name": "Mode", + "state": { + "auto": "Auto", + "manual": "Manual", + "scheduled_charge": "Scheduled Charge" + } + }, + "evcharger_position": { + "name": "Position", + "state": { + "ac_input": "AC Input", + "ac_out": "AC Out" + } + }, + "evcharger_power": { + "name": "Power" + }, + "evcharger_power_phase": { + "name": "Power {phase}" + }, + "evcharger_session_cost": { + "name": "Last session cost", + "unit_of_measurement": "$" + }, + "evcharger_session_energy": { + "name": "Last session energy" + }, + "evcharger_session_time": { + "name": "Last session time" + }, + "evcharger_set_current": { + "name": "Set current" + }, + "evcharger_status": { + "name": "Status", + "state": { + "charged": "Charged", + "charging": "Charging", + "charging_limit": "Charging limit", + "connected": "Connected", + "cp_input_test_error": "CP input test error", + "disconnected": "Disconnected", + "ground_test_error": "Ground test error", + "low_soc": "Low SOC", + "overheating_detected": "Overheating detected", + "overvoltage_detected": "Overvoltage detected", + "reserved15": "Reserved", + "reserved16": "Reserved", + "reserved17": "Reserved", + "reserved18": "Reserved", + "reserved19": "reserved", + "residual_current_detected": "Residual current detected", + "start_charging": "Start charging", + "switching_to_1_phase": "Switching to 1 phase", + "switching_to_3_phase": "Switching to 3 phase", + "undervoltage_detected": "Undervoltage detected", + "waiting_for_rfid": "Waiting for RFID", + "waiting_for_start": "Waiting for start", + "waiting_for_sun": "Waiting for sun", + "welded_contacts_test_error": "Welded contacts test error" + } + }, + "evcharger_total_energy": { + "name": "Total energy" + }, + "generator_gen_id_cool_down_timer": { + "name": "Generator cooldown timer" + }, + "generator_gen_id_qh_start_on_soc": { + "name": "Generator QH start on SOC" + }, + "generator_gen_id_qh_start_on_voltage": { + "name": "Generator QH start on voltage" + }, + "generator_gen_id_qh_stop_on_soc": { + "name": "Generator QH stop on SOC" + }, + "generator_gen_id_qh_stop_on_voltage": { + "name": "Generator QH stop on voltage" + }, + "generator_gen_id_service_interval": { + "name": "Generator service interval" + }, + "generator_gen_id_shut_down_timer": { + "name": "Generator shutdown timer" + }, + "generator_gen_id_start_on_soc": { + "name": "Generator start on SOC" + }, + "generator_gen_id_start_on_soc_timer": { + "name": "Generator start on SOC timer" + }, + "generator_gen_id_start_on_temp_timer": { + "name": "Generator start on temp timer" + }, + "generator_gen_id_start_on_voltage": { + "name": "Generator start on voltage" + }, + "generator_gen_id_start_on_voltage_timer": { + "name": "Generator start on voltage timer" + }, + "generator_gen_id_stop_on_soc": { + "name": "Generator stop on SOC" + }, + "generator_gen_id_stop_on_soc_timer": { + "name": "Generator stop on SOC timer" + }, + "generator_gen_id_stop_on_temp_timer": { + "name": "Generator stop on temp timer" + }, + "generator_gen_id_stop_on_voltage": { + "name": "Generator stop on voltage" + }, + "generator_gen_id_stop_on_voltage_timer": { + "name": "Generator stop on voltage timer" + }, + "generator_gen_id_warm_up_timer": { + "name": "Generator warm-up timer" + }, + "generator_run_state": { + "name": "Run state", + "state": { + "ac_load": "AC Load", + "battery_current": "Battery Current", + "battery_volts": "Battery Volts", + "inv_overload": "Inv Overload", + "inv_temp": "Inv Temp", + "lost_comms": "Lost Comms", + "manual": "Manual", + "soc": "SOC", + "stop_on_ac1": "Stop On AC1", + "stopped": "Stopped", + "test_run": "Test Run" + } + }, + "generator_service_counter": { + "name": "Service counter" + }, + "generator_today_runtime": { + "name": "Today runtime" + }, + "generator_total_runtime": { + "name": "Total runtime" + }, + "gps_altitude": { + "name": "Altitude", + "unit_of_measurement": "m" + }, + "gps_course": { + "name": "Course", + "unit_of_measurement": "°" + }, + "gps_latitude": { + "name": "Latitude", + "unit_of_measurement": "lat" + }, + "gps_longitude": { + "name": "Longitude", + "unit_of_measurement": "long" + }, + "gps_nrofsatellites": { + "name": "Number of satellites", + "unit_of_measurement": "satellites" + }, + "gps_speed": { + "name": "Speed" + }, + "grid_current": { + "name": "Current" + }, + "grid_current_n": { + "name": "Current on N" + }, + "grid_current_phase": { + "name": "Current on {phase}" + }, + "grid_energy_forward": { + "name": "Consumption" + }, + "grid_energy_forward_phase": { + "name": "Grid consumption on {phase}" + }, + "grid_energy_reverse": { + "name": "Feed-in" + }, + "grid_energy_reverse_phase": { + "name": "Feed-in on {phase}" + }, + "grid_frequency": { + "name": "Frequency" + }, + "grid_power": { + "name": "Power" + }, + "grid_power_factor": { + "name": "Power factor", + "unit_of_measurement": "factor" + }, + "grid_power_factor_phase": { + "name": "Power factor on {phase}", + "unit_of_measurement": "factor" + }, + "grid_power_phase": { + "name": "Power on {phase}" + }, + "grid_voltage": { + "name": "Voltage" + }, + "grid_voltage_pen": { + "name": "Voltage on PEN" + }, + "grid_voltage_phase": { + "name": "Voltage on {phase}" + }, + "grid_voltage_phase_next_phase": { + "name": "Voltage {phase} to {next_phase}" + }, + "heatpump_current": { + "name": "Current" + }, + "heatpump_current_phase": { + "name": "Current on {phase}" + }, + "heatpump_energy_forward": { + "name": "Consumption" + }, + "heatpump_energy_forward_phase": { + "name": "Consumption on {phase}" + }, + "heatpump_frequency": { + "name": "Frequency" + }, + "heatpump_power": { + "name": "Power" + }, + "heatpump_power_phase": { + "name": "Power on {phase}" + }, + "heatpump_voltage": { + "name": "Voltage" + }, + "heatpump_voltage_phase": { + "name": "Voltage on {phase}" + }, + "hub4_ac_grid_setpoint": { + "name": "AC grid setpoint" + }, + "inverter_mode": { + "name": "Mode", + "state": { + "eco": "Eco", + "inverter": "Inverter", + "off": "Off" + } + }, + "inverter_output_apparent_power_phase": { + "name": "Output apparent power {phase}" + }, + "inverter_output_current_phase": { + "name": "Output current {phase}" + }, + "inverter_output_power_phase": { + "name": "Output power {phase}" + }, + "inverter_output_voltage_phase": { + "name": "Output voltage {phase}" + }, + "inverter_pv_power_total": { + "name": "PV power total" + }, + "inverter_pv_voltage": { + "name": "PV bus voltage" + }, + "inverter_state": { + "name": "State", + "state": { + "absorption": "Absorption", + "auto_equalize": "Auto Equalize / Recondition", + "battery_safe": "Battery Safe", + "bulk": "Bulk", + "discharging": "Discharging", + "equalize": "Equalize", + "external_control": "External Control", + "fault": "Fault", + "float": "Float", + "inverting": "Inverting", + "low_power": "Low Power", + "off": "Off", + "passthrough": "Passthrough", + "power_assist": "Power Assist", + "power_supply": "Power Supply", + "recharging": "Recharging", + "repeated_absorption": "Repeated Absorption", + "scheduled_recharging": "Scheduled Recharging", + "starting_up": "Starting Up", + "storage": "Storage", + "sustain": "Sustain", + "sustain_alt": "Sustain Alt" + } + }, + "inverter_total_pv_yield_system": { + "name": "Total PV yield system" + }, + "inverter_total_pv_yield_user": { + "name": "Total PV yield user" + }, + "multi_acin1_to_acout": { + "name": "AC-in-1 to AC-out" + }, + "multi_acin1_to_inverter": { + "name": "AC-in-1 to inverter" + }, + "multi_acin_current_phase": { + "name": "Current {phase}" + }, + "multi_acin_power_phase": { + "name": "Power on {phase}" + }, + "multi_acin_voltage_phase": { + "name": "Voltage on {phase}" + }, + "multi_acout_output_current_phase": { + "name": "AC-out-{output} current on {phase}" + }, + "multi_acout_output_power_phase": { + "name": "AC-out-{output} power on {phase}" + }, + "multi_acout_output_voltage_phase": { + "name": "AC-out-{output} voltage on {phase}" + }, + "multi_acout_to_acin1": { + "name": "AC-out to AC-in-1" + }, + "multi_acout_to_inverter": { + "name": "AC-out to inverter" + }, + "multi_active_input": { + "name": "Active AC input", + "state": { + "ac_input_1": "AC Input 1", + "ac_input_2": "AC Input 2", + "disconnected": "Disconnected" + } + }, + "multi_ess_ac_power_setpoint": { + "name": "ESS AC power setpoint" + }, + "multi_ess_min_soc_limit": { + "name": "ESS minimum SOC limit" + }, + "multi_ess_mode": { + "name": "ESS mode", + "state": { + "external_control": "External control", + "keep_charged": "keep charged", + "self_consumption": "self consumption", + "self_consumption_batterylife": "self consumption (batterylife)" + } + }, + "multi_inverter_power_setpoint": { + "name": "Inverter power setpoint" + }, + "multi_inverter_to_acin1": { + "name": "Inverter to AC-in-1" + }, + "multi_inverter_to_acout": { + "name": "Inverter to AC-out" + }, + "multi_max_power_today": { + "name": "Max power today" + }, + "multi_max_power_yesterday": { + "name": "Max power yesterday" + }, + "multi_mppt_mppt_id_yield_today": { + "name": "MPPT {mppt_id} yield today" + }, + "multi_mppt_mppt_id_yield_yesterday": { + "name": "MPPT {mppt_id} yield yesterday" + }, + "multi_mppt_mpptnumber_power": { + "name": "MPPT {mpptnumber} power" + }, + "multi_mppt_mpptnumber_state": { + "name": "MPPT {mpptnumber} state", + "state": { + "mppt_active": "MPPT active", + "not_available": "Not available", + "off": "Off", + "voltage_current_limited": "Voltage/current limited" + } + }, + "multi_mppt_mpptnumber_voltage": { + "name": "MPPT {mpptnumber} PV voltage" + }, + "multi_phases": { + "name": "Phases", + "unit_of_measurement": "phases" + }, + "multi_pv_power_total": { + "name": "PV power total" + }, + "multi_shore_current_limit": { + "name": "Shore current limit" + }, + "multi_solar_to_acin1": { + "name": "Solar to AC-in-1" + }, + "multi_solar_to_acout": { + "name": "Solar to AC-out" + }, + "multi_solar_to_battery": { + "name": "Solar to battery" + }, + "multi_state": { + "name": "State", + "state": { + "absorption": "Absorption", + "auto_equalize": "Auto Equalize / Recondition", + "battery_safe": "Battery Safe", + "bulk": "Bulk", + "discharging": "Discharging", + "equalize": "Equalize", + "external_control": "External Control", + "fault": "Fault", + "float": "Float", + "inverting": "Inverting", + "low_power": "Low Power", + "off": "Off", + "passthrough": "Passthrough", + "power_assist": "Power Assist", + "power_supply": "Power Supply", + "recharging": "Recharging", + "repeated_absorption": "Repeated Absorption", + "scheduled_recharging": "Scheduled Recharging", + "starting_up": "Starting Up", + "storage": "Storage", + "sustain": "Sustain", + "sustain_alt": "Sustain Alt" + } + }, + "multi_total_pv_yield": { + "name": "Total PV yield user" + }, + "multi_yield_today": { + "name": "Yield today" + }, + "multi_yield_yesterday": { + "name": "Yield yesterday" + }, + "multiplus_assist_current_boost_factor": { + "name": "Assist current boost factor", + "unit_of_measurement": "factor" + }, + "platform_venus_firmware_available_version": { + "name": "Available version" + }, + "platform_venus_firmware_installed_version": { + "name": "Installed version" + }, + "pvinverter_current_phase": { + "name": "Current {phase}" + }, + "pvinverter_power_phase": { + "name": "Power {phase}" + }, + "pvinverter_power_total": { + "name": "Power total" + }, + "pvinverter_voltage_phase": { + "name": "Voltage {phase}" + }, + "pvinverter_yield_phase": { + "name": "Yield {phase}" + }, + "pvinverter_yield_total": { + "name": "Total yield" + }, + "solarcharger_current": { + "name": "PV bus current" + }, + "solarcharger_dc_current": { + "name": "DC (battery) bus current" + }, + "solarcharger_dc_voltage": { + "name": "DC (battery) bus voltage" + }, + "solarcharger_device_off_reason": { + "name": "Device-off reason", + "state": { + "active_alarm": "Active alarm", + "analysing_input_voltage": "Analysing input voltage", + "engine_shutdown": "Engine shutdown on low input voltage", + "low_temperature": "Low temperature", + "need_token": "Need token for operation", + "no_battery_power": "No/Low battery power", + "no_input_power": "No/Low input power", + "no_panel_power": "No/Low panel power", + "none": "-", + "protective_action": "Protection active", + "remote_input": "Remote input", + "signal_from_bms": "Signal from BMS", + "switched_off_device_mode_register": "Switched off (device mode register)", + "switched_off_power_switch": "Switched off (power switch)" + } + }, + "solarcharger_error_code": { + "name": "Error code", + "state": { + "battery_voltage_too_high": "Battery voltage too high", + "bms_connection_lost": "BMS connection lost", + "bulk_time_limit_exceeded": "Bulk time limit exceeded", + "charger_current_reversed": "Charger current reversed", + "charger_over_current": "Charger over current", + "charger_temperature_too_high": "Charger temperature too high", + "converter_issue": "Converter issue", + "current_sensor_issue": "Current sensor issue", + "factory_calibration_data_lost": "Factory calibration data lost", + "input_current_too_high": "Input current too high (solar panel)", + "input_shutdown_battery_voltage_too_high": "Input shutdown (battery voltage too high)", + "input_shutdown_reverse_current": "Input shutdown (reverse current)", + "input_voltage_too_high": "Input voltage too high (solar panel)", + "invalid_incompatible_firmware": "Invalid/incompatible firmware", + "lost_communication_with_device": "Lost communication with device", + "network_misconfigured": "Network misconfigured", + "no_error": "No error", + "synchronized_charging_config_issue": "Synchronized charging config issue", + "terminals_overheated": "Terminals overheated", + "user_settings_invalid": "User settings invalid" + } + }, + "solarcharger_load_current": { + "name": "Load bus current" + }, + "solarcharger_max_battery_voltage_today": { + "name": "Max battery voltage today" + }, + "solarcharger_max_power_today": { + "name": "Max power today" + }, + "solarcharger_max_power_yesterday": { + "name": "Max power yesterday" + }, + "solarcharger_min_battery_voltage_today": { + "name": "Min battery voltage today" + }, + "solarcharger_mppt_operation_mode": { + "name": "MPPT operation mode", + "state": { + "mppt_active": "MPPT active", + "not_available": "Not available", + "off": "Off", + "voltage_current_limited": "Voltage/current limited" + } + }, + "solarcharger_state": { + "name": "State", + "state": { + "absorption": "Absorption", + "auto_equalize": "Auto Equalize / Recondition", + "battery_safe": "Battery Safe", + "bulk": "Bulk", + "discharging": "Discharging", + "equalize": "Equalize", + "external_control": "External Control", + "fault": "Fault", + "float": "Float", + "inverting": "Inverting", + "low_power": "Low Power", + "off": "Off", + "passthrough": "Passthrough", + "power_assist": "Power Assist", + "power_supply": "Power Supply", + "recharging": "Recharging", + "repeated_absorption": "Repeated Absorption", + "scheduled_recharging": "Scheduled Recharging", + "starting_up": "Starting Up", + "storage": "Storage", + "sustain": "Sustain", + "sustain_alt": "Sustain Alt" + } + }, + "solarcharger_time_in_absorption_today": { + "name": "Time in absorption today" + }, + "solarcharger_time_in_bulk_today": { + "name": "Time in bulk today" + }, + "solarcharger_time_in_float_today": { + "name": "Time in float today" + }, + "solarcharger_tracker_tracker_max_power_today": { + "name": "Tracker {tracker} max power today" + }, + "solarcharger_tracker_tracker_max_voltage_today": { + "name": "Tracker {tracker} max voltage today" + }, + "solarcharger_tracker_tracker_name": { + "name": "PV tracker {tracker} name" + }, + "solarcharger_tracker_tracker_operation_mode": { + "name": "PV tracker {tracker} operation mode", + "state": { + "mppt_active": "MPPT active", + "not_available": "Not available", + "off": "Off", + "voltage_current_limited": "Voltage/current limited" + } + }, + "solarcharger_tracker_tracker_power": { + "name": "PV tracker {tracker} power" + }, + "solarcharger_tracker_tracker_voltage": { + "name": "PV tracker {tracker} voltage" + }, + "solarcharger_tracker_tracker_yield_today": { + "name": "Tracker {tracker} yield today" + }, + "solarcharger_voltage": { + "name": "PV bus voltage" + }, + "solarcharger_yield_power": { + "name": "PV yield power" + }, + "solarcharger_yield_today": { + "name": "Yield today" + }, + "solarcharger_yield_total": { + "name": "Total yield" + }, + "solarcharger_yield_yesterday": { + "name": "Yield yesterday" + }, + "switch_output_custom_name": { + "name": "{output} custom name" + }, + "switch_output_dimming": { + "name": "{output} dimming", + "unit_of_measurement": "%" + }, + "switchable_output_output_custom_name": { + "name": "Switchable output {output} custom name" + }, + "system_ac_active_input_source": { + "name": "AC active input source", + "state": { + "generator": "Generator", + "grid": "Grid", + "not_connected": "Not connected", + "shore_power": "Shore power", + "unknown": "Unknown" + } + }, + "system_ac_export_limit": { + "name": "AC export limit" + }, + "system_ac_input_limit": { + "name": "AC input limit" + }, + "system_ac_loads_phase": { + "name": "AC loads on {phase}" + }, + "system_ac_power_set_point": { + "name": "AC power setpoint" + }, + "system_consumption_current_phase": { + "name": "Consumption current {phase}" + }, + "system_consumption_on_output_phases": { + "name": "Consumption on output phases", + "unit_of_measurement": "phases" + }, + "system_consumption_phases": { + "name": "Consumption phases", + "unit_of_measurement": "phases" + }, + "system_consumption_power_phase": { + "name": "Consumption power {phase}" + }, + "system_control_active_soc_limit": { + "name": "Active SOC limit" + }, + "system_control_scheduled_soc": { + "name": "Scheduled SOC" + }, + "system_critical_loads_phase": { + "name": "Critical loads on {phase}" + }, + "system_dc_alternator_power": { + "name": "DC alternator power" + }, + "system_dc_battery_charge_energy": { + "name": "DC battery charge energy" + }, + "system_dc_battery_current": { + "name": "DC battery current" + }, + "system_dc_battery_discharge_energy": { + "name": "DC battery discharge energy" + }, + "system_dc_battery_power": { + "name": "DC battery power" + }, + "system_dc_battery_soc": { + "name": "DC battery charge" + }, + "system_dc_battery_state": { + "name": "DC battery state", + "state": { + "charging": "Charging", + "discharging": "Discharging", + "idle": "Idle" + } + }, + "system_dc_battery_voltage": { + "name": "DC battery voltage" + }, + "system_dc_consumption": { + "name": "DC consumption" + }, + "system_dc_pv_current": { + "name": "PV current" + }, + "system_dc_pv_energy": { + "name": "PV energy" + }, + "system_dc_pv_power": { + "name": "PV power" + }, + "system_dynamicess_available_overhead": { + "name": "Dynamic ESS available overhead" + }, + "system_dynamicess_error": { + "name": "Dynamic ESS error", + "state": { + "battry_capacity_not_configured": "Battery Capacity Not Configured", + "ess_mode": "ESS Mode", + "no_error": "No Error", + "no_ess": "No ESS", + "no_schedule": "No Matching Schedule", + "soc_low": "SOC low" + } + }, + "system_dynamicess_last_scheduled_end": { + "name": "Dynamic ESS last scheduled end" + }, + "system_dynamicess_last_scheduled_start": { + "name": "Dynamic ESS last scheduled start" + }, + "system_dynamicess_minimum_soc": { + "name": "Dynamic ESS minimum SOC" + }, + "system_dynamicess_reactive_strategy": { + "name": "Dynamic ESS reactive strategy", + "state": { + "dess_disabled": "DESS Disabled", + "ess_low_soc": "ESS Low SOC", + "idle_maintain_surplus": "Idle Maintain Surplus", + "idle_maintain_targetsoc": "Idle Maintain Target SOC", + "idle_no_opportunity": "Idle No Opportunity", + "idle_scheduled_feedin": "Idle Scheduled Feed-In", + "keep_battery_charged": "Keep Battery Charged", + "no_window": "No Window", + "scheduled_charge_allow_grid": "Scheduled Charge Allow Grid", + "scheduled_charge_enhanced": "Scheduled Charge Enhanced", + "scheduled_charge_feedin": "Scheduled Charge Feed-In", + "scheduled_charge_no_grid": "Scheduled Charge No Grid", + "scheduled_charge_smooth_transition": "Scheduled Charge Smooth Transition", + "scheduled_discharge": "Scheduled Discharge", + "scheduled_discharge_smooth_transition": "Scheduled Discharge Smooth Transition", + "scheduled_minimum_discharge": "Scheduled Minimum Discharge", + "scheduled_selfconsume": "Scheduled Self-Consume", + "selfconsume_accept_charge": "Self-Consume Accept Charge", + "selfconsume_accept_discharge": "Self-Consume Accept Discharge", + "selfconsume_faulty_chargerate": "Self-Consume Faulty Charge Rate", + "selfconsume_increased_discharge": "Self-Consume Increased Discharge", + "selfconsume_no_grid": "Self-Consume No Grid", + "selfconsume_unexpected_exception": "Self-Consume Unexpected Exception", + "selfconsume_unmapped_state": "Self-Consume Unmapped State", + "selfconsume_unpredicted": "Self-Consume Unpredicted", + "unknown_operating_mode": "Unknown Operating Mode", + "unscheduled_charge_catchup_targetsoc": "Unscheduled Charge Catch-Up Target SOC" + } + }, + "system_dynamicess_restrictions": { + "name": "Dynamic ESS restrictions", + "state": { + "battery_to_grid_restricted": "Battery to grid energy flow restricted", + "grid_to_battery_restricted": "Grid to battery energy flow restricted", + "no_flow": "No energy flow between battery and grid", + "no_restrictions": "No Restrictions between battery and the grid" + } + }, + "system_dynamicess_schedule_count": { + "name": "Dynamic ESS number of schedules", + "unit_of_measurement": "schedules" + }, + "system_dynamicess_strategy": { + "name": "Dynamic ESS strategy", + "state": { + "probattery": "Pro Battery", + "progrid": "Pro Grid", + "selfconsume": "Self-Consume", + "targetsoc": "Target SOC" + } + }, + "system_dynamicess_target_soc": { + "name": "Dynamic ESS target SOC" + }, + "system_ess_batterylife_state": { + "name": "ESS BatteryLife state", + "state": { + "keep_batteries_charged": "'Keep batteries charged' mode enabled", + "recharge": "Recharge, SOC dropped 5% or more below MinSOC", + "recharge_no_battery_life": "Recharge, SOC dropped 5% or more below MinSOC (No BatteryLife)", + "self_consumption": "Self consumption", + "self_consumption_soc_above_min": "Self consumption, SoC at or above minimum SoC", + "self_consumption_soc_at_100": "Self consumption, SoC at 100%", + "self_consumption_soc_below_min": "Self consumption, SoC is below minimum SoC", + "self_consumption_soc_exceeds_85": "Self consumption, SoC exceeds 85%", + "soc_below_battery_life_dynamic_soc_limit": "SoC below BatteryLife dynamic SoC limit", + "soc_below_soc_limit_24_hours": "SoC has been below SoC limit for more than 24 hours. Charging with battery with 5amps", + "sustain": "Multi/Quattro is in sustain", + "with_battery_life": "Optimized mode with BatteryLife" + } + }, + "system_ess_max_charge_current": { + "name": "ESS max charge current" + }, + "system_ess_max_charge_power": { + "name": "ESS max charge power limit" + }, + "system_ess_max_charge_voltage": { + "name": "ESS max charge voltage" + }, + "system_ess_max_feed_in_power": { + "name": "ESS max feed-in power" + }, + "system_ess_max_inverter_power_limit": { + "name": "ESS max inverter power limit" + }, + "system_ess_min_soc_limit": { + "name": "ESS min SOC limit" + }, + "system_ess_mode": { + "name": "ESS mode (Hub4)", + "state": { + "external_control": "External control", + "phase_compensation_disabled": "Optimized mode or 'keep batteries charged' and phase compensation disabled", + "phase_compensation_enabled": "Optimized mode or 'keep batteries charged' and phase compensation enabled" + } + }, + "system_ess_schedule_charge_slot_days": { + "name": "ESS BatteryLife schedule charge {slot} days", + "state": { + "disabled_every_day": "Disabled (Every day)", + "disabled_friday": "Disabled (Friday)", + "disabled_monday": "Disabled (Monday)", + "disabled_saturday": "Disabled (Saturday)", + "disabled_sunday": "Disabled (Sunday)", + "disabled_thursday": "Disabled (Thursday)", + "disabled_tuesday": "Disabled (Tuesday)", + "disabled_wednesday": "Disabled (Wednesday)", + "disabled_weekdays": "Disabled (Weekdays)", + "disabled_weekend": "Disabled (Weekends)", + "every_day": "Every day", + "friday": "Friday", + "monday": "Monday", + "saturday": "Saturday", + "sunday": "Sunday", + "thursday": "Thursday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "weekdays": "Weekdays", + "weekends": "Weekends" + } + }, + "system_ess_schedule_charge_slot_duration": { + "name": "ESS BatteryLife schedule charge {slot} duration" + }, + "system_ess_schedule_charge_slot_soc": { + "name": "ESS BatteryLife schedule charge {slot} SOC" + }, + "system_generator_load_phase": { + "name": "Genset load {phase}" + }, + "system_grid_current_phase": { + "name": "Grid current {phase}" + }, + "system_grid_phases": { + "name": "Grid phases", + "unit_of_measurement": "phases" + }, + "system_grid_power_phase": { + "name": "Grid power {phase}" + }, + "system_heartbeat": { + "name": "GX system heartbeat" + }, + "system_pv_on_output_current_phase": { + "name": "PV on output current {phase}" + }, + "system_pv_on_output_phases": { + "name": "PV on output phases", + "unit_of_measurement": "phases" + }, + "system_pv_on_output_power_phase": { + "name": "PV on output power {phase}" + }, + "system_relay_relay_custom_name": { + "name": "Relay {relay} custom name" + }, + "system_settings_dess_mode": { + "name": "DESS mode", + "state": { + "auto_vrm": "Auto / VRM", + "buy": "Buy", + "node_red": "Node-RED", + "off": "Off", + "sell": "Sell" + } + }, + "system_state": { + "name": "System state", + "state": { + "absorption": "Absorption", + "auto_equalize": "Auto Equalize / Recondition", + "battery_safe": "Battery Safe", + "bulk": "Bulk", + "discharging": "Discharging", + "equalize": "Equalize", + "external_control": "External Control", + "fault": "Fault", + "float": "Float", + "inverting": "Inverting", + "low_power": "Low Power", + "off": "Off", + "passthrough": "Passthrough", + "power_assist": "Power Assist", + "power_supply": "Power Supply", + "recharging": "Recharging", + "repeated_absorption": "Repeated Absorption", + "scheduled_recharging": "Scheduled Recharging", + "starting_up": "Starting Up", + "storage": "Storage", + "sustain": "Sustain", + "sustain_alt": "Sustain Alt" + } + }, + "tank_battery_voltage": { + "name": "Sensor battery voltage" + }, + "tank_fluid_type": { + "name": "Fluid type", + "state": { + "black_water": "Black water (sewage)", + "diesel": "Diesel", + "fresh_water": "Fresh Water", + "fuel": "Fuel", + "gasoline": "Gasoline", + "hydraulic_oil": "Hydraulic oil", + "live_well": "Live Well", + "lng": "Liquid Natural Gas (LNG)", + "lpg": "Liquid Petroleum Gas (LPG)", + "oil": "Oil", + "raw_water": "Raw water", + "waste_water": "Waste Water" + } + }, + "tank_level": { + "name": "Level", + "unit_of_measurement": "%" + }, + "tank_remaining": { + "name": "Remaining" + }, + "tank_temperature": { + "name": "Temperature" + }, + "temperature_battery_voltage": { + "name": "Sensor battery voltage" + }, + "temperature_humidity": { + "name": "Humidity", + "unit_of_measurement": "%" + }, + "temperature_offset": { + "name": "Offset" + }, + "temperature_pressure": { + "name": "Pressure", + "unit_of_measurement": "hPa" + }, + "temperature_scale": { + "name": "Scale factor", + "unit_of_measurement": "factor" + }, + "temperature_status": { + "name": "Sensor status", + "state": { + "disconnected": "Disconnected", + "ok": "Ok", + "reverse_polarity": "Reverse polarity", + "short_circuited": "Short circuited", + "unknown": "Unknown" + } + }, + "temperature_temperature": { + "name": "Temperature" + }, + "temperature_type": { + "name": "Sensor type", + "state": { + "battery": "Battery", + "freezer": "Freezer", + "fridge": "Fridge", + "generic": "Generic", + "outdoor": "Outdoor", + "room": "Room", + "water_heater": "Water Heater" + } + }, + "transfer_switch_generator_current_limit": { + "name": "Generator AC current limit" + }, + "vebus_ac_power_setpoint_phase": { + "name": "AC power setpoint {phase}" + }, + "vebus_device_device_number_input_power_l1": { + "name": "{device_number} line 1 input power" + }, + "vebus_device_device_number_input_power_phase": { + "name": "{device_number} line {phase} input power" + }, + "vebus_device_device_number_inverted_power": { + "name": "{device_number} inverted power" + }, + "vebus_device_device_number_output_power_l1": { + "name": "{device_number} line 1 output power" + }, + "vebus_device_device_number_output_power_phase": { + "name": "{device_number} line {phase} output power" + }, + "vebus_energy_ac_in1_to_ac_out": { + "name": "Energy from AC-in-1 to AC-out" + }, + "vebus_energy_ac_in1_to_inverter": { + "name": "Energy from AC-in-1 to inverter" + }, + "vebus_energy_ac_in2_to_ac_out": { + "name": "Energy from AC-in-2 to AC-out" + }, + "vebus_energy_ac_in2_to_inverter": { + "name": "Energy from AC-in-2 to inverter" + }, + "vebus_energy_ac_out_to_ac_in1": { + "name": "Energy from AC-out to AC-in-1" + }, + "vebus_energy_ac_out_to_ac_in2": { + "name": "Energy from AC-out to AC-in-2" + }, + "vebus_energy_inverter_to_ac_in1": { + "name": "Energy from inverter to AC-in-1" + }, + "vebus_energy_inverter_to_ac_in2": { + "name": "Energy from inverter to AC-in-2" + }, + "vebus_energy_inverter_to_ac_out": { + "name": "Energy from inverter to AC-out" + }, + "vebus_energy_out_to_inverter": { + "name": "Energy from out to inverter" + }, + "vebus_inverter_active_input": { + "name": "Active AC input", + "state": { + "generator": "Generator", + "grid": "Grid", + "not_connected": "Not connected", + "shore_power": "Shore power", + "unknown": "Unknown" + } + }, + "vebus_inverter_alarm_grid_lost": { + "name": "Grid lost alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_alarm_high_dc_current": { + "name": "High DC current alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_alarm_high_dc_voltage": { + "name": "High DC voltage alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_alarm_high_temperature": { + "name": "High temperature alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_alarm_low_battery": { + "name": "Low battery alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_alarm_overload": { + "name": "Overload alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_alarm_phase_rotation": { + "name": "Phase rotation alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_alarm_ripple": { + "name": "Ripple alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_alarm_temperature_sensor": { + "name": "Temperature sensor alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_alarm_voltage_sensor": { + "name": "Voltage sensor alarm", + "state": { + "alarm": "Alarm", + "no_alarm": "No Alarm", + "warning": "Warning" + } + }, + "vebus_inverter_current_limit": { + "name": "Current limit" + }, + "vebus_inverter_dc_current": { + "name": "DC current" + }, + "vebus_inverter_dc_power": { + "name": "DC power" + }, + "vebus_inverter_dc_temperature": { + "name": "DC temperature" + }, + "vebus_inverter_dc_voltage": { + "name": "DC voltage" + }, + "vebus_inverter_ignoreacin1_state": { + "name": "State of ignore AC-in-1", + "state": { + "off": "Off", + "on": "On" + } + }, + "vebus_inverter_input_apparent_power_phase": { + "name": "Input apparent power {phase}" + }, + "vebus_inverter_input_current_phase": { + "name": "Input current {phase}" + }, + "vebus_inverter_input_frequency_phase": { + "name": "Input frequency {phase}" + }, + "vebus_inverter_input_power_phase": { + "name": "Input power {phase}" + }, + "vebus_inverter_input_voltage_phase": { + "name": "Input voltage {phase}" + }, + "vebus_inverter_mode": { + "name": "Mode", + "state": { + "charger_only": "Charger Only", + "inverter_only": "Inverter Only", + "off": "Off", + "on": "On" + } + }, + "vebus_inverter_output_apparent_power_phase": { + "name": "Output apparent power {phase}" + }, + "vebus_inverter_output_current_phase": { + "name": "Output current {phase}" + }, + "vebus_inverter_output_frequency_phase": { + "name": "Output frequency {phase}" + }, + "vebus_inverter_output_power_phase": { + "name": "Output power {phase}" + }, + "vebus_inverter_output_voltage_phase": { + "name": "Output voltage {phase}" + }, + "vebus_inverter_state": { + "name": "State", + "state": { + "absorption": "Absorption", + "auto_equalize": "Auto Equalize / Recondition", + "battery_safe": "Battery Safe", + "bulk": "Bulk", + "discharging": "Discharging", + "equalize": "Equalize", + "external_control": "External Control", + "fault": "Fault", + "float": "Float", + "inverting": "Inverting", + "low_power": "Low Power", + "off": "Off", + "passthrough": "Passthrough", + "power_assist": "Power Assist", + "power_supply": "Power Supply", + "recharging": "Recharging", + "repeated_absorption": "Repeated Absorption", + "scheduled_recharging": "Scheduled Recharging", + "starting_up": "Starting Up", + "storage": "Storage", + "sustain": "Sustain", + "sustain_alt": "Sustain Alt" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b28eb0a3c74e36..eb103b00ced2e1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -783,6 +783,7 @@ "vesync", "vicare", "victron_ble", + "victron_gx", "victron_remote_monitoring", "vilfo", "vivotek", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a13d5b35294a18..1f9c0286ca3708 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7599,6 +7599,12 @@ "victron": { "name": "Victron", "integrations": { + "victron_gx": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Victron GX" + }, "victron_ble": { "integration_type": "device", "config_flow": true, diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index d7777d68ff1fb4..a1ab04fdb3ad5b 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -391,6 +391,12 @@ "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:2", }, ], + "victron_gx": [ + { + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy", + }, + ], "webostv": [ { "st": "urn:lge-com:service:webos-second-screen:1", diff --git a/requirements_all.txt b/requirements_all.txt index 024d09e6ca7d13..ed7dbd44ff016a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3236,6 +3236,9 @@ viaggiatreno_ha==0.2.4 # homeassistant.components.victron_ble victron-ble-ha-parser==0.6.3 +# homeassistant.components.victron_gx +victron-mqtt==2026.4.0 + # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03fe746e9a07eb..bbfcc7a962c555 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2739,6 +2739,9 @@ venstarcolortouch==0.21 # homeassistant.components.victron_ble victron-ble-ha-parser==0.6.3 +# homeassistant.components.victron_gx +victron-mqtt==2026.4.0 + # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/tests/components/victron_gx/__init__.py b/tests/components/victron_gx/__init__.py new file mode 100644 index 00000000000000..b35f25c161ce65 --- /dev/null +++ b/tests/components/victron_gx/__init__.py @@ -0,0 +1 @@ +"""Tests for the victron_gx integration.""" diff --git a/tests/components/victron_gx/conftest.py b/tests/components/victron_gx/conftest.py new file mode 100644 index 00000000000000..2ecfddfd0db02f --- /dev/null +++ b/tests/components/victron_gx/conftest.py @@ -0,0 +1,76 @@ +"""Common fixtures for the victron_gx tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from victron_mqtt import Hub as VictronVenusHub +from victron_mqtt.testing import create_mocked_hub + +from homeassistant.components.victron_gx.const import ( + CONF_INSTALLATION_ID, + CONF_MODEL, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant + +from .const import MOCK_HOST, MOCK_INSTALLATION_ID, MOCK_MODEL, MOCK_SERIAL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.victron_gx.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_INSTALLATION_ID, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: 1883, + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_pass", + CONF_SSL: False, + CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, + CONF_MODEL: MOCK_MODEL, + CONF_SERIAL: MOCK_SERIAL, + }, + title=f"Victron OS {MOCK_INSTALLATION_ID} ({MOCK_HOST}:1883)", + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> tuple[VictronVenusHub, MockConfigEntry]: + """Set up the Victron GX MQTT integration for testing.""" + mock_config_entry.add_to_hass(hass) + + victron_hub = await create_mocked_hub() + + with patch( + "homeassistant.components.victron_gx.hub.VictronVenusHub" + ) as mock_hub_class: + mock_hub_class.return_value = victron_hub + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return victron_hub, mock_config_entry diff --git a/tests/components/victron_gx/const.py b/tests/components/victron_gx/const.py new file mode 100644 index 00000000000000..6fa11d9c36212e --- /dev/null +++ b/tests/components/victron_gx/const.py @@ -0,0 +1,7 @@ +"""Constants for victron_gx tests.""" + +MOCK_INSTALLATION_ID = "123" +MOCK_SERIAL = "HQ2234ABCDE" +MOCK_MODEL = "Cerbo GX" +MOCK_FRIENDLY_NAME = "Venus GX" +MOCK_HOST = "192.168.1.100" diff --git a/tests/components/victron_gx/test_config_flow.py b/tests/components/victron_gx/test_config_flow.py new file mode 100644 index 00000000000000..1a11dffffad0da --- /dev/null +++ b/tests/components/victron_gx/test_config_flow.py @@ -0,0 +1,545 @@ +"""Test the victron GX config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from victron_mqtt import AuthenticationError, CannotConnectError + +from homeassistant.components.victron_gx.config_flow import DEFAULT_PORT +from homeassistant.components.victron_gx.const import ( + CONF_INSTALLATION_ID, + CONF_MODEL, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo + +from .const import ( + MOCK_FRIENDLY_NAME, + MOCK_HOST, + MOCK_INSTALLATION_ID, + MOCK_MODEL, + MOCK_SERIAL, +) + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +def assert_entry_title( + result: dict[str, object], + installation_id: str = MOCK_INSTALLATION_ID, + host: str = MOCK_HOST, + port: int = DEFAULT_PORT, +) -> None: + """Assert the config entry title format.""" + assert result["title"] == f"Victron OS {installation_id} ({host}:{port})" + + +@pytest.fixture +def mock_victron_hub(): + """Mock the Victron Hub.""" + with patch( + "homeassistant.components.victron_gx.config_flow.VictronVenusHub" + ) as mock_hub: + hub_instance = MagicMock() + hub_instance.connect = AsyncMock() + hub_instance.disconnect = AsyncMock() + hub_instance.installation_id = MOCK_INSTALLATION_ID + mock_hub.return_value = hub_instance + yield mock_hub + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_user_flow_full_config(hass: HomeAssistant) -> None: + """Test the full user flow with all configuration options.""" + 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 result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_INSTALLATION_ID + assert_entry_title(result) + assert result["data"] == { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_SERIAL: None, + CONF_MODEL: None, + CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, + } + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_user_flow_minimal_config(hass: HomeAssistant) -> None: + """Test the user flow with minimal configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_INSTALLATION_ID + assert_entry_title(result) + assert result["data"] == { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + CONF_SERIAL: None, + CONF_MODEL: None, + CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (CannotConnectError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + (AuthenticationError("Invalid credentials"), "invalid_auth"), + ], +) +async def test_user_flow_error( + hass: HomeAssistant, + mock_victron_hub: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test we handle errors in user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_victron_hub.return_value.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Recover from error + mock_victron_hub.return_value.connect.side_effect = None + mock_victron_hub.return_value.installation_id = MOCK_INSTALLATION_ID + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_INSTALLATION_ID + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test configuration flow aborts when device is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_ssdp_flow_success(hass: HomeAssistant) -> None: + """Test SSDP discovery flow with successful connection.""" + discovery_info = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + ssdp_location="http://192.168.1.100:80/", + upnp={ + "serialNumber": MOCK_SERIAL, + "X_VrmPortalId": MOCK_INSTALLATION_ID, + "modelName": MOCK_MODEL, + "friendlyName": MOCK_FRIENDLY_NAME, + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_INSTALLATION_ID + assert_entry_title(result) + assert result["data"] == { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SERIAL: MOCK_SERIAL, + CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, + CONF_MODEL: MOCK_MODEL, + } + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (CannotConnectError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_ssdp_discovery_error( + hass: HomeAssistant, + mock_victron_hub: MagicMock, + exception: Exception, + reason: str, +) -> None: + """Test SSDP discovery aborts on connection errors.""" + mock_victron_hub.return_value.connect.side_effect = exception + + discovery_info = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + ssdp_location="http://192.168.1.100:80/", + upnp={ + "serialNumber": MOCK_SERIAL, + "X_VrmPortalId": MOCK_INSTALLATION_ID, + "modelName": MOCK_MODEL, + "friendlyName": MOCK_FRIENDLY_NAME, + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_ssdp_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test SSDP discovery flow aborts when device is already configured.""" + mock_config_entry.add_to_hass(hass) + discovery_info = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + ssdp_location="http://192.168.1.100:80/", + upnp={ + "serialNumber": MOCK_SERIAL, + "X_VrmPortalId": MOCK_INSTALLATION_ID, + "modelName": MOCK_MODEL, + "friendlyName": MOCK_FRIENDLY_NAME, + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_ssdp_flow_auth_required( + hass: HomeAssistant, mock_victron_hub: MagicMock +) -> None: + """Test SSDP discovery flow when authentication is required.""" + mock_victron_hub.return_value.connect.side_effect = AuthenticationError( + "Authentication required" + ) + + discovery_info = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + ssdp_location="http://192.168.1.100:80/", + upnp={ + "serialNumber": MOCK_SERIAL, + "X_VrmPortalId": MOCK_INSTALLATION_ID, + "modelName": MOCK_MODEL, + "friendlyName": MOCK_FRIENDLY_NAME, + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_auth" + + # Test providing credentials + mock_victron_hub.return_value.connect.side_effect = None + mock_victron_hub.return_value.installation_id = MOCK_INSTALLATION_ID + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_INSTALLATION_ID + assert_entry_title(result) + assert result["data"] == { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SERIAL: MOCK_SERIAL, + CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + } + + +async def test_ssdp_auth_invalid_credentials( + hass: HomeAssistant, mock_victron_hub: MagicMock +) -> None: + """Test SSDP auth flow with invalid credentials.""" + mock_victron_hub.return_value.connect.side_effect = AuthenticationError( + "Authentication required" + ) + + discovery_info = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + ssdp_location="http://192.168.1.100:80/", + upnp={ + "serialNumber": MOCK_SERIAL, + "X_VrmPortalId": MOCK_INSTALLATION_ID, + "modelName": MOCK_MODEL, + "friendlyName": MOCK_FRIENDLY_NAME, + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_auth" + + # Test with wrong credentials + mock_victron_hub.return_value.connect.side_effect = AuthenticationError( + "Invalid credentials" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "wrong-user", + CONF_PASSWORD: "wrong-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Retry with correct credentials + mock_victron_hub.return_value.connect.side_effect = None + mock_victron_hub.return_value.installation_id = MOCK_INSTALLATION_ID + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_INSTALLATION_ID + assert_entry_title(result) + assert result["data"] == { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SERIAL: MOCK_SERIAL, + CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (CannotConnectError("Cannot connect"), "cannot_connect"), + (Exception("Unknown error"), "unknown"), + ], +) +async def test_ssdp_auth_error( + hass: HomeAssistant, + mock_victron_hub: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test SSDP auth flow error handling.""" + mock_victron_hub.return_value.connect.side_effect = AuthenticationError( + "Authentication required" + ) + + discovery_info = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + ssdp_location="http://192.168.1.100:80/", + upnp={ + "serialNumber": MOCK_SERIAL, + "X_VrmPortalId": MOCK_INSTALLATION_ID, + "modelName": MOCK_MODEL, + "friendlyName": MOCK_FRIENDLY_NAME, + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_auth" + + mock_victron_hub.return_value.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + +async def test_user_flow_disconnect_error_ignored( + hass: HomeAssistant, mock_victron_hub: MagicMock +) -> None: + """Test config flow succeeds even when disconnect raises.""" + mock_victron_hub.return_value.disconnect.side_effect = Exception("disconnect fail") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_INSTALLATION_ID + + +async def test_user_flow_missing_installation_id( + hass: HomeAssistant, mock_victron_hub: MagicMock +) -> None: + """Test config flow handles hub returning no installation id.""" + mock_victron_hub.return_value.installation_id = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/victron_gx/test_init.py b/tests/components/victron_gx/test_init.py new file mode 100644 index 00000000000000..ed87cf710e3b3e --- /dev/null +++ b/tests/components/victron_gx/test_init.py @@ -0,0 +1,188 @@ +"""Test the victron_gx init.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from victron_mqtt import ( + AuthenticationError, + CannotConnectError, + Hub as VictronVenusHub, + MetricKind, +) + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from .const import MOCK_INSTALLATION_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_victron_hub_library(): + """Mock the victron_mqtt library.""" + with patch("homeassistant.components.victron_gx.hub.VictronVenusHub") as mock_lib: + hub_instance = MagicMock() + hub_instance.connect = AsyncMock() + hub_instance.disconnect = AsyncMock() + hub_instance.installation_id = MOCK_INSTALLATION_ID + mock_lib.return_value = hub_instance + yield mock_lib + + +@pytest.mark.usefixtures("mock_victron_hub_library") +async def test_load_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test unload entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("mock_victron_hub_library") +async def test_unload_entry_does_not_cleanup_on_platform_unload_failure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test unload failure does not stop hub or clear callbacks.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_config_entry.runtime_data.new_metric_callbacks[MetricKind.SENSOR] = MagicMock() + hub_disconnect = mock_config_entry.runtime_data._hub.disconnect + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=False, + ): + assert not await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.FAILED_UNLOAD + hub_disconnect.assert_not_awaited() + + +@pytest.mark.usefixtures("mock_victron_hub_library") +async def test_stop_on_homeassistant_stop( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test hub stops when Home Assistant stops.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + hub_disconnect = mock_config_entry.runtime_data._hub.disconnect + hub_disconnect.assert_not_awaited() + + # Fire the stop event + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + hub_disconnect.assert_awaited_once() + + +@pytest.mark.parametrize( + ("connect_exception", "expected_state"), + [ + (CannotConnectError("Connection failed"), ConfigEntryState.SETUP_RETRY), + (AuthenticationError("Auth failed"), ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_entry_start_failure_unloads_platforms_and_callbacks( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_victron_hub_library: MagicMock, + connect_exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup cleanup when hub start fails after platform forwarding.""" + mock_config_entry.add_to_hass(hass) + mock_victron_hub_library.return_value.connect.side_effect = connect_exception + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + assert mock_config_entry.runtime_data.new_metric_callbacks == {} + + +async def test_hub_start_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_victron_hub_library: MagicMock, +) -> None: + """Test hub start with connection error.""" + mock_config_entry.add_to_hass(hass) + + mock_victron_hub_library.return_value.connect.side_effect = CannotConnectError( + "Connection failed" + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_hub_start_success( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], +) -> None: + """Test successful hub start.""" + victron_hub, mock_config_entry = init_integration + + # Verify the hub was started (integration was set up successfully) + assert mock_config_entry.state is ConfigEntryState.LOADED + assert victron_hub.installation_id == MOCK_INSTALLATION_ID + + +async def test_hub_start_authentication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_victron_hub_library: MagicMock, +) -> None: + """Test hub start with authentication error.""" + mock_config_entry.add_to_hass(hass) + + mock_victron_hub_library.return_value.connect.side_effect = AuthenticationError( + "Authentication failed" + ) + + # Attempt to set up the config entry - should fail with auth error + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the config entry is in SETUP_ERROR state (auth failed) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_hub_stop( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], +) -> None: + """Test hub stop.""" + _, mock_config_entry = init_integration + + # Verify it's initially loaded + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Unload the config entry (which stops the hub) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Verify hub is disconnected by checking config entry state + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/victron_gx/test_sensor.py b/tests/components/victron_gx/test_sensor.py new file mode 100644 index 00000000000000..256161152551d2 --- /dev/null +++ b/tests/components/victron_gx/test_sensor.py @@ -0,0 +1,106 @@ +"""Tests for Victron GX MQTT sensors.""" + +from __future__ import annotations + +from victron_mqtt import Hub as VictronVenusHub +from victron_mqtt.testing import finalize_injection, inject_message + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.components.victron_gx.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import MOCK_INSTALLATION_ID + +from tests.common import MockConfigEntry + + +async def test_victron_battery_sensor( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test SENSOR MetricKind - battery current sensor is created and updated.""" + victron_hub, mock_config_entry = init_integration + + # Inject a sensor metric (battery current) + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/battery/0/Dc/0/Current", + '{"value": 10.5}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + # Verify entity was created by checking entity registry + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + # Exactly one entity is expected for this injected metric. + assert len(entities) == 1 + entity = entities[0] + assert entity.entity_id == "sensor.battery_dc_bus_current" + assert entity.unique_id == f"{MOCK_INSTALLATION_ID}_battery_0_battery_current" + assert entity.original_device_class is SensorDeviceClass.CURRENT + assert entity.unit_of_measurement == "A" + assert entity.translation_key == "battery_current" + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "10.5" + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT + assert state.attributes["device_class"] == "current" + assert state.attributes["unit_of_measurement"] == "A" + + # Verify device info was registered correctly + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{MOCK_INSTALLATION_ID}_battery_0")} + ) + assert device is not None + assert device.manufacturer == "Victron Energy" + assert device.name == "Battery" + + # Update the same metric to exercise the entity update callback path. + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/battery/0/Dc/0/Current", + '{"value": 11.2}', + ) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "11.2" + + +async def test_victron_enum_sensor( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], + device_registry: dr.DeviceRegistry, +) -> None: + """Test sensor with VictronEnum value normalizes to enum id.""" + victron_hub, _mock_config_entry = init_integration + + # SystemState/State produces a VictronEnum (State enum) + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/system/0/SystemState/State", + '{"value": 1}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + state = hass.states.get("sensor.victron_venus_system_state") + assert state is not None + # Value 1 maps to State.LOW_POWER with id="low_power" + assert state.state == "low_power" + + # Verify system device has no via_device (it IS the gateway) + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{MOCK_INSTALLATION_ID}_system_0")} + ) + assert device is not None + assert device.manufacturer == "Victron Energy" + assert device.via_device_id is None From 56e7b8ddbbccb54c60a6534b35f97e8596676029 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Apr 2026 13:00:14 +0200 Subject: [PATCH 0519/1707] Improve ProxmoxVE permissions handling (#167370) --- homeassistant/components/proxmoxve/button.py | 26 ++++++++++++++----- homeassistant/components/proxmoxve/const.py | 1 + .../components/proxmoxve/strings.json | 2 +- tests/components/proxmoxve/__init__.py | 10 +++++-- tests/components/proxmoxve/test_button.py | 3 ++- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 833600d8ebddd6..3af7401c36ad26 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -28,14 +28,17 @@ from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity from .helpers import is_granted +NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power" + @dataclass(frozen=True, kw_only=True) class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): """Class to hold Proxmox node button description.""" press_action: Callable[[ProxmoxCoordinator, str], None] - permission: ProxmoxPermission = ProxmoxPermission.POWER + permission: ProxmoxPermission = ProxmoxPermission.SYSPOWER permission_raise: str = "no_permission_node_power" + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -44,7 +47,8 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = "no_permission_vm_lxc_power" + permission_raise: str = NO_PERM_VM_LXC_POWER + permission_target: str = "vms" @dataclass(frozen=True, kw_only=True) @@ -53,7 +57,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = "no_permission_vm_lxc_power" + permission_raise: str = NO_PERM_VM_LXC_POWER + permission_target: str = "vms" NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( @@ -76,6 +81,9 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ProxmoxNodeButtonNodeEntityDescription( key="start_all", translation_key="start_all", + permission=ProxmoxPermission.POWER, + permission_raise=NO_PERM_VM_LXC_POWER, + permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node ).startall.post(), @@ -84,6 +92,9 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ProxmoxNodeButtonNodeEntityDescription( key="stop_all", translation_key="stop_all", + permission=ProxmoxPermission.POWER, + permission_raise=NO_PERM_VM_LXC_POWER, + permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node ).stopall.post(), @@ -92,6 +103,9 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ProxmoxNodeButtonNodeEntityDescription( key="suspend_all", translation_key="suspend_all", + permission=ProxmoxPermission.POWER, + permission_raise=NO_PERM_VM_LXC_POWER, + permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node ).suspendall.post(), @@ -327,7 +341,7 @@ async def _async_press_call(self) -> None: node_id = self._node_data.node["node"] if not is_granted( self.coordinator.permissions, - p_type="nodes", + p_type=self.entity_description.permission_target, p_id=node_id, permission=self.entity_description.permission, ): @@ -352,7 +366,7 @@ async def _async_press_call(self) -> None: vmid = self.vm_data["vmid"] if not is_granted( self.coordinator.permissions, - p_type="vms", + p_type=self.entity_description.permission_target, p_id=vmid, permission=self.entity_description.permission, ): @@ -379,7 +393,7 @@ async def _async_press_call(self) -> None: # Container power actions fall under vms if not is_granted( self.coordinator.permissions, - p_type="vms", + p_type=self.entity_description.permission_target, p_id=vmid, permission=self.entity_description.permission, ): diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index babedc499a794d..cd7bd7db54b6b8 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -41,3 +41,4 @@ class ProxmoxPermission(StrEnum): POWER = "VM.PowerMgmt" SNAPSHOT = "VM.Snapshot" + SYSPOWER = "Sys.PowerMgmt" diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index cc5571c168adf5..56c9a79781a632 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -313,7 +313,7 @@ "message": "No active nodes were found on the Proxmox VE server." }, "no_permission_node_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'VM.PowerMgmt' permission and try again." + "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'Sys.PowerMgmt' permission and try again." }, "no_permission_snapshot": { "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again." diff --git a/tests/components/proxmoxve/__init__.py b/tests/components/proxmoxve/__init__.py index e8c596b77dc375..1cf65ea7874689 100644 --- a/tests/components/proxmoxve/__init__.py +++ b/tests/components/proxmoxve/__init__.py @@ -25,8 +25,14 @@ } POWER_PERMISSIONS = { - "/": {"VM.PowerMgmt": 1}, - "/nodes": {"VM.PowerMgmt": 1}, + "/": { + "Sys.PowerMgmt": 1, + "VM.PowerMgmt": 1, + }, + "/nodes": { + "Sys.PowerMgmt": 1, + "VM.PowerMgmt": 1, + }, "/vms": {"VM.PowerMgmt": 1}, "/vms/101": {"VM.PowerMgmt": 0}, } diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index cd0de10923fd41..60c0bbf89c58ff 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -367,7 +367,8 @@ async def test_container_buttons_exceptions( @pytest.mark.parametrize( ("entity_id", "translation_key"), [ - ("button.pve1_start_all", "no_permission_node_power"), + ("button.pve1_shut_down", "no_permission_node_power"), + ("button.pve1_start_all", "no_permission_vm_lxc_power"), ("button.ct_nginx_start", "no_permission_vm_lxc_power"), ("button.vm_web_start", "no_permission_vm_lxc_power"), ("button.vm_web_create_snapshot", "no_permission_snapshot"), From d2539ccaf2db801261ab370cf343990e8d60c93c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Apr 2026 13:02:03 +0200 Subject: [PATCH 0520/1707] Add resume button to Proxmox qemu (#167329) --- homeassistant/components/proxmoxve/button.py | 8 ++ homeassistant/components/proxmoxve/icons.json | 3 + .../components/proxmoxve/strings.json | 3 + .../proxmoxve/snapshots/test_button.ambr | 100 ++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 3af7401c36ad26..8098152fbb5a7d 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -146,6 +146,14 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ), entity_category=EntityCategory.CONFIG, ), + ProxmoxVMButtonEntityDescription( + key="resume", + translation_key="resume", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).qemu(vmid).status.resume.post() + ), + entity_category=EntityCategory.CONFIG, + ), ProxmoxVMButtonEntityDescription( key="reset", translation_key="reset", diff --git a/homeassistant/components/proxmoxve/icons.json b/homeassistant/components/proxmoxve/icons.json index 9f1a83a98cc3b0..7c60fef838f6f0 100644 --- a/homeassistant/components/proxmoxve/icons.json +++ b/homeassistant/components/proxmoxve/icons.json @@ -27,6 +27,9 @@ "reset": { "default": "mdi:restart" }, + "resume": { + "default": "mdi:play" + }, "shutdown": { "default": "mdi:power" }, diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 56c9a79781a632..a88c366f1fd323 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -140,6 +140,9 @@ "reset": { "name": "Reset" }, + "resume_all": { + "name": "Resume" + }, "shutdown": { "name": "Shut down" }, diff --git a/tests/components/proxmoxve/snapshots/test_button.ambr b/tests/components/proxmoxve/snapshots/test_button.ambr index 14a3745a56fb2b..d34ea9ff382c5d 100644 --- a/tests/components/proxmoxve/snapshots/test_button.ambr +++ b/tests/components/proxmoxve/snapshots/test_button.ambr @@ -652,6 +652,56 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities[button.vm_db-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vm_db', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'proxmoxve', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'resume', + 'unique_id': '1234_101_resume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities[button.vm_db-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'vm-db', + }), + 'context': , + 'entity_id': 'button.vm_db', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities[button.vm_db_create_snapshot-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1003,6 +1053,56 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities[button.vm_web-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vm_web', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'proxmoxve', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'resume', + 'unique_id': '1234_100_resume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities[button.vm_web-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'vm-web', + }), + 'context': , + 'entity_id': 'button.vm_web', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities[button.vm_web_create_snapshot-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From ca40d684171952fd3706a06af0ba864c0da04170 Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Mon, 6 Apr 2026 13:29:54 +0100 Subject: [PATCH 0521/1707] Bump pynintendoparental to 2.3.4 (#167510) --- .../components/nintendo_parental_controls/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nintendo_parental_controls/manifest.json b/homeassistant/components/nintendo_parental_controls/manifest.json index 53fb013cf6492e..fd1fe831b68740 100644 --- a/homeassistant/components/nintendo_parental_controls/manifest.json +++ b/homeassistant/components/nintendo_parental_controls/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pynintendoauth", "pynintendoparental"], "quality_scale": "bronze", - "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.3"] + "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index ed7dbd44ff016a..c1ac9c7828cbdb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2317,7 +2317,7 @@ pynina==1.0.2 pynintendoauth==1.0.2 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.3.3 +pynintendoparental==2.3.4 # homeassistant.components.nobo_hub pynobo==1.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbfcc7a962c555..28605ce1cd780b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ pynina==1.0.2 pynintendoauth==1.0.2 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.3.3 +pynintendoparental==2.3.4 # homeassistant.components.nobo_hub pynobo==1.8.1 From 8986477c96fb6a0e557efd7b094c8d0a53de8a52 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 14:31:25 +0200 Subject: [PATCH 0522/1707] Replace "custom" with "community" in `homeassistant` (#167507) --- homeassistant/components/homeassistant/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index dd69ab6b4a6d33..36478b7cc589b5 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -148,7 +148,7 @@ }, "step": { "init": { - "description": "The integration `{domain}` could not be found. This happens when a (custom) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.", + "description": "The integration `{domain}` could not be found. This happens when a (community) integration was removed from Home Assistant, but there are still configurations for this `integration`. Please use the buttons below to either remove the previous configurations for `{domain}` or ignore this.", "menu_options": { "confirm": "Remove previous configurations", "ignore": "Ignore" @@ -236,7 +236,7 @@ "description": "Restarts Home Assistant.", "fields": { "safe_mode": { - "description": "Disable custom integrations and custom cards.", + "description": "Disable community integrations and community cards.", "name": "Safe mode" } }, From 73fbc87639a6beca5588acfd862afe110473282c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 14:35:47 +0200 Subject: [PATCH 0523/1707] Replace 'custom component' with 'community integration' in `bmw_connected_drive` (#167505) --- homeassistant/components/bmw_connected_drive/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 7ff1b1eb99c13b..2dacdadc36c6c9 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -1,7 +1,7 @@ { "issues": { "integration_removed": { - "description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).", + "description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a [community integration]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).", "title": "The BMW Connected Drive integration has been removed" } } From 9e1c521fede69af004307cbd7ba8aa3fedcaf1e3 Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Mon, 6 Apr 2026 09:09:18 -0400 Subject: [PATCH 0524/1707] Casper Glow: add dimming end time (#166769) --- .../components/casper_glow/quality_scale.yaml | 14 +- .../components/casper_glow/sensor.py | 75 +++++- .../components/casper_glow/strings.json | 5 + .../casper_glow/snapshots/test_sensor.ambr | 51 +++++ tests/components/casper_glow/test_sensor.py | 216 +++++++++++++++++- 5 files changed, 355 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index 45dec1b1cc3aae..5e2053ed86f56d 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -51,18 +51,24 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: + status: exempt + comment: Each config entry represents a single device. entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: + status: exempt + comment: No user-configurable settings in the configuration flow. repair-issues: status: exempt comment: Integration does not register repair issues. - stale-devices: todo + stale-devices: + status: exempt + comment: Each config entry represents a single device. # Platinum async-dependency: done diff --git a/homeassistant/components/casper_glow/sensor.py b/homeassistant/components/casper_glow/sensor.py index 8ecc26dad84c72..820fcc4b3fff2c 100644 --- a/homeassistant/components/casper_glow/sensor.py +++ b/homeassistant/components/casper_glow/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import datetime, timedelta + from pycasperglow import GlowState from homeassistant.components.sensor import ( @@ -13,6 +15,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow +from homeassistant.util.variance import ignore_variance from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator from .entity import CasperGlowEntity @@ -26,7 +30,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform for Casper Glow.""" - async_add_entities([CasperGlowBatterySensor(entry.runtime_data)]) + async_add_entities( + [ + CasperGlowBatterySensor(entry.runtime_data), + CasperGlowDimmingEndTimeSensor(entry.runtime_data), + ] + ) class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity): @@ -59,3 +68,67 @@ def _async_handle_state_update(self, state: GlowState) -> None: if new_value != self._attr_native_value: self._attr_native_value = new_value self.async_write_ha_state() + + +class CasperGlowDimmingEndTimeSensor(CasperGlowEntity, SensorEntity): + """Sensor entity for Casper Glow dimming end time.""" + + _attr_translation_key = "dimming_end_time" + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_entity_registry_enabled_default = False + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the dimming end time sensor.""" + super().__init__(coordinator) + self._attr_unique_id = ( + f"{format_mac(coordinator.device.address)}_dimming_end_time" + ) + self._is_paused = False + self._projected_end_time = ignore_variance( + self._calculate_end_time, + timedelta(minutes=1, seconds=30), + ) + self._update_from_state(coordinator.device.state) + + @staticmethod + def _calculate_end_time(remaining_ms: int) -> datetime: + """Calculate projected dimming end time from remaining milliseconds.""" + return utcnow() + timedelta(milliseconds=remaining_ms) + + async def async_added_to_hass(self) -> None: + """Register state update callback when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + def _reset_projected_end_time(self) -> None: + """Clear the projected end time and reset the variance filter.""" + self._attr_native_value = None + self._projected_end_time = ignore_variance( + self._calculate_end_time, + timedelta(minutes=1, seconds=30), + ) + + @callback + def _update_from_state(self, state: GlowState) -> None: + """Update entity attributes from device state.""" + if state.is_paused is not None: + self._is_paused = state.is_paused + + if self._is_paused: + self._reset_projected_end_time() + return + + remaining_ms = state.dimming_time_remaining_ms + if not remaining_ms: + if remaining_ms == 0 or state.is_on is False: + self._reset_projected_end_time() + return + self._attr_native_value = self._projected_end_time(remaining_ms) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + self._update_from_state(state) + self.async_write_ha_state() diff --git a/homeassistant/components/casper_glow/strings.json b/homeassistant/components/casper_glow/strings.json index 27a25b6ed4f5cb..afe72eb0c01deb 100644 --- a/homeassistant/components/casper_glow/strings.json +++ b/homeassistant/components/casper_glow/strings.json @@ -44,6 +44,11 @@ "dimming_time": { "name": "Dimming time" } + }, + "sensor": { + "dimming_end_time": { + "name": "Dimming end time" + } } }, "exceptions": { diff --git a/tests/components/casper_glow/snapshots/test_sensor.ambr b/tests/components/casper_glow/snapshots/test_sensor.ambr index 63da94d743cee8..a531e9b75779f4 100644 --- a/tests/components/casper_glow/snapshots/test_sensor.ambr +++ b/tests/components/casper_glow/snapshots/test_sensor.ambr @@ -54,3 +54,54 @@ 'state': 'unknown', }) # --- +# name: test_entities[sensor.jar_dimming_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.jar_dimming_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dimming end time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dimming end time', + 'platform': 'casper_glow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dimming_end_time', + 'unique_id': 'aa:bb:cc:dd:ee:ff_dimming_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.jar_dimming_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Jar Dimming end time', + }), + 'context': , + 'entity_id': 'sensor.jar_dimming_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/casper_glow/test_sensor.py b/tests/components/casper_glow/test_sensor.py index c6eb68512a96fb..18ea0c56133753 100644 --- a/tests/components/casper_glow/test_sensor.py +++ b/tests/components/casper_glow/test_sensor.py @@ -7,7 +7,8 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -16,8 +17,10 @@ from tests.common import MockConfigEntry, snapshot_platform BATTERY_ENTITY_ID = "sensor.jar_battery" +DIMMING_END_TIME_ENTITY_ID = "sensor.jar_dimming_end_time" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entities( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -70,3 +73,214 @@ async def test_battery_state_updated_via_callback( state = hass.states.get(BATTERY_ENTITY_ID) assert state is not None assert state.state == "50" + + +async def test_dimming_end_time_disabled_by_default( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that dimming end time sensor is disabled by default.""" + entry = entity_registry.async_get(DIMMING_END_TIME_ENTITY_ID) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_dimming_end_time_when_enabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test dimming end time sensor reports a future timestamp when enabled.""" + mock_casper_glow.state = GlowState(dimming_time_remaining_ms=2_520_000) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + assert state.attributes["device_class"] == SensorDeviceClass.TIMESTAMP + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_dimming_end_time_unknown_when_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], Awaitable[None]], +) -> None: + """Test dimming end time sensor resets to unknown when device turns off.""" + mock_casper_glow.state = GlowState(dimming_time_remaining_ms=900_000) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + + # Device turns off — sensor should reset to unknown + await fire_callbacks(GlowState(is_on=False, dimming_time_remaining_ms=0)) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_dimming_end_time_unknown_when_off_partial_callback( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], Awaitable[None]], +) -> None: + """Test dimming end time resets to unknown on an off-only partial callback.""" + mock_casper_glow.state = GlowState(dimming_time_remaining_ms=900_000) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + + # Partial callback: only is_on=False, no remaining_ms + await fire_callbacks(GlowState(is_on=False)) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_dimming_end_time_unknown_when_paused( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], Awaitable[None]], +) -> None: + """Test dimming end time sensor resets to unknown when dimming is paused.""" + mock_casper_glow.state = GlowState(dimming_time_remaining_ms=900_000) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + + # Dimming paused — remaining_ms stays constant but end time is meaningless + await fire_callbacks(GlowState(is_paused=True, dimming_time_remaining_ms=900_000)) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + # Dimming resumed — end time should be projected again + await fire_callbacks(GlowState(is_paused=False, dimming_time_remaining_ms=900_000)) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_dimming_end_time_unknown_when_paused_partial_callback( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], Awaitable[None]], +) -> None: + """Test dimming end time resets to unknown on a pause-only partial callback.""" + mock_casper_glow.state = GlowState(dimming_time_remaining_ms=900_000) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + + # Partial callback: only is_paused, no remaining_ms + await fire_callbacks(GlowState(is_paused=True)) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_dimming_end_time_variance_reset_on_resume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], Awaitable[None]], +) -> None: + """Test that pause/resume resets the variance filter for a fresh timestamp.""" + mock_casper_glow.state = GlowState(dimming_time_remaining_ms=900_000) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + first_value = state.state + + # Pause, then resume with 10 seconds less remaining. Without the + # variance reset, ignore_variance would return the cached pre-pause + # value since 10 seconds is within the 90-second deadband. + await fire_callbacks(GlowState(is_paused=True, dimming_time_remaining_ms=900_000)) + await fire_callbacks(GlowState(is_paused=False, dimming_time_remaining_ms=890_000)) + + state = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + assert state.state != first_value + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_dimming_end_time_jitter_suppression( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], Awaitable[None]], +) -> None: + """Test that small BLE jitter does not change the projected end time.""" + mock_casper_glow.state = GlowState(dimming_time_remaining_ms=900_000) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state1 = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state1 is not None + first_value = state1.state + + # Simulate a small jitter — 10 seconds less (within 90-second deadband) + await fire_callbacks(GlowState(dimming_time_remaining_ms=890_000)) + + state2 = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state2 is not None + assert state2.state == first_value + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_dimming_end_time_updates_on_significant_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], Awaitable[None]], +) -> None: + """Test that a large change in remaining time updates the projected end time.""" + mock_casper_glow.state = GlowState(dimming_time_remaining_ms=900_000) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state1 = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state1 is not None + first_value = state1.state + + # Simulate a significant change — 10 minutes less (outside 90-second deadband) + await fire_callbacks(GlowState(dimming_time_remaining_ms=300_000)) + + state2 = hass.states.get(DIMMING_END_TIME_ENTITY_ID) + assert state2 is not None + assert state2.state != first_value From e2688f909b8038339530047eb1074edf0f6be20c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 15:39:35 +0200 Subject: [PATCH 0525/1707] Update "custom component" to "community integration" in Shelly (#167515) --- homeassistant/components/shelly/__init__.py | 14 +++++++------- tests/components/shelly/test_init.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 2120f5e50e6328..f8e72a4ffe9dbd 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -128,16 +128,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bo """Set up Shelly from a config entry.""" entry.runtime_data = ShellyEntryData([]) - # The custom component for Shelly devices uses shelly domain as well as core - # integration. If the user removes the custom component but doesn't remove the - # config entry, core integration will try to configure that config entry with an - # error. The config entry data for this custom component doesn't contain host - # value, so if host isn't present, config entry will not be configured. + # The community integration for Shelly devices uses Shelly domain as well as Core + # integration. If the user removes the community integration but doesn't remove + # the config entry, Core integration will try to configure that config entry with + # an error. The config entry data for this community integration doesn't contain + # host value, so if host isn't present, config entry will not be configured. if not entry.data.get(CONF_HOST): LOGGER.warning( ( - "The config entry %s probably comes from a custom integration, please" - " remove it if you want to use core Shelly integration" + "The config entry %s probably comes from a community integration, " + "please remove it if you want to use the Core Shelly integration" ), entry.title, ) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 70f005e5934a17..f0adfe33439c79 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -149,7 +149,7 @@ async def test_setup_entry_not_shelly( ) -> None: """Test not Shelly entry.""" await init_integration(hass, 1, data={}) - assert "probably comes from a custom integration" in caplog.text + assert "probably comes from a community integration" in caplog.text @pytest.mark.parametrize("gen", [1, 2, 3]) From 4ed9113e3501ef55ee037c68213777fcd307d116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 6 Apr 2026 16:57:02 +0200 Subject: [PATCH 0526/1707] Bump python-roborock from 5.3.0 to 5.5.1 (#167520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 3efdfb0b7e6ad6..49f08024092018 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==5.3.0", + "python-roborock==5.5.1", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/requirements_all.txt b/requirements_all.txt index c1ac9c7828cbdb..4865ddd903e1e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2663,7 +2663,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==5.3.0 +python-roborock==5.5.1 # homeassistant.components.smarttub python-smarttub==0.0.47 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28605ce1cd780b..99c1d30474651e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2262,7 +2262,7 @@ python-qube-heatpump==1.8.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==5.3.0 +python-roborock==5.5.1 # homeassistant.components.smarttub python-smarttub==0.0.47 From 49f5557947e7055030c719bf0828c321f0a59dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 6 Apr 2026 17:13:44 +0200 Subject: [PATCH 0527/1707] Add sensor entities for Roborock q10 s5+ (#166120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- .../components/roborock/coordinator.py | 5 +- homeassistant/components/roborock/icons.json | 15 + homeassistant/components/roborock/sensor.py | 142 ++++ .../components/roborock/strings.json | 28 + tests/components/roborock/mock_data.py | 6 +- .../roborock/snapshots/test_sensor.ambr | 694 ++++++++++++++++++ tests/components/roborock/test_init.py | 4 +- tests/components/roborock/test_vacuum.py | 6 +- 8 files changed, 892 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 146ba9653651ea..645cbbea0c39ac 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -608,8 +608,9 @@ def __init__( async def _async_update_data(self) -> None: """Request a status push from the device. - This sends a fire-and-forget REQUEST_DPS command. The actual data - update will arrive asynchronously via the push listener. + This coordinator does not wait for any specific MQTT payload because + push messages are asynchronous and not guaranteed to contain every + field. Entities subscribe to trait updates and update as values arrive. """ try: await self.api.refresh() diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index f6053090bb7a97..e5c1c6e208184d 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -40,6 +40,9 @@ } }, "sensor": { + "brush_remaining": { + "default": "mdi:brush" + }, "clean_percent": { "default": "mdi:progress-check" }, @@ -49,6 +52,9 @@ "cleaning_brush_time_left": { "default": "mdi:brush" }, + "cleaning_time": { + "default": "mdi:clock-outline" + }, "countdown": { "default": "mdi:clock-outline" }, @@ -67,6 +73,12 @@ "main_brush_time_left": { "default": "mdi:brush" }, + "mop_drying_remaining_time": { + "default": "mdi:clock-outline" + }, + "mop_life_time_left": { + "default": "mdi:texture" + }, "sensor_time_left": { "default": "mdi:eye-outline" }, @@ -79,6 +91,9 @@ "strainer_time_left": { "default": "mdi:filter-variant" }, + "times_after_clean": { + "default": "mdi:counter" + }, "total_cleaning_area": { "default": "mdi:texture-box" }, diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 48f5407f86cee1..467aa47bcb1210 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -19,6 +19,8 @@ ZeoError, ZeoState, ) +from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceState +from roborock.devices.traits.b01.q10.status import StatusTrait as Q10StatusTrait from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from homeassistant.components.sensor import ( @@ -34,6 +36,7 @@ from .coordinator import ( RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, @@ -43,6 +46,7 @@ from .entity import ( RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01Q7, + RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1, RoborockEntity, ) @@ -77,6 +81,13 @@ class RoborockSensorDescriptionB01(SensorEntityDescription): value_fn: Callable[[B01Props], StateType] +@dataclass(frozen=True, kw_only=True) +class RoborockSensorDescriptionQ10(SensorEntityDescription): + """A class that describes Roborock Q10 sensors.""" + + value_fn: Callable[[Q10StatusTrait], StateType] + + def _dock_error_value_fn(state: DeviceState) -> str | None: if ( status := state.status.dock_error_status @@ -412,6 +423,105 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: ] +Q10_B01_SENSOR_DESCRIPTIONS = [ + RoborockSensorDescriptionQ10( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.status.value if data.status is not None else None, + entity_category=EntityCategory.DIAGNOSTIC, + options=YXDeviceState.keys(), + ), + RoborockSensorDescriptionQ10( + key="battery", + value_fn=lambda data: data.battery, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + RoborockSensorDescriptionQ10( + key="cleaning_time", + translation_key="cleaning_time", + value_fn=lambda data: data.clean_time, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="cleaning_area", + translation_key="cleaning_area", + value_fn=lambda data: data.clean_area, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, + ), + RoborockSensorDescriptionQ10( + key="total_cleaning_count", + translation_key="total_cleaning_count", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.total_clean_count, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionQ10( + key="total_cleaning_area", + translation_key="total_cleaning_area", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.total_clean_area, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, + ), + RoborockSensorDescriptionQ10( + key="total_cleaning_time", + translation_key="total_cleaning_time", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.total_clean_time, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="main_brush_life", + translation_key="main_brush_life", + value_fn=lambda data: data.main_brush_life, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="side_brush_life", + translation_key="side_brush_life", + value_fn=lambda data: data.side_brush_life, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="filter_life", + translation_key="filter_life", + value_fn=lambda data: data.filter_life, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="sensor_life", + translation_key="sensor_life", + value_fn=lambda data: data.sensor_life, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="clean_percent", + translation_key="clean_percent", + value_fn=lambda data: data.cleaning_progress, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, @@ -460,6 +570,11 @@ async def async_setup_entry( for description in Q7_B01_SENSOR_DESCRIPTIONS if description.value_fn(coordinator.data) is not None ) + entities.extend( + RoborockSensorEntityB01Q10(coordinator, description) + for coordinator in coordinators.b01_q10 + for description in Q10_B01_SENSOR_DESCRIPTIONS + ) async_add_entities(entities) @@ -568,3 +683,30 @@ def __init__( def native_value(self) -> StateType: """Return the value reported by the sensor.""" return self.entity_description.value_fn(self.coordinator.data) + + +class RoborockSensorEntityB01Q10(RoborockCoordinatedEntityB01Q10, SensorEntity): + """Representation of a B01 Q10 Roborock sensor.""" + + entity_description: RoborockSensorDescriptionQ10 + + def __init__( + self, + coordinator: RoborockB01Q10UpdateCoordinator, + description: RoborockSensorDescriptionQ10, + ) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator) + + async def async_added_to_hass(self) -> None: + """Register trait listener for push-based status updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.api.status.add_update_listener(self.async_write_ha_state) + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.api.status) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index e3ba066f9ba9a2..d23bac00324857 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -368,6 +368,9 @@ "water_empty": "Water empty" } }, + "filter_life": { + "name": "Filter time used" + }, "filter_time_left": { "name": "Filter time left" }, @@ -377,6 +380,9 @@ "last_clean_start": { "name": "Last clean begin" }, + "main_brush_life": { + "name": "Main brush time used" + }, "main_brush_time_left": { "name": "Main brush time left" }, @@ -402,9 +408,15 @@ "waiting_for_orders": "Waiting for orders" } }, + "sensor_life": { + "name": "Sensor time used" + }, "sensor_time_left": { "name": "Sensor time left" }, + "side_brush_life": { + "name": "Side brush time used" + }, "side_brush_time_left": { "name": "Side brush time left" }, @@ -430,15 +442,23 @@ "locked": "Locked", "manual_mode": "Manual mode", "mapping": "Mapping", + "mopping": "Mopping", "paused": "[%key:common::state::paused%]", + "relocating": "Relocating", "remote_control_active": "Remote control active", "returning_home": "Returning home", + "saving_map": "Saving map", "segment_cleaning": "Segment cleaning", "shutting_down": "Shutting down", + "sleeping": "Sleeping", "spot_cleaning": "Spot cleaning", "starting": "Starting", + "sweep_and_mop": "Sweep and mop", + "sweeping": "Sweeping", + "transitioning": "Transitioning", "unknown": "Unknown", "updating": "Updating", + "waiting_to_charge": "Waiting to charge", "washing_the_mop": "Washing the mop", "zoned_cleaning": "Zoned cleaning" } @@ -461,10 +481,14 @@ "vacuum_error": { "name": "Vacuum error", "state": { + "audio_error": "Audio error", "battery_error": "Battery error", "bumper_stuck": "Bumper stuck", "cannot_cross_carpet": "Cannot cross carpet", "charging_error": "Charging error", + "check_clean_carouse": "Check the cleaning carousel", + "clean_carousel_exception": "Cleaning carousel error", + "clean_carousel_water_full": "Cleaning carousel water full", "clear_brush_exception": "Check that the water filter has been correctly installed", "clear_brush_exception_2": "Positioning button error", "clear_water_box_exception": "Clean water tank empty", @@ -476,6 +500,7 @@ "dirty_water_box_hoare": "Check the dirty water tank", "dock": "Dock not connected to power", "dock_locator_error": "Dock locator error", + "drain_water_exception": "Drain water exception", "fan_error": "Fan error", "filter_blocked": "Filter blocked", "filter_screen_exception": "Clean the dock water filter", @@ -491,6 +516,7 @@ "no_dustbin": "No dustbin", "nogo_zone_detected": "No-go zone detected", "none": "None", + "optical_flow_sensor_dirt": "Optical flow sensor dirty", "return_to_dock_fail": "Return to dock fail", "robot_on_carpet": "Robot on carpet", "robot_tilted": "Robot tilted", @@ -500,10 +526,12 @@ "sink_strainer_hoare": "Reinstall the water filter", "strainer_error": "Filter is wet or blocked", "temperature_protection": "Unit temperature protection", + "up_water_exception": "Water supply exception", "vertical_bumper_pressed": "Vertical bumper pressed", "vibrarise_jammed": "VibraRise jammed", "visual_sensor": "Camera error", "wall_sensor_dirty": "Wall sensor dirty", + "water_carriage_drop": "Water carriage dropped", "wheels_jammed": "Wheels jammed", "wheels_suspended": "Wheels suspended" } diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index f55c6dbec30f83..b62697fffc1d6f 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1568,11 +1568,15 @@ ) Q10_STATUS = Q10Status( - clean_time=120, + clean_time=1800, clean_area=15, battery=100, status=YXDeviceState.CHARGING, fan_level=YXFanLevel.BALANCED, water_level=YXWaterLevel.MEDIUM, clean_count=1, + main_brush_life=81, + side_brush_life=90, + filter_life=90, + sensor_life=28, ) diff --git a/tests/components/roborock/snapshots/test_sensor.ambr b/tests/components/roborock/snapshots/test_sensor.ambr index 0d9b0829ccaa67..541b94addd0d44 100644 --- a/tests/components/roborock/snapshots/test_sensor.ambr +++ b/tests/components/roborock/snapshots/test_sensor.ambr @@ -407,6 +407,700 @@ 'state': '3.55', }) # --- +# name: test_sensors[sensor.roborock_q10_s5_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_q10_duid', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Roborock Q10 S5+ Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cleaning area', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning area', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_area', + 'unique_id': 'cleaning_area_q10_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock Q10 S5+ Cleaning area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_cleaning_progress-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_cleaning_progress', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cleaning progress', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning progress', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'clean_percent', + 'unique_id': 'clean_percent_q10_duid', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_cleaning_progress-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock Q10 S5+ Cleaning progress', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_cleaning_progress', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cleaning time', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_time', + 'unique_id': 'cleaning_time_q10_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q10 S5+ Cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_filter_time_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_filter_time_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filter time used', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter time used', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_life', + 'unique_id': 'filter_life_q10_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_filter_time_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q10 S5+ Filter time used', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_filter_time_used', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_main_brush_time_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_main_brush_time_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Main brush time used', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main brush time used', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_life', + 'unique_id': 'main_brush_life_q10_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_main_brush_time_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q10 S5+ Main brush time used', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_main_brush_time_used', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '81', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_sensor_time_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_sensor_time_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensor time used', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sensor time used', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_life', + 'unique_id': 'sensor_life_q10_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_sensor_time_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q10 S5+ Sensor time used', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_sensor_time_used', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_side_brush_time_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_side_brush_time_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Side brush time used', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Side brush time used', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_life', + 'unique_id': 'side_brush_life_q10_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_side_brush_time_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q10 S5+ Side brush time used', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_side_brush_time_used', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unknown', + 'sleeping', + 'idle', + 'cleaning', + 'returning_home', + 'remote_control_active', + 'charging', + 'paused', + 'error', + 'updating', + 'emptying_the_bin', + 'mapping', + 'saving_map', + 'relocating', + 'sweeping', + 'mopping', + 'sweep_and_mop', + 'transitioning', + 'waiting_to_charge', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'status_q10_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Roborock Q10 S5+ Status', + 'options': list([ + 'unknown', + 'sleeping', + 'idle', + 'cleaning', + 'returning_home', + 'remote_control_active', + 'charging', + 'paused', + 'error', + 'updating', + 'emptying_the_bin', + 'mapping', + 'saving_map', + 'relocating', + 'sweeping', + 'mopping', + 'sweep_and_mop', + 'transitioning', + 'waiting_to_charge', + ]), + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_total_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_total_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total cleaning area', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning area', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_area', + 'unique_id': 'total_cleaning_area_q10_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_total_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock Q10 S5+ Total cleaning area', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_total_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_total_cleaning_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_total_cleaning_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total cleaning count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning count', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_count', + 'unique_id': 'total_cleaning_count_q10_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_total_cleaning_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Roborock Q10 S5+ Total cleaning count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_total_cleaning_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.roborock_q10_s5_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total cleaning time', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'total_cleaning_time_q10_duid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.roborock_q10_s5_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Roborock Q10 S5+ Total cleaning time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.roborock_q10_s5_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.roborock_q7_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index f4b766742351e3..b4cadaa9e88a26 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -566,8 +566,8 @@ async def test_zeo_device_fails_setup( "Roborock S7 2 Dock", "Dyad Pro", "Roborock Q7", + "Roborock Q10 S5+", # Zeo device is missing - # Q10 has no sensor entities } @@ -621,7 +621,7 @@ async def test_dyad_device_fails_setup( # Dyad device is missing "Zeo One", "Roborock Q7", - # Q10 has no sensor entities + "Roborock Q10 S5+", } diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 953c390b8e148c..80db9ae931b8e2 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -1012,14 +1012,14 @@ async def test_q10_push_status_update( assert fake_q10_vacuum.b01_q10_properties is not None api = fake_q10_vacuum.b01_q10_properties - # Verify initial state is "docked" (from Q10_STATUS fixture: CHARGING_STATE) + # Verify initial state is "docked" (from Q10_STATUS fixture: CHARGING) vacuum = hass.states.get(Q10_ENTITY_ID) assert vacuum assert vacuum.state == "docked" # Simulate the device pushing a status change via DPS data # (e.g. user started cleaning from the Roborock app) - api.status.update_from_dps({B01_Q10_DP.STATUS: 5}) # CLEANING_STATE + api.status.update_from_dps({B01_Q10_DP.STATUS: 5}) # CLEANING await hass.async_block_till_done() # Verify the entity state updated to "cleaning" @@ -1028,7 +1028,7 @@ async def test_q10_push_status_update( assert vacuum.state == "cleaning" # Simulate returning to dock - api.status.update_from_dps({B01_Q10_DP.STATUS: 6}) # TO_CHARGE_STATE + api.status.update_from_dps({B01_Q10_DP.STATUS: 6}) # RETURNING_HOME await hass.async_block_till_done() vacuum = hass.states.get(Q10_ENTITY_ID) From 4776b99f5fb644f97e217f3cb0a6fb256d535e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 6 Apr 2026 17:29:46 +0200 Subject: [PATCH 0528/1707] Bump pyTibber to 0.37.0 (#167283) --- homeassistant/components/tibber/__init__.py | 2 +- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tibber/conftest.py | 2 +- tests/components/tibber/test_init.py | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 40a882a5b04bae..0596a5a2dc07ca 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -55,7 +55,7 @@ async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber: time_zone=dt_util.get_default_time_zone(), ssl=ssl_util.get_default_context(), ) - self._client.set_access_token(access_token) + await self._client.set_access_token(access_token) return self._client diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 14f4f26a81bc14..ceda353e743a56 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.36.0"] + "requirements": ["pyTibber==0.37.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4865ddd903e1e1..0ad10f09b26e87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1925,7 +1925,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.36.0 +pyTibber==0.37.0 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99c1d30474651e..b643cb9b68ac7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1668,7 +1668,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.36.0 +pyTibber==0.37.0 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index 9aee6b097b4606..befc3b68c87943 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -183,7 +183,7 @@ def tibber_mock() -> AsyncGenerator[MagicMock]: tibber_mock.send_notification = AsyncMock() tibber_mock.rt_disconnect = AsyncMock() tibber_mock.get_homes = MagicMock(return_value=[]) - tibber_mock.set_access_token = MagicMock() + tibber_mock.set_access_token = AsyncMock() data_api_mock = MagicMock() data_api_mock.get_all_devices = AsyncMock(return_value={}) diff --git a/tests/components/tibber/test_init.py b/tests/components/tibber/test_init.py index 111ff50c0c36ba..8aab4cf7c4bd81 100644 --- a/tests/components/tibber/test_init.py +++ b/tests/components/tibber/test_init.py @@ -40,7 +40,7 @@ async def test_data_api_runtime_creates_client(hass: HomeAssistant) -> None: with patch("homeassistant.components.tibber.tibber.Tibber") as mock_client_cls: mock_client = MagicMock() - mock_client.set_access_token = MagicMock() + mock_client.set_access_token = AsyncMock() mock_client_cls.return_value = mock_client client = await runtime.async_get_client(hass) @@ -49,7 +49,7 @@ async def test_data_api_runtime_creates_client(hass: HomeAssistant) -> None: access_token="access-token", websession=ANY, time_zone=ANY, ssl=ANY ) session.async_ensure_token_valid.assert_awaited_once() - mock_client.set_access_token.assert_called_once_with("access-token") + mock_client.set_access_token.assert_awaited_once_with("access-token") assert client is mock_client mock_client.set_access_token.reset_mock() @@ -59,7 +59,7 @@ async def test_data_api_runtime_creates_client(hass: HomeAssistant) -> None: mock_client_cls.assert_called_once() session.async_ensure_token_valid.assert_awaited_once() - mock_client.set_access_token.assert_called_once_with("access-token") + mock_client.set_access_token.assert_awaited_once_with("access-token") assert cached_client is client From dfb61ee881c619c235deeb6a9502dd5c487f8dfc Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:38:31 +0300 Subject: [PATCH 0529/1707] Mark Huum documenation quality scale items done (#167413) --- homeassistant/components/huum/quality_scale.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huum/quality_scale.yaml b/homeassistant/components/huum/quality_scale.yaml index fec8eea47a1ea4..72fc2db3428b32 100644 --- a/homeassistant/components/huum/quality_scale.yaml +++ b/homeassistant/components/huum/quality_scale.yaml @@ -47,11 +47,11 @@ rules: discovery: todo discovery-update-info: todo docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: status: exempt From 6dfcc1c4f60505434c60cb432817c90fce351d88 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 6 Apr 2026 18:36:46 +0200 Subject: [PATCH 0530/1707] Always use quality scale shorthand rule notation (#167516) --- .../components/energyid/quality_scale.yaml | 81 +++++++------------ .../green_planet_energy/quality_scale.yaml | 6 +- .../components/hanna/quality_scale.yaml | 3 +- .../components/huawei_lte/quality_scale.yaml | 3 +- .../components/indevolt/quality_scale.yaml | 30 +++---- .../components/opower/quality_scale.yaml | 3 +- .../components/palazzetti/quality_scale.yaml | 3 +- .../route_b_smart_meter/quality_scale.yaml | 3 +- .../components/squeezebox/quality_scale.yaml | 3 +- .../components/switchbot/quality_scale.yaml | 4 +- .../components/victron_ble/quality_scale.yaml | 6 +- .../components/zimi/quality_scale.yaml | 3 +- script/hassfest/quality_scale.py | 2 +- 13 files changed, 50 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/energyid/quality_scale.yaml b/homeassistant/components/energyid/quality_scale.yaml index be2dd37d6fc57f..ff4ed64e2c9607 100644 --- a/homeassistant/components/energyid/quality_scale.yaml +++ b/homeassistant/components/energyid/quality_scale.yaml @@ -6,25 +6,17 @@ rules: appropriate-polling: status: exempt comment: The integration uses a push-based mechanism with a background sync task, not polling. - brands: - status: done - common-modules: - status: done - config-flow-test-coverage: - status: done - config-flow: - status: done - dependency-transparency: - status: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done docs-actions: status: exempt comment: The integration does not expose any custom service actions. - docs-high-level-description: - status: done - docs-installation-instructions: - status: done - docs-removal-instructions: - status: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: This integration does not create its own entities. @@ -34,40 +26,30 @@ rules: has-entity-name: status: exempt comment: This integration does not create its own entities. - runtime-data: - status: done - test-before-configure: - status: done - test-before-setup: - status: done - unique-config-entry: - status: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done # Silver action-exceptions: status: exempt comment: The integration does not expose any custom service actions. - config-entry-unloading: - status: done - docs-configuration-parameters: - status: done - docs-installation-parameters: - status: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: status: exempt comment: This integration does not create its own entities. - integration-owner: - status: done + integration-owner: done log-when-unavailable: status: done comment: The integration logs a single message when the EnergyID service is unavailable. parallel-updates: status: exempt comment: This integration does not create its own entities. - reauthentication-flow: - status: done - test-coverage: - status: done + reauthentication-flow: done + test-coverage: done # Gold devices: @@ -82,21 +64,15 @@ rules: discovery-update-info: status: exempt comment: No discovery mechanism is used. - docs-data-update: - status: done - docs-examples: - status: done - docs-known-limitations: - status: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: status: exempt comment: This is a service integration not tied to specific device models. - docs-supported-functions: - status: done - docs-troubleshooting: - status: done - docs-use-cases: - status: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: The integration creates a single device entry for the service connection. @@ -112,8 +88,7 @@ rules: entity-translations: status: exempt comment: This integration does not create its own entities. - exception-translations: - status: done + exception-translations: done icon-translations: status: exempt comment: This integration does not create its own entities. @@ -128,10 +103,8 @@ rules: comment: Creates a single service device entry tied to the config entry. # Platinum - async-dependency: - status: done - inject-websession: - status: done + async-dependency: done + inject-websession: done strict-typing: status: todo comment: Full strict typing compliance will be addressed in a future update. diff --git a/homeassistant/components/green_planet_energy/quality_scale.yaml b/homeassistant/components/green_planet_energy/quality_scale.yaml index 8ac2acdeed8384..acebf2736678d4 100644 --- a/homeassistant/components/green_planet_energy/quality_scale.yaml +++ b/homeassistant/components/green_planet_energy/quality_scale.yaml @@ -5,8 +5,7 @@ rules: comment: The integration registers no actions. appropriate-polling: done brands: done - common-modules: - status: done + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done @@ -47,8 +46,7 @@ rules: test-coverage: done # Gold - devices: - status: done + devices: done diagnostics: todo discovery-update-info: status: exempt diff --git a/homeassistant/components/hanna/quality_scale.yaml b/homeassistant/components/hanna/quality_scale.yaml index f4eb96842e6d67..405bd7a6c5276d 100644 --- a/homeassistant/components/hanna/quality_scale.yaml +++ b/homeassistant/components/hanna/quality_scale.yaml @@ -4,8 +4,7 @@ rules: status: exempt comment: | This integration doesn't add actions. - appropriate-polling: - status: done + appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done diff --git a/homeassistant/components/huawei_lte/quality_scale.yaml b/homeassistant/components/huawei_lte/quality_scale.yaml index 57fce90fdd6aa2..96b48a5827ead2 100644 --- a/homeassistant/components/huawei_lte/quality_scale.yaml +++ b/homeassistant/components/huawei_lte/quality_scale.yaml @@ -81,5 +81,4 @@ rules: inject-websession: status: exempt comment: Underlying huawei-lte-api does not use aiohttp or httpx, so this does not apply. - strict-typing: - status: done + strict-typing: done diff --git a/homeassistant/components/indevolt/quality_scale.yaml b/homeassistant/components/indevolt/quality_scale.yaml index 9e948fd93653ad..a532a7868ac3b2 100644 --- a/homeassistant/components/indevolt/quality_scale.yaml +++ b/homeassistant/components/indevolt/quality_scale.yaml @@ -52,20 +52,13 @@ rules: discovery: status: exempt comment: Integration does not support network discovery - docs-data-update: - status: todo - docs-examples: - status: todo - docs-known-limitations: - status: todo - docs-supported-devices: - status: todo - docs-supported-functions: - status: todo - docs-troubleshooting: - status: todo - docs-use-cases: - status: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo dynamic-devices: status: exempt comment: Integration represents a single device, not a hub with multiple devices @@ -73,10 +66,8 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: - status: todo - icon-translations: - status: todo + exception-translations: todo + icon-translations: todo reconfiguration-flow: done repair-issues: status: exempt @@ -88,5 +79,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: - status: todo + strict-typing: todo diff --git a/homeassistant/components/opower/quality_scale.yaml b/homeassistant/components/opower/quality_scale.yaml index c51fa99c8fff10..2df7bc5764dcb3 100644 --- a/homeassistant/components/opower/quality_scale.yaml +++ b/homeassistant/components/opower/quality_scale.yaml @@ -42,8 +42,7 @@ rules: test-coverage: done # Gold - devices: - status: done + devices: done diagnostics: done discovery-update-info: status: exempt diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index ff8461ad1938ac..d4ef278705cfe7 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -66,8 +66,7 @@ rules: entity-disabled-by-default: todo entity-translations: done exception-translations: done - icon-translations: - status: done + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/route_b_smart_meter/quality_scale.yaml b/homeassistant/components/route_b_smart_meter/quality_scale.yaml index f6123b6e4c916e..7e8f13e05a8840 100644 --- a/homeassistant/components/route_b_smart_meter/quality_scale.yaml +++ b/homeassistant/components/route_b_smart_meter/quality_scale.yaml @@ -4,8 +4,7 @@ rules: status: exempt comment: | The integration does not provide any additional actions. - appropriate-polling: - status: done + appropriate-polling: done brands: status: exempt comment: | diff --git a/homeassistant/components/squeezebox/quality_scale.yaml b/homeassistant/components/squeezebox/quality_scale.yaml index 0817aead782283..2df336aeca7440 100644 --- a/homeassistant/components/squeezebox/quality_scale.yaml +++ b/homeassistant/components/squeezebox/quality_scale.yaml @@ -22,8 +22,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: done + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/switchbot/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml index 5226016c527f1e..21b79d086b3eb8 100644 --- a/homeassistant/components/switchbot/quality_scale.yaml +++ b/homeassistant/components/switchbot/quality_scale.yaml @@ -39,9 +39,7 @@ rules: comment: | Once a cryptographic key is successfully obtained for SwitchBot devices, it will be granted perpetual validity with no expiration constraints. - test-coverage: - status: done - + test-coverage: done # Gold devices: done diagnostics: done diff --git a/homeassistant/components/victron_ble/quality_scale.yaml b/homeassistant/components/victron_ble/quality_scale.yaml index 5eedb4ea163a43..13853d1d1becbb 100644 --- a/homeassistant/components/victron_ble/quality_scale.yaml +++ b/homeassistant/components/victron_ble/quality_scale.yaml @@ -42,10 +42,8 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: todo - parallel-updates: - status: done - reauthentication-flow: - status: todo + parallel-updates: done + reauthentication-flow: todo test-coverage: done # Gold devices: done diff --git a/homeassistant/components/zimi/quality_scale.yaml b/homeassistant/components/zimi/quality_scale.yaml index 8b8b85c71f41d8..a031bfc48aa8f0 100644 --- a/homeassistant/components/zimi/quality_scale.yaml +++ b/homeassistant/components/zimi/quality_scale.yaml @@ -96,5 +96,4 @@ rules: status: exempt comment: | This integration does not use web sessions. - strict-typing: - status: todo + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index bd3288e75107d3..7ce31295a5af19 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2172,7 +2172,7 @@ class Rule: vol.Schema( { vol.Required("status"): vol.In(["todo", "done"]), - vol.Optional("comment"): str, + vol.Required("comment"): str, } ), vol.Schema( From a6c66a86eed7b085373962d29c04bd8a5baaa6b3 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Apr 2026 19:00:16 +0200 Subject: [PATCH 0531/1707] Tado add rate limit indicator (#164132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: abmantis Co-authored-by: Abílio Costa --- homeassistant/components/tado/config_flow.py | 18 ++++++++++++++++- homeassistant/components/tado/coordinator.py | 9 ++++++++- homeassistant/components/tado/diagnostics.py | 3 ++- homeassistant/components/tado/strings.json | 1 + tests/components/tado/conftest.py | 8 ++++++++ .../tado/snapshots/test_diagnostics.ambr | 5 +++++ tests/components/tado/test_config_flow.py | 20 ++++++++++++++++++- 7 files changed, 60 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index b161661f310838..a581a5b7647341 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -81,6 +81,15 @@ async def _wait_for_login() -> None: try: await self.hass.async_add_executor_job(self.tado.device_activation) except Exception as ex: + ratelimit = await self.hass.async_add_executor_job( + self.tado.rate_limit_info + ) + if ratelimit.get("remaining") == "0": + _LOGGER.error( + "Tado API rate limit reached while waiting for device activation: %s", + ex, + ) + raise TadoRateLimitExceeded from ex _LOGGER.exception("Error while waiting for device activation") raise CannotConnect from ex @@ -97,7 +106,10 @@ async def _wait_for_login() -> None: if self.login_task.done(): _LOGGER.debug("Login task is done, checking results") - if self.login_task.exception(): + ex = self.login_task.exception() + if isinstance(ex, TadoRateLimitExceeded): + return self.async_abort(reason="api_rate_limit_reached") + if ex: return self.async_show_progress_done(next_step_id="timeout") self.refresh_token = await self.hass.async_add_executor_job( self.tado.get_refresh_token @@ -209,3 +221,7 @@ async def async_step_init( class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" + + +class TadoRateLimitExceeded(HomeAssistantError): + """Error to indicate Tado API rate limit exceeded.""" diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 44d7bbfe3279d9..6c9c5fa3c02370 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -81,7 +81,6 @@ def fallback(self) -> str: async def _async_update_data(self) -> dict[str, dict]: """Fetch the (initial) latest data from Tado.""" - try: _LOGGER.debug("Preloading home data") tado_home_call = await self.hass.async_add_executor_job(self._tado.get_me) @@ -91,6 +90,10 @@ async def _async_update_data(self) -> dict[str, dict]: self._tado.get_devices ) except RequestException as err: + _LOGGER.debug("Checking rate limit") + ratelimit = self.get_rate_limit() + if ratelimit.get("remaining") == "0": + raise UpdateFailed(f"Tado API rate limit reached: {err}") from err raise UpdateFailed(f"Error during Tado setup: {err}") from err tado_home = tado_home_call["homes"][0] @@ -362,3 +365,7 @@ async def set_child_lock(self, device_id: str, enabled: bool) -> None: ) except RequestException as exc: raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc + + def get_rate_limit(self) -> dict[str, str]: + """Get the current rate limit status from Tado.""" + return self._tado.rate_limit_info() diff --git a/homeassistant/components/tado/diagnostics.py b/homeassistant/components/tado/diagnostics.py index fa85b30c11c01e..42e3138cbc3637 100644 --- a/homeassistant/components/tado/diagnostics.py +++ b/homeassistant/components/tado/diagnostics.py @@ -14,4 +14,5 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a Tado config entry.""" - return {"data": config_entry.runtime_data.data} + rate_limit = config_entry.runtime_data.get_rate_limit() + return {"data": config_entry.runtime_data.data, "rate_limit": rate_limit} diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 4d917f91f526f3..c9a931d8412872 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "api_rate_limit_reached": "Tado API rate limit reached. Please wait and try again later.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "could_not_authenticate": "Could not authenticate with Tado.", "no_homes": "There are no homes linked to this Tado account.", diff --git a/tests/components/tado/conftest.py b/tests/components/tado/conftest.py index 99f808ec2efe6f..dbed6b3f248573 100644 --- a/tests/components/tado/conftest.py +++ b/tests/components/tado/conftest.py @@ -27,6 +27,10 @@ def mock_tado_api() -> Generator[MagicMock]: client.device_activation_status.return_value = DeviceActivationStatus.COMPLETED client.get_me.return_value = load_json_object_fixture("me.json", DOMAIN) client.get_refresh_token.return_value = "refresh" + client.rate_limit_info.return_value = { + "limit": "1000", + "remaining": "999", + } yield client @@ -115,6 +119,10 @@ async def init_integration(hass: HomeAssistant): m.get( "https://my.tado.com/api/v2/homes/1/weather", text=await async_load_fixture(hass, weather_fixture, DOMAIN), + headers={ + "RateLimit-Policy": '"perday";q=20000;w=86400', + "RateLimit": '"perday";r=15211', + }, ) m.get( "https://my.tado.com/api/v2/homes/1/state", diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr index c09a36214f840d..2e2c4dd1023f71 100644 --- a/tests/components/tado/snapshots/test_diagnostics.ambr +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -111,5 +111,10 @@ }), }), }), + 'rate_limit': dict({ + 'per-day': '20000', + 'remaining': '15211', + 'window-seconds': '86400', + }), }) # --- diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 2fd8e6a04683e3..2614c9b6745175 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -189,11 +189,29 @@ async def test_wait_for_login_exception( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - # @joostlek: I think the timeout step is not rightfully named, but heck, it works + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE assert result["step_id"] == error +async def test_wait_for_login_rate_limit( + hass: HomeAssistant, + mock_tado_api: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a rate limit error in wait_for_login is handled properly.""" + mock_tado_api.device_activation.side_effect = TadoException("rate limited") + mock_tado_api.rate_limit_info.return_value = {"remaining": "0"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_rate_limit_reached" + assert "Tado API rate limit reached" in caplog.text + + async def test_options_flow( hass: HomeAssistant, mock_tado_api: MagicMock, From 2a4b8c88a8c8ca7934d6a1364e0ce39b433925ae Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 19:20:38 +0200 Subject: [PATCH 0532/1707] Improve Google Cast action naming consistency (#167152) --- homeassistant/components/cast/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 2f5a0e2875669d..fd8b4382e06fee 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -44,10 +44,10 @@ }, "services": { "show_lovelace_view": { - "description": "Shows a dashboard view on a Chromecast device.", + "description": "Shows a dashboard view on a Google Cast device.", "fields": { "dashboard_path": { - "description": "The URL path of the dashboard to show, defaults to lovelace if not specified.", + "description": "The URL path of the dashboard to show, defaults to `lovelace` if not specified.", "name": "Dashboard path" }, "entity_id": { @@ -59,7 +59,7 @@ "name": "View path" } }, - "name": "Show dashboard view" + "name": "Show dashboard view via Google Cast" } } } From e7bc593fa87bb4e9d51847acd018b5f1b908b0cb Mon Sep 17 00:00:00 2001 From: johnmph Date: Mon, 6 Apr 2026 19:49:28 +0200 Subject: [PATCH 0533/1707] Add support for hiding walls and rooms in map rendering (#162053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/roborock/__init__.py | 4 ++++ homeassistant/components/roborock/config_flow.py | 16 ++++++++++++++++ homeassistant/components/roborock/const.py | 3 +++ homeassistant/components/roborock/strings.json | 4 ++++ tests/components/roborock/test_config_flow.py | 10 +++++++++- 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index aa468570b0481b..8cffd29357d165 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -30,6 +30,8 @@ from .const import ( CONF_BASE_URL, CONF_SHOW_BACKGROUND, + CONF_SHOW_ROOMS, + CONF_SHOW_WALLS, CONF_USER_DATA, DEFAULT_DRAWABLES, DOMAIN, @@ -87,6 +89,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> if entry.options.get(DRAWABLES, {}).get(drawable, default_value) ], show_background=entry.options.get(CONF_SHOW_BACKGROUND, False), + show_rooms=entry.options.get(CONF_SHOW_ROOMS, True), + show_walls=entry.options.get(CONF_SHOW_WALLS, True), map_scale=MAP_SCALE, ), mqtt_session_unauthorized_hook=lambda: entry.async_start_reauth(hass), diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 3cf0848ca45734..4061564c3bda0e 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -42,6 +42,8 @@ CONF_ENTRY_CODE, CONF_REGION, CONF_SHOW_BACKGROUND, + CONF_SHOW_ROOMS, + CONF_SHOW_WALLS, CONF_USER_DATA, DEFAULT_DRAWABLES, DOMAIN, @@ -246,6 +248,8 @@ async def async_step_drawables( """Manage the map object drawable options.""" if user_input is not None: self.options[CONF_SHOW_BACKGROUND] = user_input.pop(CONF_SHOW_BACKGROUND) + self.options[CONF_SHOW_ROOMS] = user_input.pop(CONF_SHOW_ROOMS) + self.options[CONF_SHOW_WALLS] = user_input.pop(CONF_SHOW_WALLS) self.options.setdefault(DRAWABLES, {}).update(user_input) return self.async_create_entry(title="", data=self.options) data_schema = {} @@ -264,6 +268,18 @@ async def async_step_drawables( default=self.config_entry.options.get(CONF_SHOW_BACKGROUND, False), ) ] = bool + data_schema[ + vol.Required( + CONF_SHOW_ROOMS, + default=self.config_entry.options.get(CONF_SHOW_ROOMS, True), + ) + ] = bool + data_schema[ + vol.Required( + CONF_SHOW_WALLS, + default=self.config_entry.options.get(CONF_SHOW_WALLS, True), + ) + ] = bool return self.async_show_form( step_id=DRAWABLES, data_schema=vol.Schema(data_schema), diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 9393b58d6d935f..1ed0df695b8670 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -11,8 +11,11 @@ CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" CONF_SHOW_BACKGROUND = "show_background" +CONF_SHOW_WALLS = "show_walls" +CONF_SHOW_ROOMS = "show_rooms" CONF_REGION = "region" REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"] + # Option Flow steps DRAWABLES = "drawables" diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index d23bac00324857..2e688c64064eb0 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -714,6 +714,8 @@ "predicted_path": "Predicted path", "room_names": "Room names", "show_background": "Show background", + "show_rooms": "Show rooms", + "show_walls": "Show walls", "vacuum_position": "Vacuum position", "virtual_walls": "Virtual walls", "zones": "Zones" @@ -734,6 +736,8 @@ "predicted_path": "Show the predicted path on the map.", "room_names": "Show room names on the map.", "show_background": "Add a background to the map.", + "show_rooms": "Show the rooms on the map.", + "show_walls": "Show the walls on the map.", "vacuum_position": "Show the vacuum position on the map.", "virtual_walls": "Show virtual walls on the map.", "zones": "Show zones on the map." diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index ebc5ef762a072f..384b987083cec9 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -20,6 +20,8 @@ CONF_BASE_URL, CONF_ENTRY_CODE, CONF_REGION, + CONF_SHOW_ROOMS, + CONF_SHOW_WALLS, DOMAIN, DRAWABLES, ) @@ -229,12 +231,18 @@ async def test_options_flow_drawables( ) as mock_setup: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={Drawable.PREDICTED_PATH: True}, + user_input={ + Drawable.PREDICTED_PATH: True, + CONF_SHOW_ROOMS: True, + CONF_SHOW_WALLS: True, + }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert mock_roborock_entry.options[DRAWABLES][Drawable.PREDICTED_PATH] is True + assert mock_roborock_entry.options[CONF_SHOW_ROOMS] is True + assert mock_roborock_entry.options[CONF_SHOW_WALLS] is True assert len(mock_setup.mock_calls) == 1 From 9b2abb0acc2a0f8a1db46f18240cd13ea022577b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 6 Apr 2026 20:12:34 +0200 Subject: [PATCH 0534/1707] Move `program_running` to a property at Home Connect sensors (#167523) Co-authored-by: Martin Hjelmare --- .../components/home_connect/sensor.py | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 177c58b8982f01..a88ad6df746494 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -601,8 +601,6 @@ async def fetch_unit(self) -> None: class HomeConnectProgramSensor(HomeConnectSensor): """Sensor class for Home Connect sensors that reports information related to the running program.""" - program_running: bool = False - async def async_added_to_hass(self) -> None: """Register listener.""" await super().async_added_to_hass() @@ -616,18 +614,21 @@ async def async_added_to_hass(self) -> None: @callback def _handle_operation_state_event(self) -> None: """Update status when an event for the entity is received.""" - self.program_running = ( - status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) - ) is not None and status.value in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] if not self.program_running: # reset the value when the program is not running, paused or finished self._attr_native_value = None self.async_write_ha_state() + @property + def program_running(self) -> bool: + """Return whether a program is running, paused or finished.""" + status = self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + return status is not None and status.value in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + @property def available(self) -> bool: """Return true if the sensor is available.""" @@ -637,13 +638,6 @@ def available(self) -> bool: def update_native_value(self) -> None: """Update the program sensor's status.""" - self.program_running = ( - status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) - ) is not None and status.value in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] event = self.appliance.events.get(cast(EventKey, self.bsh_key)) if event: self._update_native_value(event.value) From 12abff5b9e110a3e528aa8e6c3cf28410ba623ac Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:14:08 -0400 Subject: [PATCH 0535/1707] Allow listing non-USB serial ports with `scan_serial_ports` (#166029) Co-authored-by: Paulus Schoutsen Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../homeassistant_sky_connect/__init__.py | 2 + homeassistant/components/usb/__init__.py | 18 +++-- homeassistant/components/usb/models.py | 14 +++- homeassistant/components/usb/utils.py | 46 ++++++++--- homeassistant/components/zha/config_flow.py | 38 +++++---- tests/components/usb/test_init.py | 78 ++++++++++++++++++- 6 files changed, 162 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 943892fc910018..a386a49894ad32 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -172,6 +172,8 @@ async def async_migrate_entry( f"USB device {device} is missing, cannot migrate" ) + assert isinstance(usb_info, USBDevice) + hass.config_entries.async_update_entry( config_entry, data={ diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index ec726bba460667..0ffba6bdb92058 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -32,7 +32,10 @@ from homeassistant.util.hass_dict import HassKey from .const import DOMAIN -from .models import USBDevice +from .models import ( + SerialDevice, # noqa: F401 + USBDevice, +) from .utils import ( scan_serial_ports, usb_device_from_path, # noqa: F401 @@ -425,11 +428,16 @@ def _async_delayed_add_remove_scan(self) -> None: async def _async_scan_serial(self) -> None: """Scan serial ports.""" - _LOGGER.debug("Executing comports scan") + _LOGGER.debug("Executing USB serial device scan") async with self._scan_lock: - await self._async_process_ports( - await self.hass.async_add_executor_job(scan_serial_ports) - ) + # Only consider USB-serial ports for discovery + usb_ports = [ + p + for p in await self.hass.async_add_executor_job(scan_serial_ports) + if isinstance(p, USBDevice) + ] + + await self._async_process_ports(usb_ports) if self.initial_scan_done: return diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index 11eccd9cd9b533..149b86627eae90 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -6,12 +6,18 @@ @dataclass(slots=True, frozen=True, kw_only=True) -class USBDevice: - """A usb device.""" +class SerialDevice: + """A serial device.""" device: str - vid: str - pid: str serial_number: str | None manufacturer: str | None description: str | None + + +@dataclass(slots=True, frozen=True, kw_only=True) +class USBDevice(SerialDevice): + """A usb device.""" + + vid: str + pid: str diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index 23248e19f58681..d9481ff8ba9594 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -13,11 +13,14 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.loader import USBMatcher -from .models import USBDevice +from .models import SerialDevice, USBDevice def usb_device_from_port(port: ListPortInfo) -> USBDevice: """Convert serial ListPortInfo to USBDevice.""" + assert port.vid is not None + assert port.pid is not None + return USBDevice( device=port.device, vid=f"{hex(port.vid)[2:]:0>4}".upper(), @@ -28,8 +31,28 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice: ) -def scan_serial_ports() -> Sequence[USBDevice]: - """Scan serial ports for USB devices.""" +def serial_device_from_port(port: ListPortInfo) -> SerialDevice: + """Convert serial ListPortInfo to SerialDevice.""" + return SerialDevice( + device=port.device, + serial_number=port.serial_number, + manufacturer=port.manufacturer, + description=port.description, + ) + + +def usb_serial_device_from_port(port: ListPortInfo) -> USBDevice | SerialDevice: + """Convert serial ListPortInfo to USBDevice or SerialDevice.""" + if port.vid is not None or port.pid is not None: + assert port.vid is not None + assert port.pid is not None + + return usb_device_from_port(port) + return serial_device_from_port(port) + + +def scan_serial_ports() -> Sequence[USBDevice | SerialDevice]: + """Scan serial ports and return USB and other serial devices.""" # Scan all symlinks first by_id = "/dev/serial/by-id" @@ -41,15 +64,14 @@ def scan_serial_ports() -> Sequence[USBDevice]: serial_ports = [] for port in comports(): - if port.vid is not None or port.pid is not None: - usb_device = usb_device_from_port(port) - device_path = realpath_to_by_id.get(port.device, port.device) + device = usb_serial_device_from_port(port) + device_path = realpath_to_by_id.get(port.device, port.device) - if device_path != port.device: - # Prefer the unique /dev/serial/by-id/ path if it exists - usb_device = dataclasses.replace(usb_device, device=device_path) + if device_path != port.device: + # Prefer the unique /dev/serial/by-id/ path if it exists + device = dataclasses.replace(device, device=device_path) - serial_ports.append(usb_device) + serial_ports.append(device) return serial_ports @@ -60,6 +82,10 @@ def usb_device_from_path(device_path: str) -> USBDevice | None: device_path_real = os.path.realpath(device_path) for device in scan_serial_ports(): + # Skip non-USB serial devices + if not isinstance(device, USBDevice): + continue + if os.path.realpath(device.device) == device_path_real: return device diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 54034fc6b13409..2342023540b4d0 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -26,7 +26,7 @@ ZigbeeFlowStrategy, ) from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware -from homeassistant.components.usb import USBDevice, scan_serial_ports +from homeassistant.components.usb import SerialDevice, USBDevice, scan_serial_ports from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_ZEROCONF, @@ -134,9 +134,27 @@ def _format_backup_choice( return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})" -async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]: +def _format_serial_port_choice( + serial_port: USBDevice | SerialDevice, resolved_paths: dict[str, str] +) -> str: + """Format a serial port selector entry into a line of text.""" + text = resolved_paths[serial_port.device] + + if serial_port.description: + text += f" - {serial_port.description}" + + if serial_port.serial_number: + text += f", s/n: {serial_port.serial_number}" + + if serial_port.manufacturer: + text += f" - {serial_port.manufacturer}" + + return text + + +async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice | SerialDevice]: """List all serial ports, including the Yellow radio and the multi-PAN addon.""" - ports: list[USBDevice] = [] + ports: list[USBDevice | SerialDevice] = [] ports.extend(await hass.async_add_executor_job(scan_serial_ports)) # Add useful info to the Yellow's serial port selection screen @@ -147,10 +165,8 @@ async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]: else: # PySerial does not properly handle the Yellow's serial port with the CM5 # so we manually include it - port = USBDevice( + port = SerialDevice( device="/dev/ttyAMA1", - vid="ffff", # This is technically not a USB device - pid="ffff", serial_number=None, manufacturer="Nabu Casa", description="Yellow Zigbee module", @@ -171,10 +187,8 @@ async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]: addon_info = None if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: - addon_port = USBDevice( + addon_port = SerialDevice( device=silabs_multiprotocol_addon.get_zigbee_socket(), - vid="ffff", # This is technically not a USB device - pid="ffff", serial_number=None, manufacturer="Nabu Casa", description="Silicon Labs Multiprotocol add-on", @@ -262,11 +276,7 @@ async def async_step_choose_serial_port( for p in ports } - list_of_ports = [ - f"{resolved_paths[p.device]} - {p.description}{', s/n: ' + p.serial_number if p.serial_number else ''}" - + (f" - {p.manufacturer}" if p.manufacturer else "") - for p in ports - ] + list_of_ports = [_format_serial_port_choice(p, resolved_paths) for p in ports] if not list_of_ports: return await self.async_step_manual_pick_radio_type() diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 0880577b571a93..6945c80ce07ebc 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -11,7 +11,7 @@ from homeassistant import config_entries from homeassistant.components import usb from homeassistant.components.usb import DOMAIN -from homeassistant.components.usb.models import USBDevice +from homeassistant.components.usb.models import SerialDevice, USBDevice from homeassistant.components.usb.utils import scan_serial_ports, usb_device_from_path from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -287,6 +287,54 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("force_usb_polling_watcher") +async def test_non_usb_ignored_by_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that polling ignores native serial ports.""" + new_usb = [ + {"domain": "everything_matches"}, + ] + + with ( + patch("sys.platform", "linux"), + patch( + "homeassistant.components.usb.POLLING_MONITOR_SCAN_PERIOD", + timedelta(seconds=0.01), + ), + patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), + patch_scanned_serial_ports( + return_value=[ + USBDevice( + device=slae_sh_device.device, + vid="3039", + pid="3039", + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ), + # Non-USB serial devices are skipped for now + SerialDevice( + device="/dev/ttyAMA1", + serial_number=None, + manufacturer=None, + description=None, + ), + ] + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + ): + assert await async_setup_component(hass, DOMAIN, {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + # Only one config flow should be started + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0].args[0] == "everything_matches" + assert mock_config_flow.mock_calls[0].kwargs["data"].device == slae_sh_device.device + + @pytest.mark.usefixtures("force_usb_polling_watcher") async def test_most_targeted_matcher_wins( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -1321,6 +1369,34 @@ def test_scan_serial_ports_without_unique_symlinks() -> None: assert devices[0].vid == "1234" +def test_scan_serial_ports_no_vid_pid() -> None: + """Test scan_serial_ports returns devices without VID:PID.""" + mock_port = MagicMock() + mock_port.device = "/dev/ttyAMA1" + mock_port.vid = None + mock_port.pid = None + mock_port.serial_number = None + mock_port.manufacturer = None + mock_port.description = None + + with ( + patch("os.path.isdir", return_value=False), + patch("os.path.realpath", side_effect=lambda x: x), + patch( + "homeassistant.components.usb.utils.comports", + return_value=[mock_port], + ), + ): + devices = scan_serial_ports() + + assert len(devices) == 1 + assert isinstance(devices[0], SerialDevice) + assert devices[0].device == "/dev/ttyAMA1" + assert devices[0].serial_number is None + assert devices[0].manufacturer is None + assert devices[0].description is None + + def test_usb_device_from_path_finds_by_symlink() -> None: """Test usb_device_from_path finds device by symlink path.""" scanned_device = USBDevice( From 9709fbd6c6125daa9bdc956ce813a2b1df36e8bb Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Apr 2026 22:36:09 +0200 Subject: [PATCH 0536/1707] Bump pyportainer 1.0.37 (#167535) Co-authored-by: Martin Hjelmare --- homeassistant/components/portainer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index 4640fb49673bd0..bd054dc9239760 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.36"] + "requirements": ["pyportainer==1.0.37"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0ad10f09b26e87..300be34ccabca4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2397,7 +2397,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.36 +pyportainer==1.0.37 # homeassistant.components.probe_plus pyprobeplus==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b643cb9b68ac7a..1ee800066f85e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2053,7 +2053,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.36 +pyportainer==1.0.37 # homeassistant.components.probe_plus pyprobeplus==1.1.2 From 54a63d2c3e47ef4da54c26dc20a8f0f068a0e1d6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 6 Apr 2026 22:37:38 +0200 Subject: [PATCH 0537/1707] Bump incomfort-client to v0.7.0 (#167546) --- homeassistant/components/incomfort/manifest.json | 2 +- homeassistant/components/incomfort/strings.json | 3 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index ad904f31c778ff..b87e82266cdaed 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["incomfortclient"], "quality_scale": "platinum", - "requirements": ["incomfort-client==0.6.12"] + "requirements": ["incomfort-client==0.7.0"] } diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 6b3ef1aa45ea31..8c331741a9954f 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -92,11 +92,13 @@ "central_heating": "Central heating", "central_heating_low": "Central heating low", "central_heating_rf": "Central heating rf", + "central_heating_wait": "Central heating waiting", "cv_temperature_too_high_e1": "Temperature too high", "flame_detection_fault_e6": "Flame detection fault", "frost": "Frost protection", "gas_valve_relay_faulty_e29": "Gas valve relay faulty", "gas_valve_relay_faulty_e30": "[%key:component::incomfort::entity::water_heater::boiler::state::gas_valve_relay_faulty_e29%]", + "hp_error_recovery": "Heat pump error recovery", "incorrect_fan_speed_e8": "Incorrect fan speed", "no_flame_signal_e4": "No flame signal", "off": "[%key:common::state::off%]", @@ -120,6 +122,7 @@ "service": "Service", "shortcut_outside_sensor_temperature_e27": "Shortcut outside temperature sensor", "standby": "[%key:common::state::standby%]", + "starting_ch": "Starting central heating", "tapwater": "Tap water", "tapwater_int": "Tap water internal", "unknown": "Unknown" diff --git a/requirements_all.txt b/requirements_all.txt index 300be34ccabca4..65c7fd103f5b89 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1310,7 +1310,7 @@ imeon_inverter_api==0.4.0 imgw_pib==2.0.2 # homeassistant.components.incomfort -incomfort-client==0.6.12 +incomfort-client==0.7.0 # homeassistant.components.indevolt indevolt-api==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ee800066f85e9..abd57c6a622501 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1162,7 +1162,7 @@ imeon_inverter_api==0.4.0 imgw_pib==2.0.2 # homeassistant.components.incomfort -incomfort-client==0.6.12 +incomfort-client==0.7.0 # homeassistant.components.indevolt indevolt-api==1.2.3 From 7f279158256d49b5794384771b2de41d9aefdc26 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:56:22 +0300 Subject: [PATCH 0538/1707] Bump victron-mqtt to 2026.4.1 (#167547) --- .../components/victron_gx/manifest.json | 2 +- .../components/victron_gx/strings.json | 122 ++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 125 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index 1dfc01475d8ba9..aced508123bc61 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["victron-mqtt==2026.4.0"], + "requirements": ["victron-mqtt==2026.4.1"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 093310b9d5fc05..3eb2923f405d09 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -47,6 +47,128 @@ } }, "entity": { + "binary_sensor": { + "alternator_mode": { + "name": "Mode" + }, + "dcdc_mode": { + "name": "Mode" + }, + "digitalinput_settings_invert_translation": { + "name": "Invert digital input" + }, + "evcharger_charge": { + "name": "EV charging" + }, + "evcharger_connected": { + "name": "Connected" + }, + "generator_autorun": { + "name": "Auto-start enabled" + }, + "generator_gen_id_quiet_hours_enabled": { + "name": "Generator quiet hours enabled" + }, + "generator_gen_id_start_on_soc_enabled": { + "name": "Generator start on SOC enabled" + }, + "generator_gen_id_start_on_temp_enabled": { + "name": "Generator start on high temp enabled" + }, + "generator_gen_id_start_on_voltage_enabled": { + "name": "Generator start on voltage enabled" + }, + "generator_manual_start": { + "name": "Manual start" + }, + "gps_connected": { + "name": "Connected" + }, + "gps_fix": { + "name": "Fix" + }, + "inverter_alarm_high_temperature": { + "name": "High temperature alarm" + }, + "inverter_alarm_high_voltage": { + "name": "High voltage alarm" + }, + "inverter_alarm_high_voltage_ac_out": { + "name": "High voltage AC-out alarm" + }, + "inverter_alarm_low_temperature": { + "name": "Low temperature alarm" + }, + "inverter_alarm_low_voltage": { + "name": "Low voltage alarm" + }, + "inverter_alarm_low_voltage_ac_out": { + "name": "Low voltage AC-out alarm" + }, + "inverter_alarm_overload": { + "name": "Overload alarm" + }, + "inverter_alarm_ripple": { + "name": "Ripple alarm" + }, + "multi_disable_charge": { + "name": "ESS disable charge" + }, + "multi_disable_feed_in": { + "name": "ESS disable feed-in" + }, + "multi_relay0_state": { + "name": "Relay on Multi RS state" + }, + "solarcharger_load_state": { + "name": "Load state" + }, + "solarcharger_mode": { + "name": "Mode" + }, + "solarcharger_relay_state": { + "name": "Relay state" + }, + "switch_output_state": { + "name": "Switch {output} state" + }, + "switchable_output_output_state": { + "name": "Switchable output {output} state" + }, + "system_dynamicess_active": { + "name": "Dynamic ESS active" + }, + "system_dynamicess_allow_gridfeedin": { + "name": "Dynamic ESS allow grid feed-in" + }, + "system_dynamicess_available": { + "name": "Dynamic ESS available" + }, + "system_ess_battery_use": { + "name": "ESS only critical loads from battery" + }, + "system_ess_schedule_charge_slot_enabled": { + "name": "ESS BatteryLife schedule charge {slot} enabled" + }, + "system_relay_relay": { + "name": "Relay {relay} state" + }, + "system_settings_overvoltage_feedin": { + "name": "PV DC overvoltage feed-in" + }, + "vebus_device_device_number_power_assist_enabled": { + "name": "{device_number} PowerAssist enabled" + }, + "vebus_inverter_connected": { + "name": "Connected" + }, + "vebus_inverter_ignoreacin1_onoff_control": { + "name": "Control ignore AC-in-1" + }, + "vebus_inverter_setting_alarm_grid_lost": { + "name": "Grid lost alarm setting" + } + }, "sensor": { "acload_current": { "name": "Load current" diff --git a/requirements_all.txt b/requirements_all.txt index 65c7fd103f5b89..5d60045d5f17f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3237,7 +3237,7 @@ viaggiatreno_ha==0.2.4 victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_gx -victron-mqtt==2026.4.0 +victron-mqtt==2026.4.1 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index abd57c6a622501..7dcbafe210f448 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2740,7 +2740,7 @@ venstarcolortouch==0.21 victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_gx -victron-mqtt==2026.4.0 +victron-mqtt==2026.4.1 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 From 43c83cc8506d0e6b7321fdadd647b3b088cb267b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 22:57:03 +0200 Subject: [PATCH 0539/1707] Improve Persistent Notification action naming consistency (#167531) --- .../components/persistent_notification/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/persistent_notification/strings.json b/homeassistant/components/persistent_notification/strings.json index e2271dd7bf6a8b..c6e98b48447fd0 100644 --- a/homeassistant/components/persistent_notification/strings.json +++ b/homeassistant/components/persistent_notification/strings.json @@ -1,7 +1,7 @@ { "services": { "create": { - "description": "Shows a notification on the notifications panel.", + "description": "Shows a persistent notification on the notifications panel.", "fields": { "message": { "description": "Message body of the notification.", @@ -16,21 +16,21 @@ "name": "Title" } }, - "name": "Create" + "name": "Create persistent notification" }, "dismiss": { - "description": "Deletes a notification from the notifications panel.", + "description": "Deletes a persistent notification from the notifications panel.", "fields": { "notification_id": { "description": "ID of the notification to be deleted.", "name": "[%key:component::persistent_notification::services::create::fields::notification_id::name%]" } }, - "name": "Dismiss" + "name": "Dismiss persistent notification" }, "dismiss_all": { - "description": "Deletes all notifications from the notifications panel.", - "name": "Dismiss all" + "description": "Deletes all persistent notifications from the notifications panel.", + "name": "Dismiss all persistent notifications" } }, "title": "Persistent Notification" From e1fa894572394dd23760bdc87f1573f326df0e75 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 6 Apr 2026 23:00:09 +0200 Subject: [PATCH 0540/1707] Improve Activity (`logbook`) action naming consistency (#167533) --- homeassistant/components/logbook/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json index d56cc2cfd69acb..e5d2ec3a822442 100644 --- a/homeassistant/components/logbook/strings.json +++ b/homeassistant/components/logbook/strings.json @@ -20,7 +20,7 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Log" + "name": "Log activity" } }, "title": "Activity" From 9d6a3351277cb284b8226cfa8a6df1801cf107d1 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Apr 2026 23:05:10 +0200 Subject: [PATCH 0541/1707] Refactor enums in Portainer (#167540) --- .../components/portainer/binary_sensor.py | 5 +-- homeassistant/components/portainer/const.py | 31 ------------------- .../components/portainer/coordinator.py | 6 ++-- homeassistant/components/portainer/sensor.py | 3 +- homeassistant/components/portainer/switch.py | 7 +++-- 5 files changed, 13 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index 787656b0268047..201b07c1c21e46 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -5,6 +5,8 @@ from collections.abc import Callable from dataclasses import dataclass +from pyportainer import DockerContainerState, EndpointStatus, StackStatus + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -15,7 +17,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry -from .const import ContainerState, EndpointStatus, StackStatus from .coordinator import PortainerContainerData from .entity import ( PortainerContainerEntity, @@ -53,7 +54,7 @@ class PortainerStackBinarySensorEntityDescription(BinarySensorEntityDescription) PortainerContainerBinarySensorEntityDescription( key="status", translation_key="status", - state_fn=lambda data: data.container.state == ContainerState.RUNNING, + state_fn=lambda data: data.container.state == DockerContainerState.RUNNING, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/portainer/const.py b/homeassistant/components/portainer/const.py index 356f9eb30d7b89..ae8e8ee98dffd7 100644 --- a/homeassistant/components/portainer/const.py +++ b/homeassistant/components/portainer/const.py @@ -1,37 +1,6 @@ """Constants for the Portainer integration.""" -from enum import IntEnum, StrEnum - DOMAIN = "portainer" DEFAULT_NAME = "Portainer" API_MAX_RETRIES = 3 - - -class EndpointStatus(IntEnum): - """Portainer endpoint status.""" - - UP = 1 - DOWN = 2 - - -class ContainerState(StrEnum): - """Portainer container state.""" - - RUNNING = "running" - PAUSED = "paused" - - -class StackStatus(IntEnum): - """Portainer stack status.""" - - ACTIVE = 1 - INACTIVE = 2 - - -class StackType(IntEnum): - """Portainer stack type.""" - - SWARM = 1 - COMPOSE = 2 - KUBERNETES = 3 diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index 84a8fb069ad594..f05cf4b53d267f 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -9,6 +9,8 @@ import logging from pyportainer import ( + DockerContainerState, + EndpointStatus, Portainer, PortainerAuthenticationError, PortainerConnectionError, @@ -29,7 +31,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, ContainerState, EndpointStatus +from .const import DOMAIN type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] @@ -217,7 +219,7 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: container for container in containers if container.state - in (ContainerState.RUNNING, ContainerState.PAUSED) + in (DockerContainerState.RUNNING, DockerContainerState.PAUSED) ] if running_containers: container_stats = dict( diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 503c6e1093ec56..8f5fdd2bdde16e 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -5,6 +5,8 @@ from collections.abc import Callable from dataclasses import dataclass +from pyportainer import StackType + from homeassistant.components.sensor import ( EntityCategory, SensorDeviceClass, @@ -17,7 +19,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import StackType from .coordinator import ( PortainerConfigEntry, PortainerContainerData, diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index 3d8a661845df19..2b162abe98ceac 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any -from pyportainer import Portainer +from pyportainer import DockerContainerState, Portainer, StackStatus from pyportainer.exceptions import ( PortainerAuthenticationError, PortainerConnectionError, @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry -from .const import DOMAIN, ContainerState, StackStatus +from .const import DOMAIN from .coordinator import ( PortainerContainerData, PortainerCoordinator, @@ -89,7 +89,8 @@ async def _perform_action( translation_key="container", device_class=SwitchDeviceClass.SWITCH, is_on_fn=lambda data: ( - data.container.state in (ContainerState.RUNNING, ContainerState.PAUSED) + data.container.state + in (DockerContainerState.RUNNING, DockerContainerState.PAUSED) ), turn_on_fn=lambda portainer: portainer.start_container, turn_off_fn=lambda portainer: portainer.stop_container, From 12ee952a97da4c83a315416fd4629143cadf63df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Mon, 6 Apr 2026 23:08:10 +0200 Subject: [PATCH 0542/1707] Use retry_after for UpdateFailed exception in Picnic (#167525) --- .../components/picnic/coordinator.py | 6 +++-- tests/components/picnic/test_coordinator.py | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 tests/components/picnic/test_coordinator.py diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 17c6e446a17f4a..55827ee1e843bb 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -49,8 +49,6 @@ def __init__( async def _async_update_data(self) -> dict: """Fetch data from API endpoint.""" try: - # Note: TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) @@ -60,6 +58,10 @@ async def _async_update_data(self) -> dict: raise UpdateFailed(f"API response was malformed: {error}") from error except PicnicAuthError as error: raise ConfigEntryAuthFailed from error + except TimeoutError as error: + raise UpdateFailed( + "Timeout while connecting to the Picnic API", retry_after=120 + ) from error # Return the fetched data return data diff --git a/tests/components/picnic/test_coordinator.py b/tests/components/picnic/test_coordinator.py new file mode 100644 index 00000000000000..9279ec07b4976c --- /dev/null +++ b/tests/components/picnic/test_coordinator.py @@ -0,0 +1,23 @@ +"""Tests for the Picnic coordinator.""" + +from unittest.mock import MagicMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_timeout_failed_with_retry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, +) -> None: + """Test that a TimeoutError is handled properly.""" + mock_picnic_api.get_cart.side_effect = TimeoutError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From f6fb6f40ddeab6d4e7ae59876bd48438a0cfb3d7 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 6 Apr 2026 23:11:37 +0200 Subject: [PATCH 0543/1707] Proxmox refactor coordinator typing (#167500) --- .../components/proxmoxve/coordinator.py | 59 ++++++++----------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index 4a4891d60c465f..a15b7c897f19ba 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -43,6 +43,16 @@ _LOGGER = logging.getLogger(__name__) +@dataclass(slots=True, kw_only=True) +class NodeResources: + """Raw API resources fetched for a single Proxmox node.""" + + vms: list[dict[str, Any]] + containers: list[dict[str, Any]] + storages: list[dict[str, Any]] + backups: list[dict[str, Any]] + + @dataclass(slots=True, kw_only=True) class ProxmoxNodeData: """All resources for a single Proxmox node.""" @@ -140,9 +150,7 @@ async def _async_update_data(self) -> dict[str, ProxmoxNodeData]: """Fetch data from Proxmox VE API.""" try: - nodes, vms_containers = await self.hass.async_add_executor_job( - self._fetch_all_nodes - ) + node_pairs = await self.hass.async_add_executor_job(self._fetch_all_nodes) except AuthenticationError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -174,17 +182,16 @@ async def _async_update_data(self) -> dict[str, ProxmoxNodeData]: ) from err data: dict[str, ProxmoxNodeData] = {} - for node, (vms, containers, storages, backups) in zip( - nodes, vms_containers, strict=True - ): + for node, resources in node_pairs: data[node[CONF_NODE]] = ProxmoxNodeData( node=node, - vms={int(vm["vmid"]): vm for vm in vms}, + vms={int(vm["vmid"]): vm for vm in resources.vms}, containers={ - int(container["vmid"]): container for container in containers + int(container["vmid"]): container + for container in resources.containers }, - storages={s["storage"]: s for s in storages}, - backups=backups, + storages={s["storage"]: s for s in resources.storages}, + backups=resources.backups, ) self._async_add_remove_nodes(data) @@ -229,40 +236,22 @@ def _init_proxmox(self) -> None: raise ProxmoxNodesNotFoundError from err raise ProxmoxServerError from err - def _fetch_all_nodes( - self, - ) -> tuple[ - list[dict[str, Any]], - list[ - tuple[ - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], - ] - ], - ]: - """Fetch all nodes, and then proceed to the VMs, containers, storages, and backups.""" + def _fetch_all_nodes(self) -> list[tuple[dict[str, Any], NodeResources]]: + """Fetch all nodes with their VMs, containers, storages, and backups.""" nodes = self.proxmox.nodes.get() or [] - node_data = [self._get_node_data(node) for node in nodes] - return nodes, node_data + return [(node, self._get_node_data(node)) for node in nodes] def _get_node_data( self, node: dict[str, Any], - ) -> tuple[ - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], - ]: + ) -> NodeResources: """Get vms, containers, storages, and backups for a node.""" if node.get("status") != NODE_ONLINE: _LOGGER.debug( "Node %s is offline, skipping VM/container/storage fetch", node[CONF_NODE], ) - return [], [], [], [] + return NodeResources(vms=[], containers=[], storages=[], backups=[]) vms = self.proxmox.nodes(node[CONF_NODE]).qemu.get() or [] containers = self.proxmox.nodes(node[CONF_NODE]).lxc.get() or [] @@ -272,7 +261,9 @@ def _get_node_data( or [] ) - return vms, containers, storages, backups + return NodeResources( + vms=vms, containers=containers, storages=storages, backups=backups + ) def _async_add_remove_nodes(self, data: dict[str, ProxmoxNodeData]) -> None: """Add new nodes/VMs/containers, track removals.""" From 298b9b962fc2ea894d82cb2572bd0249d8fc5a15 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 6 Apr 2026 22:58:11 +0100 Subject: [PATCH 0544/1707] Use fixtures for all entity IDs in Evohome tests (#167479) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- tests/components/evohome/conftest.py | 51 +++++++++++++++---- tests/components/evohome/test_water_heater.py | 27 ++++------ 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index ca960cefb22449..9e35b3a87244c0 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -8,7 +8,7 @@ from typing import Any from unittest.mock import MagicMock, patch -from evohomeasync2 import EvohomeClient +from evohomeasync2 import EvohomeClient, HotWater from evohomeasync2.auth import AbstractTokenManager, Auth from evohomeasync2.control_system import ControlSystem from evohomeasync2.zone import Zone @@ -18,8 +18,9 @@ from homeassistant.components.evohome.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util, slugify +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonArrayType, JsonObjectType from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME @@ -210,20 +211,52 @@ async def evohome( @pytest.fixture -def ctl_id(evohome: MagicMock) -> str: - """Return the entity_id of the evohome integration's controller.""" +def ctl_id(evohome: MagicMock, entity_id: Callable[[Platform, str], str]) -> str: + """Return the entity_id of evohome's controller (a Climate entity).""" evo: EvohomeClient = evohome.return_value - ctl: ControlSystem = evo.tcs + tcs: ControlSystem = evo.tcs - return f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" + return entity_id(Platform.CLIMATE, tcs.id) @pytest.fixture -def zone_id(evohome: MagicMock) -> str: - """Return the entity_id of the evohome integration's first zone.""" +def zone_id(evohome: MagicMock, entity_id: Callable[[Platform, str], str]) -> str: + """Return the entity_id of evohome's first zone (a Climate entity).""" evo: EvohomeClient = evohome.return_value + ctl: ControlSystem = evo.tcs + zone: Zone = evo.tcs.zones[0] - return f"{Platform.CLIMATE}.{slugify(zone.name)}" + return entity_id(Platform.CLIMATE, f"{zone.id}z" if zone.id == ctl.id else zone.id) + + +@pytest.fixture +def dhw_id(evohome: MagicMock, entity_id: Callable[[Platform, str], str]) -> str: + """Return the entity_id of Evohome's DHW controller (a WaterHeater entity).""" + + evo: EvohomeClient = evohome.return_value + dhw: HotWater | None = evo.tcs.hotwater + + assert dhw is not None, "Fixture has no DHW zone" + + return entity_id(Platform.WATER_HEATER, dhw.id) + + +@pytest.fixture +def entity_id( + entity_registry: er.EntityRegistry, +) -> Callable[[Platform, str], str]: + """Return a helper to lookup an entity_id from platform and unique_id.""" + + def get_entity_id(platform: Platform, unique_id: str) -> str: + """Return an entity_id from the entity registry.""" + + entity = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + assert entity is not None, ( + f"Entity not found for platform={platform}: {unique_id}" + ) + return entity + + return get_entity_id diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index ba2e33e85b880e..7d0123f2962eba 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -24,8 +24,6 @@ from .conftest import setup_evohome from .const import TEST_INSTALLS_WITH_DHW -DHW_ENTITY_ID = "water_heater.domestic_hot_water" - @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) async def test_setup_platform( @@ -49,9 +47,9 @@ async def test_setup_platform( @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) -@pytest.mark.usefixtures("evohome") async def test_set_operation_mode( hass: HomeAssistant, + dhw_id: str, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: @@ -66,7 +64,7 @@ async def test_set_operation_mode( WATER_HEATER_DOMAIN, SERVICE_SET_OPERATION_MODE, { - ATTR_ENTITY_ID: DHW_ENTITY_ID, + ATTR_ENTITY_ID: dhw_id, ATTR_OPERATION_MODE: "auto", }, blocking=True, @@ -80,7 +78,7 @@ async def test_set_operation_mode( WATER_HEATER_DOMAIN, SERVICE_SET_OPERATION_MODE, { - ATTR_ENTITY_ID: DHW_ENTITY_ID, + ATTR_ENTITY_ID: dhw_id, ATTR_OPERATION_MODE: "off", }, blocking=True, @@ -100,7 +98,7 @@ async def test_set_operation_mode( WATER_HEATER_DOMAIN, SERVICE_SET_OPERATION_MODE, { - ATTR_ENTITY_ID: DHW_ENTITY_ID, + ATTR_ENTITY_ID: dhw_id, ATTR_OPERATION_MODE: "on", }, blocking=True, @@ -118,8 +116,7 @@ async def test_set_operation_mode( @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) -@pytest.mark.usefixtures("evohome") -async def test_set_away_mode(hass: HomeAssistant) -> None: +async def test_set_away_mode(hass: HomeAssistant, dhw_id: str) -> None: """Test SERVICE_SET_AWAY_MODE of an evohome DHW zone.""" # set_away_mode: off @@ -128,7 +125,7 @@ async def test_set_away_mode(hass: HomeAssistant) -> None: WATER_HEATER_DOMAIN, SERVICE_SET_AWAY_MODE, { - ATTR_ENTITY_ID: DHW_ENTITY_ID, + ATTR_ENTITY_ID: dhw_id, ATTR_AWAY_MODE: "off", }, blocking=True, @@ -142,7 +139,7 @@ async def test_set_away_mode(hass: HomeAssistant) -> None: WATER_HEATER_DOMAIN, SERVICE_SET_AWAY_MODE, { - ATTR_ENTITY_ID: DHW_ENTITY_ID, + ATTR_ENTITY_ID: dhw_id, ATTR_AWAY_MODE: "on", }, blocking=True, @@ -152,8 +149,7 @@ async def test_set_away_mode(hass: HomeAssistant) -> None: @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) -@pytest.mark.usefixtures("evohome") -async def test_turn_off(hass: HomeAssistant) -> None: +async def test_turn_off(hass: HomeAssistant, dhw_id: str) -> None: """Test SERVICE_TURN_OFF of an evohome DHW zone.""" # turn_off @@ -162,7 +158,7 @@ async def test_turn_off(hass: HomeAssistant) -> None: WATER_HEATER_DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: DHW_ENTITY_ID, + ATTR_ENTITY_ID: dhw_id, }, blocking=True, ) @@ -171,8 +167,7 @@ async def test_turn_off(hass: HomeAssistant) -> None: @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) -@pytest.mark.usefixtures("evohome") -async def test_turn_on(hass: HomeAssistant) -> None: +async def test_turn_on(hass: HomeAssistant, dhw_id: str) -> None: """Test SERVICE_TURN_ON of an evohome DHW zone.""" # turn_on @@ -181,7 +176,7 @@ async def test_turn_on(hass: HomeAssistant) -> None: WATER_HEATER_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: DHW_ENTITY_ID, + ATTR_ENTITY_ID: dhw_id, }, blocking=True, ) From 4dba27f15e7ff604a3d47636b53e36985c563d5a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 7 Apr 2026 00:19:03 +0200 Subject: [PATCH 0545/1707] Replace "custom" with "community" in `analytics_insights` (#167506) --- .../components/analytics_insights/strings.json | 6 +++--- .../analytics_insights/snapshots/test_sensor.ambr | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index e01c8bdfd311a9..e3d850da6a5a14 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -11,12 +11,12 @@ "user": { "data": { "tracked_apps": "Apps", - "tracked_custom_integrations": "Custom integrations", + "tracked_custom_integrations": "Community integrations", "tracked_integrations": "Integrations" }, "data_description": { "tracked_apps": "Select the apps you want to track", - "tracked_custom_integrations": "Select the custom integrations you want to track", + "tracked_custom_integrations": "Select the community integrations you want to track", "tracked_integrations": "Select the integrations you want to track" } } @@ -31,7 +31,7 @@ "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" }, "custom_integrations": { - "name": "{custom_integration_domain} (custom)", + "name": "{custom_integration_domain} (community)", "unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]" }, "total_active_installations": { diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr index 58fe89c8b9156b..86b7d55d1078aa 100644 --- a/tests/components/analytics_insights/snapshots/test_sensor.ambr +++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr @@ -53,7 +53,7 @@ 'state': '76357', }) # --- -# name: test_all_entities[sensor.homeassistant_analytics_hacs_custom-entry] +# name: test_all_entities[sensor.homeassistant_analytics_hacs_community-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -69,7 +69,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.homeassistant_analytics_hacs_custom', + 'entity_id': 'sensor.homeassistant_analytics_hacs_community', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -77,12 +77,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'hacs (custom)', + 'object_id_base': 'hacs (community)', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'hacs (custom)', + 'original_name': 'hacs (community)', 'platform': 'analytics_insights', 'previous_unique_id': None, 'suggested_object_id': None, @@ -92,15 +92,15 @@ 'unit_of_measurement': 'active installations', }) # --- -# name: test_all_entities[sensor.homeassistant_analytics_hacs_custom-state] +# name: test_all_entities[sensor.homeassistant_analytics_hacs_community-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Homeassistant Analytics hacs (custom)', + 'friendly_name': 'Homeassistant Analytics hacs (community)', 'state_class': , 'unit_of_measurement': 'active installations', }), 'context': , - 'entity_id': 'sensor.homeassistant_analytics_hacs_custom', + 'entity_id': 'sensor.homeassistant_analytics_hacs_community', 'last_changed': , 'last_reported': , 'last_updated': , From 74a6f781a16ac9ddc48c9977a005ccfd787e1b0f Mon Sep 17 00:00:00 2001 From: Nils Ove Erstad Date: Tue, 7 Apr 2026 09:20:48 +0200 Subject: [PATCH 0546/1707] Fix missing color_mode initialization in MQTT JSON light schema (#167429) --- .../components/mqtt/light/schema_json.py | 2 +- tests/components/mqtt/test_light_json.py | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 6b1db79e269fbc..b388cdebb6516e 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -146,7 +146,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED - _fixed_color_mode: ColorMode | str | None = None _flash_times: dict[str, int | None] _topic: dict[str, str | None] _optimistic: bool @@ -190,6 +189,7 @@ def _setup_from_config(self, config: ConfigType) -> None: self._attr_supported_features |= ( config[CONF_TRANSITION] and LightEntityFeature.TRANSITION ) + self._attr_color_mode = ColorMode.UNKNOWN if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES): self._attr_supported_color_modes = supported_color_modes if self.supported_color_modes and len(self.supported_color_modes) == 1: diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 570609a86c0e26..6ea2cb3a9d4e8f 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -513,6 +513,58 @@ async def test_brightness_only( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light", + "command_topic": "test_light/set", + "supported_color_modes": ["brightness"], + } + } + }, + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light", + "command_topic": "test_light/set", + "supported_color_modes": ["color_temp"], + } + } + }, + ], +) +async def test_single_color_mode_turn_on( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test turning on a single color mode light does not raise. + + Regression test: PR #162715 changed _attr_color_mode default to None + and added a strict check. The JSON schema must initialize color_mode + during setup so that turn_on does not raise "does not report a color mode". + """ + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + + # This should not raise "does not report a color mode" + await common.async_turn_on(hass, "light.test") + mqtt_mock.async_publish.assert_called_once_with( + "test_light/set", '{"state":"ON"}', 0, False + ) + + async_fire_mqtt_message(hass, "test_light", '{"state":"ON", "brightness": 50}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + + @pytest.mark.parametrize( "hass_config", [ From 7586fe6decf0cfdc2fe3c462d13a21944145e7ce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:52:40 +0200 Subject: [PATCH 0547/1707] Remove unnecessary Renault entity description mixin (#167579) --- homeassistant/components/renault/entity.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index a608b5a7b22043..d10dd9b9149f83 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -15,18 +15,13 @@ from .renault_vehicle import RenaultVehicleProxy -@dataclass(frozen=True) -class RenaultDataRequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class RenaultDataEntityDescription(EntityDescription): + """Class describing Renault data entities.""" coordinator: str -@dataclass(frozen=True) -class RenaultDataEntityDescription(EntityDescription, RenaultDataRequiredKeysMixin): - """Class describing Renault data entities.""" - - class RenaultEntity(Entity): """Implementation of a Renault entity with a data coordinator.""" From 2a8551138dcc29199aa13a8c03101c60d1307eca Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 7 Apr 2026 10:34:29 +0200 Subject: [PATCH 0548/1707] Reject backup uploads with invalid filenames (#167211) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/backup/http.py | 7 +- homeassistant/components/backup/manager.py | 16 +++-- homeassistant/components/backup/models.py | 6 ++ tests/components/backup/test_manager.py | 80 ++++++++++++---------- tests/components/hassio/test_backup.py | 38 ++++++++++ 5 files changed, 103 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index b40ea76cd5924b..82571296e8cfe6 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -23,7 +23,7 @@ from .agent import BackupAgent from .const import DATA_MANAGER from .manager import BackupManager -from .models import AgentBackup, BackupNotFound +from .models import AgentBackup, BackupNotFound, InvalidBackupFilename @callback @@ -195,6 +195,11 @@ async def _post(self, request: Request) -> Response: backup_id = await manager.async_receive_backup( contents=contents, agent_ids=agent_ids ) + except InvalidBackupFilename as err: + return Response( + body=str(err), + status=HTTPStatus.BAD_REQUEST, + ) except OSError as err: return Response( body=f"Can't write backup file: {err}", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index a05a55bf4e93a9..118b3015b0679e 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -68,6 +68,7 @@ BackupReaderWriterError, BaseBackup, Folder, + InvalidBackupFilename, ) from .store import BackupStore from .util import ( @@ -1006,6 +1007,14 @@ async def _async_receive_backup( ) -> str: """Receive and store a backup file from upload.""" contents.chunk_size = BUF_SIZE + suggested_filename = contents.filename or "backup.tar" + safe_filename = PureWindowsPath(suggested_filename).name + if ( + not safe_filename + or safe_filename != suggested_filename + or safe_filename == ".." + ): + raise InvalidBackupFilename(f"Invalid filename: {suggested_filename}") self.async_on_backup_event( ReceiveBackupEvent( reason=None, @@ -1016,7 +1025,7 @@ async def _async_receive_backup( written_backup = await self._reader_writer.async_receive_backup( agent_ids=agent_ids, stream=contents, - suggested_filename=contents.filename or "backup.tar", + suggested_filename=suggested_filename, ) self.async_on_backup_event( ReceiveBackupEvent( @@ -1957,10 +1966,7 @@ async def async_receive_backup( suggested_filename: str, ) -> WrittenBackup: """Receive a backup.""" - safe_filename = PureWindowsPath(suggested_filename).name - if not safe_filename or safe_filename == "..": - safe_filename = "backup.tar" - temp_file = Path(self.temp_backup_dir, safe_filename) + temp_file = Path(self.temp_backup_dir, suggested_filename) async_add_executor_job = self._hass.async_add_executor_job await async_add_executor_job(make_backup_dir, self.temp_backup_dir) diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index d927cd0bac5691..c89a4137355bbd 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -95,6 +95,12 @@ class BackupReaderWriterError(BackupError): error_code = "backup_reader_writer_error" +class InvalidBackupFilename(BackupManagerError): + """Raised when a backup filename is invalid.""" + + error_code = "invalid_backup_filename" + + class BackupNotFound(BackupAgentError, BackupManagerError): """Raised when a backup is not found.""" diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 48c80e397f4c77..2c17db4b91e2b3 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2018,58 +2018,28 @@ async def test_receive_backup( assert unlink_mock.call_count == temp_file_unlink_call_count -@pytest.mark.parametrize( - ("suggested_filename", "expected_filename"), - [ - ("backup.tar", "backup.tar"), - ("../traversal.tar", "traversal.tar"), - ("../../etc/passwd", "passwd"), - ("subdir/backup.tar", "backup.tar"), - (".", "backup.tar"), - ("..", "backup.tar"), - ("../..", "backup.tar"), - ("..\\traversal.tar", "traversal.tar"), - ("C:\\fakepath\\backup.tar", "backup.tar"), - ], -) -async def test_receive_backup_path_traversal( +async def test_receive_backup_valid_filename( hass: HomeAssistant, hass_client: ClientSessionGenerator, - suggested_filename: str, - expected_filename: str, ) -> None: - """Test path traversal in suggested filename is prevented.""" + """Test receive backup with a valid filename.""" await setup_backup_integration(hass) - # Make sure we wait for Platform.EVENT and Platform.SENSOR to be fully processed, - # to avoid interference with the Path.open patching below which is used to verify - # that the file is written to the expected location. - await hass.async_block_till_done(True) client = await hass_client() - upload_data = "test" - open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) - expected_path = Path(hass.config.path("tmp_backups"), expected_filename) - opened_paths: list[Path] = [] - - def track_open(self: Path, *args: Any, **kwargs: Any) -> Any: - opened_paths.append(self) - return open_mock(self, *args, **kwargs) + expected_path = Path(hass.config.path("tmp_backups"), "backup.tar") with ( - patch("pathlib.Path.open", track_open), - patch("homeassistant.components.backup.manager.make_backup_dir"), patch("shutil.move"), patch( "homeassistant.components.backup.manager.read_backup", return_value=TEST_BACKUP_ABC123, ) as read_backup_mock, - patch("pathlib.Path.unlink"), ): data = FormData(quote_fields=False) data.add_field( "file", - upload_data, - filename=suggested_filename, + "test", + filename="backup.tar", content_type="application/octet-stream", ) resp = await client.post( @@ -2079,12 +2049,46 @@ def track_open(self: Path, *args: Any, **kwargs: Any) -> Any: await hass.async_block_till_done() assert resp.status == 201 - # Verify all file opens went to the expected safe path - assert opened_paths == [expected_path] - # read_backup is called with the temp_file path; verify it's sanitized read_backup_mock.assert_called_once_with(expected_path) +@pytest.mark.parametrize( + "suggested_filename", + [ + "../traversal.tar", + "../../etc/passwd", + "subdir/backup.tar", + ".", + "..", + "../..", + "..\\traversal.tar", + "C:\\fakepath\\backup.tar", + ], +) +async def test_receive_backup_path_traversal( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + suggested_filename: str, +) -> None: + """Test receive backup rejects filenames with path traversal.""" + await setup_backup_integration(hass) + client = await hass_client() + + data = FormData(quote_fields=False) + data.add_field( + "file", + "test", + filename=suggested_filename, + content_type="application/octet-stream", + ) + resp = await client.post( + "/api/backup/upload?agent_id=backup.local", + data=data, + ) + + assert resp.status == 400 + + async def test_receive_backup_busy_manager( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index ed2bc4f5443734..34b8c76ccc9a69 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -31,6 +31,7 @@ ) from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from aiohasupervisor.models.mounts import MountsInfo +from aiohttp import FormData from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -687,6 +688,43 @@ async def mock_upload( ) +@pytest.mark.parametrize( + "suggested_filename", + [ + "../traversal.tar", + "../../etc/passwd", + "subdir/backup.tar", + ".", + "..", + "../..", + "..\\traversal.tar", + "C:\\fakepath\\backup.tar", + ], +) +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_agent_upload_path_traversal( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + suggested_filename: str, +) -> None: + """Test agent upload rejects filenames with path traversal.""" + client = await hass_client() + + data = FormData(quote_fields=False) + data.add_field( + "file", + "test", + filename=suggested_filename, + content_type="application/octet-stream", + ) + resp = await client.post( + "/api/backup/upload?agent_id=hassio.local", + data=data, + ) + + assert resp.status == 400 + + @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_agent_get_backup( hass: HomeAssistant, From eaba7b0e486e873d8ecad0c38fece25f47ebb93e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:08:12 +0200 Subject: [PATCH 0549/1707] Improve typing in Renault number descriptions (#167580) --- homeassistant/components/renault/number.py | 41 +++++++++++++--------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/renault/number.py b/homeassistant/components/renault/number.py index b487eedd3dd75b..4b71f77718b896 100644 --- a/homeassistant/components/renault/number.py +++ b/homeassistant/components/renault/number.py @@ -4,9 +4,12 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any, cast +from typing import Any -from renault_api.kamereon.models import KamereonVehicleBatterySocData +from renault_api.kamereon.models import ( + KamereonVehicleBatterySocData, + KamereonVehicleDataAttributes, +) from homeassistant.components.number import ( NumberDeviceClass, @@ -29,16 +32,18 @@ @dataclass(frozen=True, kw_only=True) -class RenaultNumberEntityDescription( +class RenaultNumberEntityDescription[T: KamereonVehicleDataAttributes]( NumberEntityDescription, RenaultDataEntityDescription ): """Class describing Renault number entities.""" - data_key: str - update_fn: Callable[[RenaultNumberEntity, float], Coroutine[Any, Any, None]] + value_fn: Callable[[RenaultNumberEntity[T]], float | None] + update_fn: Callable[[RenaultNumberEntity[T], float], Coroutine[Any, Any, None]] -async def _set_charge_limit_min(entity: RenaultNumberEntity, value: float) -> None: +async def _set_charge_limit_min( + entity: RenaultNumberEntity[KamereonVehicleBatterySocData], value: float +) -> None: """Set the minimum SOC. The target SOC is required to set the minimum SOC, so we need to fetch it first. @@ -51,7 +56,9 @@ async def _set_charge_limit_min(entity: RenaultNumberEntity, value: float) -> No await _set_charge_limits(entity, min_soc=round(value), target_soc=target_soc) -async def _set_charge_limit_target(entity: RenaultNumberEntity, value: float) -> None: +async def _set_charge_limit_target( + entity: RenaultNumberEntity[KamereonVehicleBatterySocData], value: float +) -> None: """Set the target SOC. The minimum SOC is required to set the target SOC, so we need to fetch it first. @@ -65,7 +72,9 @@ async def _set_charge_limit_target(entity: RenaultNumberEntity, value: float) -> async def _set_charge_limits( - entity: RenaultNumberEntity, min_soc: int, target_soc: int + entity: RenaultNumberEntity[KamereonVehicleBatterySocData], + min_soc: int, + target_soc: int, ) -> None: """Set the minimum and target SOC. @@ -95,17 +104,17 @@ async def async_setup_entry( async_add_entities(entities) -class RenaultNumberEntity( - RenaultDataEntity[KamereonVehicleBatterySocData], NumberEntity +class RenaultNumberEntity[T: KamereonVehicleDataAttributes]( + RenaultDataEntity[T], NumberEntity ): """Mixin for number specific attributes.""" - entity_description: RenaultNumberEntityDescription + entity_description: RenaultNumberEntityDescription[T] @property def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" - return cast(float | None, self._get_data_attr(self.entity_description.data_key)) + return self.entity_description.value_fn(self) async def async_set_native_value(self, value: float) -> None: """Update the current value.""" @@ -113,10 +122,9 @@ async def async_set_native_value(self, value: float) -> None: NUMBER_TYPES: tuple[RenaultNumberEntityDescription, ...] = ( - RenaultNumberEntityDescription( + RenaultNumberEntityDescription[KamereonVehicleBatterySocData]( key="charge_limit_min", coordinator="battery_soc", - data_key="socMin", update_fn=_set_charge_limit_min, device_class=NumberDeviceClass.BATTERY, native_min_value=15, @@ -125,11 +133,11 @@ async def async_set_native_value(self, value: float) -> None: native_unit_of_measurement=PERCENTAGE, mode=NumberMode.SLIDER, translation_key="charge_limit_min", + value_fn=lambda entity: entity.coordinator.data.socMin, ), - RenaultNumberEntityDescription( + RenaultNumberEntityDescription[KamereonVehicleBatterySocData]( key="charge_limit_target", coordinator="battery_soc", - data_key="socTarget", update_fn=_set_charge_limit_target, device_class=NumberDeviceClass.BATTERY, native_min_value=55, @@ -138,5 +146,6 @@ async def async_set_native_value(self, value: float) -> None: native_unit_of_measurement=PERCENTAGE, mode=NumberMode.SLIDER, translation_key="charge_limit_target", + value_fn=lambda entity: entity.coordinator.data.socTarget, ), ) From bd4ac7993f96f22ba38622eadb631ce7faa54a64 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:09:30 +0200 Subject: [PATCH 0550/1707] Improve typing in Renault select descriptions (#167578) --- homeassistant/components/renault/select.py | 34 +++++++++++----------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index cddf83bb8603c2..514378411b5319 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -2,15 +2,18 @@ from __future__ import annotations +from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import cast +from typing import Any -from renault_api.kamereon.models import KamereonVehicleBatteryStatusData +from renault_api.kamereon.models import ( + KamereonVehicleChargeModeData, + KamereonVehicleDataAttributes, +) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription @@ -21,12 +24,13 @@ @dataclass(frozen=True, kw_only=True) -class RenaultSelectEntityDescription( +class RenaultSelectEntityDescription[T: KamereonVehicleDataAttributes]( SelectEntityDescription, RenaultDataEntityDescription ): """Class describing Renault select entities.""" - data_key: str + value_fn: Callable[[RenaultSelectEntity[T]], str | None] + update_fn: Callable[[RenaultSelectEntity[T], str], Coroutine[Any, Any, Any]] async def async_setup_entry( @@ -44,34 +48,30 @@ async def async_setup_entry( async_add_entities(entities) -class RenaultSelectEntity( - RenaultDataEntity[KamereonVehicleBatteryStatusData], SelectEntity +class RenaultSelectEntity[T: KamereonVehicleDataAttributes]( + RenaultDataEntity[T], SelectEntity ): """Mixin for sensor specific attributes.""" - entity_description: RenaultSelectEntityDescription + entity_description: RenaultSelectEntityDescription[T] @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - return cast(str, self.data) - - @property - def data(self) -> StateType: - """Return the state of this entity.""" - return self._get_data_attr(self.entity_description.data_key) + return self.entity_description.value_fn(self) async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self.vehicle.set_charge_mode(option) + await self.entity_description.update_fn(self, option) SENSOR_TYPES: tuple[RenaultSelectEntityDescription, ...] = ( - RenaultSelectEntityDescription( + RenaultSelectEntityDescription[KamereonVehicleChargeModeData]( key="charge_mode", coordinator="charge_mode", - data_key="chargeMode", translation_key="charge_mode", options=["always", "always_charging", "schedule_mode", "scheduled"], + update_fn=lambda e, option: e.vehicle.set_charge_mode(option), + value_fn=lambda e: e.coordinator.data.chargeMode, ), ) From 17fbb9909c621e6e01dbc3565232fcc377b747c3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:11:32 +0200 Subject: [PATCH 0551/1707] Improve typing in Renault binary_sensor descriptions (#167577) --- .../components/renault/binary_sensor.py | 174 +++++++++++------- 1 file changed, 108 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 702e23d1489ee5..4c09ca44601de4 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -6,7 +6,12 @@ from dataclasses import dataclass from renault_api.kamereon.enums import ChargeState, PlugState -from renault_api.kamereon.models import KamereonVehicleBatteryStatusData +from renault_api.kamereon.models import ( + KamereonVehicleBatteryStatusData, + KamereonVehicleDataAttributes, + KamereonVehicleHvacStatusData, + KamereonVehicleLockStatusData, +) from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -15,7 +20,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription @@ -35,15 +39,13 @@ @dataclass(frozen=True, kw_only=True) -class RenaultBinarySensorEntityDescription( +class RenaultBinarySensorEntityDescription[T: KamereonVehicleDataAttributes]( BinarySensorEntityDescription, RenaultDataEntityDescription, ): """Class describing Renault binary sensor entities.""" - on_key: str | None = None - on_value: StateType | None = None - value_lambda: Callable[[RenaultBinarySensor], bool | None] | None = None + value_lambda: Callable[[RenaultBinarySensor[T]], bool | None] async def async_setup_entry( @@ -61,28 +63,22 @@ async def async_setup_entry( async_add_entities(entities) -class RenaultBinarySensor( - RenaultDataEntity[KamereonVehicleBatteryStatusData], BinarySensorEntity +class RenaultBinarySensor[T: KamereonVehicleDataAttributes]( + RenaultDataEntity[T], BinarySensorEntity ): """Mixin for binary sensor specific attributes.""" - entity_description: RenaultBinarySensorEntityDescription + entity_description: RenaultBinarySensorEntityDescription[T] @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" + return self.entity_description.value_lambda(self) - if self.entity_description.value_lambda is not None: - return self.entity_description.value_lambda(self) - if self.entity_description.on_key is None: - raise NotImplementedError("Either value_lambda or on_key must be set") - if (data := self._get_data_attr(self.entity_description.on_key)) is None: - return None - - return data == self.entity_description.on_value - -def _plugged_in_value_lambda(self: RenaultBinarySensor) -> bool | None: +def _plugged_in_value_lambda( + self: RenaultBinarySensor[KamereonVehicleBatteryStatusData], +) -> bool | None: """Return true if the vehicle is plugged in.""" if (plug_status := self.coordinator.data.get_plug_status()) is not None: return plug_status == PlugState.PLUGGED @@ -95,56 +91,102 @@ def _plugged_in_value_lambda(self: RenaultBinarySensor) -> bool | None: return None -BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( - [ - RenaultBinarySensorEntityDescription( - key="plugged_in", - coordinator="battery", - device_class=BinarySensorDeviceClass.PLUG, - value_lambda=_plugged_in_value_lambda, +BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = ( + RenaultBinarySensorEntityDescription[KamereonVehicleBatteryStatusData]( + key="plugged_in", + coordinator="battery", + device_class=BinarySensorDeviceClass.PLUG, + value_lambda=_plugged_in_value_lambda, + ), + RenaultBinarySensorEntityDescription[KamereonVehicleBatteryStatusData]( + key="charging", + coordinator="battery", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_lambda=lambda e: ( + e.coordinator.data.chargingStatus == ChargeState.CHARGE_IN_PROGRESS.value + if e.coordinator.data.chargingStatus is not None + else None ), - RenaultBinarySensorEntityDescription( - key="charging", - coordinator="battery", - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - on_key="chargingStatus", - on_value=ChargeState.CHARGE_IN_PROGRESS.value, + ), + RenaultBinarySensorEntityDescription[KamereonVehicleHvacStatusData]( + key="hvac_status", + coordinator="hvac_status", + translation_key="hvac_status", + value_lambda=lambda e: ( + e.coordinator.data.hvacStatus == "on" + if e.coordinator.data.hvacStatus is not None + else None ), - RenaultBinarySensorEntityDescription( - key="hvac_status", - coordinator="hvac_status", - on_key="hvacStatus", - on_value="on", - translation_key="hvac_status", + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="lock_status", + coordinator="lock_status", + # lock: on means open (unlocked), off means closed (locked) + device_class=BinarySensorDeviceClass.LOCK, + value_lambda=lambda e: ( + e.coordinator.data.lockStatus == "unlocked" + if e.coordinator.data.lockStatus is not None + else None ), - RenaultBinarySensorEntityDescription( - key="lock_status", - coordinator="lock_status", - # lock: on means open (unlocked), off means closed (locked) - device_class=BinarySensorDeviceClass.LOCK, - on_key="lockStatus", - on_value="unlocked", + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="hatch_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="hatch_status", + value_lambda=lambda e: ( + e.coordinator.data.hatchStatus == "open" + if e.coordinator.data.hatchStatus is not None + else None ), - RenaultBinarySensorEntityDescription( - key="hatch_status", - coordinator="lock_status", - # On means open, Off means closed - device_class=BinarySensorDeviceClass.DOOR, - on_key="hatchStatus", - on_value="open", - translation_key="hatch_status", + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="rear_left_door_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="rear_left_door_status", + value_lambda=lambda e: ( + e.coordinator.data.doorStatusRearLeft == "open" + if e.coordinator.data.doorStatusRearLeft is not None + else None ), - ] - + [ - RenaultBinarySensorEntityDescription( - key=f"{door.replace(' ', '_').lower()}_door_status", - coordinator="lock_status", - # On means open, Off means closed - device_class=BinarySensorDeviceClass.DOOR, - on_key=f"doorStatus{door.replace(' ', '')}", - on_value="open", - translation_key=f"{door.lower().replace(' ', '_')}_door_status", - ) - for door in ("Rear Left", "Rear Right", "Driver", "Passenger") - ], + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="rear_right_door_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="rear_right_door_status", + value_lambda=lambda e: ( + e.coordinator.data.doorStatusRearRight == "open" + if e.coordinator.data.doorStatusRearRight is not None + else None + ), + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="driver_door_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="driver_door_status", + value_lambda=lambda e: ( + e.coordinator.data.doorStatusDriver == "open" + if e.coordinator.data.doorStatusDriver is not None + else None + ), + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="passenger_door_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="passenger_door_status", + value_lambda=lambda e: ( + e.coordinator.data.doorStatusPassenger == "open" + if e.coordinator.data.doorStatusPassenger is not None + else None + ), + ), ) From d5ffd7f37a1af2b9fd87a917c17352f233adf611 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:13:19 +0200 Subject: [PATCH 0552/1707] Improve typing in Renault sensor descriptions (#167571) --- homeassistant/components/renault/sensor.py | 85 ++++++++-------------- 1 file changed, 32 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index e2df7ab7a4488d..d5791e0c6aff17 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING, Any, cast +from typing import Any from renault_api.kamereon.models import ( KamereonVehicleBatteryStatusData, @@ -53,10 +53,9 @@ class RenaultSensorEntityDescription[T: KamereonVehicleDataAttributes]( ): """Class describing Renault sensor entities.""" - data_key: str condition_lambda: Callable[[RenaultVehicleProxy], bool] | None = None requires_fuel: bool = False - value_lambda: Callable[[RenaultSensor[T]], StateType | datetime] | None = None + value_lambda: Callable[[RenaultSensor[T]], StateType | datetime] async def async_setup_entry( @@ -83,18 +82,9 @@ class RenaultSensor[T: KamereonVehicleDataAttributes]( entity_description: RenaultSensorEntityDescription[T] - @property - def data(self) -> StateType: - """Return the state of this entity.""" - return self._get_data_attr(self.entity_description.data_key) - @property def native_value(self) -> StateType | datetime: """Return the state of this entity.""" - if self.data is None: - return None - if self.entity_description.value_lambda is None: - return self.data return self.entity_description.value_lambda(self) @@ -102,7 +92,9 @@ def _get_charging_power( entity: RenaultSensor[KamereonVehicleBatteryStatusData], ) -> StateType: """Return the charging_power of this entity.""" - return cast(float, entity.data) / 1000 + if (data := entity.coordinator.data.chargingInstantaneousPower) is None: + return None + return data / 1000 def _get_charge_state_formatted( @@ -121,20 +113,17 @@ def _get_plug_state_formatted( return plug_status.name.lower() if plug_status else None -def _get_rounded_value[T: KamereonVehicleDataAttributes]( - entity: RenaultSensor[T], -) -> float: +def _get_rounded_value(value: float | None) -> int | None: """Return the rounded value of this entity.""" - return round(cast(float, entity.data)) + if value is None: + return None + return round(value) -def _get_utc_value[T: KamereonVehicleDataAttributes]( - entity: RenaultSensor[T], -) -> datetime: +def _get_utc_value(value: str | None) -> datetime | None: """Return the UTC value of this entity.""" - original_dt = parse_datetime(cast(str, entity.data)) - if TYPE_CHECKING: - assert original_dt is not None + if (value is None) or (original_dt := parse_datetime(value)) is None: + return None return as_utc(original_dt) @@ -150,15 +139,14 @@ def _get_charging_settings_mode_formatted( RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_level", coordinator="battery", - data_key="batteryLevel", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + value_lambda=lambda e: e.coordinator.data.batteryLevel, ), RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="charge_state", coordinator="battery", - data_key="chargingStatus", translation_key="charge_state", device_class=SensorDeviceClass.ENUM, options=[ @@ -176,11 +164,11 @@ def _get_charging_settings_mode_formatted( RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="charging_remaining_time", coordinator="battery", - data_key="chargingRemainingTime", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, translation_key="charging_remaining_time", + value_lambda=lambda e: e.coordinator.data.chargingRemainingTime, ), RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( # For vehicles that DO NOT report charging power in watts, this seems to @@ -189,11 +177,11 @@ def _get_charging_settings_mode_formatted( key="charging_power", condition_lambda=lambda a: not a.details.reports_charging_power_in_watts(), coordinator="battery", - data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, translation_key="admissible_charging_power", + value_lambda=lambda e: e.coordinator.data.chargingInstantaneousPower, ), RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( # For vehicles that DO report charging power in watts, this is the power @@ -201,7 +189,6 @@ def _get_charging_settings_mode_formatted( key="charging_power", condition_lambda=lambda a: a.details.reports_charging_power_in_watts(), coordinator="battery", - data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -211,7 +198,6 @@ def _get_charging_settings_mode_formatted( RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="plug_state", coordinator="battery", - data_key="plugStatus", translation_key="plug_state", device_class=SensorDeviceClass.ENUM, options=[ @@ -226,122 +212,115 @@ def _get_charging_settings_mode_formatted( RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_autonomy", coordinator="battery", - data_key="batteryAutonomy", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, translation_key="battery_autonomy", + value_lambda=lambda e: e.coordinator.data.batteryAutonomy, ), RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_available_energy", coordinator="battery", - data_key="batteryAvailableEnergy", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, translation_key="battery_available_energy", + value_lambda=lambda e: e.coordinator.data.batteryAvailableEnergy, ), RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_temperature", coordinator="battery", - data_key="batteryTemperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, translation_key="battery_temperature", + value_lambda=lambda e: e.coordinator.data.batteryTemperature, ), RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_last_activity", coordinator="battery", device_class=SensorDeviceClass.TIMESTAMP, - data_key="timestamp", entity_registry_enabled_default=False, - value_lambda=_get_utc_value, + value_lambda=lambda e: _get_utc_value(e.coordinator.data.timestamp), translation_key="battery_last_activity", ), RenaultSensorEntityDescription[KamereonVehicleCockpitData]( key="mileage", coordinator="cockpit", - data_key="totalMileage", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, - value_lambda=_get_rounded_value, + value_lambda=lambda e: _get_rounded_value(e.coordinator.data.totalMileage), translation_key="mileage", ), RenaultSensorEntityDescription[KamereonVehicleCockpitData]( key="fuel_autonomy", coordinator="cockpit", - data_key="fuelAutonomy", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, requires_fuel=True, - value_lambda=_get_rounded_value, + value_lambda=lambda e: _get_rounded_value(e.coordinator.data.fuelAutonomy), translation_key="fuel_autonomy", ), RenaultSensorEntityDescription[KamereonVehicleCockpitData]( key="fuel_quantity", coordinator="cockpit", - data_key="fuelQuantity", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.TOTAL, requires_fuel=True, - value_lambda=_get_rounded_value, + value_lambda=lambda e: _get_rounded_value(e.coordinator.data.fuelQuantity), translation_key="fuel_quantity", ), RenaultSensorEntityDescription[KamereonVehicleHvacStatusData]( key="outside_temperature", coordinator="hvac_status", device_class=SensorDeviceClass.TEMPERATURE, - data_key="externalTemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, translation_key="outside_temperature", + value_lambda=lambda e: e.coordinator.data.externalTemperature, ), RenaultSensorEntityDescription[KamereonVehicleHvacStatusData]( key="hvac_soc_threshold", coordinator="hvac_status", - data_key="socThreshold", native_unit_of_measurement=PERCENTAGE, translation_key="hvac_soc_threshold", + value_lambda=lambda e: e.coordinator.data.socThreshold, ), RenaultSensorEntityDescription[KamereonVehicleHvacStatusData]( key="hvac_last_activity", coordinator="hvac_status", device_class=SensorDeviceClass.TIMESTAMP, - data_key="lastUpdateTime", entity_registry_enabled_default=False, translation_key="hvac_last_activity", - value_lambda=_get_utc_value, + value_lambda=lambda e: _get_utc_value(e.coordinator.data.lastUpdateTime), ), RenaultSensorEntityDescription[KamereonVehicleLocationData]( key="location_last_activity", coordinator="location", device_class=SensorDeviceClass.TIMESTAMP, - data_key="lastUpdateTime", entity_registry_enabled_default=False, translation_key="location_last_activity", - value_lambda=_get_utc_value, + value_lambda=lambda e: _get_utc_value(e.coordinator.data.lastUpdateTime), ), RenaultSensorEntityDescription[KamereonVehicleResStateData]( key="res_state", coordinator="res_state", - data_key="details", translation_key="res_state", + value_lambda=lambda e: e.coordinator.data.details, ), RenaultSensorEntityDescription[KamereonVehicleResStateData]( key="res_state_code", coordinator="res_state", - data_key="code", entity_registry_enabled_default=False, translation_key="res_state_code", + value_lambda=lambda e: e.coordinator.data.code, ), RenaultSensorEntityDescription[KamereonVehicleChargingSettingsData]( key="charging_settings_mode", coordinator="charging_settings", - data_key="mode", translation_key="charging_settings_mode", device_class=SensorDeviceClass.ENUM, options=[ @@ -354,37 +333,37 @@ def _get_charging_settings_mode_formatted( RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="front_left_pressure", coordinator="pressure", - data_key="flPressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="front_left_pressure", + value_lambda=lambda e: e.coordinator.data.flPressure, ), RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="front_right_pressure", coordinator="pressure", - data_key="frPressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="front_right_pressure", + value_lambda=lambda e: e.coordinator.data.frPressure, ), RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="rear_left_pressure", coordinator="pressure", - data_key="rlPressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="rear_left_pressure", + value_lambda=lambda e: e.coordinator.data.rlPressure, ), RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="rear_right_pressure", coordinator="pressure", - data_key="rrPressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="rear_right_pressure", + value_lambda=lambda e: e.coordinator.data.rrPressure, ), ) From 554b9067881741c8e6fe38d07a2bea97e4dd2276 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:16:06 +0200 Subject: [PATCH 0553/1707] Migrate qnap_qsw to use runtime_data (#167200) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/qnap_qsw/__init__.py | 26 +++++++++---------- .../components/qnap_qsw/binary_sensor.py | 11 ++++---- homeassistant/components/qnap_qsw/button.py | 11 ++++---- homeassistant/components/qnap_qsw/const.py | 2 -- .../components/qnap_qsw/coordinator.py | 20 +++++++++++--- .../components/qnap_qsw/diagnostics.py | 18 ++++++------- homeassistant/components/qnap_qsw/entity.py | 7 +++-- homeassistant/components/qnap_qsw/sensor.py | 11 ++++---- homeassistant/components/qnap_qsw/update.py | 13 ++++------ tests/components/qnap_qsw/test_diagnostics.py | 1 - tests/components/qnap_qsw/util.py | 2 +- 11 files changed, 61 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/qnap_qsw/__init__.py b/homeassistant/components/qnap_qsw/__init__.py index f9faca025a52a2..8e90e06bc10234 100644 --- a/homeassistant/components/qnap_qsw/__init__.py +++ b/homeassistant/components/qnap_qsw/__init__.py @@ -6,14 +6,17 @@ from aioqsw.localapi import ConnectionOptions, QnapQswApi -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import DOMAIN, QSW_COORD_DATA, QSW_COORD_FW -from .coordinator import QswDataCoordinator, QswFirmwareCoordinator +from .coordinator import ( + QnapQswConfigEntry, + QnapQswData, + QswDataCoordinator, + QswFirmwareCoordinator, +) _LOGGER = logging.getLogger(__name__) @@ -25,7 +28,7 @@ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: QnapQswConfigEntry) -> bool: """Set up QNAP QSW from a config entry.""" options = ConnectionOptions( entry.data[CONF_URL], @@ -44,19 +47,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ConfigEntryNotReady as error: _LOGGER.warning(error) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - QSW_COORD_DATA: coord_data, - QSW_COORD_FW: coord_fw, - } + entry.runtime_data = QnapQswData( + data_coordinator=coord_data, + firmware_coordinator=coord_fw, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: QnapQswConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py index c1f77d068dfa35..bae91da4b488d1 100644 --- a/homeassistant/components/qnap_qsw/binary_sensor.py +++ b/homeassistant/components/qnap_qsw/binary_sensor.py @@ -20,14 +20,13 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED -from .const import ATTR_MESSAGE, DOMAIN, QSW_COORD_DATA -from .coordinator import QswDataCoordinator +from .const import ATTR_MESSAGE +from .coordinator import QnapQswConfigEntry, QswDataCoordinator from .entity import QswEntityDescription, QswEntityType, QswSensorEntity @@ -79,11 +78,11 @@ class QswBinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: QnapQswConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW binary sensors from a config_entry.""" - coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] + coordinator = entry.runtime_data.data_coordinator entities: list[QswBinarySensor] = [ QswBinarySensor(coordinator, description, entry) @@ -138,7 +137,7 @@ def __init__( self, coordinator: QswDataCoordinator, description: QswBinarySensorEntityDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, type_id: int | None = None, ) -> None: """Initialize.""" diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py index 02cf96766f2679..8ca05db84cd8e4 100644 --- a/homeassistant/components/qnap_qsw/button.py +++ b/homeassistant/components/qnap_qsw/button.py @@ -13,13 +13,12 @@ ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, QSW_COORD_DATA, QSW_REBOOT -from .coordinator import QswDataCoordinator +from .const import QSW_REBOOT +from .coordinator import QnapQswConfigEntry, QswDataCoordinator from .entity import QswDataEntity @@ -42,11 +41,11 @@ class QswButtonDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: QnapQswConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW buttons from a config_entry.""" - coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] + coordinator = entry.runtime_data.data_coordinator async_add_entities( QswButton(coordinator, description, entry) for description in BUTTON_TYPES ) @@ -63,7 +62,7 @@ def __init__( self, coordinator: QswDataCoordinator, description: QswButtonDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator, entry) diff --git a/homeassistant/components/qnap_qsw/const.py b/homeassistant/components/qnap_qsw/const.py index 4b5fa9a4a2c843..05eeea031b51a0 100644 --- a/homeassistant/components/qnap_qsw/const.py +++ b/homeassistant/components/qnap_qsw/const.py @@ -10,8 +10,6 @@ RPM: Final = "rpm" -QSW_COORD_DATA: Final = "coordinator-data" -QSW_COORD_FW: Final = "coordinator-firmware" QSW_REBOOT = "reboot" QSW_TIMEOUT_SEC: Final = 25 QSW_UPDATE: Final = "update" diff --git a/homeassistant/components/qnap_qsw/coordinator.py b/homeassistant/components/qnap_qsw/coordinator.py index b72bed7105ccab..6f369915a6c13d 100644 --- a/homeassistant/components/qnap_qsw/coordinator.py +++ b/homeassistant/components/qnap_qsw/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -22,13 +23,24 @@ _LOGGER = logging.getLogger(__name__) +@dataclass +class QnapQswData: + """Data for the QNAP QSW integration.""" + + data_coordinator: QswDataCoordinator + firmware_coordinator: QswFirmwareCoordinator + + +type QnapQswConfigEntry = ConfigEntry[QnapQswData] + + class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the QNAP QSW device.""" - config_entry: ConfigEntry + config_entry: QnapQswConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, qsw: QnapQswApi + self, hass: HomeAssistant, config_entry: QnapQswConfigEntry, qsw: QnapQswApi ) -> None: """Initialize.""" self.qsw = qsw @@ -54,10 +66,10 @@ async def _async_update_data(self) -> dict[str, Any]: class QswFirmwareCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching firmware data from the QNAP QSW device.""" - config_entry: ConfigEntry + config_entry: QnapQswConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, qsw: QnapQswApi + self, hass: HomeAssistant, config_entry: QnapQswConfigEntry, qsw: QnapQswApi ) -> None: """Initialize.""" self.qsw = qsw diff --git a/homeassistant/components/qnap_qsw/diagnostics.py b/homeassistant/components/qnap_qsw/diagnostics.py index 6f42fb82cb7d78..d6a8b95882903d 100644 --- a/homeassistant/components/qnap_qsw/diagnostics.py +++ b/homeassistant/components/qnap_qsw/diagnostics.py @@ -7,12 +7,10 @@ from aioqsw.const import QSD_MAC, QSD_SERIAL from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN, QSW_COORD_DATA, QSW_COORD_FW -from .coordinator import QswDataCoordinator, QswFirmwareCoordinator +from .coordinator import QnapQswConfigEntry TO_REDACT_CONFIG = [ CONF_USERNAME, @@ -27,15 +25,15 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: QnapQswConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - coord_data: QswDataCoordinator = entry_data[QSW_COORD_DATA] - coord_fw: QswFirmwareCoordinator = entry_data[QSW_COORD_FW] - return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT_CONFIG), - "coord_data": async_redact_data(coord_data.data, TO_REDACT_DATA), - "coord_fw": async_redact_data(coord_fw.data, TO_REDACT_DATA), + "coord_data": async_redact_data( + config_entry.runtime_data.data_coordinator.data, TO_REDACT_DATA + ), + "coord_fw": async_redact_data( + config_entry.runtime_data.firmware_coordinator.data, TO_REDACT_DATA + ), } diff --git a/homeassistant/components/qnap_qsw/entity.py b/homeassistant/components/qnap_qsw/entity.py index a3038b1fc7b5db..40670c9f28815b 100644 --- a/homeassistant/components/qnap_qsw/entity.py +++ b/homeassistant/components/qnap_qsw/entity.py @@ -16,7 +16,6 @@ QSD_SYSTEM_BOARD, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -24,7 +23,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import MANUFACTURER -from .coordinator import QswDataCoordinator, QswFirmwareCoordinator +from .coordinator import QnapQswConfigEntry, QswDataCoordinator, QswFirmwareCoordinator class QswEntityType(StrEnum): @@ -40,7 +39,7 @@ class QswDataEntity(CoordinatorEntity[QswDataCoordinator]): def __init__( self, coordinator: QswDataCoordinator, - entry: ConfigEntry, + entry: QnapQswConfigEntry, type_id: int | None = None, ) -> None: """Initialize.""" @@ -127,7 +126,7 @@ class QswFirmwareEntity(CoordinatorEntity[QswFirmwareCoordinator]): def __init__( self, coordinator: QswFirmwareCoordinator, - entry: ConfigEntry, + entry: QnapQswConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator) diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index af02c121656af5..bed69472c85398 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -36,7 +36,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfDataRate, @@ -48,8 +47,8 @@ from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util import dt as dt_util -from .const import ATTR_MAX, DOMAIN, QSW_COORD_DATA, RPM -from .coordinator import QswDataCoordinator +from .const import ATTR_MAX, RPM +from .coordinator import QnapQswConfigEntry, QswDataCoordinator from .entity import QswEntityDescription, QswEntityType, QswSensorEntity @@ -287,11 +286,11 @@ class QswSensorEntityDescription(SensorEntityDescription, QswEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: QnapQswConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW sensors from a config_entry.""" - coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] + coordinator = entry.runtime_data.data_coordinator entities: list[QswSensor] = [ QswSensor(coordinator, description, entry) @@ -354,7 +353,7 @@ def __init__( self, coordinator: QswDataCoordinator, description: QswSensorEntityDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, type_id: int | None = None, ) -> None: """Initialize.""" diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py index c5cef7298493fe..f9652d4e4f46db 100644 --- a/homeassistant/components/qnap_qsw/update.py +++ b/homeassistant/components/qnap_qsw/update.py @@ -17,13 +17,12 @@ UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, QSW_COORD_FW, QSW_UPDATE -from .coordinator import QswFirmwareCoordinator +from .const import QSW_UPDATE +from .coordinator import QnapQswConfigEntry, QswFirmwareCoordinator from .entity import QswFirmwareEntity UPDATE_TYPES: Final[tuple[UpdateEntityDescription, ...]] = ( @@ -37,13 +36,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: QnapQswConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW updates from a config_entry.""" - coordinator: QswFirmwareCoordinator = hass.data[DOMAIN][entry.entry_id][ - QSW_COORD_FW - ] + coordinator = entry.runtime_data.firmware_coordinator async_add_entities( QswUpdate(coordinator, description, entry) for description in UPDATE_TYPES ) @@ -59,7 +56,7 @@ def __init__( self, coordinator: QswFirmwareCoordinator, description: UpdateEntityDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator, entry) diff --git a/tests/components/qnap_qsw/test_diagnostics.py b/tests/components/qnap_qsw/test_diagnostics.py index ccaac458b12240..1254d121d899b3 100644 --- a/tests/components/qnap_qsw/test_diagnostics.py +++ b/tests/components/qnap_qsw/test_diagnostics.py @@ -53,7 +53,6 @@ async def test_config_entry_diagnostics( ) -> None: """Test config entry diagnostics.""" await async_init_integration(hass) - assert hass.data[DOMAIN] config_entry = hass.config_entries.async_entries(DOMAIN)[0] diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) diff --git a/tests/components/qnap_qsw/util.py b/tests/components/qnap_qsw/util.py index 5132c1061ec591..1810340978bd89 100644 --- a/tests/components/qnap_qsw/util.py +++ b/tests/components/qnap_qsw/util.py @@ -47,7 +47,7 @@ API_VERSION, ) -from homeassistant.components.qnap_qsw import DOMAIN +from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant From f34301f2362c624fdfb9376bcf2ce1644f6fc162 Mon Sep 17 00:00:00 2001 From: Fabian Neundorf Date: Tue, 7 Apr 2026 12:20:22 +0200 Subject: [PATCH 0554/1707] Bump python-picnic-api2 to 1.3.4 (#167539) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/picnic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index c1bc18b6c65193..d75b145aecf8a8 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_picnic_api2"], - "requirements": ["python-picnic-api2==1.3.1"] + "requirements": ["python-picnic-api2==1.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5d60045d5f17f1..ed06e1946d21ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2648,7 +2648,7 @@ python-otbr-api==2.9.0 python-overseerr==0.9.0 # homeassistant.components.picnic -python-picnic-api2==1.3.1 +python-picnic-api2==1.3.4 # homeassistant.components.pooldose python-pooldose==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7dcbafe210f448..e34cef29fee091 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2250,7 +2250,7 @@ python-otbr-api==2.9.0 python-overseerr==0.9.0 # homeassistant.components.picnic -python-picnic-api2==1.3.1 +python-picnic-api2==1.3.4 # homeassistant.components.pooldose python-pooldose==0.9.0 From cb71628ee2241fc8e35882f0dfe604b6f8e00723 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:36:37 +0200 Subject: [PATCH 0555/1707] Validate entity ID domain (#167228) --- homeassistant/helpers/entity_platform.py | 56 ++++++++++++++---------- tests/helpers/test_entity.py | 2 +- tests/helpers/test_entity_component.py | 8 ++-- tests/helpers/test_entity_platform.py | 32 +++++++++++++- 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 9ad5fbd5f61a78..3ef6dfac39c519 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -787,7 +787,7 @@ def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: already_exists = True return (already_exists, restored) - async def _async_add_entity( + async def _async_add_entity( # noqa: C901 self, entity: Entity, update_before_add: bool, @@ -822,29 +822,41 @@ async def _async_add_entity( # An entity may suggest the entity_id by setting entity_id itself if not hasattr(entity, "internal_integration_suggested_object_id"): - if entity.entity_id is not None and not valid_entity_id(entity.entity_id): - if entity.unique_id is not None: - report_usage( - f"sets an invalid entity ID: '{entity.entity_id}'. " - "In most cases, entities should not set entity_id," - " but if they do, it should be a valid entity ID.", - integration_domain=self.platform_name, - breaks_in_ha_version="2027.2.0", + if entity.entity_id is None: + entity.internal_integration_suggested_object_id = None # type: ignore[unreachable] + else: + if not valid_entity_id(entity.entity_id): + if entity.unique_id is not None: + report_usage( + f"sets an invalid entity ID: '{entity.entity_id}'. " + "In most cases, entities should not set entity_id," + " but if they do, it should be a valid entity ID", + integration_domain=self.platform_name, + breaks_in_ha_version="2027.2.0", + ) + else: + entity.add_to_platform_abort() + raise HomeAssistantError( + f"Invalid entity ID: {entity.entity_id}" + ) + try: + domain, entity.internal_integration_suggested_object_id = ( + split_entity_id(entity.entity_id) ) - else: + if domain != self.domain: + report_usage( + f"sets an entity ID with wrong domain: '{entity.entity_id}'. " + f"Expected domain is '{self.domain}'", + integration_domain=self.platform_name, + breaks_in_ha_version="2027.5.0", + ) + except ValueError: + # This error handling should be removed once we remove + # the invalid entity ID deprecation above. entity.add_to_platform_abort() - raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}") - try: - entity.internal_integration_suggested_object_id = ( - split_entity_id(entity.entity_id)[1] - if entity.entity_id is not None - else None - ) - except ValueError: - entity.add_to_platform_abort() - raise HomeAssistantError( - f"Invalid entity ID: {entity.entity_id}" - ) from None + raise HomeAssistantError( + f"Invalid entity ID: {entity.entity_id}" + ) from None # Get entity_id from unique ID registration if entity.unique_id is not None: diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index b8f9182c92dc52..fd4f5b35e8e25e 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1638,7 +1638,7 @@ def state(self): """Return the state.""" raise ValueError("Boom") - platform = MockEntityPlatform(hass, domain="hello") + platform = MockEntityPlatform(hass, domain="test") my_entity = MyEntity(entity_id="test.test", available=False) # Not yet added diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 6cdbcc8bb60abe..d5e81a6a5b88ab 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -301,13 +301,15 @@ async def test_extract_from_service_no_group_expand(hass: HomeAssistant) -> None """Test not expanding a group.""" component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_setup({}) - await component.async_add_entities([MockEntity(entity_id="group.test_group")]) + await component.async_add_entities([MockEntity(entity_id="test_domain.test_group")]) - call = ServiceCall(hass, "test", "service", {"entity_id": ["group.test_group"]}) + call = ServiceCall( + hass, "test", "service", {"entity_id": ["test_domain.test_group"]} + ) extracted = await component.async_extract_from_service(call, expand_group=False) assert len(extracted) == 1 - assert extracted[0].entity_id == "group.test_group" + assert extracted[0].entity_id == "test_domain.test_group" async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 285be9bdfb5c8f..c440ed6883d53c 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -683,9 +683,9 @@ async def test_using_prescribed_entity_id(hass: HomeAssistant) -> None: component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_setup({}) await component.async_add_entities( - [MockEntity(name="bla", entity_id="hello.world")] + [MockEntity(name="bla", entity_id="test_domain.world")] ) - assert "hello.world" in hass.states.async_entity_ids() + assert "test_domain.world" in hass.states.async_entity_ids() async def test_using_prescribed_entity_id_with_unique_id(hass: HomeAssistant) -> None: @@ -2001,6 +2001,34 @@ async def test_invalid_entity_id_report_usage( assert entity.platform is not None +async def test_wrong_domain_entity_id_report_usage( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that setting an entity_id with wrong domain reports usage.""" + platform = MockEntityPlatform(hass) + entity = MockEntity(entity_id="wrong_domain.some_entity", unique_id="unique") + + mock_integration = Mock(is_built_in=True, domain="test_platform") + with ( + caplog.at_level(logging.WARNING), + patch( + "homeassistant.helpers.frame.async_get_issue_integration", + return_value=mock_integration, + ), + ): + await platform.async_add_entities([entity]) + + assert ( + "Detected that integration 'test_platform' " + "sets an entity ID with wrong domain: 'wrong_domain.some_entity'. " + "Expected domain is 'test_domain'" + ) in caplog.text + + # Ensure the entity was still added + assert entity.hass is not None + assert entity.platform is not None + + class MockBlockingEntity(MockEntity): """Class to mock an entity that will block adding entities.""" From 856d363ca8fbe4aa818dbd90561ca6b9bad551b4 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 7 Apr 2026 12:39:13 +0200 Subject: [PATCH 0556/1707] Align Fritz test data mock to real implementation (#167511) --- homeassistant/components/fritz/coordinator.py | 2 ++ tests/components/fritz/const.py | 11 ++++-- .../fritz/snapshots/test_binary_sensor.ambr | 4 +-- .../fritz/snapshots/test_button.ambr | 8 ++--- .../fritz/snapshots/test_diagnostics.ambr | 2 +- .../fritz/snapshots/test_sensor.ambr | 34 +++++++++---------- .../fritz/snapshots/test_switch.ambr | 34 +++++++++---------- .../fritz/snapshots/test_update.ambr | 6 ++-- tests/components/fritz/test_coordinator.py | 4 +-- tests/components/fritz/test_image.py | 15 +++++--- tests/components/fritz/test_services.py | 18 +++++----- tests/components/fritz/test_switch.py | 7 ++-- 12 files changed, 79 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index f2e28e06366aca..55743af5e8362d 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -379,6 +379,8 @@ def mac(self) -> str: """Return device Mac address.""" if not self._unique_id: raise ClassSetupMissing + # Unique ID is the serial number of the device + # which is the MAC of the device without the colons return dr.format_mac(self._unique_id) @property diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index 49ab453dac3c4f..0055192c79c8b4 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -48,10 +48,15 @@ MOCK_FIRMWARE_RELEASE_URL = ( "http://download.avm.de/fritzbox/fritzbox-7530-ax/deutschland/fritz.os/info_de.txt" ) -MOCK_SERIAL_NUMBER = "fake_serial_number" + +# The serial number needs to be in sync with the MAC address of the router +# because the second is computed from the first one in the code. +MOCK_SERIAL_NUMBER = "1CED6F123411" +MOCK_MESH_MASTER_MAC = "1C:ED:6F:12:34:11" + MOCK_FIRMWARE_INFO = [True, "1.1.1", "some-release-url"] MOCK_MESH_SSID = "TestSSID" -MOCK_MESH_MASTER_MAC = "1C:ED:6F:12:34:11" + MOCK_MESH_MASTER_WIFI1_MAC = "1C:ED:6F:12:34:12" MOCK_MESH_SLAVE_MAC = "1C:ED:6F:12:34:21" MOCK_MESH_SLAVE_WIFI1_MAC = "1C:ED:6F:12:34:22" @@ -59,7 +64,7 @@ MOCK_FB_SERVICES: dict[str, dict[str, Any]] = { "DeviceInfo1": { "GetInfo": { - "NewSerialNumber": MOCK_MESH_MASTER_MAC, + "NewSerialNumber": MOCK_SERIAL_NUMBER, "NewName": "TheName", "NewManufacturerName": "AVM", "NewManufacturerOUI": "00040E", diff --git a/tests/components/fritz/snapshots/test_binary_sensor.ambr b/tests/components/fritz/snapshots/test_binary_sensor.ambr index a68a8a6cacd599..a599380edb4f6a 100644 --- a/tests/components/fritz/snapshots/test_binary_sensor.ambr +++ b/tests/components/fritz/snapshots/test_binary_sensor.ambr @@ -32,7 +32,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_connected', - 'unique_id': '1C:ED:6F:12:34:11-is_connected', + 'unique_id': '1CED6F123411-is_connected', 'unit_of_measurement': None, }) # --- @@ -83,7 +83,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'is_linked', - 'unique_id': '1C:ED:6F:12:34:11-is_linked', + 'unique_id': '1CED6F123411-is_linked', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/fritz/snapshots/test_button.ambr b/tests/components/fritz/snapshots/test_button.ambr index 19a64f4e553bd6..498f4b9d1bbbe6 100644 --- a/tests/components/fritz/snapshots/test_button.ambr +++ b/tests/components/fritz/snapshots/test_button.ambr @@ -32,7 +32,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cleanup', - 'unique_id': '1C:ED:6F:12:34:11-cleanup', + 'unique_id': '1CED6F123411-cleanup', 'unit_of_measurement': None, }) # --- @@ -82,7 +82,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'firmware_update', - 'unique_id': '1C:ED:6F:12:34:11-firmware_update', + 'unique_id': '1CED6F123411-firmware_update', 'unit_of_measurement': None, }) # --- @@ -133,7 +133,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reconnect', - 'unique_id': '1C:ED:6F:12:34:11-reconnect', + 'unique_id': '1CED6F123411-reconnect', 'unit_of_measurement': None, }) # --- @@ -184,7 +184,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-reboot', + 'unique_id': '1CED6F123411-reboot', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index bfea484e7f77d8..ee750a05d02fd4 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -49,7 +49,7 @@ 'latest_firmware': None, 'mesh_role': 'master', 'model': 'FRITZ!Box 7530 AX', - 'unique_id': '1C:ED:XX:XX:34:11', + 'unique_id': '1CED6FXX:XX1', 'update_available': False, 'wan_link_properties': dict({ 'NewLayer1DownstreamMaxBitRate': 318557000, diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index 5e6c762866b52a..74cd49f6e400a7 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -32,7 +32,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'connection_uptime', - 'unique_id': '1C:ED:6F:12:34:11-connection_uptime', + 'unique_id': '1CED6F123411-connection_uptime', 'unit_of_measurement': None, }) # --- @@ -88,7 +88,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cpu_temperature', - 'unique_id': '1C:ED:6F:12:34:11-cpu_temperature', + 'unique_id': '1CED6F123411-cpu_temperature', 'unit_of_measurement': , }) # --- @@ -146,7 +146,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'kb_s_received', - 'unique_id': '1C:ED:6F:12:34:11-kb_s_received', + 'unique_id': '1CED6F123411-kb_s_received', 'unit_of_measurement': , }) # --- @@ -199,7 +199,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'external_ip', - 'unique_id': '1C:ED:6F:12:34:11-external_ip', + 'unique_id': '1CED6F123411-external_ip', 'unit_of_measurement': None, }) # --- @@ -249,7 +249,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'external_ipv6', - 'unique_id': '1C:ED:6F:12:34:11-external_ipv6', + 'unique_id': '1CED6F123411-external_ipv6', 'unit_of_measurement': None, }) # --- @@ -304,7 +304,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gb_received', - 'unique_id': '1C:ED:6F:12:34:11-gb_received', + 'unique_id': '1CED6F123411-gb_received', 'unit_of_measurement': , }) # --- @@ -362,7 +362,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gb_sent', - 'unique_id': '1C:ED:6F:12:34:11-gb_sent', + 'unique_id': '1CED6F123411-gb_sent', 'unit_of_measurement': , }) # --- @@ -415,7 +415,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'device_uptime', - 'unique_id': '1C:ED:6F:12:34:11-device_uptime', + 'unique_id': '1CED6F123411-device_uptime', 'unit_of_measurement': None, }) # --- @@ -466,7 +466,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_noise_margin_received', - 'unique_id': '1C:ED:6F:12:34:11-link_noise_margin_received', + 'unique_id': '1CED6F123411-link_noise_margin_received', 'unit_of_measurement': 'dB', }) # --- @@ -517,7 +517,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_attenuation_received', - 'unique_id': '1C:ED:6F:12:34:11-link_attenuation_received', + 'unique_id': '1CED6F123411-link_attenuation_received', 'unit_of_measurement': 'dB', }) # --- @@ -571,7 +571,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_kb_s_received', - 'unique_id': '1C:ED:6F:12:34:11-link_kb_s_received', + 'unique_id': '1CED6F123411-link_kb_s_received', 'unit_of_measurement': , }) # --- @@ -623,7 +623,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_noise_margin_sent', - 'unique_id': '1C:ED:6F:12:34:11-link_noise_margin_sent', + 'unique_id': '1CED6F123411-link_noise_margin_sent', 'unit_of_measurement': 'dB', }) # --- @@ -674,7 +674,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_attenuation_sent', - 'unique_id': '1C:ED:6F:12:34:11-link_attenuation_sent', + 'unique_id': '1CED6F123411-link_attenuation_sent', 'unit_of_measurement': 'dB', }) # --- @@ -728,7 +728,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'link_kb_s_sent', - 'unique_id': '1C:ED:6F:12:34:11-link_kb_s_sent', + 'unique_id': '1CED6F123411-link_kb_s_sent', 'unit_of_measurement': , }) # --- @@ -783,7 +783,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_kb_s_received', - 'unique_id': '1C:ED:6F:12:34:11-max_kb_s_received', + 'unique_id': '1CED6F123411-max_kb_s_received', 'unit_of_measurement': , }) # --- @@ -838,7 +838,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'max_kb_s_sent', - 'unique_id': '1C:ED:6F:12:34:11-max_kb_s_sent', + 'unique_id': '1CED6F123411-max_kb_s_sent', 'unit_of_measurement': , }) # --- @@ -895,7 +895,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'kb_s_sent', - 'unique_id': '1C:ED:6F:12:34:11-kb_s_sent', + 'unique_id': '1CED6F123411-kb_s_sent', 'unit_of_measurement': , }) # --- diff --git a/tests/components/fritz/snapshots/test_switch.ambr b/tests/components/fritz/snapshots/test_switch.ambr index 9002c780243ad2..16a0e5d88df988 100644 --- a/tests/components/fritz/snapshots/test_switch.ambr +++ b/tests/components/fritz/snapshots/test_switch.ambr @@ -32,7 +32,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-port_forward_test_port_mapping', + 'unique_id': '1CED6F123411-port_forward_test_port_mapping', 'unit_of_measurement': None, }) # --- @@ -83,7 +83,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-port_forward_test_port_mapping_81', + 'unique_id': '1CED6F123411-port_forward_test_port_mapping_81', 'unit_of_measurement': None, }) # --- @@ -134,7 +134,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wi_fi_guest', - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guest', + 'unique_id': '1CED6F123411-wi_fi_guest', 'unit_of_measurement': None, }) # --- @@ -185,7 +185,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wi_fi_main_2_4ghz', - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_main_2_4ghz', + 'unique_id': '1CED6F123411-wi_fi_main_2_4ghz', 'unit_of_measurement': None, }) # --- @@ -286,7 +286,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-port_forward_test_port_mapping', + 'unique_id': '1CED6F123411-port_forward_test_port_mapping', 'unit_of_measurement': None, }) # --- @@ -337,7 +337,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-port_forward_test_port_mapping_81', + 'unique_id': '1CED6F123411-port_forward_test_port_mapping_81', 'unit_of_measurement': None, }) # --- @@ -388,7 +388,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wi_fi_guest', - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guest', + 'unique_id': '1CED6F123411-wi_fi_guest', 'unit_of_measurement': None, }) # --- @@ -439,7 +439,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wi_fi_main_2_4ghz', - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_main_2_4ghz', + 'unique_id': '1CED6F123411-wi_fi_main_2_4ghz', 'unit_of_measurement': None, }) # --- @@ -540,7 +540,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-port_forward_test_port_mapping', + 'unique_id': '1CED6F123411-port_forward_test_port_mapping', 'unit_of_measurement': None, }) # --- @@ -591,7 +591,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-port_forward_test_port_mapping_81', + 'unique_id': '1CED6F123411-port_forward_test_port_mapping_81', 'unit_of_measurement': None, }) # --- @@ -642,7 +642,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wi_fi_guest', - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guest', + 'unique_id': '1CED6F123411-wi_fi_guest', 'unit_of_measurement': None, }) # --- @@ -693,7 +693,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wi_fi_main_2_4ghz', - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_main_2_4ghz', + 'unique_id': '1CED6F123411-wi_fi_main_2_4ghz', 'unit_of_measurement': None, }) # --- @@ -794,7 +794,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-call_deflection_0', + 'unique_id': '1CED6F123411-call_deflection_0', 'unit_of_measurement': None, }) # --- @@ -851,7 +851,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-port_forward_test_port_mapping', + 'unique_id': '1CED6F123411-port_forward_test_port_mapping', 'unit_of_measurement': None, }) # --- @@ -902,7 +902,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-port_forward_test_port_mapping_81', + 'unique_id': '1CED6F123411-port_forward_test_port_mapping_81', 'unit_of_measurement': None, }) # --- @@ -953,7 +953,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wi_fi_guest', - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guest', + 'unique_id': '1CED6F123411-wi_fi_guest', 'unit_of_measurement': None, }) # --- @@ -1004,7 +1004,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wi_fi_main_2_4ghz', - 'unique_id': '1C:ED:6F:12:34:11-wi_fi_main_2_4ghz', + 'unique_id': '1CED6F123411-wi_fi_main_2_4ghz', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/fritz/snapshots/test_update.ambr b/tests/components/fritz/snapshots/test_update.ambr index 3643bbdb3716e2..9a3f64efc87dbe 100644 --- a/tests/components/fritz/snapshots/test_update.ambr +++ b/tests/components/fritz/snapshots/test_update.ambr @@ -32,7 +32,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-update', + 'unique_id': '1CED6F123411-update', 'unit_of_measurement': None, }) # --- @@ -94,7 +94,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-update', + 'unique_id': '1CED6F123411-update', 'unit_of_measurement': None, }) # --- @@ -156,7 +156,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '1C:ED:6F:12:34:11-update', + 'unique_id': '1CED6F123411-update', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/fritz/test_coordinator.py b/tests/components/fritz/test_coordinator.py index ad149467de568e..a11dba83fed829 100644 --- a/tests/components/fritz/test_coordinator.py +++ b/tests/components/fritz/test_coordinator.py @@ -40,7 +40,7 @@ from homeassistant.helpers import device_registry as dr from .conftest import FritzConnectionMock, FritzServiceMock -from .const import MOCK_MESH_MASTER_MAC, MOCK_STATUS_DEVICE_INFO_DATA, MOCK_USER_DATA +from .const import MOCK_SERIAL_NUMBER, MOCK_STATUS_DEVICE_INFO_DATA, MOCK_USER_DATA from tests.common import MockConfigEntry @@ -195,7 +195,7 @@ async def test_no_software_version( assert entry.state is ConfigEntryState.LOADED device = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_MESH_MASTER_MAC)} + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} ) assert device assert device.sw_version == "string_version_not_number" diff --git a/tests/components/fritz/test_image.py b/tests/components/fritz/test_image.py index 8f553bfd3bd268..500e793a326ed4 100644 --- a/tests/components/fritz/test_image.py +++ b/tests/components/fritz/test_image.py @@ -17,7 +17,12 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import slugify -from .const import MOCK_FB_SERVICES, MOCK_MESH_MASTER_MAC, MOCK_USER_DATA +from .const import ( + MOCK_FB_SERVICES, + MOCK_MESH_MASTER_MAC, + MOCK_SERIAL_NUMBER, + MOCK_USER_DATA, +) from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -108,7 +113,7 @@ async def test_image_entity( } assert (state := entity_registry.async_get("image.mock_title_guestwifi")) - assert state.unique_id == "1C:ED:6F:12:34:11-guest_wifi_qr_code" + assert state.unique_id == f"{MOCK_SERIAL_NUMBER}-guest_wifi_qr_code" # test image download client = await hass_client() @@ -224,8 +229,8 @@ async def test_migrate_to_new_unique_id( ) entry.add_to_hass(hass) - old_unique_id = slugify(f"{MOCK_MESH_MASTER_MAC}-GuestWifi-qr-code") - new_unique_id = f"{MOCK_MESH_MASTER_MAC}-guest_wifi_qr_code" + old_unique_id = slugify(f"{MOCK_SERIAL_NUMBER}-GuestWifi-qr-code") + new_unique_id = f"{MOCK_SERIAL_NUMBER}-guest_wifi_qr_code" entity_registry.async_get_or_create( suggested_object_id="mock_title_mywifi", @@ -238,7 +243,7 @@ async def test_migrate_to_new_unique_id( device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, mock_unique_id)}, + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)}, connections={(dr.CONNECTION_NETWORK_MAC, MOCK_MESH_MASTER_MAC)}, ) await hass.async_block_till_done() diff --git a/tests/components/fritz/test_services.py b/tests/components/fritz/test_services.py index 2cb7b948514cea..b1885c3122dcfd 100644 --- a/tests/components/fritz/test_services.py +++ b/tests/components/fritz/test_services.py @@ -19,7 +19,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .const import MOCK_MESH_MASTER_MAC, MOCK_USER_DATA +from .const import MOCK_SERIAL_NUMBER, MOCK_USER_DATA from tests.common import MockConfigEntry @@ -50,7 +50,7 @@ async def test_service_set_guest_wifi_password( await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_MESH_MASTER_MAC)} + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} ) assert device with patch( @@ -78,7 +78,7 @@ async def test_service_set_guest_wifi_password_unknown_parameter( await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_MESH_MASTER_MAC)} + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} ) assert device @@ -109,7 +109,7 @@ async def test_service_set_guest_wifi_password_service_not_supported( await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_MESH_MASTER_MAC)} + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} ) assert device @@ -161,7 +161,7 @@ async def test_service_dial( await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_MESH_MASTER_MAC)} + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} ) assert device with patch( @@ -193,7 +193,7 @@ async def test_service_dial_unknown_parameter( await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_MESH_MASTER_MAC)} + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} ) assert device @@ -226,7 +226,7 @@ async def test_service_dial_wrong_parameter( await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_MESH_MASTER_MAC)} + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} ) assert device @@ -276,7 +276,7 @@ async def test_service_dial_service_not_supported( await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_MESH_MASTER_MAC)} + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} ) assert device @@ -309,7 +309,7 @@ async def test_service_dial_failed( await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, MOCK_MESH_MASTER_MAC)} + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} ) assert device diff --git a/tests/components/fritz/test_switch.py b/tests/components/fritz/test_switch.py index 895b738451a8cc..2bf53065f1ee85 100644 --- a/tests/components/fritz/test_switch.py +++ b/tests/components/fritz/test_switch.py @@ -36,6 +36,7 @@ MOCK_FB_SERVICES, MOCK_HOST_ATTRIBUTES_DATA, MOCK_MESH_MASTER_MAC, + MOCK_SERIAL_NUMBER, MOCK_USER_DATA, ) @@ -515,8 +516,8 @@ async def test_migrate_to_new_unique_id( for old_description, new_identifier in zip( old_descriptions, new_identifiers, strict=True ): - old_unique_id = f"{MOCK_MESH_MASTER_MAC}-{slugify(old_description)}" - new_unique_id = f"{MOCK_MESH_MASTER_MAC}-wi_fi_{new_identifier}" + old_unique_id = f"{MOCK_SERIAL_NUMBER}-{slugify(old_description)}" + new_unique_id = f"{MOCK_SERIAL_NUMBER}-wi_fi_{new_identifier}" old_unique_ids.append(old_unique_id) new_unique_ids.append(new_unique_id) entity_ids.append(f"switch.fritz_{slugify(old_unique_id)}") @@ -531,7 +532,7 @@ async def test_migrate_to_new_unique_id( device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, MOCK_UNIQUE_ID)}, + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)}, connections={ (dr.CONNECTION_NETWORK_MAC, MOCK_MESH_MASTER_MAC), }, From c9cdbcc3db86828d31539571e47fcea5ccf85856 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:46:38 +0300 Subject: [PATCH 0557/1707] Bump victron-mqtt to 2026.4.2 (#167565) --- homeassistant/components/victron_gx/entity.py | 12 +- .../components/victron_gx/manifest.json | 2 +- .../components/victron_gx/strings.json | 126 ------------------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/victron_gx/test_init.py | 24 ++++ tests/components/victron_gx/test_sensor.py | 34 +++++ 7 files changed, 69 insertions(+), 133 deletions(-) diff --git a/homeassistant/components/victron_gx/entity.py b/homeassistant/components/victron_gx/entity.py index 321f371f51d199..059eca0256fb8f 100644 --- a/homeassistant/components/victron_gx/entity.py +++ b/homeassistant/components/victron_gx/entity.py @@ -35,10 +35,14 @@ def __init__( self._attr_device_info = device_info self._attr_unique_id = f"{installation_id}_{metric.unique_id}" self._attr_suggested_display_precision = metric.precision - self._attr_translation_key = metric.generic_short_id.replace("{", "").replace( - "}", "" - ) - self._attr_translation_placeholders = metric.key_values + # When main_topic is set, omit translation_key/name so HA uses the device name (via _attr_has_entity_name). + if metric.main_topic: + self._attr_name = None + else: + self._attr_translation_key = metric.generic_short_id.replace( + "{", "" + ).replace("}", "") + self._attr_translation_placeholders = metric.key_values self._attr_entity_category = ( EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index aced508123bc61..4c09621e1f324a 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["victron-mqtt==2026.4.1"], + "requirements": ["victron-mqtt==2026.4.2"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 3eb2923f405d09..cb7eadda9c028c 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -47,128 +47,6 @@ } }, "entity": { - "binary_sensor": { - "alternator_mode": { - "name": "Mode" - }, - "dcdc_mode": { - "name": "Mode" - }, - "digitalinput_settings_invert_translation": { - "name": "Invert digital input" - }, - "evcharger_charge": { - "name": "EV charging" - }, - "evcharger_connected": { - "name": "Connected" - }, - "generator_autorun": { - "name": "Auto-start enabled" - }, - "generator_gen_id_quiet_hours_enabled": { - "name": "Generator quiet hours enabled" - }, - "generator_gen_id_start_on_soc_enabled": { - "name": "Generator start on SOC enabled" - }, - "generator_gen_id_start_on_temp_enabled": { - "name": "Generator start on high temp enabled" - }, - "generator_gen_id_start_on_voltage_enabled": { - "name": "Generator start on voltage enabled" - }, - "generator_manual_start": { - "name": "Manual start" - }, - "gps_connected": { - "name": "Connected" - }, - "gps_fix": { - "name": "Fix" - }, - "inverter_alarm_high_temperature": { - "name": "High temperature alarm" - }, - "inverter_alarm_high_voltage": { - "name": "High voltage alarm" - }, - "inverter_alarm_high_voltage_ac_out": { - "name": "High voltage AC-out alarm" - }, - "inverter_alarm_low_temperature": { - "name": "Low temperature alarm" - }, - "inverter_alarm_low_voltage": { - "name": "Low voltage alarm" - }, - "inverter_alarm_low_voltage_ac_out": { - "name": "Low voltage AC-out alarm" - }, - "inverter_alarm_overload": { - "name": "Overload alarm" - }, - "inverter_alarm_ripple": { - "name": "Ripple alarm" - }, - "multi_disable_charge": { - "name": "ESS disable charge" - }, - "multi_disable_feed_in": { - "name": "ESS disable feed-in" - }, - "multi_relay0_state": { - "name": "Relay on Multi RS state" - }, - "solarcharger_load_state": { - "name": "Load state" - }, - "solarcharger_mode": { - "name": "Mode" - }, - "solarcharger_relay_state": { - "name": "Relay state" - }, - "switch_output_state": { - "name": "Switch {output} state" - }, - "switchable_output_output_state": { - "name": "Switchable output {output} state" - }, - "system_dynamicess_active": { - "name": "Dynamic ESS active" - }, - "system_dynamicess_allow_gridfeedin": { - "name": "Dynamic ESS allow grid feed-in" - }, - "system_dynamicess_available": { - "name": "Dynamic ESS available" - }, - "system_ess_battery_use": { - "name": "ESS only critical loads from battery" - }, - "system_ess_schedule_charge_slot_enabled": { - "name": "ESS BatteryLife schedule charge {slot} enabled" - }, - "system_relay_relay": { - "name": "Relay {relay} state" - }, - "system_settings_overvoltage_feedin": { - "name": "PV DC overvoltage feed-in" - }, - "vebus_device_device_number_power_assist_enabled": { - "name": "{device_number} PowerAssist enabled" - }, - "vebus_inverter_connected": { - "name": "Connected" - }, - "vebus_inverter_ignoreacin1_onoff_control": { - "name": "Control ignore AC-in-1" - }, - "vebus_inverter_setting_alarm_grid_lost": { - "name": "Grid lost alarm setting" - } - }, "sensor": { "acload_current": { "name": "Load current" @@ -198,7 +76,6 @@ "name": "Voltage on {phase}" }, "acsystem_mode": { - "name": "Mode", "state": { "charger_only": "Charger only", "inverter_only": "Inverter only", @@ -884,7 +761,6 @@ "name": "AC grid setpoint" }, "inverter_mode": { - "name": "Mode", "state": { "eco": "Eco", "inverter": "Inverter", @@ -1020,7 +896,6 @@ "name": "MPPT {mpptnumber} power" }, "multi_mppt_mpptnumber_state": { - "name": "MPPT {mpptnumber} state", "state": { "mppt_active": "MPPT active", "not_available": "Not available", @@ -1837,7 +1712,6 @@ "name": "Input voltage {phase}" }, "vebus_inverter_mode": { - "name": "Mode", "state": { "charger_only": "Charger Only", "inverter_only": "Inverter Only", diff --git a/requirements_all.txt b/requirements_all.txt index ed06e1946d21ad..0656f9492e9384 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3237,7 +3237,7 @@ viaggiatreno_ha==0.2.4 victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_gx -victron-mqtt==2026.4.1 +victron-mqtt==2026.4.2 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e34cef29fee091..556d678433662a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2740,7 +2740,7 @@ venstarcolortouch==0.21 victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_gx -victron-mqtt==2026.4.1 +victron-mqtt==2026.4.2 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/tests/components/victron_gx/test_init.py b/tests/components/victron_gx/test_init.py index ed87cf710e3b3e..4b54f79c1bc777 100644 --- a/tests/components/victron_gx/test_init.py +++ b/tests/components/victron_gx/test_init.py @@ -186,3 +186,27 @@ async def test_hub_stop( # Verify hub is disconnected by checking config entry state assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("mock_victron_hub_library") +async def test_hub_stop_disconnect_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_victron_hub_library: MagicMock, +) -> None: + """Test hub stop gracefully handles disconnect errors.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Make disconnect raise an error + mock_victron_hub_library.return_value.disconnect.side_effect = Exception( + "disconnect failed" + ) + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/victron_gx/test_sensor.py b/tests/components/victron_gx/test_sensor.py index 256161152551d2..8d41d345ee01ce 100644 --- a/tests/components/victron_gx/test_sensor.py +++ b/tests/components/victron_gx/test_sensor.py @@ -104,3 +104,37 @@ async def test_victron_enum_sensor( assert device is not None assert device.manufacturer == "Victron Energy" assert device.via_device_id is None + + +async def test_victron_main_topic_sensor( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor whose metric has main_topic=True uses name instead of translation key.""" + victron_hub, mock_config_entry = init_integration + + # Multi RS MPPT MppOperationMode is a main_topic metric + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/multi/0/Pv/1/MppOperationMode", + '{"value": 2}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert len(entities) == 1 + entity = entities[0] + assert entity.unique_id == f"{MOCK_INSTALLATION_ID}_multi_0_multi_mppt_1_state" + # main_topic entities get their name from the device, not a translation key + assert entity.translation_key is None + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "mppt_active" + # Entity uses device name only (no separate entity name) + assert state.attributes["friendly_name"] == "Multi RS Solar" From 293db4710123d2d79e1156f22113062a2ecdd678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 7 Apr 2026 12:47:03 +0200 Subject: [PATCH 0558/1707] Bump aiohomeconnect to 0.34.0 (#167592) --- homeassistant/components/home_connect/const.py | 7 ++++++- homeassistant/components/home_connect/coordinator.py | 12 ++++++++++-- homeassistant/components/home_connect/manifest.json | 2 +- homeassistant/components/home_connect/select.py | 6 +++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 9090859456de9e..0719d41c65e027 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -77,7 +77,12 @@ TRANSLATION_KEYS_PROGRAMS_MAP = { bsh_key_to_translation_key(program.value): program for program in ProgramKey - if program not in (ProgramKey.UNKNOWN, ProgramKey.BSH_COMMON_FAVORITE_001) + if program + not in ( + ProgramKey.UNKNOWN, + ProgramKey.BSH_COMMON_FAVORITE_001, + ProgramKey.BSH_COMMON_FAVORITE_002, + ) } PROGRAMS_TRANSLATION_KEYS_MAP = { diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 7b8c04f8d23e32..3ae43ded4b13d7 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -533,7 +533,11 @@ async def get_appliance_data(self) -> None: current_program_key = program.key program_options = program.options if ( - current_program_key == ProgramKey.BSH_COMMON_FAVORITE_001 + current_program_key + in ( + ProgramKey.BSH_COMMON_FAVORITE_001, + ProgramKey.BSH_COMMON_FAVORITE_002, + ) and program_options ): # The API doesn't allow to fetch the options from the favorite program. @@ -616,7 +620,11 @@ async def update_options(self, program_key: ProgramKey) -> None: options_to_notify = options.copy() options.clear() if ( - program_key == ProgramKey.BSH_COMMON_FAVORITE_001 + program_key + in ( + ProgramKey.BSH_COMMON_FAVORITE_001, + ProgramKey.BSH_COMMON_FAVORITE_002, + ) and (event := events.get(EventKey.BSH_COMMON_OPTION_BASE_PROGRAM)) and isinstance(event.value, str) ): diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index d5955e07e22358..9dbb60de095641 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -23,6 +23,6 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "quality_scale": "platinum", - "requirements": ["aiohomeconnect==0.33.0"], + "requirements": ["aiohomeconnect==0.34.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index ee0926768e5f19..164d27ca64456b 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -436,7 +436,11 @@ def update_native_value(self) -> None: else None ) if ( - program_key == ProgramKey.BSH_COMMON_FAVORITE_001 + program_key + in ( + ProgramKey.BSH_COMMON_FAVORITE_001, + ProgramKey.BSH_COMMON_FAVORITE_002, + ) and ( base_program_event := self.appliance.events.get( EventKey.BSH_COMMON_OPTION_BASE_PROGRAM diff --git a/requirements_all.txt b/requirements_all.txt index 0656f9492e9384..043c6e48ce03b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -279,7 +279,7 @@ aioharmony==0.5.3 aiohasupervisor==0.4.3 # homeassistant.components.home_connect -aiohomeconnect==0.33.0 +aiohomeconnect==0.34.0 # homeassistant.components.homekit_controller aiohomekit==3.2.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 556d678433662a..ee51ed7715764e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -267,7 +267,7 @@ aioharmony==0.5.3 aiohasupervisor==0.4.3 # homeassistant.components.home_connect -aiohomeconnect==0.33.0 +aiohomeconnect==0.34.0 # homeassistant.components.homekit_controller aiohomekit==3.2.20 From a10f16ce3ec3a890ae99c11fc29cf6fb4678c176 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 7 Apr 2026 12:47:48 +0200 Subject: [PATCH 0559/1707] Add missing Miele dishwasher program ID 201 (#167536) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/miele/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 96794aa1edb210..2a3ea75a982183 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -498,7 +498,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True): intensive = 1, 26, 205 maintenance = 2, 27, 214 eco = 3, 22, 28, 200 - automatic = 6, 7, 31, 32, 202 + automatic = 6, 7, 31, 32, 201, 202 solar_save = 9, 34 gentle = 10, 35, 210 extra_quiet = 11, 36, 207 From 61c02c854f96fe47b13dc7fc44fee7041f2b0f63 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 7 Apr 2026 14:04:51 +0300 Subject: [PATCH 0560/1707] Add websocket subscription support for calendar events (#156340) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/calendar/__init__.py | 169 +++++++++++++ tests/components/calendar/test_init.py | 234 ++++++++++++++++++ 2 files changed, 403 insertions(+) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index db49440d449940..cedf3402f2b919 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -17,6 +17,7 @@ from homeassistant.components import frontend, http, websocket_api from homeassistant.components.websocket_api import ( + ERR_INVALID_FORMAT, ERR_NOT_FOUND, ERR_NOT_SUPPORTED, ActiveConnection, @@ -33,6 +34,7 @@ ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_time @@ -76,6 +78,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = datetime.timedelta(seconds=60) +EVENT_LISTENER_DEBOUNCE_COOLDOWN = 1.0 # seconds # Don't support rrules more often than daily VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} @@ -320,6 +323,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, handle_calendar_event_create) websocket_api.async_register_command(hass, handle_calendar_event_delete) websocket_api.async_register_command(hass, handle_calendar_event_update) + websocket_api.async_register_command(hass, handle_calendar_event_subscribe) component.async_register_entity_service( CREATE_EVENT_SERVICE, @@ -517,6 +521,17 @@ class CalendarEntity(Entity): _entity_component_unrecorded_attributes = frozenset({"description"}) _alarm_unsubs: list[CALLBACK_TYPE] | None = None + _event_listeners: ( + list[ + tuple[ + datetime.datetime, + datetime.datetime, + Callable[[list[JsonValueType] | None], None], + ] + ] + | None + ) = None + _event_listener_debouncer: Debouncer[None] | None = None _attr_initial_color: str | None @@ -585,6 +600,10 @@ def _async_write_ha_state(self) -> None: the current or upcoming event. """ super()._async_write_ha_state() + + # Notify websocket subscribers of event changes (debounced) + if self._event_listeners and self._event_listener_debouncer: + self._event_listener_debouncer.async_schedule_call() if self._alarm_unsubs is None: self._alarm_unsubs = [] _LOGGER.debug( @@ -625,6 +644,13 @@ def update(_: datetime.datetime) -> None: event.end_datetime_local, ) + @callback + def _async_cancel_event_listener_debouncer(self) -> None: + """Cancel and clear the event listener debouncer.""" + if self._event_listener_debouncer: + self._event_listener_debouncer.async_cancel() + self._event_listener_debouncer = None + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -633,6 +659,90 @@ async def async_will_remove_from_hass(self) -> None: for unsub in self._alarm_unsubs or (): unsub() self._alarm_unsubs = None + self._async_cancel_event_listener_debouncer() + + @final + @callback + def async_subscribe_events( + self, + start_date: datetime.datetime, + end_date: datetime.datetime, + event_listener: Callable[[list[JsonValueType] | None], None], + ) -> CALLBACK_TYPE: + """Subscribe to calendar event updates. + + Called by websocket API. + """ + if self._event_listeners is None: + self._event_listeners = [] + + if self._event_listener_debouncer is None: + self._event_listener_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=EVENT_LISTENER_DEBOUNCE_COOLDOWN, + immediate=True, + function=self.async_update_event_listeners, + ) + + listener_data = (start_date, end_date, event_listener) + self._event_listeners.append(listener_data) + + @callback + def unsubscribe() -> None: + if self._event_listeners: + self._event_listeners.remove(listener_data) + if not self._event_listeners: + self._async_cancel_event_listener_debouncer() + + return unsubscribe + + @final + @callback + def async_update_event_listeners(self) -> None: + """Push updated calendar events to all listeners.""" + if not self._event_listeners: + return + + for start_date, end_date, listener in self._event_listeners: + self.async_update_single_event_listener(start_date, end_date, listener) + + @final + @callback + def async_update_single_event_listener( + self, + start_date: datetime.datetime, + end_date: datetime.datetime, + listener: Callable[[list[JsonValueType] | None], None], + ) -> None: + """Schedule an event fetch and push to a single listener.""" + self.hass.async_create_task( + self._async_update_listener(start_date, end_date, listener) + ) + + async def _async_update_listener( + self, + start_date: datetime.datetime, + end_date: datetime.datetime, + listener: Callable[[list[JsonValueType] | None], None], + ) -> None: + """Fetch events and push to a single listener.""" + try: + events = await self.async_get_events(self.hass, start_date, end_date) + except HomeAssistantError as err: + _LOGGER.debug( + "Error fetching calendar events for %s: %s", + self.entity_id, + err, + ) + listener(None) + return + + event_list: list[JsonValueType] = [ + dataclasses.asdict(event, dict_factory=_list_events_dict_factory) + for event in events + ] + listener(event_list) async def async_get_events( self, @@ -867,6 +977,65 @@ async def handle_calendar_event_update( connection.send_result(msg["id"]) +@websocket_api.websocket_command( + { + vol.Required("type"): "calendar/event/subscribe", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + vol.Required("start"): cv.datetime, + vol.Required("end"): cv.datetime, + } +) +@websocket_api.async_response +async def handle_calendar_event_subscribe( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Subscribe to calendar event updates.""" + entity_id: str = msg["entity_id"] + + if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)): + connection.send_error( + msg["id"], + ERR_NOT_FOUND, + f"Calendar entity not found: {entity_id}", + ) + return + + start_date = dt_util.as_local(msg["start"]) + end_date = dt_util.as_local(msg["end"]) + + if start_date >= end_date: + connection.send_error( + msg["id"], + ERR_INVALID_FORMAT, + "Start must be before end", + ) + return + + subscription_id = msg["id"] + + @callback + def event_listener(events: list[JsonValueType] | None) -> None: + """Push updated calendar events to websocket.""" + if subscription_id not in connection.subscriptions: + return + connection.send_message( + websocket_api.event_message( + subscription_id, + { + "events": events, + }, + ) + ) + + connection.subscriptions[subscription_id] = entity.async_subscribe_events( + start_date, end_date, event_listener + ) + connection.send_result(subscription_id) + + # Push initial events only to the new subscriber + entity.async_update_single_event_listener(start_date, end_date, event_listener) + + def _validate_timespan( values: dict[str, Any], ) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]: diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 4945cddf9c9aff..ec1e0495d7a85c 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -28,6 +28,7 @@ from .conftest import MockCalendarEntity, MockConfigEntry +from tests.common import async_fire_time_changed from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -715,3 +716,236 @@ def __init__( entity = TestCalendarEntity(description_color, attr_color) assert entity.initial_color == expected_color + + +async def test_websocket_handle_subscribe_calendar_events( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entities: list[MockCalendarEntity], +) -> None: + """Test subscribing to calendar event updates via websocket.""" + client = await hass_ws_client(hass) + + start = dt_util.now() + end = start + timedelta(days=1) + + await client.send_json_auto_id( + { + "type": "calendar/event/subscribe", + "entity_id": "calendar.calendar_1", + "start": start.isoformat(), + "end": end.isoformat(), + } + ) + msg = await client.receive_json() + assert msg["success"] + subscription_id = msg["id"] + + # Should receive initial event list + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + assert "events" in msg["event"] + events = msg["event"]["events"] + assert len(events) == 1 + assert events[0]["summary"] == "Future Event" + + +async def test_websocket_subscribe_updates_on_state_change( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entities: list[MockCalendarEntity], +) -> None: + """Test that subscribers receive updates when calendar state changes.""" + client = await hass_ws_client(hass) + + start = dt_util.now() + end = start + timedelta(days=1) + + await client.send_json_auto_id( + { + "type": "calendar/event/subscribe", + "entity_id": "calendar.calendar_1", + "start": start.isoformat(), + "end": end.isoformat(), + } + ) + msg = await client.receive_json() + assert msg["success"] + subscription_id = msg["id"] + + # Receive initial event list + msg = await client.receive_json() + assert msg["id"] == subscription_id + + # Add a new event and trigger state update + entity = test_entities[0] + entity.create_event( + start=start + timedelta(hours=2), + end=start + timedelta(hours=3), + summary="New Event", + ) + entity.async_write_ha_state() + await hass.async_block_till_done() + + # Should receive updated event list + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + events = msg["event"]["events"] + assert len(events) == 2 + summaries = {event["summary"] for event in events} + assert "Future Event" in summaries + assert "New Event" in summaries + + +async def test_websocket_subscribe_entity_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test subscribing to a non-existent calendar entity.""" + client = await hass_ws_client(hass) + + start = dt_util.now() + end = start + timedelta(days=1) + + await client.send_json_auto_id( + { + "type": "calendar/event/subscribe", + "entity_id": "calendar.nonexistent", + "start": start.isoformat(), + "end": end.isoformat(), + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + assert "Calendar entity not found" in msg["error"]["message"] + + +async def test_websocket_subscribe_event_fetch_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entities: list[MockCalendarEntity], +) -> None: + """Test subscription handles event fetch errors gracefully.""" + client = await hass_ws_client(hass) + + start = dt_util.now() + end = start + timedelta(days=1) + + # Set up entity to fail on async_get_events + test_entities[0].async_get_events.side_effect = HomeAssistantError("API Error") + + await client.send_json_auto_id( + { + "type": "calendar/event/subscribe", + "entity_id": "calendar.calendar_1", + "start": start.isoformat(), + "end": end.isoformat(), + } + ) + msg = await client.receive_json() + assert msg["success"] + subscription_id = msg["id"] + + # Should receive None for events due to error + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + assert msg["event"]["events"] is None + + +async def test_websocket_subscribe_invalid_timespan( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entities: list[MockCalendarEntity], +) -> None: + """Test subscribing with start after end returns an error.""" + client = await hass_ws_client(hass) + + now = dt_util.now() + start = now + timedelta(days=1) + end = now + + await client.send_json_auto_id( + { + "type": "calendar/event/subscribe", + "entity_id": "calendar.calendar_1", + "start": start.isoformat(), + "end": end.isoformat(), + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "invalid_format" + assert "Start must be before end" in msg["error"]["message"] + + +async def test_websocket_subscribe_debounces_rapid_updates( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entities: list[MockCalendarEntity], +) -> None: + """Test that rapid state writes are debounced for event listeners.""" + client = await hass_ws_client(hass) + + start = dt_util.now() + end = start + timedelta(days=1) + + await client.send_json_auto_id( + { + "type": "calendar/event/subscribe", + "entity_id": "calendar.calendar_1", + "start": start.isoformat(), + "end": end.isoformat(), + } + ) + msg = await client.receive_json() + assert msg["success"] + subscription_id = msg["id"] + + # Receive initial event list + msg = await client.receive_json() + assert msg["id"] == subscription_id + + entity = test_entities[0] + entity.async_get_events.reset_mock() + + # Rapidly write state multiple times + for i in range(5): + entity.create_event( + start=start + timedelta(hours=i + 2), + end=start + timedelta(hours=i + 3), + summary=f"Rapid Event {i}", + ) + entity.async_write_ha_state() + + await hass.async_block_till_done() + + # The debouncer with immediate=True fires the first call immediately + # and coalesces the rest into one call after the cooldown. + # Without debouncing this would be 5 calls. + assert entity.async_get_events.call_count == 1 + + # Advance time past the debounce cooldown to fire the trailing call + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done() + + # Should be exactly 2 total: immediate + one coalesced trailing call + assert entity.async_get_events.call_count == 2 + + # Drain messages: immediate update + trailing debounced update + messages: list[dict] = [] + for _ in range(10): + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + messages.append(msg) + if len(msg["event"]["events"]) == 6: # 1 original + 5 rapid + break + else: + pytest.fail("Did not receive expected calendar event list with 6 events") + + # The final message has all events + assert len(messages[-1]["event"]["events"]) == 6 From 2f0488f98558864d3e2c3e9353db75a18a4d3f53 Mon Sep 17 00:00:00 2001 From: Kevin McCormack Date: Tue, 7 Apr 2026 07:19:49 -0400 Subject: [PATCH 0561/1707] Opnsense swap to aiopnsense (#167026) Co-authored-by: Snuffy2 Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 4 +- homeassistant/components/opnsense/__init__.py | 85 +++++++++++++++---- .../components/opnsense/device_tracker.py | 14 ++- .../components/opnsense/manifest.json | 6 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- script/hassfest/requirements.py | 9 -- .../opnsense/test_device_tracker.py | 18 ++-- 8 files changed, 96 insertions(+), 52 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index ca1135832fe98c..109248b6c72729 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1263,8 +1263,8 @@ CLAUDE.md @home-assistant/core /tests/components/openuv/ @bachya /homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck /tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck -/homeassistant/components/opnsense/ @mtreinish -/tests/components/opnsense/ @mtreinish +/homeassistant/components/opnsense/ @HarlemSquirrel @Snuffy2 +/tests/components/opnsense/ @HarlemSquirrel @Snuffy2 /homeassistant/components/opower/ @tronikos /tests/components/opower/ @tronikos /homeassistant/components/oralb/ @bdraco @Lash-L diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py index bc085dbfa4d92e..822851aca746a6 100644 --- a/homeassistant/components/opnsense/__init__.py +++ b/homeassistant/components/opnsense/__init__.py @@ -2,13 +2,23 @@ import logging -from pyopnsense import diagnostics -from pyopnsense.exceptions import APIException +from aiopnsense import ( + OPNsenseBelowMinFirmware, + OPNsenseClient, + OPNsenseConnectionError, + OPNsenseInvalidAuth, + OPNsenseInvalidURL, + OPNsensePrivilegeMissing, + OPNsenseSSLError, + OPNsenseTimeoutError, + OPNsenseUnknownFirmware, +) import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType @@ -40,7 +50,7 @@ ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the opnsense component.""" conf = config[DOMAIN] @@ -50,30 +60,73 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: verify_ssl = conf[CONF_VERIFY_SSL] tracker_interfaces = conf[CONF_TRACKER_INTERFACES] - interfaces_client = diagnostics.InterfaceClient( - api_key, api_secret, url, verify_ssl, timeout=20 + session = async_get_clientsession(hass, verify_ssl=verify_ssl) + client = OPNsenseClient( + url, + api_key, + api_secret, + session, + opts={"verify_ssl": verify_ssl}, ) try: - interfaces_client.get_arp() - except APIException: - _LOGGER.exception("Failure while connecting to OPNsense API endpoint") + await client.validate() + if tracker_interfaces: + interfaces_resp = await client.get_interfaces() + except OPNsenseUnknownFirmware: + _LOGGER.error("Error checking the OPNsense firmware version at %s", url) + return False + except OPNsenseBelowMinFirmware: + _LOGGER.error( + "OPNsense Firmware is below the minimum supported version at %s", url + ) + return False + except OPNsenseInvalidURL: + _LOGGER.error( + "Invalid URL while connecting to OPNsense API endpoint at %s", url + ) + return False + except OPNsenseTimeoutError: + _LOGGER.error("Timeout while connecting to OPNsense API endpoint at %s", url) + return False + except OPNsenseSSLError: + _LOGGER.error( + "Unable to verify SSL while connecting to OPNsense API endpoint at %s", url + ) + return False + except OPNsenseInvalidAuth: + _LOGGER.error( + "Authentication failure while connecting to OPNsense API endpoint at %s", + url, + ) + return False + except OPNsensePrivilegeMissing: + _LOGGER.error( + "Invalid Permissions while connecting to OPNsense API endpoint at %s", + url, + ) + return False + except OPNsenseConnectionError: + _LOGGER.error( + "Connection failure while connecting to OPNsense API endpoint at %s", + url, + ) return False if tracker_interfaces: # Verify that specified tracker interfaces are valid - netinsight_client = diagnostics.NetworkInsightClient( - api_key, api_secret, url, verify_ssl, timeout=20 - ) - interfaces = list(netinsight_client.get_interfaces().values()) - for interface in tracker_interfaces: - if interface not in interfaces: + known_interfaces = [ + ifinfo.get("name", "") for ifinfo in interfaces_resp.values() + ] + for intf_description in tracker_interfaces: + if intf_description not in known_interfaces: _LOGGER.error( - "Specified OPNsense tracker interface %s is not found", interface + "Specified OPNsense tracker interface %s is not found", + intf_description, ) return False hass.data[OPNSENSE_DATA] = { - CONF_INTERFACE_CLIENT: interfaces_client, + CONF_INTERFACE_CLIENT: client, CONF_TRACKER_INTERFACES: tracker_interfaces, } diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py index 5f6d8d2d43638f..259a6394e69c57 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -2,7 +2,7 @@ from typing import Any, NewType -from pyopnsense import diagnostics +from aiopnsense import OPNsenseClient from homeassistant.components.device_tracker import DeviceScanner from homeassistant.core import HomeAssistant @@ -27,9 +27,7 @@ async def async_get_scanner( class OPNsenseDeviceScanner(DeviceScanner): """This class queries a router running OPNsense.""" - def __init__( - self, client: diagnostics.InterfaceClient, interfaces: list[str] - ) -> None: + def __init__(self, client: OPNsenseClient, interfaces: list[str]) -> None: """Initialize the scanner.""" self.last_results: dict[str, Any] = {} self.client = client @@ -43,9 +41,9 @@ def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC | d out_devices[device["mac"]] = device return out_devices - def scan_devices(self) -> list[str]: + async def async_scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" - self.update_info() + await self._async_update_info() return list(self.last_results) def get_device_name(self, device: str) -> str | None: @@ -54,12 +52,12 @@ def get_device_name(self, device: str) -> str | None: return None return self.last_results[device].get("hostname") or None - def update_info(self) -> bool: + async def _async_update_info(self) -> bool: """Ensure the information from the OPNsense router is up to date. Return boolean if scanning successful. """ - devices = self.client.get_arp() + devices = await self.client.get_arp_table(True) self.last_results = self._get_mac_addrs(devices) return True diff --git a/homeassistant/components/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json index 7d2712b38d5af7..b2d57e017c2eb9 100644 --- a/homeassistant/components/opnsense/manifest.json +++ b/homeassistant/components/opnsense/manifest.json @@ -1,11 +1,11 @@ { "domain": "opnsense", "name": "OPNsense", - "codeowners": ["@mtreinish"], + "codeowners": ["@HarlemSquirrel", "@Snuffy2"], "documentation": "https://www.home-assistant.io/integrations/opnsense", "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["pbr", "pyopnsense"], + "loggers": ["aiopnsense"], "quality_scale": "legacy", - "requirements": ["pyopnsense==0.4.0"] + "requirements": ["aiopnsense==1.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 043c6e48ce03b7..31de03393208c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,6 +356,9 @@ aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 +# homeassistant.components.opnsense +aiopnsense==1.0.8 + # homeassistant.components.acmeda aiopulse==0.4.6 @@ -2352,9 +2355,6 @@ pyopenuv==2023.02.0 # homeassistant.components.openweathermap pyopenweathermap==0.2.2 -# homeassistant.components.opnsense -pyopnsense==0.4.0 - # homeassistant.components.opple pyoppleio-legacy==1.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee51ed7715764e..cb6d650a5ea905 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -341,6 +341,9 @@ aiooui==0.1.9 # homeassistant.components.pegel_online aiopegelonline==0.1.1 +# homeassistant.components.opnsense +aiopnsense==1.0.8 + # homeassistant.components.acmeda aiopulse==0.4.6 @@ -2014,9 +2017,6 @@ pyopenuv==2023.02.0 # homeassistant.components.openweathermap pyopenweathermap==0.2.2 -# homeassistant.components.opnsense -pyopnsense==0.4.0 - # homeassistant.components.osoenergy pyosoenergyapi==1.2.4 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index fbdacc552f35d2..164b6347a63393 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -189,11 +189,6 @@ "norway_air": {"pymetno": {"async-timeout"}}, "opengarage": {"open-garage": {"async-timeout"}}, "opensensemap": {"opensensemap-api": {"async-timeout"}}, - "opnsense": { - # https://github.com/mtreinish/pyopnsense/issues/27 - # pyopnsense > pbr > setuptools - "pbr": {"setuptools"} - }, "pvpc_hourly_pricing": {"aiopvpc": {"async-timeout"}}, "remote_rpi_gpio": { # https://github.com/waveform80/colorzero/issues/9 @@ -298,10 +293,6 @@ }, # https://github.com/ejpenney/pyobihai "obihai": {"homeassistant": {"pyobihai"}}, - "opnsense": { - # Setuptools - distutils-precedence.pth - "pbr": {"setuptools"} - }, # https://github.com/iamkubi/pydactyl "pterodactyl": {"homeassistant": {"py-dactyl"}}, "remote_rpi_gpio": { diff --git a/tests/components/opnsense/test_device_tracker.py b/tests/components/opnsense/test_device_tracker.py index 6da01d8f44d58a..7772601ad88c7d 100644 --- a/tests/components/opnsense/test_device_tracker.py +++ b/tests/components/opnsense/test_device_tracker.py @@ -14,8 +14,8 @@ @pytest.fixture(name="mocked_opnsense") def mocked_opnsense(): - """Mock for pyopnense.diagnostics.""" - with mock.patch.object(opnsense, "diagnostics") as mocked_opn: + """Mock for aiopnsense.OPNsenseClient.""" + with mock.patch.object(opnsense, "OPNsenseClient") as mocked_opn: yield mocked_opn @@ -23,9 +23,9 @@ async def test_get_scanner( hass: HomeAssistant, mocked_opnsense, mock_device_tracker_conf: list[legacy.Device] ) -> None: """Test creating an opnsense scanner.""" - interface_client = mock.MagicMock() - mocked_opnsense.InterfaceClient.return_value = interface_client - interface_client.get_arp.return_value = [ + opnsense_client = mock.AsyncMock() + mocked_opnsense.return_value = opnsense_client + opnsense_client.get_arp_table.return_value = [ { "hostname": "", "intf": "igb1", @@ -43,9 +43,11 @@ async def test_get_scanner( "manufacturer": "OEM", }, ] - network_insight_client = mock.MagicMock() - mocked_opnsense.NetworkInsightClient.return_value = network_insight_client - network_insight_client.get_interfaces.return_value = {"igb0": "WAN", "igb1": "LAN"} + + opnsense_client.get_interfaces.return_value = { + "wan": {"name": "WAN"}, + "lan": {"name": "LAN"}, + } result = await async_setup_component( hass, From 6e30de3a1c97032a77967b055e8833f0ed560b23 Mon Sep 17 00:00:00 2001 From: markhannon Date: Tue, 7 Apr 2026 21:31:51 +1000 Subject: [PATCH 0562/1707] Bump zcc-helper to 3.8 (#167555) --- homeassistant/components/zimi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zimi/manifest.json b/homeassistant/components/zimi/manifest.json index eea74330970025..fef7b764a995da 100644 --- a/homeassistant/components/zimi/manifest.json +++ b/homeassistant/components/zimi/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["zcc-helper==3.7"] + "requirements": ["zcc-helper==3.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 31de03393208c0..644a33c2ea9cfc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3380,7 +3380,7 @@ zabbix-utils==2.0.3 zamg==0.3.6 # homeassistant.components.zimi -zcc-helper==3.7 +zcc-helper==3.8 # homeassistant.components.zeroconf zeroconf==0.148.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb6d650a5ea905..3076ef728abf46 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2862,7 +2862,7 @@ yt-dlp[default]==2026.03.17 zamg==0.3.6 # homeassistant.components.zimi -zcc-helper==3.7 +zcc-helper==3.8 # homeassistant.components.zeroconf zeroconf==0.148.0 From 1aa214fb61362e3cd925e5c0d43214f82a4d7259 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 7 Apr 2026 14:36:49 +0300 Subject: [PATCH 0563/1707] Enable minimal thinking budget by default for Anthropic integration (#167593) --- homeassistant/components/anthropic/const.py | 6 +- .../anthropic/snapshots/test_ai_task.ambr | 4 +- .../snapshots/test_conversation.ambr | 69 ++++++++++++++++++- tests/components/anthropic/test_ai_task.py | 12 +++- .../components/anthropic/test_config_flow.py | 12 ++-- .../components/anthropic/test_conversation.py | 22 ++++-- 6 files changed, 105 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 6f47704db4b51e..750153ec076aee 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -36,13 +36,15 @@ class PromptCaching(StrEnum): AUTOMATIC = "automatic" +MIN_THINKING_BUDGET = 1024 + DEFAULT = { CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_CODE_EXECUTION: False, CONF_MAX_TOKENS: 3000, CONF_PROMPT_CACHING: PromptCaching.PROMPT.value, CONF_TEMPERATURE: 1.0, - CONF_THINKING_BUDGET: 0, + CONF_THINKING_BUDGET: MIN_THINKING_BUDGET, CONF_THINKING_EFFORT: "low", CONF_TOOL_SEARCH: False, CONF_WEB_SEARCH: False, @@ -50,8 +52,6 @@ class PromptCaching(StrEnum): CONF_WEB_SEARCH_MAX_USES: 5, } -MIN_THINKING_BUDGET = 1024 - NON_THINKING_MODELS = [ "claude-3-haiku", ] diff --git a/tests/components/anthropic/snapshots/test_ai_task.ambr b/tests/components/anthropic/snapshots/test_ai_task.ambr index 069387d2f90b1c..ca4908e1d3daff 100644 --- a/tests/components/anthropic/snapshots/test_ai_task.ambr +++ b/tests/components/anthropic/snapshots/test_ai_task.ambr @@ -47,9 +47,9 @@ 'type': 'text', }), ]), - 'temperature': 1.0, 'thinking': dict({ - 'type': 'disabled', + 'budget_tokens': 1024, + 'type': 'enabled', }), }) # --- diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 963a631ac73fb2..0a800ed1e46040 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -195,7 +195,7 @@ }), ]) # --- -# name: test_disabled_thinking +# name: test_disabled_thinking[subentry_data0] list([ dict({ 'content': ''' @@ -224,7 +224,72 @@ }), ]) # --- -# name: test_disabled_thinking.1 +# name: test_disabled_thinking[subentry_data0].1 + dict({ + 'container': None, + 'max_tokens': 3000, + 'messages': list([ + dict({ + 'content': 'hello', + 'role': 'user', + }), + dict({ + 'content': 'Hello, how can I help you today?', + 'role': 'assistant', + }), + ]), + 'model': 'claude-haiku-4-5', + 'stream': True, + 'system': list([ + dict({ + 'cache_control': dict({ + 'type': 'ephemeral', + }), + 'text': ''' + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + ''', + 'type': 'text', + }), + ]), + 'temperature': 1.0, + 'thinking': dict({ + 'type': 'disabled', + }), + }) +# --- +# name: test_disabled_thinking[subentry_data1] + list([ + dict({ + 'content': ''' + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + ''', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'system', + }), + dict({ + 'attachments': None, + 'content': 'hello', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude_conversation', + 'content': 'Hello, how can I help you today?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_disabled_thinking[subentry_data1].1 dict({ 'container': None, 'max_tokens': 3000, diff --git a/tests/components/anthropic/test_ai_task.py b/tests/components/anthropic/test_ai_task.py index f1abf956222831..8eb8722cf7123a 100644 --- a/tests/components/anthropic/test_ai_task.py +++ b/tests/components/anthropic/test_ai_task.py @@ -10,7 +10,10 @@ import voluptuous as vol from homeassistant.components import ai_task, media_source -from homeassistant.components.anthropic.const import CONF_CHAT_MODEL +from homeassistant.components.anthropic.const import ( + CONF_CHAT_MODEL, + CONF_THINKING_BUDGET, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -127,6 +130,7 @@ async def test_generate_structured_data_legacy( subentry, data={ CONF_CHAT_MODEL: "claude-sonnet-4-0", + CONF_THINKING_BUDGET: 0, }, ) @@ -183,7 +187,11 @@ async def test_generate_structured_data_legacy_tools( hass.config_entries.async_update_subentry( mock_config_entry, subentry, - data={"chat_model": "claude-sonnet-4-0", "web_search": True}, + data={ + "chat_model": "claude-sonnet-4-0", + "web_search": True, + "thinking_budget": 0, + }, ) result = await ai_task.async_generate_data( diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 05611f07372267..e8265e56d00dd1 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -330,7 +330,7 @@ async def test_subentry_web_search_user_location( "recommended": False, "region": "California", "temperature": 1.0, - "thinking_budget": 0, + "thinking_budget": 1024, "timezone": "America/Los_Angeles", "tool_search": False, "user_location": True, @@ -487,7 +487,7 @@ async def test_model_list_error( CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], - CONF_THINKING_BUDGET: 0, + CONF_THINKING_BUDGET: DEFAULT[CONF_THINKING_BUDGET], CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 10, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -614,7 +614,7 @@ async def test_model_list_error( CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: DEFAULT[CONF_CHAT_MODEL], CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS], - CONF_THINKING_BUDGET: 0, + CONF_THINKING_BUDGET: DEFAULT[CONF_THINKING_BUDGET], CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, @@ -788,7 +788,7 @@ async def test_creating_ai_task_subentry_advanced( result["flow_id"], { CONF_CHAT_MODEL: "claude-sonnet-4-5", - CONF_MAX_TOKENS: 200, + CONF_MAX_TOKENS: 1200, CONF_TEMPERATURE: 0.5, }, ) @@ -809,13 +809,13 @@ async def test_creating_ai_task_subentry_advanced( assert result4.get("data") == { CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "claude-sonnet-4-5", - CONF_MAX_TOKENS: 200, + CONF_MAX_TOKENS: 1200, CONF_TEMPERATURE: 0.5, CONF_TOOL_SEARCH: False, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_MAX_USES: 5, CONF_WEB_SEARCH_USER_LOCATION: False, - CONF_THINKING_BUDGET: 0, + CONF_THINKING_BUDGET: 1024, CONF_CODE_EXECUTION: False, CONF_PROMPT_CACHING: "prompt", } diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index de617438dfefd2..d4a73ad7491120 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -706,6 +706,21 @@ async def test_extended_thinking( assert call_args == snapshot +@pytest.mark.parametrize( + "subentry_data", + [ + { + CONF_LLM_HASS_API: "assist", + CONF_CHAT_MODEL: "claude-haiku-4-5", + CONF_THINKING_BUDGET: 0, + }, + { + CONF_LLM_HASS_API: "assist", + CONF_CHAT_MODEL: "claude-opus-4-6", + CONF_THINKING_EFFORT: "none", + }, + ], +) @freeze_time("2024-05-24 12:00:00") async def test_disabled_thinking( hass: HomeAssistant, @@ -713,16 +728,13 @@ async def test_disabled_thinking( mock_init_component, mock_create_stream: AsyncMock, snapshot: SnapshotAssertion, + subentry_data: dict[str, Any], ) -> None: """Test conversation with thinking effort disabled.""" hass.config_entries.async_update_subentry( mock_config_entry, next(iter(mock_config_entry.subentries.values())), - data={ - CONF_LLM_HASS_API: "assist", - CONF_CHAT_MODEL: "claude-opus-4-6", - CONF_THINKING_EFFORT: "none", - }, + data=subentry_data, ) mock_create_stream.return_value = [ From b76627a442639f7c449aed1710ca9b74ba914668 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:13:35 +0800 Subject: [PATCH 0564/1707] Add light sensor button to switchbot air purifier (#167134) Co-authored-by: Claude Sonnet 4.6 --- .../components/switchbot/__init__.py | 24 +++++++-- homeassistant/components/switchbot/button.py | 20 +++++++ homeassistant/components/switchbot/icons.json | 5 ++ .../components/switchbot/strings.json | 3 ++ tests/components/switchbot/test_button.py | 53 ++++++++++++++++++- 5 files changed, 100 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 0d47f7752a70ae..628ad86bcf19fc 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -110,10 +110,26 @@ Platform.LOCK, Platform.SENSOR, ], - SupportedModels.AIR_PURIFIER_JP.value: [Platform.FAN, Platform.SENSOR], - SupportedModels.AIR_PURIFIER_US.value: [Platform.FAN, Platform.SENSOR], - SupportedModels.AIR_PURIFIER_TABLE_JP.value: [Platform.FAN, Platform.SENSOR], - SupportedModels.AIR_PURIFIER_TABLE_US.value: [Platform.FAN, Platform.SENSOR], + SupportedModels.AIR_PURIFIER_JP.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + ], + SupportedModels.AIR_PURIFIER_US.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + ], + SupportedModels.AIR_PURIFIER_TABLE_JP.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + ], + SupportedModels.AIR_PURIFIER_TABLE_US.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + ], SupportedModels.EVAPORATIVE_HUMIDIFIER.value: [ Platform.HUMIDIFIER, Platform.SENSOR, diff --git a/homeassistant/components/switchbot/button.py b/homeassistant/components/switchbot/button.py index 3d9db9074f2026..f68a45390cbeab 100644 --- a/homeassistant/components/switchbot/button.py +++ b/homeassistant/components/switchbot/button.py @@ -24,6 +24,8 @@ async def async_setup_entry( ) -> None: """Set up Switchbot button platform.""" coordinator = entry.runtime_data + if isinstance(coordinator.device, switchbot.SwitchbotAirPurifier): + async_add_entities([LightSensorButton(coordinator)]) if isinstance(coordinator.device, switchbot.SwitchbotArtFrame): async_add_entities( @@ -37,6 +39,24 @@ async def async_setup_entry( async_add_entities([SwitchBotMeterProCO2SyncDateTimeButton(coordinator)]) +class LightSensorButton(SwitchbotEntity, ButtonEntity): + """Representation of a Switchbot light sensor button.""" + + _attr_translation_key = "light_sensor" + _device: switchbot.SwitchbotAirPurifier + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the Switchbot light sensor button.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_light_sensor" + + @exception_handler + async def async_press(self) -> None: + """Handle the button press.""" + _LOGGER.debug("Toggling light sensor mode for %s", self._address) + await self._device.open_light_sensitive_switch() + + class SwitchBotArtFrameButtonBase(SwitchbotEntity, ButtonEntity): """Base class for Art Frame buttons.""" diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index 29aedc20aa3f1a..32740998ce0609 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "light_sensor": { + "default": "mdi:brightness-auto" + } + }, "climate": { "climate": { "state_attributes": { diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 5d306ed2aaacfd..f6ece12c92af2c 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -102,6 +102,9 @@ } }, "button": { + "light_sensor": { + "name": "Light sensor" + }, "next_image": { "name": "Next image" }, diff --git a/tests/components/switchbot/test_button.py b/tests/components/switchbot/test_button.py index e01353869b98a2..03e52ae43cdd9c 100644 --- a/tests/components/switchbot/test_button.py +++ b/tests/components/switchbot/test_button.py @@ -6,12 +6,21 @@ import pytest +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import ART_FRAME_INFO, DOMAIN, WOMETERTHPC_SERVICE_INFO +from . import ( + AIR_PURIFIER_JP_SERVICE_INFO, + AIR_PURIFIER_TABLE_JP_SERVICE_INFO, + AIR_PURIFIER_TABLE_US_SERVICE_INFO, + AIR_PURIFIER_US_SERVICE_INFO, + ART_FRAME_INFO, + DOMAIN, + WOMETERTHPC_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -167,3 +176,45 @@ async def test_meter_pro_co2_sync_datetime_button_with_timezone( utc_offset_hours=expected_utc_offset_hours, utc_offset_minutes=expected_utc_offset_minutes, ) + + +@pytest.mark.parametrize( + ("service_info", "sensor_type"), + [ + (AIR_PURIFIER_JP_SERVICE_INFO, "air_purifier_jp"), + (AIR_PURIFIER_TABLE_JP_SERVICE_INFO, "air_purifier_table_jp"), + (AIR_PURIFIER_US_SERVICE_INFO, "air_purifier_us"), + (AIR_PURIFIER_TABLE_US_SERVICE_INFO, "air_purifier_table_us"), + ], +) +async def test_air_purifier_buttons( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service_info: BluetoothServiceInfoBleak, + sensor_type: str, +) -> None: + """Test pressing the air purifier buttons.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type) + entry.add_to_hass(hass) + + entity_id = "button.test_name_light_sensor" + mock_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.button.switchbot.SwitchbotAirPurifier", + update=AsyncMock(return_value=None), + get_basic_info=AsyncMock(return_value=None), + open_light_sensitive_switch=mock_instance, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_instance.assert_awaited_once() From 481eb66bc5a0ea965e35441671a7c21cad9b20ff Mon Sep 17 00:00:00 2001 From: Stefan S Date: Tue, 7 Apr 2026 14:48:21 +0200 Subject: [PATCH 0565/1707] =?UTF-8?q?Add=20unit=20'=C2=B5A'=20for=20the=20?= =?UTF-8?q?units=20of=20electric=20current=20(#166786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ariel Ebersberger --- homeassistant/components/number/const.py | 2 +- homeassistant/components/sensor/const.py | 2 +- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 1 + tests/components/knx/snapshots/test_websocket.ambr | 2 ++ tests/util/test_unit_conversion.py | 6 +++++- 6 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 30dafa575f4b28..5024fc8054a761 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -168,7 +168,7 @@ class NumberDeviceClass(StrEnum): CURRENT = "current" """Current. - Unit of measurement: `A`, `mA` + Unit of measurement: `A`, `mA`, `μA` """ DATA_RATE = "data_rate" diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 381deb0a255c42..949cc8cd5a2f02 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -180,7 +180,7 @@ class SensorDeviceClass(StrEnum): CURRENT = "current" """Current. - Unit of measurement: `A`, `mA` + Unit of measurement: `A`, `mA`, `μA` """ DATA_RATE = "data_rate" diff --git a/homeassistant/const.py b/homeassistant/const.py index 7ba7fcb9f22c96..f28a2375bd10f4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -523,6 +523,7 @@ class UnitOfEnergyDistance(StrEnum): class UnitOfElectricCurrent(StrEnum): """Electric current units.""" + MICROAMPERE = "μA" MILLIAMPERE = "mA" AMPERE = "A" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 616389479a3e82..4a5522da91cc23 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -353,6 +353,7 @@ class ElectricCurrentConverter(BaseUnitConverter): _UNIT_CONVERSION: dict[str | None, float] = { UnitOfElectricCurrent.AMPERE: 1, UnitOfElectricCurrent.MILLIAMPERE: 1e3, + UnitOfElectricCurrent.MICROAMPERE: 1e6, } VALID_UNITS = set(UnitOfElectricCurrent) diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index f5879d0d924b4f..cfc5ec81064dcd 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -1847,6 +1847,7 @@ '°', '°C', '°F', + 'μA', 'μS/cm', 'μV', 'μg', @@ -2191,6 +2192,7 @@ '°', '°C', '°F', + 'μA', 'μS/cm', 'μV', 'μg', diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 4c2ae80f020577..52c9a730f94a96 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -628,7 +628,11 @@ ], ElectricCurrentConverter: [ (5, UnitOfElectricCurrent.AMPERE, 5000, UnitOfElectricCurrent.MILLIAMPERE), - (5, UnitOfElectricCurrent.MILLIAMPERE, 0.005, UnitOfElectricCurrent.AMPERE), + (5, UnitOfElectricCurrent.AMPERE, 5e6, UnitOfElectricCurrent.MICROAMPERE), + (5, UnitOfElectricCurrent.MILLIAMPERE, 5e-3, UnitOfElectricCurrent.AMPERE), + (5, UnitOfElectricCurrent.MILLIAMPERE, 5e3, UnitOfElectricCurrent.MICROAMPERE), + (5, UnitOfElectricCurrent.MICROAMPERE, 5e-6, UnitOfElectricCurrent.AMPERE), + (5, UnitOfElectricCurrent.MICROAMPERE, 5e-3, UnitOfElectricCurrent.MILLIAMPERE), ], ElectricPotentialConverter: [ (5, UnitOfElectricPotential.VOLT, 5000, UnitOfElectricPotential.MILLIVOLT), From 4a454dff02cdb46745a9ab172dcef71e450c7fae Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:50:37 +0200 Subject: [PATCH 0566/1707] Set up condition and trigger helpers in check config script (#167589) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/check_config.py | 8 +++++++- tests/helpers/test_check_config.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 836536da9ee434..1982cc4f0c8cbe 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -31,7 +31,7 @@ async_get_integration_with_requirements, ) -from . import config_validation as cv +from . import condition, config_validation as cv, trigger from .typing import ConfigType @@ -93,6 +93,12 @@ async def async_check_ha_config_file( # noqa: C901 result = HomeAssistantConfig() async_clear_install_history(hass) + # Set up condition and trigger helpers needed for config validation. + if condition.CONDITIONS not in hass.data: + await condition.async_setup(hass) + if trigger.TRIGGERS not in hass.data: + await trigger.async_setup(hass) + def _pack_error( hass: HomeAssistant, package: str, diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index fc2df8552e700c..075b4d3f2f9584 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -15,6 +15,8 @@ HomeAssistantConfig, async_check_ha_config_file, ) +from homeassistant.helpers.condition import CONDITIONS +from homeassistant.helpers.trigger import TRIGGERS from homeassistant.requirements import RequirementsNotFound from tests.common import ( @@ -493,6 +495,11 @@ async def test_missing_included_file(hass: HomeAssistant) -> None: async def test_automation_config_platform(hass: HomeAssistant) -> None: """Test automation async config.""" + # Remove keys pre-populated by the test fixture to simulate + # the check_config script which doesn't run bootstrap. + del hass.data[TRIGGERS] + del hass.data[CONDITIONS] + files = { YAML_CONFIG_FILE: BASE_CONFIG + """ @@ -514,6 +521,9 @@ async def test_automation_config_platform(hass: HomeAssistant) -> None: trigger: platform: event event_type: !input trigger_event +condition: + condition: template + value_template: "{{ true }}" action: service: !input service_to_call """, From 920ffdb9b596c07debc3143d4f36669aea3221fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Tue, 7 Apr 2026 14:52:05 +0200 Subject: [PATCH 0567/1707] Remove homeassistant/actions/helpers/info from builder workflow (#167573) --- .github/workflows/builder.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 926b478e10b204..ff2257f9cdb08c 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -47,10 +47,6 @@ jobs: with: python-version-file: ".python-version" - - name: Get information - id: info - uses: home-assistant/actions/helpers/info@5752577ea7cc5aefb064b0b21432f18fe4d6ba90 # zizmor: ignore[unpinned-uses] - - name: Get version id: version uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses] From b52ce22ee7cfdc58d088ee8d2ccf1bff6d6273c0 Mon Sep 17 00:00:00 2001 From: Leo Periou Date: Tue, 7 Apr 2026 15:49:21 +0200 Subject: [PATCH 0568/1707] fix EWS deviceType problem (#167597) --- homeassistant/components/myneomitis/select.py | 8 ++------ tests/components/myneomitis/test_select.py | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/myneomitis/select.py b/homeassistant/components/myneomitis/select.py index c2d70e70346dfb..6ae7b6a0c79d45 100644 --- a/homeassistant/components/myneomitis/select.py +++ b/homeassistant/components/myneomitis/select.py @@ -104,12 +104,8 @@ async def async_setup_entry( def _create_entity(device: dict) -> MyNeoSelect: """Create a select entity for a device.""" if device["model"] == "EWS": - # According to the MyNeomitis API, EWS "relais" devices expose a "relayMode" - # field in their state, while "pilote" devices do not. We therefore use the - # presence of "relayMode" as an explicit heuristic to distinguish relais - # from pilote devices. If the upstream API changes this behavior, this - # detection logic must be revisited. - if "relayMode" in device.get("state", {}): + state = device.get("state") or {} + if state.get("deviceType") == 0: description = SELECT_TYPES["relais"] else: description = SELECT_TYPES["pilote"] diff --git a/tests/components/myneomitis/test_select.py b/tests/components/myneomitis/test_select.py index 8a3a9c7faf5456..ce905206c4e0d8 100644 --- a/tests/components/myneomitis/test_select.py +++ b/tests/components/myneomitis/test_select.py @@ -14,7 +14,7 @@ "_id": "relais1", "name": "Relais Device", "model": "EWS", - "state": {"relayMode": 1, "targetMode": 2}, + "state": {"deviceType": 0, "targetMode": 2}, "connected": True, "program": {"data": {}}, } @@ -23,7 +23,7 @@ "_id": "pilote1", "name": "Pilote Device", "model": "EWS", - "state": {"targetMode": 1}, + "state": {"deviceType": 1, "targetMode": 1}, "connected": True, "program": {"data": {}}, } From 74957969f7f69e9fc4f776ca6315e9b494fd501f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 7 Apr 2026 15:55:43 +0200 Subject: [PATCH 0569/1707] Add select entities for Roborock q10 s5+ (#166142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- homeassistant/components/roborock/select.py | 63 ++++++++++++++ tests/components/roborock/test_select.py | 96 +++++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 0ff27d8145f945..27305b7db2c794 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -20,6 +20,7 @@ ZeoSpin, ZeoTemperature, ) +from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType from roborock.devices.traits.b01 import Q7PropertiesApi from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.home import HomeTrait @@ -37,6 +38,7 @@ from .const import DOMAIN, MAP_SLEEP from .coordinator import ( RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, @@ -44,6 +46,7 @@ from .entity import ( RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01Q7, + RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1, ) @@ -266,6 +269,10 @@ async def async_setup_entry( for description in A01_SELECT_DESCRIPTIONS if description.data_protocol in coordinator.request_protocols ) + async_add_entities( + RoborockQ10CleanModeSelectEntity(coordinator) + for coordinator in config_entry.runtime_data.b01_q10 + ) class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity): @@ -466,3 +473,59 @@ def current_option(self) -> str | None: self.entity_description.key, ) return str(current_value) + + +class RoborockQ10CleanModeSelectEntity(RoborockCoordinatedEntityB01Q10, SelectEntity): + """Select entity for Q10 cleaning mode.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "cleaning_mode" + coordinator: RoborockB01Q10UpdateCoordinator + + def __init__( + self, + coordinator: RoborockB01Q10UpdateCoordinator, + ) -> None: + """Create a select entity for Q10 cleaning mode.""" + super().__init__( + f"cleaning_mode_{coordinator.duid_slug}", + coordinator, + ) + + async def async_added_to_hass(self) -> None: + """Register trait listener for push-based status updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.api.status.add_update_listener(self.async_write_ha_state) + ) + + @property + def options(self) -> list[str]: + """Return available cleaning modes.""" + return [mode.value for mode in YXCleanType if mode != YXCleanType.UNKNOWN] + + @property + def current_option(self) -> str | None: + """Get the current cleaning mode.""" + clean_mode = self.coordinator.api.status.clean_mode + if clean_mode is None or clean_mode == YXCleanType.UNKNOWN: + return None + return clean_mode.value + + async def async_select_option(self, option: str) -> None: + """Set the cleaning mode.""" + try: + mode = YXCleanType.from_value(option) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="select_option_failed", + ) from err + try: + await self.coordinator.api.vacuum.set_clean_mode(mode) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"command": "cleaning_mode"}, + ) from err diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index 31e77ee29c8aff..7290614fca02d8 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -10,6 +10,7 @@ WaterLevelMapping, ZeoProgram, ) +from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockZeoProtocol @@ -383,3 +384,98 @@ async def test_update_failure_zeo_invalid_option() -> None: await entity.async_select_option("invalid_option") coordinator.api.set_value.assert_not_called() + + +async def test_q10_cleaning_mode_select_current_option( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test Q10 cleaning mode select entity current option.""" + entity_id = "select.roborock_q10_s5_cleaning_mode" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + options = state.attributes.get("options") + assert options is not None + assert set(options) == {"vac_and_mop", "vacuum", "mop"} + + assert fake_q10_vacuum.b01_q10_properties + fake_q10_vacuum.b01_q10_properties.status.update_from_dps( + {B01_Q10_DP.CLEAN_MODE: YXCleanType.VAC_AND_MOP.code} + ) + await hass.async_block_till_done() + + updated_state = hass.states.get(entity_id) + assert updated_state is not None + assert updated_state.state == "vac_and_mop" + + +async def test_q10_cleaning_mode_select_update_success( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test setting Q10 cleaning mode select option.""" + entity_id = "select.roborock_q10_s5_cleaning_mode" + assert hass.states.get(entity_id) is not None + + # Test setting value + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "vac_and_mop"}, + blocking=True, + target={"entity_id": entity_id}, + ) + + assert fake_q10_vacuum.b01_q10_properties + assert fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.call_count == 1 + fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_called_once_with( + YXCleanType.VAC_AND_MOP + ) + + +async def test_q10_cleaning_mode_select_update_failure( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test failure when setting Q10 cleaning mode.""" + assert fake_q10_vacuum.b01_q10_properties + fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.side_effect = ( + RoborockException + ) + entity_id = "select.roborock_q10_s5_cleaning_mode" + assert hass.states.get(entity_id) is not None + + with pytest.raises(HomeAssistantError, match="cleaning_mode"): + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "vac_and_mop"}, + blocking=True, + target={"entity_id": entity_id}, + ) + + +async def test_q10_cleaning_mode_select_invalid_option( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test that an invalid option raises ServiceValidationError and does not call set_clean_mode.""" + entity_id = "select.roborock_q10_s5_cleaning_mode" + assert hass.states.get(entity_id) is not None + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "invalid_option"}, + blocking=True, + target={"entity_id": entity_id}, + ) + + assert fake_q10_vacuum.b01_q10_properties + fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_not_called() From 354b5860bb283d172c17e7e1030c3325b74fa8c4 Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Tue, 7 Apr 2026 14:57:55 +0100 Subject: [PATCH 0570/1707] Add read-only MCP Assist context snapshot resource (#167396) --- homeassistant/components/mcp_server/server.py | 55 ++++++++- tests/components/mcp_server/test_http.py | 115 ++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 907114f06cdd58..82ccbcd2cf13e2 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -10,10 +10,12 @@ from collections.abc import Callable, Sequence import json import logging -from typing import Any +from typing import Any, cast from mcp import types from mcp.server import Server +from mcp.server.lowlevel.helper_types import ReadResourceContents +from pydantic import AnyUrl import voluptuous as vol from voluptuous_openapi import convert @@ -25,6 +27,16 @@ _LOGGER = logging.getLogger(__name__) +SNAPSHOT_RESOURCE_URI = "homeassistant://assist/context-snapshot" +SNAPSHOT_RESOURCE_URL = AnyUrl(SNAPSHOT_RESOURCE_URI) +SNAPSHOT_RESOURCE_MIME_TYPE = "text/plain" +LIVE_CONTEXT_TOOL_NAME = "GetLiveContext" + + +def _has_live_context_tool(llm_api: llm.APIInstance) -> bool: + """Return if the selected API exposes the live context tool.""" + return any(tool.name == LIVE_CONTEXT_TOOL_NAME for tool in llm_api.tools) + def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None @@ -90,6 +102,47 @@ async def handle_get_prompt( ], ) + @server.list_resources() # type: ignore[no-untyped-call,untyped-decorator] + async def handle_list_resources() -> list[types.Resource]: + llm_api = await get_api_instance() + if not _has_live_context_tool(llm_api): + return [] + + return [ + types.Resource( + uri=SNAPSHOT_RESOURCE_URL, + name="assist_context_snapshot", + title="Assist context snapshot", + description=( + "A snapshot of the current Assist context, matching the" + " existing GetLiveContext tool output." + ), + mimeType=SNAPSHOT_RESOURCE_MIME_TYPE, + ) + ] + + @server.read_resource() # type: ignore[no-untyped-call,untyped-decorator] + async def handle_read_resource(uri: AnyUrl) -> Sequence[ReadResourceContents]: + if str(uri) != SNAPSHOT_RESOURCE_URI: + raise ValueError(f"Unknown resource: {uri}") + + llm_api = await get_api_instance() + if not _has_live_context_tool(llm_api): + raise ValueError(f"Unknown resource: {uri}") + + tool_response = await llm_api.async_call_tool( + llm.ToolInput(tool_name=LIVE_CONTEXT_TOOL_NAME, tool_args={}) + ) + if not tool_response.get("success"): + raise HomeAssistantError(cast(str, tool_response["error"])) + + return [ + ReadResourceContents( + content=cast(str, tool_response["result"]), + mime_type=SNAPSHOT_RESOURCE_MIME_TYPE, + ) + ] + @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator] async def list_tools() -> list[types.Tool]: """List available time tools.""" diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 1032e781c1e3c6..27150d263ad2f1 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -44,6 +44,8 @@ _LOGGER = logging.getLogger(__name__) TEST_ENTITY = "light.kitchen" +SNAPSHOT_RESOURCE_URI = "homeassistant://assist/context-snapshot" +TEST_LLM_API_ID = "test-api" INITIALIZE_MESSAGE = { "jsonrpc": "2.0", "id": "request-id-1", @@ -66,6 +68,21 @@ """ +class MockLLMAPI(llm.API): + """Test LLM API that does not expose any tools.""" + + async def async_get_api_instance( + self, llm_context: llm.LLMContext + ) -> llm.APIInstance: + """Return a test API instance.""" + return llm.APIInstance( + api=self, + api_prompt="Test prompt", + llm_context=llm_context, + tools=[], + ) + + @pytest.fixture async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Set up the config entry.""" @@ -481,6 +498,104 @@ async def test_get_unknown_prompt( await session.get_prompt(name="Unknown") +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) +async def test_mcp_resources_list( + hass: HomeAssistant, + setup_integration: None, + mcp_url: str, + mcp_client: Any, + hass_supervisor_access_token: str, +) -> None: + """Test the resource list endpoint.""" + + async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: + result = await session.list_resources() + + assert len(result.resources) == 1 + resource = result.resources[0] + assert str(resource.uri) == SNAPSHOT_RESOURCE_URI + assert resource.name == "assist_context_snapshot" + assert resource.title == "Assist context snapshot" + assert resource.description is not None + assert resource.mimeType == "text/plain" + + +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) +async def test_mcp_resource_read( + hass: HomeAssistant, + setup_integration: None, + mcp_url: str, + mcp_client: Any, + hass_supervisor_access_token: str, +) -> None: + """Test reading an MCP resource.""" + + async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: + resources = await session.list_resources() + resource = resources.resources[0] + result = await session.read_resource(resource.uri) + + assert len(result.contents) == 1 + content = result.contents[0] + assert content.uri == resource.uri + assert content.mimeType == "text/plain" + assert content.text == ( + "Live Context: An overview of the areas and the devices in this smart home:\n" + "- names: Kitchen Light\n" + " domain: light\n" + " state: 'off'\n" + " areas: Kitchen\n" + ) + + +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) +async def test_mcp_resource_read_unknown_resource( + hass: HomeAssistant, + setup_integration: None, + mcp_url: str, + mcp_client: Any, + hass_supervisor_access_token: str, +) -> None: + """Test reading an unknown MCP resource.""" + + unknown_uri = mcp.types.Resource( + uri="homeassistant://assist/missing", + name="missing", + ).uri + + async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: + with pytest.raises(McpError, match="Unknown resource"): + await session.read_resource(unknown_uri) + + +@pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID]) +async def test_mcp_resources_unavailable_without_live_context_tool( + hass: HomeAssistant, + setup_integration: None, + mcp_url: str, + mcp_client: Any, + hass_supervisor_access_token: str, +) -> None: + """Test resources are unavailable when the selected API exposes no live context.""" + + llm.async_register_api( + hass, MockLLMAPI(hass=hass, id=TEST_LLM_API_ID, name="Test API") + ) + + resource_uri = mcp.types.Resource( + uri=SNAPSHOT_RESOURCE_URI, + name="assist_context_snapshot", + ).uri + + async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: + result = await session.list_resources() + + assert result.resources == [] + + with pytest.raises(McpError, match="Unknown resource"): + await session.read_resource(resource_uri) + + @pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST]) async def test_mcp_tool_call_unicode( hass: HomeAssistant, From 906475249cbf6e7c054a2d39d91a6fef1c46a714 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Apr 2026 16:00:06 +0200 Subject: [PATCH 0571/1707] Bump securetar to 2026.4.0 (#167600) --- homeassistant/components/backup/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 0c1db47c05f7da..ccc63073515601 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.7", "securetar==2026.2.0"], + "requirements": ["cronsim==2.7", "securetar==2026.4.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37ac4db4a9634c..a016a4a3d22217 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 requests==2.33.1 -securetar==2026.2.0 +securetar==2026.4.0 SQLAlchemy==2.0.41 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/pyproject.toml b/pyproject.toml index 194ee7b33a00d9..b6382b12ef2d0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.3", "requests==2.33.1", - "securetar==2026.2.0", + "securetar==2026.4.0", "SQLAlchemy==2.0.41", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", diff --git a/requirements.txt b/requirements.txt index 0527a296113749..55c8375f517681 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 requests==2.33.1 -securetar==2026.2.0 +securetar==2026.4.0 SQLAlchemy==2.0.41 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 644a33c2ea9cfc..a49eb7832c38c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2895,7 +2895,7 @@ screenlogicpy==0.10.2 scsgate==0.1.0 # homeassistant.components.backup -securetar==2026.2.0 +securetar==2026.4.0 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3076ef728abf46..9ebd77afefecf9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2455,7 +2455,7 @@ satel-integra==1.1.0 screenlogicpy==0.10.2 # homeassistant.components.backup -securetar==2026.2.0 +securetar==2026.4.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense From 8aa0e9f6c3ffd9769135bdd38ff1e7a7319fa0b3 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 7 Apr 2026 18:27:43 +0200 Subject: [PATCH 0572/1707] Refactor to active_containers (#167529) --- homeassistant/components/portainer/coordinator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index f05cf4b53d267f..5e36c8337c3a4e 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -214,19 +214,19 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: else None, ) - # Separately fetch stats for running containers - running_containers = [ + # Separately fetch stats for active containers + active_containers = [ container for container in containers if container.state in (DockerContainerState.RUNNING, DockerContainerState.PAUSED) ] - if running_containers: + if active_containers: container_stats = dict( zip( ( self._get_container_name(container.names[0]) - for container in running_containers + for container in active_containers ), await asyncio.gather( *( @@ -234,7 +234,7 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: endpoint_id=endpoint.id, container_id=container.id, ) - for container in running_containers + for container in active_containers ) ), strict=False, From 323b3a4d96ef6311ef97a64ac6ee232e064b22e1 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 7 Apr 2026 18:29:05 +0200 Subject: [PATCH 0573/1707] Add contour and position names to gardena (#167512) --- .../components/gardena_bluetooth/__init__.py | 1 + .../components/gardena_bluetooth/icons.json | 12 + .../components/gardena_bluetooth/strings.json | 8 + .../components/gardena_bluetooth/text.py | 88 +++ .../snapshots/test_text.ambr | 591 ++++++++++++++++++ .../components/gardena_bluetooth/test_text.py | 87 +++ 6 files changed, 787 insertions(+) create mode 100644 homeassistant/components/gardena_bluetooth/icons.json create mode 100644 homeassistant/components/gardena_bluetooth/text.py create mode 100644 tests/components/gardena_bluetooth/snapshots/test_text.ambr create mode 100644 tests/components/gardena_bluetooth/test_text.py diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 2e915beb22ee56..2b1bc9c7bfba0d 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -38,6 +38,7 @@ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TEXT, Platform.VALVE, ] LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/gardena_bluetooth/icons.json b/homeassistant/components/gardena_bluetooth/icons.json new file mode 100644 index 00000000000000..9ac18776773ce1 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "text": { + "contour_name": { + "default": "mdi:vector-polygon" + }, + "position_name": { + "default": "mdi:map-marker-radius" + } + } + } +} diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 80fdd63bf682c8..043579493b8b96 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -154,6 +154,14 @@ "state": { "name": "[%key:common::state::open%]" } + }, + "text": { + "contour_name": { + "name": "Contour {number}" + }, + "position_name": { + "name": "Position {number}" + } } } } diff --git a/homeassistant/components/gardena_bluetooth/text.py b/homeassistant/components/gardena_bluetooth/text.py new file mode 100644 index 00000000000000..ec27759382ebc2 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/text.py @@ -0,0 +1,88 @@ +"""Support for text entities.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from gardena_bluetooth.const import AquaContourContours, AquaContourPosition +from gardena_bluetooth.parse import CharacteristicNullString + +from homeassistant.components.text import TextEntity, TextEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import GardenaBluetoothConfigEntry +from .entity import GardenaBluetoothDescriptorEntity + + +@dataclass(frozen=True, kw_only=True) +class GardenaBluetoothTextEntityDescription(TextEntityDescription): + """Description of entity.""" + + char: CharacteristicNullString + + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + return {self.char.uuid} + + +DESCRIPTIONS = ( + *( + GardenaBluetoothTextEntityDescription( + key=f"position_{i}_name", + translation_key="position_name", + translation_placeholders={"number": str(i)}, + has_entity_name=True, + char=getattr(AquaContourPosition, f"position_name_{i}"), + native_max=20, + entity_category=EntityCategory.CONFIG, + ) + for i in range(1, 6) + ), + *( + GardenaBluetoothTextEntityDescription( + key=f"contour_{i}_name", + translation_key="contour_name", + translation_placeholders={"number": str(i)}, + has_entity_name=True, + char=getattr(AquaContourContours, f"contour_name_{i}"), + native_max=20, + entity_category=EntityCategory.CONFIG, + ) + for i in range(1, 6) + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GardenaBluetoothConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up text based on a config entry.""" + coordinator = entry.runtime_data + entities = [ + GardenaBluetoothTextEntity(coordinator, description, description.context) + for description in DESCRIPTIONS + if description.char.unique_id in coordinator.characteristics + ] + async_add_entities(entities) + + +class GardenaBluetoothTextEntity(GardenaBluetoothDescriptorEntity, TextEntity): + """Representation of a text entity.""" + + entity_description: GardenaBluetoothTextEntityDescription + + @property + def native_value(self) -> str | None: + """Return the value reported by the text.""" + char = self.entity_description.char + return self.coordinator.get_cached(char) + + async def async_set_value(self, value: str) -> None: + """Change the text.""" + char = self.entity_description.char + await self.coordinator.write(char, value) diff --git a/tests/components/gardena_bluetooth/snapshots/test_text.ambr b/tests/components/gardena_bluetooth/snapshots/test_text.ambr new file mode 100644 index 00000000000000..0b9edcf50b98f9 --- /dev/null +++ b/tests/components/gardena_bluetooth/snapshots/test_text.ambr @@ -0,0 +1,591 @@ +# serializer version: 1 +# name: test_setup[aqua_contour][text.mock_title_contour_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_contour_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Contour 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Contour 1', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'contour_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-contour_1_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_contour_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Contour 1', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_contour_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Contour 1', + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_contour_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_contour_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Contour 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Contour 2', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'contour_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-contour_2_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_contour_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Contour 2', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_contour_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Contour 2', + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_contour_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_contour_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Contour 3', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Contour 3', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'contour_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-contour_3_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_contour_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Contour 3', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_contour_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Contour 3', + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_contour_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_contour_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Contour 4', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Contour 4', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'contour_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-contour_4_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_contour_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Contour 4', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_contour_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Contour 4', + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_contour_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_contour_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Contour 5', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Contour 5', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'contour_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-contour_5_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_contour_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Contour 5', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_contour_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Contour 5', + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_position_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Position 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Position 1', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-position_1_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Position 1', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_position_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Position 1', + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_position_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Position 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Position 2', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-position_2_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Position 2', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_position_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Position 2', + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_position_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Position 3', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Position 3', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-position_3_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Position 3', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_position_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Position 3', + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_position_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Position 4', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Position 4', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-position_4_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Position 4', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_position_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Position 4', + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.mock_title_position_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Position 5', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Position 5', + 'platform': 'gardena_bluetooth', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'position_name', + 'unique_id': '00000000-0000-0000-0000-000000000003-position_5_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[aqua_contour][text.mock_title_position_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Position 5', + 'max': 20, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.mock_title_position_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Position 5', + }) +# --- diff --git a/tests/components/gardena_bluetooth/test_text.py b/tests/components/gardena_bluetooth/test_text.py new file mode 100644 index 00000000000000..1ca52d61f3702a --- /dev/null +++ b/tests/components/gardena_bluetooth/test_text.py @@ -0,0 +1,87 @@ +"""Test Gardena Bluetooth text entities.""" + +from unittest.mock import Mock + +from gardena_bluetooth.const import AquaContourContours, AquaContourPosition +from habluetooth import BluetoothServiceInfo +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.text import ( + ATTR_VALUE, + DOMAIN as TEXT_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import AQUA_CONTOUR_SERVICE_INFO, setup_entry + +from tests.common import snapshot_platform + + +@pytest.mark.parametrize( + ("service_info", "raw"), + [ + pytest.param( + AQUA_CONTOUR_SERVICE_INFO, + { + AquaContourPosition.position_name_1.uuid: b"Position 1\x00", + AquaContourPosition.position_name_2.uuid: b"Position 2\x00", + AquaContourPosition.position_name_3.uuid: b"Position 3\x00", + AquaContourPosition.position_name_4.uuid: b"Position 4\x00", + AquaContourPosition.position_name_5.uuid: b"Position 5\x00", + AquaContourContours.contour_name_1.uuid: b"Contour 1\x00", + AquaContourContours.contour_name_2.uuid: b"Contour 2\x00", + AquaContourContours.contour_name_3.uuid: b"Contour 3\x00", + AquaContourContours.contour_name_4.uuid: b"Contour 4\x00", + AquaContourContours.contour_name_5.uuid: b"Contour 5\x00", + }, + id="aqua_contour", + ), + ], +) +async def test_setup( + hass: HomeAssistant, + mock_read_char_raw: dict[str, bytes], + service_info: BluetoothServiceInfo, + raw: dict[str, bytes], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test text entities.""" + mock_read_char_raw.update(raw) + + entry = await setup_entry( + hass, platforms=[Platform.TEXT], service_info=service_info + ) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_text_set_value( + hass: HomeAssistant, + mock_read_char_raw: dict[str, bytes], + mock_client: Mock, +) -> None: + """Test setting text value.""" + mock_read_char_raw[AquaContourPosition.position_name_1.uuid] = b"Position 1\x00" + + await setup_entry( + hass, platforms=[Platform.TEXT], service_info=AQUA_CONTOUR_SERVICE_INFO + ) + + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "text.mock_title_position_1", + ATTR_VALUE: "New Position Name", + }, + blocking=True, + ) + + assert len(mock_client.write_char.mock_calls) == 1 + args = mock_client.write_char.mock_calls[0].args + assert args[0] == AquaContourPosition.position_name_1 + assert args[1] == "New Position Name" From 2f0889ac0284ca685732b8acf3c62238ae80c231 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Apr 2026 18:40:06 +0200 Subject: [PATCH 0574/1707] Fix securetar size calculation when encrypting backup (#167602) --- homeassistant/components/backup/util.py | 5 ++++- tests/components/backup/test_util.py | 12 ++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index d93290d675cba1..fe060521668770 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -22,6 +22,7 @@ SecureTarFile, SecureTarReadError, SecureTarRootKeyContext, + get_archive_max_ciphertext_size, ) from homeassistant.core import HomeAssistant @@ -431,7 +432,9 @@ def __init__( def size(self) -> int: """Return the maximum size of the decrypted or encrypted backup.""" - return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE + return get_archive_max_ciphertext_size( # type: ignore[no-any-return] + self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files() + ) def _num_tar_files(self) -> int: """Return the number of inner tar files.""" diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 83b7a6a794408e..2345747091a6cf 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -282,14 +282,14 @@ def test_validate_password_no_homeassistant(caplog: pytest.LogCaptureFixture) -> AddonInfo(name="Core 1", slug="core1", version="1.0.0"), AddonInfo(name="Core 2", slug="core2", version="1.0.0"), ], - 40960, # 4 x 10240 byte of padding + 51200, # 5 x 10240 byte of padding "test_backups/c0cb53bd.tar.decrypted", ), ( [ AddonInfo(name="Core 1", slug="core1", version="1.0.0"), ], - 30720, # 3 x 10240 byte of padding + 40960, # 4 x 10240 byte of padding "test_backups/c0cb53bd.tar.decrypted_skip_core2", ), ], @@ -460,14 +460,14 @@ async def open_backup() -> AsyncIterator[bytes]: AddonInfo(name="Core 1", slug="core1", version="1.0.0"), AddonInfo(name="Core 2", slug="core2", version="1.0.0"), ], - 40960, # 4 x 10240 byte of padding + 51200, # 5 x 10240 byte of padding "test_backups/c0cb53bd.tar.encrypted_v3", ), ( [ AddonInfo(name="Core 1", slug="core1", version="1.0.0"), ], - 30720, # 3 x 10240 byte of padding + 40960, # 4 x 10240 byte of padding "test_backups/c0cb53bd.tar.encrypted_v3_skip_core2", ), ], @@ -674,8 +674,8 @@ async def read_stream(stream: AsyncIterator[bytes]) -> bytes: # Expect the output length to match the stored encrypted backup file, with # additional padding. encrypted_backup_data = encrypted_backup_path.read_bytes() - # 4 x 10240 byte of padding - assert len(encrypted_output1) == len(encrypted_backup_data) + 40960 + # 5 x 10240 byte of padding + assert len(encrypted_output1) == len(encrypted_backup_data) + 51200 assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data From a1414717ada30f67489bdf97632dfd0b76fc896c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 7 Apr 2026 18:41:28 +0200 Subject: [PATCH 0575/1707] Bump holidays to 0.94 (#167604) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index a3771b354cce2f..03f30da74a1866 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.93", "babel==2.15.0"] + "requirements": ["holidays==0.94", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index ce93a7e823b292..84918b2bad45e4 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.93"] + "requirements": ["holidays==0.94"] } diff --git a/requirements_all.txt b/requirements_all.txt index a49eb7832c38c9..1adb1e6ca8a472 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.93 +holidays==0.94 # homeassistant.components.frontend home-assistant-frontend==20260325.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ebd77afefecf9..05c11952862bc6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.93 +holidays==0.94 # homeassistant.components.frontend home-assistant-frontend==20260325.6 From f7b2f5e8f13a97e912a54c367865073d3cd13c55 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 7 Apr 2026 18:48:05 +0200 Subject: [PATCH 0576/1707] Improve Remote action naming consistency (#167382) --- homeassistant/components/remote/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index 8cad5e289acfda..fd9c3a779d0f78 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -41,7 +41,7 @@ }, "services": { "delete_command": { - "description": "Deletes a command or a list of commands from the database.", + "description": "Deletes a command or a list of commands from a remote's database.", "fields": { "command": { "description": "The single command or the list of commands to be deleted.", @@ -52,10 +52,10 @@ "name": "Device" } }, - "name": "Delete command" + "name": "Delete remote command" }, "learn_command": { - "description": "Learns a command or a list of commands from a device.", + "description": "Teaches a remote a command or list of commands from a device.", "fields": { "alternative": { "description": "If code must be stored as an alternative. This is useful for discrete codes. Discrete codes are used for toggles that only perform one function. For example, a code to only turn a device on. If it is on already, sending the code won't change the state.", @@ -78,7 +78,7 @@ "name": "Timeout" } }, - "name": "Learn command" + "name": "Learn remote command" }, "send_command": { "description": "Sends a command or a list of commands to a device.", @@ -104,15 +104,15 @@ "name": "Repeats" } }, - "name": "Send command" + "name": "Send remote command" }, "toggle": { "description": "Sends the toggle command.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle via remote" }, "turn_off": { "description": "Sends the turn off command.", - "name": "[%key:common::action::turn_off%]" + "name": "Turn off via remote" }, "turn_on": { "description": "Sends the turn on command.", @@ -122,7 +122,7 @@ "name": "Activity" } }, - "name": "[%key:common::action::turn_on%]" + "name": "Turn on via remote" } }, "title": "Remote", From 09ee76c26594dfd2fe502026426e88c2c9c7613a Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:51:26 +0200 Subject: [PATCH 0577/1707] Add initial support for PlayerOptions: Number entities to Music Assistant (#162669) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/music_assistant/__init__.py | 6 +- .../components/music_assistant/const.py | 2 + .../components/music_assistant/entity.py | 45 +++++- .../components/music_assistant/number.py | 127 +++++++++++++++ .../components/music_assistant/strings.json | 29 ++++ .../music_assistant/fixtures/players.json | 125 ++++++++++++++ .../snapshots/test_number.ambr | 119 ++++++++++++++ .../components/music_assistant/test_number.py | 153 ++++++++++++++++++ 8 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/music_assistant/number.py create mode 100644 tests/components/music_assistant/snapshots/test_number.ambr create mode 100644 tests/components/music_assistant/test_number.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index c0d56abba2b96a..c5b7706d219177 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -49,7 +49,11 @@ from homeassistant.helpers.typing import ConfigType -PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] +PLATFORMS = [ + Platform.BUTTON, + Platform.MEDIA_PLAYER, + Platform.NUMBER, +] CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 035da439db6120..5a89510471fa19 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -80,3 +80,5 @@ ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha" LOGGER = logging.getLogger(__package__) + +PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX = "player_options." diff --git a/homeassistant/components/music_assistant/entity.py b/homeassistant/components/music_assistant/entity.py index 21fc072a639090..c12bf107fc027d 100644 --- a/homeassistant/components/music_assistant/entity.py +++ b/homeassistant/components/music_assistant/entity.py @@ -6,8 +6,9 @@ from music_assistant_models.enums import EventType from music_assistant_models.event import MassEvent -from music_assistant_models.player import Player +from music_assistant_models.player import Player, PlayerOption +from homeassistant.const import EntityCategory from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -84,3 +85,45 @@ async def __on_mass_update(self, event: MassEvent) -> None: async def async_on_update(self) -> None: """Handle player updates.""" + + +class MusicAssistantPlayerOptionEntity(MusicAssistantEntity): + """Base entity for Music Assistant Player Options.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, mass: MusicAssistantClient, player_id: str, player_option: PlayerOption + ) -> None: + """Initialize MusicAssistantPlayerOptionEntity.""" + super().__init__(mass, player_id) + + self.mass_option_key = player_option.key + self.mass_type = player_option.type + + self.on_player_option_update(player_option) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + # need callbacks of parent to catch availability + await super().async_added_to_hass() + + # main callback for player options + self.async_on_remove( + self.mass.subscribe( + self.__on_mass_player_options_update, + EventType.PLAYER_OPTIONS_UPDATED, + self.player_id, + ) + ) + + def __on_mass_player_options_update(self, event: MassEvent) -> None: + """Call when we receive an event from MusicAssistant.""" + for option in self.player.options: + if option.key == self.mass_option_key: + self.on_player_option_update(option) + self.async_write_ha_state() + break + + def on_player_option_update(self, player_option: PlayerOption) -> None: + """Callback for player option updates.""" diff --git a/homeassistant/components/music_assistant/number.py b/homeassistant/components/music_assistant/number.py new file mode 100644 index 00000000000000..622c9adebcd435 --- /dev/null +++ b/homeassistant/components/music_assistant/number.py @@ -0,0 +1,127 @@ +"""Music Assistant Number platform.""" + +from __future__ import annotations + +from typing import Final + +from music_assistant_client.client import MusicAssistantClient +from music_assistant_models.player import PlayerOption, PlayerOptionType + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX +from .entity import MusicAssistantPlayerOptionEntity +from .helpers import catch_musicassistant_error + +PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER: Final[list[str]] = [ + "bass", + "dialogue_level", + "dialogue_lift", + "dts_dialogue_control", + "equalizer_high", + "equalizer_low", + "equalizer_mid", + "subwoofer_volume", + "treble", +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant Number Entities (Player Options) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + player = mass.players.get(player_id) + if player is None: + return + entities: list[MusicAssistantPlayerConfigNumber] = [] + for player_option in player.options: + if ( + not player_option.read_only + and player_option.type + in ( + PlayerOptionType.INTEGER, + PlayerOptionType.FLOAT, + ) + and not player_option.options # these we map to select + ): + # the MA translation key must have the format player_options. + # we ignore entities with unknown translation keys. + if ( + player_option.translation_key is None + or not player_option.translation_key.startswith( + PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX + ) + ): + continue + translation_key = player_option.translation_key[ + len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) : + ] + if translation_key not in PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER: + continue + + entities.append( + MusicAssistantPlayerConfigNumber( + mass, + player_id, + player_option=player_option, + entity_description=NumberEntityDescription( + key=player_option.key, + translation_key=translation_key, + ), + ) + ) + async_add_entities(entities) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.NUMBER, add_player) + + +class MusicAssistantPlayerConfigNumber(MusicAssistantPlayerOptionEntity, NumberEntity): + """Representation of a Number entity to control player provider dependent settings.""" + + def __init__( + self, + mass: MusicAssistantClient, + player_id: str, + player_option: PlayerOption, + entity_description: NumberEntityDescription, + ) -> None: + """Initialize MusicAssistantPlayerConfigNumber.""" + super().__init__(mass, player_id, player_option) + + self.entity_description = entity_description + + @catch_musicassistant_error + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + _value = round(value) if self.mass_type == PlayerOptionType.INTEGER else value + await self.mass.players.set_option( + self.player_id, + self.mass_option_key, + _value, + ) + + def on_player_option_update(self, player_option: PlayerOption) -> None: + """Update on player option update.""" + if player_option.min_value is not None: + self._attr_native_min_value = player_option.min_value + if player_option.max_value is not None: + self._attr_native_max_value = player_option.max_value + if player_option.step is not None: + self._attr_native_step = player_option.step + + self._attr_native_value = ( + player_option.value + if isinstance(player_option.value, (int, float)) + else None + ) diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 57c5e1745b4387..a1af34ab851339 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -53,6 +53,35 @@ "favorite_now_playing": { "name": "Favorite current song" } + }, + "number": { + "bass": { + "name": "Bass" + }, + "dialogue_level": { + "name": "Dialogue level" + }, + "dialogue_lift": { + "name": "Dialogue lift" + }, + "dts_dialogue_control": { + "name": "DTS dialogue control" + }, + "equalizer_high": { + "name": "Equalizer high" + }, + "equalizer_low": { + "name": "Equalizer low" + }, + "equalizer_mid": { + "name": "Equalizer mid" + }, + "subwoofer_volume": { + "name": "Subwoofer volume" + }, + "treble": { + "name": "Treble" + } } }, "issues": { diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 5116c97a6aecde..5306b17d1bc925 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -26,6 +26,131 @@ "volume_level": 20, "volume_muted": false, "group_members": [], + "options": [ + { + "key": "treble", + "name": "Treble", + "type": "integer", + "translation_key": "player_options.treble", + "translation_params": null, + "value": -6, + "read_only": false, + "min_value": -10, + "max_value": 10, + "step": 1, + "options": null + }, + { + "key": "bass", + "name": "Bass", + "type": "float", + "translation_key": "player_options.bass", + "translation_params": null, + "value": -6.0, + "read_only": false, + "min_value": -10.0, + "max_value": 10.0, + "step": 1.0, + "options": null + }, + { + "key": "treble_ro", + "name": "Treble RO", + "type": "integer", + "translation_key": "player_options.treble", + "translation_params": null, + "value": -6, + "read_only": true, + "min_value": null, + "max_value": null, + "step": null, + "options": null + }, + { + "key": "enhancer", + "name": "Enhancer", + "type": "boolean", + "translation_key": "player_options.enhancer", + "translation_params": null, + "value": false, + "read_only": false, + "min_value": null, + "max_value": null, + "step": null, + "options": null + }, + { + "key": "enhancer_ro", + "name": "Enhancer RO", + "type": "boolean", + "translation_key": "player_options.enhancer", + "translation_params": null, + "value": false, + "read_only": true, + "min_value": null, + "max_value": null, + "step": null, + "options": null + }, + { + "key": "network_name", + "name": "Network Name", + "type": "string", + "translation_key": "player_options.network_name", + "translation_params": null, + "value": "receiver", + "read_only": false, + "min_value": null, + "max_value": null, + "step": null, + "options": null + }, + { + "key": "network_name_ro", + "name": "Network Name RO", + "type": "string", + "translation_key": "player_options.network_name", + "translation_params": null, + "value": "receiver ro", + "read_only": true, + "min_value": null, + "max_value": null, + "step": null, + "options": null + }, + { + "key": "link_audio_delay", + "name": "Link Audio Delay", + "type": "string", + "translation_key": "player_options.link_audio_delay", + "translation_params": null, + "value": "lip_sync", + "read_only": false, + "min_value": null, + "max_value": null, + "step": null, + "options": [ + { + "key": "audio_sync", + "name": "audio_sync", + "type": "string", + "value": "audio_sync" + }, + { + "key": "balanced", + "name": "balanced", + "type": "string", + "value": "balanced" + }, + { + "key": "lip_sync", + "name": "lip_sync", + "type": "string", + "value": "lip_sync" + } + ] + } + ], "active_source": "00:00:00:00:00:01", "active_group": null, "current_media": null, diff --git a/tests/components/music_assistant/snapshots/test_number.ambr b/tests/components/music_assistant/snapshots/test_number.ambr new file mode 100644 index 00000000000000..f454c5ce159b5d --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_number.ambr @@ -0,0 +1,119 @@ +# serializer version: 1 +# name: test_number_entities[number.test_player_1_bass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_player_1_bass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bass', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bass', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bass', + 'unique_id': '00:00:00:00:00:01_bass', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[number.test_player_1_bass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Player 1 Bass', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_player_1_bass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-6.0', + }) +# --- +# name: test_number_entities[number.test_player_1_treble-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_player_1_treble', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Treble', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Treble', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'treble', + 'unique_id': '00:00:00:00:00:01_treble', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[number.test_player_1_treble-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Player 1 Treble', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.test_player_1_treble', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-6', + }) +# --- diff --git a/tests/components/music_assistant/test_number.py b/tests/components/music_assistant/test_number.py new file mode 100644 index 00000000000000..a037c4e6b432d2 --- /dev/null +++ b/tests/components/music_assistant/test_number.py @@ -0,0 +1,153 @@ +"""Test Music Assistant number entities.""" + +from unittest.mock import MagicMock, call + +from music_assistant_models.enums import EventType +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.components.music_assistant.number import ( + PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER, +) +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.translation import LOCALE_EN, async_get_translations + +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) + + +async def test_number_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + music_assistant_client: MagicMock, +) -> None: + """Test number entities.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + snapshot_music_assistant_entities(hass, entity_registry, snapshot, Platform.NUMBER) + + +async def test_number_set_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test number set action.""" + mass_player_id = "00:00:00:00:00:01" + mass_option_key = "treble" + entity_id = "number.test_player_1_treble" + + option_value = 3 + + await setup_integration_from_fixtures(hass, music_assistant_client) + state = hass.states.get(entity_id) + assert state + + # test within range + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: option_value, + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/set_option", + player_id=mass_player_id, + option_key=mass_option_key, + option_value=option_value, + ) + + # test out of range + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 20, + }, + blocking=True, + ) + + +async def test_external_update( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test external value update.""" + mass_player_id = "00:00:00:00:00:01" + mass_option_key = "treble" + entity_id = "number.test_player_1_treble" + + await setup_integration_from_fixtures(hass, music_assistant_client) + + # get current option and remove it + number_option = next( + option + for option in music_assistant_client.players._players[mass_player_id].options + if option.key == mass_option_key + ) + music_assistant_client.players._players[mass_player_id].options.remove( + number_option + ) + + # set new value different from previous one + previous_value = number_option.value + new_value = 5 + number_option.value = new_value + assert previous_value != number_option.value + music_assistant_client.players._players[mass_player_id].options.append( + number_option + ) + + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_OPTIONS_UPDATED, mass_player_id + ) + state = hass.states.get(entity_id) + assert state + assert int(float(state.state)) == new_value + + +async def test_ignored( + hass: HomeAssistant, + music_assistant_client: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test that non-compatible player options are ignored.""" + config_entry = await setup_integration_from_fixtures(hass, music_assistant_client) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + # we only have two non read-only player options, bass and treble + assert sum(1 for entry in registry_entries if entry.domain == NUMBER_DOMAIN) == 2 + + +async def test_name_translation_availability( + hass: HomeAssistant, +) -> None: + """Verify, that the list of available translation keys is reflected in strings.json.""" + # verify, that PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER matches strings.json + translations = await async_get_translations( + hass, language=LOCALE_EN, category="entity", integrations=[DOMAIN] + ) + prefix = f"component.{DOMAIN}.entity.{Platform.NUMBER.value}." + for translation_key in PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER: + assert translations.get(f"{prefix}{translation_key}.name") is not None, ( + f"{translation_key} is missing in strings.json for platform number" + ) From 550e53d19212e480562ea00fc4cc286becada1c3 Mon Sep 17 00:00:00 2001 From: Oliver Verity Date: Tue, 7 Apr 2026 20:19:25 +0100 Subject: [PATCH 0578/1707] Add support for storing OpenAI conversation responses (#165723) --- .../openai_conversation/__init__.py | 6 ++- .../openai_conversation/config_flow.py | 10 ++++- .../components/openai_conversation/const.py | 2 + .../components/openai_conversation/entity.py | 8 +++- .../openai_conversation/strings.json | 8 ++++ .../openai_conversation/test_ai_task.py | 21 +++++++++- .../openai_conversation/test_config_flow.py | 33 +++++++++++++-- .../openai_conversation/test_conversation.py | 41 +++++++++++++++++++ .../openai_conversation/test_init.py | 17 +++++++- 9 files changed, 137 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 44fed05e1365d9..28ada1a71d51d0 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -46,6 +46,7 @@ CONF_MAX_TOKENS, CONF_PROMPT, CONF_REASONING_EFFORT, + CONF_STORE_RESPONSES, CONF_TEMPERATURE, CONF_TOP_P, DEFAULT_AI_TASK_NAME, @@ -58,6 +59,7 @@ RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, + RECOMMENDED_STORE_RESPONSES, RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, @@ -208,7 +210,9 @@ async def send_prompt(call: ServiceCall) -> ServiceResponse: CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE ), "user": call.context.user_id, - "store": False, + "store": conversation_subentry.data.get( + CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES + ), } if model.startswith("o"): diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 5843e2f36c8d45..1a2bcbed4a651a 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -55,6 +55,7 @@ CONF_REASONING_SUMMARY, CONF_RECOMMENDED, CONF_SERVICE_TIER, + CONF_STORE_RESPONSES, CONF_TEMPERATURE, CONF_TOP_P, CONF_TTS_SPEED, @@ -82,6 +83,7 @@ RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, + RECOMMENDED_STORE_RESPONSES, RECOMMENDED_STT_MODEL, RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, @@ -357,6 +359,10 @@ async def async_step_advanced( CONF_TEMPERATURE, default=RECOMMENDED_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + vol.Optional( + CONF_STORE_RESPONSES, + default=RECOMMENDED_STORE_RESPONSES, + ): bool, } if user_input is not None: @@ -641,7 +647,9 @@ async def _get_location_data(self) -> dict[str, str]: "strict": False, } }, - store=False, + store=self.options.get( + CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES + ), ) location_data = location_schema(json.loads(response.output_text) or {}) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 2acf2aa9791593..5c45dee4d1ec16 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -24,6 +24,7 @@ CONF_REASONING_EFFORT = "reasoning_effort" CONF_REASONING_SUMMARY = "reasoning_summary" CONF_RECOMMENDED = "recommended" +CONF_STORE_RESPONSES = "store_responses" CONF_SERVICE_TIER = "service_tier" CONF_TEMPERATURE = "temperature" CONF_TOP_P = "top_p" @@ -42,6 +43,7 @@ RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5" RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" +RECOMMENDED_STORE_RESPONSES = False RECOMMENDED_REASONING_SUMMARY = "auto" RECOMMENDED_SERVICE_TIER = "auto" RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe" diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 50a4f6f8f7e906..93d72d0b72ad99 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -75,6 +75,7 @@ CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, + CONF_STORE_RESPONSES, CONF_TEMPERATURE, CONF_TOP_P, CONF_VERBOSITY, @@ -94,6 +95,7 @@ RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, + RECOMMENDED_STORE_RESPONSES, RECOMMENDED_STT_MODEL, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, @@ -508,7 +510,7 @@ async def _async_handle_chat_log( max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), user=chat_log.conversation_id, service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER), - store=False, + store=options.get(CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES), stream=True, ) @@ -611,8 +613,10 @@ async def _async_handle_chat_log( if image_model != "gpt-image-1-mini": image_tool["input_fidelity"] = "high" tools.append(image_tool) + # Keep image state on OpenAI so follow-up prompts can continue by + # conversation ID without resending the generated image data. + model_args["store"] = True model_args["tool_choice"] = ToolChoiceTypesParam(type="image_generation") - model_args["store"] = True # Avoid sending image data back and forth if tools: model_args["tools"] = tools diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 178910ae0978f1..58e0f35ba3cc31 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -51,9 +51,13 @@ "data": { "chat_model": "[%key:common::generic::model%]", "max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]", + "store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::store_responses%]", "temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]", "top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]" }, + "data_description": { + "store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data_description::store_responses%]" + }, "title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]" }, "init": { @@ -109,9 +113,13 @@ "data": { "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", + "store_responses": "Store requests and responses in OpenAI", "temperature": "Temperature", "top_p": "Top P" }, + "data_description": { + "store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs" + }, "title": "Advanced settings" }, "init": { diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index 990c4322a04625..9cc7822ea729a3 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -10,6 +10,7 @@ from homeassistant.components import ai_task, media_source from homeassistant.components.openai_conversation import DOMAIN +from homeassistant.components.openai_conversation.const import CONF_STORE_RESPONSES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir, selector @@ -20,11 +21,13 @@ @pytest.mark.usefixtures("mock_init_component") +@pytest.mark.parametrize("expected_store", [False, True]) async def test_generate_data( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_create_stream: AsyncMock, entity_registry: er.EntityRegistry, + expected_store: bool, ) -> None: """Test AI Task data generation.""" entity_id = "ai_task.openai_ai_task" @@ -38,6 +41,12 @@ async def test_generate_data( if entry.subentry_type == "ai_task_data" ) ) + hass.config_entries.async_update_subentry( + mock_config_entry, + ai_task_entry, + data={**ai_task_entry.data, CONF_STORE_RESPONSES: expected_store}, + ) + await hass.async_block_till_done() assert entity_entry is not None assert entity_entry.config_entry_id == mock_config_entry.entry_id assert entity_entry.config_subentry_id == ai_task_entry.subentry_id @@ -55,6 +64,8 @@ async def test_generate_data( ) assert result.data == "The test data" + assert mock_create_stream.call_args is not None + assert mock_create_stream.call_args.kwargs["store"] is expected_store @pytest.mark.usefixtures("mock_init_component") @@ -212,6 +223,7 @@ async def test_generate_data_with_attachments( @pytest.mark.usefixtures("mock_init_component") @pytest.mark.freeze_time("2025-06-14 22:59:00") +@pytest.mark.parametrize("configured_store", [False, True]) @pytest.mark.parametrize( "image_model", ["gpt-image-1.5", "gpt-image-1", "gpt-image-1-mini"] ) @@ -222,6 +234,7 @@ async def test_generate_image( entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, image_model: str, + configured_store: bool, ) -> None: """Test AI Task image generation.""" entity_id = "ai_task.openai_ai_task" @@ -238,7 +251,11 @@ async def test_generate_image( hass.config_entries.async_update_subentry( mock_config_entry, ai_task_entry, - data={"image_model": image_model}, + data={ + **ai_task_entry.data, + "image_model": image_model, + CONF_STORE_RESPONSES: configured_store, + }, ) await hass.async_block_till_done() assert entity_entry is not None @@ -277,6 +294,8 @@ async def test_generate_image( assert result["model"] == image_model mock_upload_media.assert_called_once() + assert mock_create_stream.call_args is not None + assert mock_create_stream.call_args.kwargs["store"] is True image_data = mock_upload_media.call_args[0][1] assert image_data.file.getvalue() == b"A" assert image_data.content_type == "image/png" diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index e5b3005d6efa5f..bf2f482ab6f892 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -21,6 +21,7 @@ CONF_REASONING_SUMMARY, CONF_RECOMMENDED, CONF_SERVICE_TIER, + CONF_STORE_RESPONSES, CONF_TEMPERATURE, CONF_TOP_P, CONF_TTS_SPEED, @@ -539,6 +540,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "o1-pro", CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: 10000, + CONF_STORE_RESPONSES: False, CONF_REASONING_EFFORT: "high", CONF_CODE_INTERPRETER: True, }, @@ -575,6 +577,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_STORE_RESPONSES: False, CONF_SERVICE_TIER: "auto", CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", @@ -592,6 +595,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-4o", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: True, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -613,6 +617,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-4o", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: True, }, { CONF_WEB_SEARCH: True, @@ -630,6 +635,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-4o", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: True, CONF_SERVICE_TIER: "default", CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", @@ -686,6 +692,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: False, CONF_REASONING_EFFORT: "minimal", CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_CODE_INTERPRETER: False, @@ -799,6 +806,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "o3-mini", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: False, CONF_REASONING_EFFORT: "low", CONF_CODE_INTERPRETER: True, }, @@ -844,6 +852,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-4o", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: False, CONF_SERVICE_TIER: "priority", CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "high", @@ -896,6 +905,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-5-pro", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_STORE_RESPONSES: False, CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_VERBOSITY: "medium", CONF_WEB_SEARCH: True, @@ -915,7 +925,11 @@ async def test_subentry_switching( expected_options, ) -> None: """Test the subentry form.""" - subentry = next(iter(mock_config_entry.subentries.values())) + subentry = next( + sub + for sub in mock_config_entry.subentries.values() + if sub.subentry_type == "conversation" + ) hass.config_entries.async_update_subentry( mock_config_entry, subentry, data=current_options ) @@ -952,11 +966,19 @@ async def test_subentry_switching( assert subentry.data == expected_options +@pytest.mark.parametrize("store_responses", [False, True]) async def test_subentry_web_search_user_location( - hass: HomeAssistant, mock_config_entry, mock_init_component + hass: HomeAssistant, + mock_config_entry, + mock_init_component, + store_responses: bool, ) -> None: """Test fetching user location.""" - subentry = next(iter(mock_config_entry.subentries.values())) + subentry = next( + sub + for sub in mock_config_entry.subentries.values() + if sub.subentry_type == "conversation" + ) subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( hass, subentry.subentry_id ) @@ -982,6 +1004,7 @@ async def test_subentry_web_search_user_location( CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_STORE_RESPONSES: store_responses, }, ) await hass.async_block_till_done() @@ -1036,6 +1059,7 @@ async def test_subentry_web_search_user_location( mock_create.call_args.kwargs["input"][0]["content"] == "Where are the following" " coordinates located: (37.7749, -122.4194)?" ) + assert mock_create.call_args.kwargs["store"] is store_responses assert subentry_flow["type"] is FlowResultType.ABORT assert subentry_flow["reason"] == "reconfigure_successful" assert subentry.data == { @@ -1046,6 +1070,7 @@ async def test_subentry_web_search_user_location( CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_SERVICE_TIER: "auto", + CONF_STORE_RESPONSES: store_responses, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -1149,6 +1174,7 @@ async def test_creating_ai_task_subentry_advanced( { CONF_CHAT_MODEL: "gpt-4o", CONF_MAX_TOKENS: 200, + CONF_STORE_RESPONSES: True, CONF_TEMPERATURE: 0.5, CONF_TOP_P: 0.9, }, @@ -1172,6 +1198,7 @@ async def test_creating_ai_task_subentry_advanced( CONF_CHAT_MODEL: "gpt-4o", CONF_IMAGE_MODEL: "gpt-image-1.5", CONF_MAX_TOKENS: 200, + CONF_STORE_RESPONSES: True, CONF_TEMPERATURE: 0.5, CONF_TOP_P: 0.9, CONF_CODE_INTERPRETER: False, diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index a6abb29a3fb799..a6d0c64acd13a9 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -20,6 +20,7 @@ from homeassistant.components.openai_conversation.const import ( CONF_CODE_INTERPRETER, CONF_SERVICE_TIER, + CONF_STORE_RESPONSES, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -472,6 +473,46 @@ async def test_assist_api_tools_conversion( assert tools +@pytest.mark.parametrize( + "expected_store", + [ + False, + True, + ], +) +async def test_store_responses_forwarded_for_conversation_agent( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + expected_store: bool, +) -> None: + """Test store_responses is forwarded for the conversation agent.""" + subentry = next( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "conversation" + ) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={**subentry.data, CONF_STORE_RESPONSES: expected_store}, + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Hello!", output_index=0) + ] + + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_create_stream.call_args is not None + assert mock_create_stream.call_args.kwargs["store"] is expected_store + + @pytest.mark.parametrize("inline_citations", [True, False]) async def test_web_search( hass: HomeAssistant, diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 4ed56812afaa83..0da3842884b488 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -19,6 +19,7 @@ from homeassistant.components.openai_conversation import CONF_CHAT_MODEL from homeassistant.components.openai_conversation.const import ( + CONF_STORE_RESPONSES, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DEFAULT_STT_NAME, @@ -308,6 +309,7 @@ async def test_init_auth_error( assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR +@pytest.mark.parametrize("store_responses", [False, True]) @pytest.mark.parametrize( ("service_data", "expected_args", "number_of_files"), [ @@ -404,18 +406,31 @@ async def test_generate_content_service( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, + store_responses: bool, service_data, expected_args, number_of_files, ) -> None: """Test generate content service.""" + conversation_subentry = next( + sub + for sub in mock_config_entry.subentries.values() + if sub.subentry_type == "conversation" + ) + hass.config_entries.async_update_subentry( + mock_config_entry, + conversation_subentry, + data={**conversation_subentry.data, CONF_STORE_RESPONSES: store_responses}, + ) + await hass.async_block_till_done() + service_data["config_entry"] = mock_config_entry.entry_id expected_args["model"] = "gpt-4o-mini" expected_args["max_output_tokens"] = 3000 expected_args["top_p"] = 1.0 expected_args["temperature"] = 1.0 expected_args["user"] = None - expected_args["store"] = False + expected_args["store"] = store_responses expected_args["input"][0]["type"] = "message" expected_args["input"][0]["role"] = "user" From 08097c67eb19191abdc5d4477d1f739aeed180ca Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Apr 2026 21:19:51 +0200 Subject: [PATCH 0579/1707] Bump securetar to 2026.4.1 (#167617) --- homeassistant/components/backup/manifest.json | 2 +- homeassistant/components/backup/util.py | 13 ++++++++----- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index ccc63073515601..e72133f1ce4ec0 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.7", "securetar==2026.4.0"], + "requirements": ["cronsim==2.7", "securetar==2026.4.1"], "single_config_entry": true } diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index fe060521668770..cef18a7a97511e 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -384,9 +384,12 @@ def _encrypt_backup( if prefix not in expected_archives: LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name) continue - output_archive.import_tar( - input_tar.extractfile(obj), obj, derived_key_id=inner_tar_idx - ) + if (fileobj := input_tar.extractfile(obj)) is None: + LOGGER.debug( + "Non regular inner tar file %s will not be encrypted", obj.name + ) + continue + output_archive.import_tar(fileobj, obj, derived_key_id=inner_tar_idx) inner_tar_idx += 1 @@ -420,7 +423,7 @@ def __init__( hass: HomeAssistant, backup: AgentBackup, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - password: str | None, + password: str, ) -> None: """Initialize.""" self._workers: list[_CipherWorkerStatus] = [] @@ -432,7 +435,7 @@ def __init__( def size(self) -> int: """Return the maximum size of the decrypted or encrypted backup.""" - return get_archive_max_ciphertext_size( # type: ignore[no-any-return] + return get_archive_max_ciphertext_size( self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files() ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a016a4a3d22217..a6e604fe1a16f4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 requests==2.33.1 -securetar==2026.4.0 +securetar==2026.4.1 SQLAlchemy==2.0.41 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/pyproject.toml b/pyproject.toml index b6382b12ef2d0c..cdafd430aaaca8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.3", "requests==2.33.1", - "securetar==2026.4.0", + "securetar==2026.4.1", "SQLAlchemy==2.0.41", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", diff --git a/requirements.txt b/requirements.txt index 55c8375f517681..47240294575165 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 requests==2.33.1 -securetar==2026.4.0 +securetar==2026.4.1 SQLAlchemy==2.0.41 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1adb1e6ca8a472..76c4844db2cfa1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2895,7 +2895,7 @@ screenlogicpy==0.10.2 scsgate==0.1.0 # homeassistant.components.backup -securetar==2026.4.0 +securetar==2026.4.1 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05c11952862bc6..8c44d5035da5b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2455,7 +2455,7 @@ satel-integra==1.1.0 screenlogicpy==0.10.2 # homeassistant.components.backup -securetar==2026.4.0 +securetar==2026.4.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense From 1a048a784544e4165a09c18f40db00b857ba00a4 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 7 Apr 2026 23:43:11 +0200 Subject: [PATCH 0580/1707] Move logging for loading/unloading config entry to integration logger (#167415) --- homeassistant/config_entries.py | 60 ++++++++++++++++------------ homeassistant/loader.py | 1 + tests/components/logger/test_init.py | 6 ++- 3 files changed, 40 insertions(+), 27 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 85e1d1d3ffe299..bff03e4a14911d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -579,6 +579,13 @@ def __setattr__(self, key: str, value: Any) -> None: self.clear_state_cache() self.clear_storage_cache() + @property + def logger(self) -> logging.Logger: + """Return logger for this config entry.""" + if self._integration_for_domain: + return self._integration_for_domain.logger + return _LOGGER + @property def supports_options(self) -> bool: """Return if entry supports config options.""" @@ -698,6 +705,9 @@ async def __async_setup_with_context( integration = await loader.async_get_integration(hass, self.domain) self._integration_for_domain = integration + # Log setup to the integration logger so it's visible when debug logs are enabled. + logger = self.logger + # Only store setup result as state if it was not forwarded. if domain_is_integration := self.domain == integration.domain: if self.state in ( @@ -726,7 +736,7 @@ async def __async_setup_with_context( try: component = await integration.async_get_component() except ImportError as err: - _LOGGER.error( + logger.error( "Error importing integration %s to set up %s configuration entry: %s", integration.domain, self.domain, @@ -742,7 +752,7 @@ async def __async_setup_with_context( try: await integration.async_get_platform("config_flow") except ImportError as err: - _LOGGER.error( + logger.error( ( "Error importing platform config_flow from integration %s to" " set up %s configuration entry: %s" @@ -777,7 +787,7 @@ async def __async_setup_with_context( result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( # type: ignore[unreachable] + logger.error( # type: ignore[unreachable] "%s.async_setup_entry did not return boolean", integration.domain ) result = False @@ -785,7 +795,7 @@ async def __async_setup_with_context( error_reason = str(exc) or "Unknown fatal config entry error" error_reason_translation_key = exc.translation_key error_reason_translation_placeholders = exc.translation_placeholders - _LOGGER.exception( + logger.exception( "Error setting up entry %s for %s: %s", self.title, self.domain, @@ -800,13 +810,13 @@ async def __async_setup_with_context( auth_message = ( f"{auth_base_message}: {message}" if message else auth_base_message ) - _LOGGER.warning( + logger.warning( "Config entry '%s' for %s integration %s", self.title, self.domain, auth_message, ) - _LOGGER.debug("Full exception", exc_info=True) + logger.debug("Full exception", exc_info=True) self.async_start_reauth(hass) except ConfigEntryNotReady as exc: message = str(exc) @@ -824,14 +834,14 @@ async def __async_setup_with_context( ) self._tries += 1 ready_message = f"ready yet: {message}" if message else "ready yet" - _LOGGER.info( + logger.info( "Config entry '%s' for %s integration not %s; Retrying in %d seconds", self.title, self.domain, ready_message, wait_time, ) - _LOGGER.debug("Full exception", exc_info=True) + logger.debug("Full exception", exc_info=True) if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( @@ -854,7 +864,7 @@ async def __async_setup_with_context( except asyncio.CancelledError: # We want to propagate CancelledError if we are being cancelled. if (task := asyncio.current_task()) and task.cancelling() > 0: - _LOGGER.exception( + logger.exception( "Setup of config entry '%s' for %s integration cancelled", self.title, self.domain, @@ -869,13 +879,13 @@ async def __async_setup_with_context( raise # This was not a "real" cancellation, log it and treat as a normal error. - _LOGGER.exception( + logger.exception( "Error setting up entry %s for %s", self.title, integration.domain ) # pylint: disable-next=broad-except except SystemExit, Exception: - _LOGGER.exception( + logger.exception( "Error setting up entry %s for %s", self.title, integration.domain ) @@ -1029,7 +1039,7 @@ async def async_unload( ) except Exception as exc: - _LOGGER.exception( + self.logger.exception( "Error unloading entry %s for %s", self.title, integration.domain ) if domain_is_integration: @@ -1072,7 +1082,7 @@ async def async_remove(self, hass: HomeAssistant) -> None: try: await component.async_remove_entry(hass, self) except Exception: - _LOGGER.exception( + self.logger.exception( "Error calling entry remove callback %s for %s", self.title, integration.domain, @@ -1117,7 +1127,7 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: Returns True if config entry is up-to-date or has been migrated. """ if (handler := HANDLERS.get(self.domain)) is None: - _LOGGER.error( + self.logger.error( "Flow handler not found for entry %s for %s", self.title, self.domain ) return False @@ -1138,7 +1148,7 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: if not supports_migrate: if same_major_version: return True - _LOGGER.error( + self.logger.error( "Migration handler not found for entry %s for %s", self.title, self.domain, @@ -1148,14 +1158,14 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: try: result = await component.async_migrate_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( # type: ignore[unreachable] + self.logger.error( # type: ignore[unreachable] "%s.async_migrate_entry did not return boolean", self.domain ) return False if result: hass.config_entries._async_schedule_save() # noqa: SLF001 except Exception: - _LOGGER.exception( + self.logger.exception( "Error migrating entry %s for %s", self.title, self.domain ) return False @@ -1218,7 +1228,7 @@ async def _async_process_on_unload(self, hass: HomeAssistant) -> None: ) for task in pending: - _LOGGER.warning( + self.logger.warning( "Unloading %s (%s) config entry. Task %s did not complete in time", self.title, self.domain, @@ -1247,7 +1257,7 @@ def _async_process_on_state_change(self) -> None: try: func() except Exception: - _LOGGER.exception( + self.logger.exception( "Error calling on_state_change callback for %s (%s)", self.title, self.domain, @@ -1636,7 +1646,7 @@ async def async_finish_flow( ) } ) - _LOGGER.debug( + entry.logger.debug( "Updating discovery keys for %s entry %s %s -> %s", entry.domain, unique_id, @@ -1869,7 +1879,7 @@ def __setitem__(self, entry_id: str, entry: ConfigEntry) -> None: if entry_id in data: # This is likely a bug in a test that is adding the same entry twice. # In the future, once we have fixed the tests, this will raise HomeAssistantError. - _LOGGER.error("An entry with the id %s already exists", entry_id) + entry.logger.error("An entry with the id %s already exists", entry_id) self._unindex_entry(entry_id) data[entry_id] = entry self._index_entry(entry) @@ -1892,7 +1902,7 @@ def check_unique_id(self, entry: ConfigEntry) -> None: report_issue = async_suggest_report_issue( self._hass, integration_domain=entry.domain ) - _LOGGER.error( + entry.logger.error( ( "Config entry '%s' from integration %s has an invalid unique_id" " '%s' of type %s when a string is expected, please %s" @@ -2282,7 +2292,7 @@ async def _async_scan_orphan_ignored_entries( try: await loader.async_get_integration(self.hass, entry.domain) except loader.IntegrationNotFound: - _LOGGER.info( + entry.logger.info( "Integration for ignored config entry %s not found. Creating repair issue", entry, ) @@ -2514,7 +2524,7 @@ def _async_update_entry( report_issue = async_suggest_report_issue( self.hass, integration_domain=entry.domain ) - _LOGGER.error( + entry.logger.error( ( "Unique id of config entry '%s' from integration %s changed to" " '%s' which is already in use, please %s" @@ -4046,7 +4056,7 @@ async def _load_integration( try: await integration.async_get_platform("config_flow") except ImportError as err: - _LOGGER.error( + integration.logger.error( "Error occurred loading flow for integration %s: %s", domain, err, diff --git a/homeassistant/loader.py b/homeassistant/loader.py index dcea8c45e1481f..892e250bd328c9 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -767,6 +767,7 @@ def __init__( self.pkg_path = pkg_path self.file_path = file_path self.manifest = manifest + self.logger = logging.getLogger(pkg_path) manifest["is_built_in"] = self.is_built_in manifest["overwrites_built_in"] = self.overwrites_built_in diff --git a/tests/components/logger/test_init.py b/tests/components/logger/test_init.py index 53b8b72b385e25..51a613ab567003 100644 --- a/tests/components/logger/test_init.py +++ b/tests/components/logger/test_init.py @@ -117,7 +117,7 @@ async def test_setting_level(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert len(mocks) == 4 + assert len(mocks) == 5 assert len(mocks[""].orig_setLevel.mock_calls) == 1 assert mocks[""].orig_setLevel.mock_calls[0][1][0] == LOGSEVERITY["WARNING"] @@ -134,6 +134,8 @@ async def test_setting_level(hass: HomeAssistant) -> None: == LOGSEVERITY["WARNING"] ) + assert len(mocks["homeassistant.components.logger"].orig_setLevel.mock_calls) == 0 + # Test set default level with patch("logging.getLogger", mocks.__getitem__): await hass.services.async_call( @@ -150,7 +152,7 @@ async def test_setting_level(hass: HomeAssistant) -> None: {"test.child": "info", "new_logger": "notset"}, blocking=True, ) - assert len(mocks) == 5 + assert len(mocks) == 6 assert len(mocks["test.child"].orig_setLevel.mock_calls) == 2 assert mocks["test.child"].orig_setLevel.mock_calls[1][1][0] == LOGSEVERITY["INFO"] From 0b8390cf21ebb592260765247ed25680055e6f46 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:47:58 +0200 Subject: [PATCH 0581/1707] Bump py-unifi-access to 1.1.5 (#167633) Co-authored-by: RaHehl --- homeassistant/components/unifi_access/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi_access/manifest.json b/homeassistant/components/unifi_access/manifest.json index f7ec9953fd68a0..89baddf62e27db 100644 --- a/homeassistant/components/unifi_access/manifest.json +++ b/homeassistant/components/unifi_access/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["unifi_access_api"], "quality_scale": "silver", - "requirements": ["py-unifi-access==1.1.3"] + "requirements": ["py-unifi-access==1.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 76c4844db2cfa1..4be7b9c6d3ce7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1895,7 +1895,7 @@ py-sucks==0.9.11 py-synologydsm-api==2.7.3 # homeassistant.components.unifi_access -py-unifi-access==1.1.3 +py-unifi-access==1.1.5 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c44d5035da5b6..eb94e10f9a831f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1647,7 +1647,7 @@ py-sucks==0.9.11 py-synologydsm-api==2.7.3 # homeassistant.components.unifi_access -py-unifi-access==1.1.3 +py-unifi-access==1.1.5 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From c30ccf3750dab71e1923f99095f7e474829158c0 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 8 Apr 2026 05:36:10 +0200 Subject: [PATCH 0582/1707] Bump pyportainer 1.0.38 (#167627) --- homeassistant/components/portainer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index bd054dc9239760..f60fe1e30700d6 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.37"] + "requirements": ["pyportainer==1.0.38"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4be7b9c6d3ce7e..eddfa5dc31d5cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2397,7 +2397,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.37 +pyportainer==1.0.38 # homeassistant.components.probe_plus pyprobeplus==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb94e10f9a831f..4e12b89a1cde73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2053,7 +2053,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.37 +pyportainer==1.0.38 # homeassistant.components.probe_plus pyprobeplus==1.1.2 From f4f202a8a1d3f8934925bd04ab01ed7db83c5c50 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 8 Apr 2026 07:44:45 +0200 Subject: [PATCH 0583/1707] Fix Tractive switch availability (#167599) --- homeassistant/components/tractive/__init__.py | 6 +++++- homeassistant/components/tractive/switch.py | 10 ++++------ tests/components/tractive/test_switch.py | 9 ++++----- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index e5c20e757eaef5..87e408b2a5849e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -246,6 +246,7 @@ async def _listen(self) -> None: ): self._last_hw_time = event["hardware"]["time"] self._send_hardware_update(event) + self._send_switch_update(event) if ( "position" in event and self._last_pos_time != event["position"]["time"] @@ -302,7 +303,10 @@ def _send_switch_update(self, event: dict[str, Any]) -> None: for switch, key in SWITCH_KEY_MAP.items(): if switch_data := event.get(key): payload[switch] = switch_data["active"] - payload[ATTR_POWER_SAVING] = event.get("tracker_state_reason") == "POWER_SAVING" + if hardware := event.get("hardware", {}): + payload[ATTR_POWER_SAVING] = ( + hardware.get("power_saving_zone_id") is not None + ) self._dispatch_tracker_event( TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload ) diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 0f05a20c0ec70f..d965e43a3086fb 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -100,13 +100,11 @@ def __init__( @callback def handle_status_update(self, event: dict[str, Any]) -> None: """Handle status update.""" - if self.entity_description.key not in event: - return + if ATTR_POWER_SAVING in event: + self._attr_available = not event[ATTR_POWER_SAVING] - # We received an event, so the service is online and the switch entities should - # be available. - self._attr_available = not event[ATTR_POWER_SAVING] - self._attr_is_on = event[self.entity_description.key] + if self.entity_description.key in event: + self._attr_is_on = event[self.entity_description.key] self.async_write_ha_state() diff --git a/tests/components/tractive/test_switch.py b/tests/components/tractive/test_switch.py index 0b9213bee92b4e..71ebc757cdca11 100644 --- a/tests/components/tractive/test_switch.py +++ b/tests/components/tractive/test_switch.py @@ -248,10 +248,7 @@ async def test_switch_unavailable( event = { "tracker_id": "device_id_123", - "buzzer_control": {"active": True}, - "led_control": {"active": False}, - "live_tracking": {"active": True}, - "tracker_state_reason": "POWER_SAVING", + "hardware": {"power_saving_zone_id": "zone_id_123"}, } mock_tractive_client.send_switch_event(mock_config_entry, event) await hass.async_block_till_done() @@ -260,7 +257,9 @@ async def test_switch_unavailable( assert state assert state.state == STATE_UNAVAILABLE - mock_tractive_client.send_switch_event(mock_config_entry) + event["hardware"]["power_saving_zone_id"] = None + + mock_tractive_client.send_switch_event(mock_config_entry, event) await hass.async_block_till_done() state = hass.states.get(entity_id) From bea4eea871c6cbd2c5536e372cb169546f9ca6e7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:42:10 +0200 Subject: [PATCH 0584/1707] Use runtime_data in rainforest_eagle integration (#167652) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/rainforest_eagle/__init__.py | 20 +++++++++---------- .../rainforest_eagle/coordinator.py | 8 ++++++-- .../rainforest_eagle/diagnostics.py | 11 ++++------ .../components/rainforest_eagle/sensor.py | 7 +++---- 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/__init__.py b/homeassistant/components/rainforest_eagle/__init__.py index 5be2e778c5d241..9bb1cc8ad43a66 100644 --- a/homeassistant/components/rainforest_eagle/__init__.py +++ b/homeassistant/components/rainforest_eagle/__init__.py @@ -2,29 +2,27 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import EagleDataCoordinator +from .coordinator import EagleDataCoordinator, RainforestEagleConfigEntry PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: RainforestEagleConfigEntry +) -> bool: """Set up Rainforest Eagle from a config entry.""" coordinator = EagleDataCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: RainforestEagleConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rainforest_eagle/coordinator.py b/homeassistant/components/rainforest_eagle/coordinator.py index 11956681638d51..9b09319804e720 100644 --- a/homeassistant/components/rainforest_eagle/coordinator.py +++ b/homeassistant/components/rainforest_eagle/coordinator.py @@ -23,17 +23,21 @@ ) from .data import UPDATE_100_ERRORS +type RainforestEagleConfigEntry = ConfigEntry[EagleDataCoordinator] + _LOGGER = logging.getLogger(__name__) class EagleDataCoordinator(DataUpdateCoordinator): """Get the latest data from the Eagle device.""" - config_entry: ConfigEntry + config_entry: RainforestEagleConfigEntry eagle100_reader: Eagle100Reader | None = None eagle200_meter: aioeagle.ElectricMeter | None = None - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: RainforestEagleConfigEntry + ) -> None: """Initialize the data object.""" if config_entry.data[CONF_TYPE] == TYPE_EAGLE_100: self.model = "EAGLE-100" diff --git a/homeassistant/components/rainforest_eagle/diagnostics.py b/homeassistant/components/rainforest_eagle/diagnostics.py index ec40f2515b1281..c37a45b4e751e6 100644 --- a/homeassistant/components/rainforest_eagle/diagnostics.py +++ b/homeassistant/components/rainforest_eagle/diagnostics.py @@ -5,22 +5,19 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import CONF_CLOUD_ID, CONF_INSTALL_CODE, DOMAIN -from .coordinator import EagleDataCoordinator +from .const import CONF_CLOUD_ID, CONF_INSTALL_CODE +from .coordinator import RainforestEagleConfigEntry TO_REDACT = {CONF_CLOUD_ID, CONF_INSTALL_CODE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: RainforestEagleConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: EagleDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] - return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), - "data": coordinator.data, + "data": config_entry.runtime_data.data, } diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 6f4cbf4f02c652..297cfd7fa354bb 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -8,7 +8,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -17,7 +16,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import EagleDataCoordinator +from .coordinator import EagleDataCoordinator, RainforestEagleConfigEntry SENSORS = ( SensorEntityDescription( @@ -46,11 +45,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RainforestEagleConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [EagleSensor(coordinator, description) for description in SENSORS] if coordinator.data.get("zigbee:Price") not in (None, "invalid"): From f5ae250720694c592477b40aa8c273329d40457a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:30:27 +0200 Subject: [PATCH 0585/1707] Improve type hints in ipma system_health (#167670) --- homeassistant/components/ipma/system_health.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ipma/system_health.py b/homeassistant/components/ipma/system_health.py index 7b6a5c517c7dd4..44c0346e89840f 100644 --- a/homeassistant/components/ipma/system_health.py +++ b/homeassistant/components/ipma/system_health.py @@ -1,5 +1,7 @@ """Provide info to system health.""" +from typing import Any + from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback @@ -14,7 +16,7 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" return { "api_endpoint_reachable": system_health.async_check_can_reach_url( From c74d4047d8792d78941a85d082e2fb37612dd02e Mon Sep 17 00:00:00 2001 From: Mattheinrichs Date: Wed, 8 Apr 2026 03:37:01 -0500 Subject: [PATCH 0586/1707] Add diagnostics support to tplink_omada (#166802) Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> --- .../components/tplink_omada/diagnostics.py | 129 ++ .../tplink_omada/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 1257 +++++++++++++++++ .../tplink_omada/test_diagnostics.py | 91 ++ 4 files changed, 1478 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tplink_omada/diagnostics.py create mode 100644 tests/components/tplink_omada/snapshots/test_diagnostics.ambr create mode 100644 tests/components/tplink_omada/test_diagnostics.py diff --git a/homeassistant/components/tplink_omada/diagnostics.py b/homeassistant/components/tplink_omada/diagnostics.py new file mode 100644 index 00000000000000..a0c22dbb6920eb --- /dev/null +++ b/homeassistant/components/tplink_omada/diagnostics.py @@ -0,0 +1,129 @@ +"""Diagnostics support for TP-Link Omada.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac + +from . import OmadaConfigEntry + +ENTRY_TO_REDACT = { + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, +} + +RUNTIME_TO_REDACT = { + "addr", + "echoServer", + "gateway", + "gateway2", + "hostName", + "ip", + "priDns", + "priDns2", + "sndDns", + "sndDns2", + "ssid", + "sn", + "omadacId", +} + + +def _build_identifier_replacements(mac_values: set[str]) -> dict[str, str]: + """Build deterministic replacement values for network identifiers.""" + replacements: dict[str, str] = {} + + for index, raw_mac in enumerate(sorted(mac_values)): + pseudonym = format_mac(str(index).zfill(12)) + variants = {raw_mac, raw_mac.upper(), raw_mac.lower()} + + normalized = format_mac(raw_mac) + variants.update({normalized, normalized.upper(), normalized.lower()}) + + for variant in variants: + replacements[variant] = pseudonym + + return replacements + + +def _replace_identifiers(data: Any, to_replace: Mapping[str, str]) -> Any: + """Replace network identifiers in nested diagnostics payloads.""" + if isinstance(data, Mapping): + return { + key: _replace_identifiers(value, to_replace) for key, value in data.items() + } + + if isinstance(data, list): + return [_replace_identifiers(item, to_replace) for item in data] + + if isinstance(data, str): + return to_replace.get(data, data) + + return data + + +def _redact_runtime_record( + raw_data: Mapping[str, Any], replacements: Mapping[str, str] +) -> dict[str, Any]: + """Apply identifier replacement and key redaction to runtime data.""" + return async_redact_data( + _replace_identifiers(raw_data, replacements), + RUNTIME_TO_REDACT, + ) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: OmadaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + controller = entry.runtime_data + + devices = controller.devices_coordinator.data + clients = controller.clients_coordinator.data + + gateway_data: dict[str, Any] | None = None + if ( + gateway_coordinator := controller.gateway_coordinator + ) and gateway_coordinator.data: + gateway = next(iter(gateway_coordinator.data.values())) + gateway_data = gateway.raw_data + + mac_values = set(devices) | set(clients) + for client in clients.values(): + if ap_mac := client.raw_data.get("apMac"): + mac_values.add(ap_mac) + if gateway_data and (gateway_mac := gateway_data.get("mac")): + mac_values.add(gateway_mac) + + replacements = _build_identifier_replacements(mac_values) + + return { + "entry": async_redact_data(entry.as_dict(), ENTRY_TO_REDACT), + "runtime": { + "devices": { + replacements[mac]: _redact_runtime_record( + device.raw_data, + replacements, + ) + for mac, device in devices.items() + }, + "clients": { + replacements[mac]: _redact_runtime_record( + client.raw_data, + replacements, + ) + for mac, client in clients.items() + }, + "gateway": ( + _redact_runtime_record(gateway_data, replacements) + if gateway_data is not None + else None + ), + }, + } diff --git a/homeassistant/components/tplink_omada/quality_scale.yaml b/homeassistant/components/tplink_omada/quality_scale.yaml index ace158c44ea87b..8259d41f47a657 100644 --- a/homeassistant/components/tplink_omada/quality_scale.yaml +++ b/homeassistant/components/tplink_omada/quality_scale.yaml @@ -43,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: todo discovery: todo docs-data-update: todo diff --git a/tests/components/tplink_omada/snapshots/test_diagnostics.ambr b/tests/components/tplink_omada/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..24ae8dcfb4c32d --- /dev/null +++ b/tests/components/tplink_omada/snapshots/test_diagnostics.ambr @@ -0,0 +1,1257 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'password': '**REDACTED**', + 'site': 'Default', + 'username': '**REDACTED**', + 'verify_ssl': False, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'tplink_omada', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Test Omada Controller', + 'unique_id': '12345', + 'version': 1, + }), + 'runtime': dict({ + 'clients': dict({ + '00:00:00:00:00:00': dict({ + 'active': True, + 'activity': 96, + 'apMac': '00:00:00:00:00:05', + 'apName': 'Office', + 'authStatus': 0, + 'channel': 1, + 'connectDevType': 'ap', + 'connectType': 1, + 'connectedToWirelessRouter': False, + 'deviceType': 'unknown', + 'downPacket': 30179275, + 'guest': False, + 'healthScore': -1, + 'ip': '**REDACTED**', + 'lastSeen': 1713109713169, + 'mac': '00:00:00:00:00:00', + 'manager': False, + 'multiLink': list([ + ]), + 'name': '00:00:00:00:00:00', + 'powerSave': False, + 'radioId': 0, + 'rssi': -65, + 'rxRate': 65000, + 'signalLevel': 62, + 'signalRank': 4, + 'snr': 30, + 'ssid': '**REDACTED**', + 'stackableSwitch': False, + 'support5g2': False, + 'trafficDown': 25412800785, + 'trafficUp': 1636427981, + 'txRate': 72000, + 'upPacket': 14288106, + 'uptime': 621441, + 'vid': 0, + 'wifiMode': 4, + 'wireless': True, + }), + '00:00:00:00:00:01': dict({ + 'active': True, + 'activity': 0, + 'apMac': '00:00:00:00:00:04', + 'apName': 'Spare Room', + 'authStatus': 0, + 'channel': 44, + 'connectDevType': 'ap', + 'connectType': 1, + 'connectedToWirelessRouter': False, + 'deviceType': 'unknown', + 'downPacket': 5128, + 'guest': False, + 'healthScore': -1, + 'ip': '**REDACTED**', + 'lastSeen': 1713109728764, + 'mac': '00:00:00:00:00:01', + 'manager': False, + 'multiLink': list([ + ]), + 'name': 'Apple', + 'powerSave': False, + 'radioId': 1, + 'rssi': -63, + 'rxRate': 7000, + 'signalLevel': 67, + 'signalRank': 4, + 'snr': 32, + 'ssid': '**REDACTED**', + 'stackableSwitch': False, + 'support5g2': False, + 'trafficDown': 3327229, + 'trafficUp': 746841, + 'txRate': 390000, + 'upPacket': 3611, + 'uptime': 2091, + 'vid': 0, + 'wifiMode': 5, + 'wireless': True, + }), + }), + 'devices': dict({ + '00:00:00:00:00:02': dict({ + 'compoundModel': 'TL-SG3210XHP-M2 v1.0', + 'cpuUtil': 10, + 'firmwareVersion': '1.0.12 Build 20230203 Rel.36545', + 'fwDownload': False, + 'hwVersion': '1.0', + 'mac': '00:00:00:00:00:02', + 'memUtil': 20, + 'model': 'TL-SG3210XHP-M2', + 'modelVersion': '1.0', + 'name': 'Test PoE Switch', + 'needUpgrade': True, + 'showModel': 'TL-SG3210XHP-M2 v1.0', + 'status': 14, + 'statusCategory': 1, + 'type': 'switch', + 'uptime': '1day(s) 8h 27m 26s', + 'uptimeLong': 116846, + 'version': '1.0.12', + }), + '00:00:00:00:00:03': dict({ + 'compoundModel': 'ER7212PC v1.0', + 'cpuUtil': 16, + 'firmwareVersion': '1.1.1 Build 20230901 Rel.55651', + 'hwVersion': 'ER7212PC v1.0', + 'mac': '00:00:00:00:00:03', + 'memUtil': 47, + 'model': 'ER7212PC', + 'name': 'Test Router', + 'needUpgrade': False, + 'showModel': 'ER7212PC v1.0', + 'site': 'Test', + 'status': 14, + 'statusCategory': 1, + 'type': 'gateway', + 'uptime': '32day(s) 5h 39m 27s', + 'uptimeLong': 2785167, + 'version': '1.1.1', + }), + }), + 'gateway': dict({ + 'addedInAdvanced': False, + 'combinedGateway': True, + 'compatible': 0, + 'compoundModel': 'ER7212PC v1.0', + 'cpuUtil': 9, + 'download': 39901007520, + 'echoServer': '**REDACTED**', + 'firmwareVersion': '1.1.1 Build 20230901 Rel.55651', + 'hwOffloadEnable': True, + 'hwVersion': 'ER7212PC v1.0', + 'ip': '**REDACTED**', + 'iptvSetting': dict({ + 'igmpEnable': True, + 'igmpVersion': '2', + }), + 'lanClientStats': list([ + dict({ + 'clientNum': 3, + 'ip': '**REDACTED**', + 'lanName': 'LAN', + 'lanPortIpv6Config': dict({ + 'addr': '**REDACTED**', + }), + 'rx': 3365832108, + 'tx': 19893930205, + 'vlan': 1, + }), + ]), + 'lastSeen': 1704219948802, + 'ledSetting': 2, + 'lldpEnable': False, + 'mac': '00:00:00:00:00:03', + 'memUtil': 86, + 'model': 'ER7212PC', + 'modelVersion': '1.0', + 'multiChipGateway': True, + 'multiChipInfos': list([ + list([ + 1, + 3, + 5, + 6, + 7, + 8, + ]), + list([ + 2, + 4, + 9, + 10, + 11, + 12, + ]), + ]), + 'name': 'Test Router', + 'needUpgrade': False, + 'networkComptent': 1, + 'omadacId': '**REDACTED**', + 'poeRemain': 105.699997, + 'poeRemainPercent': 96.090904, + 'poeSettings': list([ + dict({ + 'enable': True, + 'portId': 5, + 'portName': 'LAN5', + }), + dict({ + 'enable': True, + 'portId': 6, + 'portName': 'LAN6', + }), + dict({ + 'enable': True, + 'portId': 7, + 'portName': 'LAN7', + }), + dict({ + 'enable': True, + 'portId': 8, + 'portName': 'LAN8', + }), + dict({ + 'enable': True, + 'portId': 9, + 'portName': 'LAN9', + }), + dict({ + 'enable': True, + 'portId': 10, + 'portName': 'LAN10', + }), + dict({ + 'enable': True, + 'portId': 11, + 'portName': 'LAN11', + }), + dict({ + 'enable': True, + 'portId': 12, + 'portName': 'LAN12', + }), + ]), + 'portConfigs': list([ + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 1, + 'portCap': list([ + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'SFP WAN/LAN1', + 'poe': False, + 'port': 1, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 1, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 2, + 'portCap': list([ + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'SFP WAN/LAN2', + 'poe': False, + 'port': 2, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 1, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 3, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'mirroredPorts': list([ + ]), + 'mode': -1, + 'name': 'WAN3', + 'port': 3, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 0, + }), + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 4, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 2, + 'internetState': 1, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 0, + 'name': 'WAN/LAN4', + 'poe': False, + 'port': 4, + 'proto': 'dhcp', + 'rx': 39901007520, + 'rxPkt': 33051930, + 'rxPktRate': 25, + 'rxRate': 18, + 'speed': 3, + 'status': 1, + 'tx': 8891933646, + 'txPkt': 12195464, + 'txPktRate': 22, + 'txRate': 3, + 'type': 1, + 'wanIpv6Comptent': 1, + 'wanPortIpv4Config': dict({ + 'gateway': '**REDACTED**', + 'gateway2': '**REDACTED**', + 'ip': '**REDACTED**', + 'priDns': '**REDACTED**', + 'priDns2': '**REDACTED**', + 'sndDns': '**REDACTED**', + 'sndDns2': '**REDACTED**', + }), + 'wanPortIpv6Config': dict({ + 'addr': '', + 'enable': 0, + 'gateway': '', + 'internetState': 0, + 'priDns': '', + 'sndDns': '', + }), + }), + }), + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 5, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 2, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN5', + 'poe': True, + 'port': 5, + 'rx': 4622709923, + 'rxPkt': 8985877, + 'rxPktRate': 21, + 'rxRate': 4, + 'speed': 3, + 'status': 1, + 'tx': 38465362622, + 'txPkt': 30836050, + 'txPktRate': 25, + 'txRate': 17, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 6, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN6', + 'poe': False, + 'port': 6, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 7, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 2, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN7', + 'poe': False, + 'port': 7, + 'rx': 5477288, + 'rxPkt': 52166, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 2, + 'status': 1, + 'tx': 66036305, + 'txPkt': 319810, + 'txPktRate': 1, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 8, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 2, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN8', + 'poe': False, + 'port': 8, + 'rx': 6105639661, + 'rxPkt': 6200831, + 'rxPktRate': 4, + 'rxRate': 0, + 'speed': 3, + 'status': 1, + 'tx': 3258101551, + 'txPkt': 4719927, + 'txPktRate': 4, + 'txRate': 1, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 9, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN9', + 'poe': False, + 'port': 9, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 10, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN10', + 'poe': False, + 'port': 10, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 11, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN11', + 'poe': False, + 'port': 11, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + dict({ + 'availablePvids': list([ + 1, + ]), + 'duplex': 0, + 'linkSpeed': 0, + 'mirrorEnable': False, + 'port': 12, + 'portCap': list([ + dict({ + 'duplex': 1, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 2, + }), + dict({ + 'duplex': 1, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 1, + }), + dict({ + 'duplex': 0, + 'linkSpeed': 0, + }), + dict({ + 'duplex': 2, + 'linkSpeed': 3, + }), + ]), + 'portStat': dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN12', + 'poe': False, + 'port': 12, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + 'pvid': 1, + }), + ]), + 'portNum': 12, + 'portStats': list([ + dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'SFP WAN/LAN1', + 'poe': False, + 'port': 1, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 1, + 'wanIpv6Comptent': 1, + }), + dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'SFP WAN/LAN2', + 'poe': False, + 'port': 2, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 1, + 'wanIpv6Comptent': 1, + }), + dict({ + 'mirroredPorts': list([ + ]), + 'mode': -1, + 'name': 'WAN3', + 'port': 3, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 0, + }), + dict({ + 'duplex': 2, + 'internetState': 1, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 0, + 'name': 'WAN/LAN4', + 'poe': False, + 'port': 4, + 'proto': 'dhcp', + 'rx': 39901007520, + 'rxPkt': 33051930, + 'rxPktRate': 25, + 'rxRate': 18, + 'speed': 3, + 'status': 1, + 'tx': 8891933646, + 'txPkt': 12195464, + 'txPktRate': 22, + 'txRate': 3, + 'type': 1, + 'wanIpv6Comptent': 1, + 'wanPortIpv4Config': dict({ + 'gateway': '**REDACTED**', + 'gateway2': '**REDACTED**', + 'ip': '**REDACTED**', + 'priDns': '**REDACTED**', + 'priDns2': '**REDACTED**', + 'sndDns': '**REDACTED**', + 'sndDns2': '**REDACTED**', + }), + 'wanPortIpv6Config': dict({ + 'addr': '', + 'enable': 0, + 'gateway': '', + 'internetState': 0, + 'priDns': '', + 'sndDns': '', + }), + }), + dict({ + 'duplex': 2, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN5', + 'poe': True, + 'port': 5, + 'rx': 4622709923, + 'rxPkt': 8985877, + 'rxPktRate': 21, + 'rxRate': 4, + 'speed': 3, + 'status': 1, + 'tx': 38465362622, + 'txPkt': 30836050, + 'txPktRate': 25, + 'txRate': 17, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN6', + 'poe': False, + 'port': 6, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + dict({ + 'duplex': 2, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN7', + 'poe': False, + 'port': 7, + 'rx': 5477288, + 'rxPkt': 52166, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 2, + 'status': 1, + 'tx': 66036305, + 'txPkt': 319810, + 'txPktRate': 1, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + dict({ + 'duplex': 2, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN8', + 'poe': False, + 'port': 8, + 'rx': 6105639661, + 'rxPkt': 6200831, + 'rxPktRate': 4, + 'rxRate': 0, + 'speed': 3, + 'status': 1, + 'tx': 3258101551, + 'txPkt': 4719927, + 'txPktRate': 4, + 'txRate': 1, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN9', + 'poe': False, + 'port': 9, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN10', + 'poe': False, + 'port': 10, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN11', + 'poe': False, + 'port': 11, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + dict({ + 'duplex': 1, + 'internetState': 0, + 'ip': '**REDACTED**', + 'mirroredPorts': list([ + ]), + 'mode': 1, + 'name': 'LAN12', + 'poe': False, + 'port': 12, + 'rx': 0, + 'rxPkt': 0, + 'rxPktRate': 0, + 'rxRate': 0, + 'speed': 1, + 'status': 0, + 'tx': 0, + 'txPkt': 0, + 'txPktRate': 0, + 'txRate': 0, + 'type': 2, + 'wanIpv6Comptent': 1, + }), + ]), + 'showModel': 'ER7212PC v1.0', + 'site': 'Test', + 'sn': '**REDACTED**', + 'snmpSeting': dict({ + 'contact': '', + 'location': '', + }), + 'speeds': list([ + 1, + 2, + 3, + ]), + 'status': 14, + 'statusCategory': 1, + 'supportHwOffload': False, + 'supportMirror': True, + 'supportPoe': True, + 'supportPvid': True, + 'supportSpeedDuplex': True, + 'type': 'gateway', + 'unsupportedPorts': list([ + ]), + 'upload': 8891933646, + 'uptime': '5day(s) 3h 29m 49s', + 'uptimeLong': 444589, + 'version': '1.1.1', + }), + }), + }) +# --- diff --git a/tests/components/tplink_omada/test_diagnostics.py b/tests/components/tplink_omada/test_diagnostics.py new file mode 100644 index 00000000000000..c83790b3fb350c --- /dev/null +++ b/tests/components/tplink_omada/test_diagnostics.py @@ -0,0 +1,91 @@ +"""Tests for TP-Link Omada diagnostics.""" + +from __future__ import annotations + +import json + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props +from tplink_omada_client.clients import OmadaWirelessClient + +from homeassistant.components.tplink_omada.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_load_fixture +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics payload and redaction.""" + connected_clients_data = json.loads( + await async_load_fixture(hass, "connected-clients.json", DOMAIN) + ) + + controller = init_integration.runtime_data + controller.clients_coordinator.data = { + client["mac"]: OmadaWirelessClient(client) + for client in connected_clients_data[:2] + } + + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert {"entry", "runtime"} <= result.keys() + assert {"devices", "clients", "gateway"} <= result["runtime"].keys() + + entry_data = result["entry"]["data"] + assert entry_data["host"] == "**REDACTED**" + assert entry_data["username"] == "**REDACTED**" + assert entry_data["password"] == "**REDACTED**" + + # Runtime sections should use pseudonymized MAC keys rather than raw values. + assert "AA-BB-CC-DD-EE-FF" not in result["runtime"]["devices"] + assert "16-32-50-ED-FB-15" not in result["runtime"]["clients"] + assert len(result["runtime"]["clients"]) == 2 + + payload = json.dumps(result) + assert "AA-BB-CC-DD-EE-FF" not in payload + assert "16-32-50-ED-FB-15" not in payload + assert "2E-DC-E1-C4-37-D3" not in payload + assert "192.168.1.177" not in payload + assert "OFFICE_SSID" not in payload + assert "140.100.128.10" not in payload + + assert result == snapshot(exclude=props("entry_id", "created_at", "modified_at")) + + +async def test_entry_diagnostics_no_gateway( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, +) -> None: + """Test diagnostics when no gateway coordinator is present.""" + controller = init_integration.runtime_data + controller._gateway_coordinator = None + + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert result["runtime"]["gateway"] is None + + +async def test_entry_diagnostics_empty_data( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, +) -> None: + """Test diagnostics with empty devices and clients and no gateway.""" + controller = init_integration.runtime_data + controller.devices_coordinator.data = {} + controller.clients_coordinator.data = {} + controller._gateway_coordinator = None + + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert result["runtime"]["devices"] == {} + assert result["runtime"]["clients"] == {} + assert result["runtime"]["gateway"] is None From e98eec113e824a5ab63aebec58e7df40872c8410 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 8 Apr 2026 11:36:20 +0200 Subject: [PATCH 0587/1707] Add DHCP discovery to MyStrom (#167084) Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> --- .../components/mystrom/config_flow.py | 40 ++++++++++- .../components/mystrom/manifest.json | 8 +++ homeassistant/components/mystrom/strings.json | 6 +- homeassistant/generated/dhcp.py | 8 +++ tests/components/mystrom/test_config_flow.py | 69 +++++++++++++++++++ 5 files changed, 129 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mystrom/config_flow.py b/homeassistant/components/mystrom/config_flow.py index 38b292e9f97827..c7126dc0aca4d0 100644 --- a/homeassistant/components/mystrom/config_flow.py +++ b/homeassistant/components/mystrom/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import pymystrom from pymystrom.exceptions import MyStromConnectionError @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import DOMAIN @@ -31,6 +32,8 @@ class MyStromConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _host: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -51,3 +54,38 @@ async def async_step_user( schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + mac_address = discovery_info.macaddress.upper() + await self.async_set_unique_id(mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + try: + await pymystrom.get_device_info(discovery_info.ip) + except MyStromConnectionError: + return self.async_abort(reason="cannot_connect") + + self._host = discovery_info.ip + self.context["title_placeholders"] = {"host": discovery_info.ip} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle discovery confirmation.""" + if user_input is not None: + return self.async_create_entry( + title=DEFAULT_NAME, + data={CONF_HOST: self._host}, + ) + + self._set_confirm_only() + if TYPE_CHECKING: + assert self._host is not None + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={CONF_HOST: self._host}, + ) diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index 2cab6ec12f617d..fc8dc8cba123e2 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -4,6 +4,14 @@ "codeowners": ["@fabaff"], "config_flow": true, "dependencies": ["http"], + "dhcp": [ + { + "hostname": "mystrom-*" + }, + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/mystrom", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index 2466f5f0d3cdfc..b4c8669386614e 100644 --- a/homeassistant/components/mystrom/strings.json +++ b/homeassistant/components/mystrom/strings.json @@ -1,12 +1,16 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "discovery_confirm": { + "description": "Do you want to set up the myStrom device at {host}?" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 1d2e1847c841a6..8fa8aff1b186dc 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -436,6 +436,14 @@ "domain": "motion_blinds", "hostname": "connector_*", }, + { + "domain": "mystrom", + "hostname": "mystrom-*", + }, + { + "domain": "mystrom", + "registered_devices": True, + }, { "domain": "nest", "macaddress": "18B430*", diff --git a/tests/components/mystrom/test_config_flow.py b/tests/components/mystrom/test_config_flow.py index d0b3603b211327..b576f87e56ae59 100644 --- a/tests/components/mystrom/test_config_flow.py +++ b/tests/components/mystrom/test_config_flow.py @@ -7,8 +7,11 @@ from homeassistant import config_entries from homeassistant.components.mystrom.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import DEVICE_MAC @@ -16,6 +19,12 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") +DHCP_SERVICE_INFO = DhcpServiceInfo( + ip="1.2.3.4", + hostname="mystrom-switch-946498", + macaddress="083a8d946498", +) + async def test_form_combined(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -111,3 +120,63 @@ async def test_wong_answer_from_device(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "myStrom Device" assert result2["data"] == {"host": "1.1.1.1"} + + +async def test_dhcp_discovery(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test DHCP discovery shows a confirmation form and creates an entry.""" + with patch( + "homeassistant.components.mystrom.config_flow.pymystrom.get_device_info", + return_value={"type": 101, "mac": DEVICE_MAC}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "myStrom Device" + assert result["data"] == {"host": DHCP_SERVICE_INFO.ip} + assert result["result"].unique_id == "083A8D946498" + + +async def test_dhcp_discovery_cannot_connect(hass: HomeAssistant) -> None: + """Test DHCP discovery aborts when the device is unreachable.""" + with patch( + "homeassistant.components.mystrom.config_flow.pymystrom.get_device_info", + side_effect=MyStromConnectionError(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_discovery_already_configured_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test DHCP discovery updates the host of an already-configured entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="083A8D946498", + data={CONF_HOST: "1.1.1.1"}, + title="myStrom Device", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data["host"] == DHCP_SERVICE_INFO.ip From 1a5ef199daf0c420cbe888aac1fd82e5271a3910 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:48:02 +0200 Subject: [PATCH 0588/1707] Remove duplicated FlussConfigEntry type aliases (#167676) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/fluss/__init__.py | 6 +----- homeassistant/components/fluss/button.py | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fluss/__init__.py b/homeassistant/components/fluss/__init__.py index c3d4b347ff52a6..66274257b62deb 100644 --- a/homeassistant/components/fluss/__init__.py +++ b/homeassistant/components/fluss/__init__.py @@ -2,18 +2,14 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from .coordinator import FlussDataUpdateCoordinator +from .coordinator import FlussConfigEntry, FlussDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BUTTON] -type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] - - async def async_setup_entry( hass: HomeAssistant, entry: FlussConfigEntry, diff --git a/homeassistant/components/fluss/button.py b/homeassistant/components/fluss/button.py index bc8a90e66c0eb8..7b2009fe04c849 100644 --- a/homeassistant/components/fluss/button.py +++ b/homeassistant/components/fluss/button.py @@ -1,16 +1,13 @@ """Support for Fluss Devices.""" from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import FlussApiClientError, FlussDataUpdateCoordinator +from .coordinator import FlussApiClientError, FlussConfigEntry from .entity import FlussEntity -type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, From 5620fc9e96eb8bd9cdb2f597157fd0ddfcd06e99 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:57:44 +0200 Subject: [PATCH 0589/1707] Use runtime_data in recollect_waste integration (#167655) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/recollect_waste/__init__.py | 25 ++++++++++--------- .../components/recollect_waste/calendar.py | 14 ++++------- .../components/recollect_waste/config_flow.py | 10 +++----- .../components/recollect_waste/coordinator.py | 8 ++++-- .../components/recollect_waste/diagnostics.py | 11 +++----- .../components/recollect_waste/entity.py | 5 ++-- .../components/recollect_waste/sensor.py | 13 ++++------ .../recollect_waste/test_config_flow.py | 2 +- 8 files changed, 39 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index c805b49144090a..c714383e4f3fcc 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -9,19 +9,20 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN, LOGGER -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .const import CONF_PLACE_ID, CONF_SERVICE_ID, LOGGER +from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: RecollectWasteConfigEntry +) -> bool: """Set up ReCollect Waste as config entry.""" coordinator = ReCollectWasteDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -30,18 +31,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry( + hass: HomeAssistant, entry: RecollectWasteConfigEntry +) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: RecollectWasteConfigEntry +) -> bool: """Unload an ReCollect Waste config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/recollect_waste/calendar.py b/homeassistant/components/recollect_waste/calendar.py index f057d1c3368543..273712731fdac0 100644 --- a/homeassistant/components/recollect_waste/calendar.py +++ b/homeassistant/components/recollect_waste/calendar.py @@ -7,19 +7,17 @@ from aiorecollect.client import PickupEvent from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator from .entity import ReCollectWasteEntity from .util import async_get_pickup_type_names @callback def async_get_calendar_event_from_pickup_event( - entry: ConfigEntry, pickup_event: PickupEvent + entry: RecollectWasteConfigEntry, pickup_event: PickupEvent ) -> CalendarEvent: """Get a HASS CalendarEvent from an aiorecollect PickupEvent.""" pickup_type_string = ", ".join( @@ -36,13 +34,11 @@ def async_get_calendar_event_from_pickup_event( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" - coordinator: ReCollectWasteDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([ReCollectWasteCalendar(coordinator, entry)]) + async_add_entities([ReCollectWasteCalendar(entry.runtime_data, entry)]) class ReCollectWasteCalendar(ReCollectWasteEntity, CalendarEntity): @@ -54,7 +50,7 @@ class ReCollectWasteCalendar(ReCollectWasteEntity, CalendarEntity): def __init__( self, coordinator: ReCollectWasteDataUpdateCoordinator, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, ) -> None: """Initialize the ReCollect Waste entity.""" super().__init__(coordinator, entry) diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 299af2609e34e6..a0bec85e01015a 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -8,17 +8,13 @@ from aiorecollect.errors import RecollectError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_FRIENDLY_NAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN, LOGGER +from .coordinator import RecollectWasteConfigEntry DATA_SCHEMA = vol.Schema( {vol.Required(CONF_PLACE_ID): str, vol.Required(CONF_SERVICE_ID): str} @@ -33,7 +29,7 @@ class RecollectWasteConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RecollectWasteConfigEntry, ) -> RecollectWasteOptionsFlowHandler: """Define the config flow to handle options.""" return RecollectWasteOptionsFlowHandler() diff --git a/homeassistant/components/recollect_waste/coordinator.py b/homeassistant/components/recollect_waste/coordinator.py index 4a7e9d58b125e2..c2a38258c2361b 100644 --- a/homeassistant/components/recollect_waste/coordinator.py +++ b/homeassistant/components/recollect_waste/coordinator.py @@ -14,15 +14,19 @@ from .const import CONF_PLACE_ID, CONF_SERVICE_ID, LOGGER +type RecollectWasteConfigEntry = ConfigEntry[ReCollectWasteDataUpdateCoordinator] + DEFAULT_UPDATE_INTERVAL = timedelta(days=1) class ReCollectWasteDataUpdateCoordinator(DataUpdateCoordinator[list[PickupEvent]]): """Class to manage fetching ReCollect Waste data.""" - config_entry: ConfigEntry + config_entry: RecollectWasteConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: RecollectWasteConfigEntry + ) -> None: """Initialize the coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/recollect_waste/diagnostics.py b/homeassistant/components/recollect_waste/diagnostics.py index a9007eb5d2c3c0..21c2cb3f61d6c1 100644 --- a/homeassistant/components/recollect_waste/diagnostics.py +++ b/homeassistant/components/recollect_waste/diagnostics.py @@ -6,12 +6,11 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .const import CONF_PLACE_ID, DOMAIN -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .const import CONF_PLACE_ID +from .coordinator import RecollectWasteConfigEntry CONF_AREA_NAME = "area_name" CONF_TITLE = "title" @@ -26,15 +25,13 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RecollectWasteConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: ReCollectWasteDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data( { "entry": entry.as_dict(), - "data": [dataclasses.asdict(event) for event in coordinator.data], + "data": [dataclasses.asdict(event) for event in entry.runtime_data.data], }, TO_REDACT, ) diff --git a/homeassistant/components/recollect_waste/entity.py b/homeassistant/components/recollect_waste/entity.py index 891f1706f77b15..6d051b548a5aab 100644 --- a/homeassistant/components/recollect_waste/entity.py +++ b/homeassistant/components/recollect_waste/entity.py @@ -1,11 +1,10 @@ """Define a base ReCollect Waste entity.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator class ReCollectWasteEntity(CoordinatorEntity[ReCollectWasteDataUpdateCoordinator]): @@ -16,7 +15,7 @@ class ReCollectWasteEntity(CoordinatorEntity[ReCollectWasteDataUpdateCoordinator def __init__( self, coordinator: ReCollectWasteDataUpdateCoordinator, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 97d6c1413e13f5..8ab5efca00c7de 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -9,12 +9,11 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER -from .coordinator import ReCollectWasteDataUpdateCoordinator +from .const import LOGGER +from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator from .entity import ReCollectWasteEntity from .util import async_get_pickup_type_names @@ -38,14 +37,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" - coordinator: ReCollectWasteDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - ReCollectWasteSensor(coordinator, entry, description) + ReCollectWasteSensor(entry.runtime_data, entry, description) for description in SENSOR_DESCRIPTIONS ) @@ -63,7 +60,7 @@ class ReCollectWasteSensor(ReCollectWasteEntity, SensorEntity): def __init__( self, coordinator: ReCollectWasteDataUpdateCoordinator, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize.""" diff --git a/tests/components/recollect_waste/test_config_flow.py b/tests/components/recollect_waste/test_config_flow.py index aac829f00a3c85..0d8d0141794858 100644 --- a/tests/components/recollect_waste/test_config_flow.py +++ b/tests/components/recollect_waste/test_config_flow.py @@ -5,7 +5,7 @@ from aiorecollect.errors import RecollectError import pytest -from homeassistant.components.recollect_waste import ( +from homeassistant.components.recollect_waste.const import ( CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN, From 0452bb91c7cda880f14c38389ff29deb8dc77e1e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:57:55 +0200 Subject: [PATCH 0590/1707] Cleanup unused renault base entity method (#167643) --- homeassistant/components/renault/entity.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index d10dd9b9149f83..23dfe67f501b93 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -3,12 +3,10 @@ from __future__ import annotations from dataclasses import dataclass -from typing import cast from renault_api.kamereon.models import KamereonVehicleDataAttributes from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import RenaultDataUpdateCoordinator @@ -54,10 +52,6 @@ def __init__( super().__init__(vehicle.coordinators[description.coordinator]) RenaultEntity.__init__(self, vehicle, description) - def _get_data_attr(self, key: str) -> StateType: - """Return the attribute value from the coordinator data.""" - return cast(StateType, getattr(self.coordinator.data, key)) - @property def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" From 15e434431deb48b11859dd8b0537cfd3dc33409a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:58:10 +0200 Subject: [PATCH 0591/1707] Use runtime_data in renson integration (#167664) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/renson/__init__.py | 25 ++++--------------- .../components/renson/binary_sensor.py | 12 +++------ homeassistant/components/renson/button.py | 8 +++--- .../components/renson/coordinator.py | 16 ++++++++++-- homeassistant/components/renson/fan.py | 12 +++------ homeassistant/components/renson/number.py | 12 +++------ homeassistant/components/renson/sensor.py | 9 +++---- homeassistant/components/renson/switch.py | 12 +++------ homeassistant/components/renson/time.py | 12 +++------ 9 files changed, 45 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index b88f9bb036a30e..95686526e74b19 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -2,17 +2,13 @@ from __future__ import annotations -from dataclasses import dataclass - from renson_endura_delta.renson import RensonVentilation -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator, RensonData PLATFORMS = [ Platform.BINARY_SENSOR, @@ -25,15 +21,7 @@ ] -@dataclass -class RensonData: - """Renson data class.""" - - api: RensonVentilation - coordinator: RensonCoordinator - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RensonConfigEntry) -> bool: """Set up Renson from a config entry.""" api = RensonVentilation(entry.data[CONF_HOST]) @@ -44,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RensonData( + entry.runtime_data = RensonData( api, coordinator, ) @@ -54,9 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RensonConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index 60b4f54b85ce91..aba3a889d77108 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -21,13 +21,11 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity @@ -85,15 +83,13 @@ class RensonBinarySensorEntityDescription(BinarySensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Call the Renson integration to setup.""" - api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api - coordinator: RensonCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ].coordinator + api = config_entry.runtime_data.api + coordinator = config_entry.runtime_data.coordinator async_add_entities( RensonBinarySensor(description, api, coordinator) diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index 830e5a03a4ab85..5cdda11c6da4cc 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -12,13 +12,11 @@ ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RensonCoordinator, RensonData -from .const import DOMAIN +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity @@ -53,12 +51,12 @@ class RensonButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson button platform.""" - data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data entities = [ RensonButton(description, data.api, data.coordinator) diff --git a/homeassistant/components/renson/coordinator.py b/homeassistant/components/renson/coordinator.py index 5d0a20e1c29313..1f31ad99c6c0df 100644 --- a/homeassistant/components/renson/coordinator.py +++ b/homeassistant/components/renson/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -15,18 +16,29 @@ from .const import DOMAIN +type RensonConfigEntry = ConfigEntry[RensonData] + + +@dataclass +class RensonData: + """Renson data class.""" + + api: RensonVentilation + coordinator: RensonCoordinator + + _LOGGER = logging.getLogger(__name__) class RensonCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Data update coordinator for Renson.""" - config_entry: ConfigEntry + config_entry: RensonConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, api: RensonVentilation, ) -> None: """Initialize my coordinator.""" diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index c82cad012c31b2..0f2822821cddab 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,8 +26,7 @@ ) from homeassistant.util.scaling import int_states_in_range -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) @@ -84,15 +82,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson fan platform.""" - api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api - coordinator: RensonCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ].coordinator + api = config_entry.runtime_data.api + coordinator = config_entry.runtime_data.coordinator async_add_entities([RensonFan(api, coordinator)]) diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py index 67fde1c56dc12f..36d99b71897696 100644 --- a/homeassistant/components/renson/number.py +++ b/homeassistant/components/renson/number.py @@ -12,13 +12,11 @@ NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) @@ -39,15 +37,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson number platform.""" - api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api - coordinator: RensonCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ].coordinator + api = config_entry.runtime_data.api + coordinator = config_entry.runtime_data.coordinator async_add_entities([RensonNumber(RENSON_NUMBER_DESCRIPTION, api, coordinator)]) diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index ce7e71b1c0b910..2a38c58890188c 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -34,7 +34,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -45,9 +44,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RensonData -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity @@ -271,12 +268,12 @@ def _handle_coordinator_update(self) -> None: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson sensor platform.""" - data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data entities = [ RensonSensor(description, data.api, data.coordinator) for description in SENSORS diff --git a/homeassistant/components/renson/switch.py b/homeassistant/components/renson/switch.py index 3b73bb3dffef7e..4f331c1e49d100 100644 --- a/homeassistant/components/renson/switch.py +++ b/homeassistant/components/renson/switch.py @@ -9,12 +9,10 @@ from renson_endura_delta.renson import Level, RensonVentilation from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RensonCoordinator -from .const import DOMAIN +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) @@ -67,14 +65,12 @@ def _handle_coordinator_update(self) -> None: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Call the Renson integration to setup.""" - api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api - coordinator: RensonCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ].coordinator + api = config_entry.runtime_data.api + coordinator = config_entry.runtime_data.coordinator async_add_entities([RensonBreezeSwitch(api, coordinator)]) diff --git a/homeassistant/components/renson/time.py b/homeassistant/components/renson/time.py index 0a07fd2ec4f42f..636790a98423f6 100644 --- a/homeassistant/components/renson/time.py +++ b/homeassistant/components/renson/time.py @@ -10,14 +10,11 @@ from renson_endura_delta.renson import RensonVentilation from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RensonData -from .const import DOMAIN -from .coordinator import RensonCoordinator +from .coordinator import RensonConfigEntry, RensonCoordinator from .entity import RensonEntity @@ -49,15 +46,14 @@ class RensonTimeEntityDescription(TimeEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RensonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson time platform.""" - data: RensonData = hass.data[DOMAIN][config_entry.entry_id] - + coordinator = config_entry.runtime_data.coordinator entities = [ - RensonTime(description, data.coordinator) for description in ENTITY_DESCRIPTIONS + RensonTime(description, coordinator) for description in ENTITY_DESCRIPTIONS ] async_add_entities(entities) From c0c61533e66d5a12a5fc011dc5e578827bfecfb0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:05:04 +0200 Subject: [PATCH 0592/1707] Use runtime_data in risco integration (#167659) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/risco/__init__.py | 40 +++++++++---------- .../components/risco/alarm_control_panel.py | 16 +++----- .../components/risco/binary_sensor.py | 17 ++++---- homeassistant/components/risco/config_flow.py | 14 +++---- homeassistant/components/risco/models.py | 30 +++++++++++++- homeassistant/components/risco/sensor.py | 13 +++--- homeassistant/components/risco/services.py | 12 +++--- homeassistant/components/risco/switch.py | 16 +++----- tests/components/risco/test_services.py | 7 +++- 9 files changed, 87 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index d65bd5d5abf982..bdae79a0852860 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -26,15 +26,13 @@ from .const import ( CONF_CONCURRENCY, - DATA_COORDINATOR, DEFAULT_CONCURRENCY, DOMAIN, - EVENTS_COORDINATOR, SYSTEM_UPDATE_SIGNAL, TYPE_LOCAL, ) from .coordinator import RiscoDataUpdateCoordinator, RiscoEventsDataUpdateCoordinator -from .models import LocalData +from .models import CloudData, LocalData, RiscoConfigEntry, RiscoData from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -58,7 +56,7 @@ def zone_update_signal(zone_id: int) -> str: return f"risco_zone_update_{zone_id}" -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RiscoConfigEntry) -> bool: """Set up Risco from a config entry.""" if is_local(entry): return await _async_setup_local_entry(hass, entry) @@ -66,7 +64,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await _async_setup_cloud_entry(hass, entry) -async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def _async_setup_local_entry( + hass: HomeAssistant, entry: RiscoConfigEntry +) -> bool: data = entry.data concurrency = entry.options.get(CONF_CONCURRENCY, DEFAULT_CONCURRENCY) risco = RiscoLocal( @@ -120,14 +120,15 @@ async def _system(system: System) -> None: entry.async_on_unload(entry.add_update_listener(_update_listener)) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = local_data + entry.runtime_data = RiscoData(local_data=local_data) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def _async_setup_cloud_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def _async_setup_cloud_entry( + hass: HomeAssistant, entry: RiscoConfigEntry +) -> bool: data = entry.data risco = RiscoCloud(data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_PIN]) try: @@ -143,11 +144,12 @@ async def _async_setup_cloud_entry(hass: HomeAssistant, entry: ConfigEntry) -> b entry.async_on_unload(entry.add_update_listener(_update_listener)) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - EVENTS_COORDINATOR: events_coordinator, - } + entry.runtime_data = RiscoData( + cloud_data=CloudData( + coordinator=coordinator, + events_coordinator=events_coordinator, + ) + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await events_coordinator.async_refresh() @@ -155,20 +157,16 @@ async def _async_setup_cloud_entry(hass: HomeAssistant, entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RiscoConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - if is_local(entry): - local_data: LocalData = hass.data[DOMAIN][entry.entry_id] - await local_data.system.disconnect() - - hass.data[DOMAIN].pop(entry.entry_id) + if unload_ok and (local_data := entry.runtime_data.local_data): + await local_data.system.disconnect() return unload_ok -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _update_listener(hass: HomeAssistant, entry: RiscoConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index f485c923776084..7ab65830f96f18 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -15,19 +15,16 @@ AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import LocalData, is_local from .const import ( CONF_CODE_ARM_REQUIRED, CONF_CODE_DISARM_REQUIRED, CONF_HA_STATES_TO_RISCO, CONF_RISCO_STATES_TO_HA, - DATA_COORDINATOR, DEFAULT_OPTIONS, DOMAIN, RISCO_ARM, @@ -36,6 +33,7 @@ ) from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudEntity +from .models import RiscoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -49,13 +47,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco alarm control panel.""" options = {**DEFAULT_OPTIONS, **config_entry.options} - if is_local(config_entry): - local_data: LocalData = hass.data[DOMAIN][config_entry.entry_id] + risco_data = config_entry.runtime_data + if local_data := risco_data.local_data: async_add_entities( RiscoLocalAlarm( local_data.system.id, @@ -67,10 +65,8 @@ async def async_setup_entry( ) for partition_id, partition in local_data.system.partitions.items() ) - else: - coordinator: RiscoDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][DATA_COORDINATOR] + elif cloud_data := risco_data.cloud_data: + coordinator = cloud_data.coordinator async_add_entities( RiscoCloudAlarm( coordinator, partition_id, config_entry.data[CONF_PIN], options diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index ff61985fef3846..06f91a091f2833 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -15,16 +15,15 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import LocalData, is_local -from .const import DATA_COORDINATOR, DOMAIN, SYSTEM_UPDATE_SIGNAL +from .const import DOMAIN, SYSTEM_UPDATE_SIGNAL from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudZoneEntity, RiscoLocalZoneEntity +from .models import RiscoConfigEntry SYSTEM_ENTITY_DESCRIPTIONS = [ BinarySensorEntityDescription( @@ -72,12 +71,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco alarm control panel.""" - if is_local(config_entry): - local_data: LocalData = hass.data[DOMAIN][config_entry.entry_id] + risco_data = config_entry.runtime_data + if local_data := risco_data.local_data: zone_entities = ( entity for zone_id, zone in local_data.system.zones.items() @@ -96,10 +95,8 @@ async def async_setup_entry( ) async_add_entities(chain(system_entities, zone_entities)) - else: - coordinator: RiscoDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][DATA_COORDINATOR] + elif cloud_data := risco_data.cloud_data: + coordinator = cloud_data.coordinator async_add_entities( RiscoCloudBinarySensor(coordinator, zone_id, zone) for zone_id, zone in coordinator.data.zones.items() diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index f7365d354147b7..163a83630e7d4b 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -10,12 +10,7 @@ import voluptuous as vol from homeassistant.components.alarm_control_panel import AlarmControlPanelState -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -42,6 +37,7 @@ RISCO_STATES, TYPE_LOCAL, ) +from .models import RiscoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -121,12 +117,12 @@ class RiscoConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Init the config flow.""" - self._reauth_entry: ConfigEntry | None = None + self._reauth_entry: RiscoConfigEntry | None = None @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, ) -> RiscoOptionsFlowHandler: """Define the config flow to handle options.""" return RiscoOptionsFlowHandler(config_entry) @@ -218,7 +214,7 @@ async def async_step_local( class RiscoOptionsFlowHandler(OptionsFlow): """Handle a Risco options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: RiscoConfigEntry) -> None: """Initialize.""" self._data = {**DEFAULT_OPTIONS, **config_entry.options} diff --git a/homeassistant/components/risco/models.py b/homeassistant/components/risco/models.py index 07777839e884ba..6d10be5ef87bca 100644 --- a/homeassistant/components/risco/models.py +++ b/homeassistant/components/risco/models.py @@ -1,11 +1,39 @@ """Models for Risco integration.""" +from __future__ import annotations + from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from pyrisco import RiscoLocal +from homeassistant.config_entries import ConfigEntry + +if TYPE_CHECKING: + from .coordinator import ( + RiscoDataUpdateCoordinator, + RiscoEventsDataUpdateCoordinator, + ) + +type RiscoConfigEntry = ConfigEntry[RiscoData] + + +@dataclass +class RiscoData: + """Runtime data for the Risco integration.""" + + local_data: LocalData | None = None + cloud_data: CloudData | None = None + + +@dataclass +class CloudData: + """A data class for cloud data passed to the platforms.""" + + coordinator: RiscoDataUpdateCoordinator + events_coordinator: RiscoEventsDataUpdateCoordinator + @dataclass class LocalData: diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index 93683f1aa50631..943e3d7c477e20 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -10,17 +10,16 @@ from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import is_local -from .const import DOMAIN, EVENTS_COORDINATOR +from .const import DOMAIN from .coordinator import RiscoEventsDataUpdateCoordinator from .entity import zone_unique_id +from .models import RiscoConfigEntry CATEGORIES = { 2: "Alarm", @@ -45,17 +44,15 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" - if is_local(config_entry): + if not (cloud_data := config_entry.runtime_data.cloud_data): # no events in local comm return - coordinator: RiscoEventsDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][EVENTS_COORDINATOR] + coordinator = cloud_data.events_coordinator sensors = [ RiscoSensor(coordinator, category_id, [], name, config_entry.entry_id) for category_id, name in CATEGORIES.items() diff --git a/homeassistant/components/risco/services.py b/homeassistant/components/risco/services.py index 4ea8f6edd4f0e6..d48621219c3fcc 100644 --- a/homeassistant/components/risco/services.py +++ b/homeassistant/components/risco/services.py @@ -4,26 +4,26 @@ import voluptuous as vol -from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME, CONF_TYPE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, service -from .const import DOMAIN, SERVICE_SET_TIME, TYPE_LOCAL -from .models import LocalData +from .const import DOMAIN, SERVICE_SET_TIME +from .models import RiscoConfigEntry async def async_setup_services(hass: HomeAssistant) -> None: """Create the Risco Services/Actions.""" async def _set_time(service_call: ServiceCall) -> None: - entry = service.async_get_config_entry( + entry: RiscoConfigEntry = service.async_get_config_entry( service_call.hass, DOMAIN, service_call.data[ATTR_CONFIG_ENTRY_ID] ) time = service_call.data.get(ATTR_TIME) # Validate config entry is local (not cloud) - if entry.data.get(CONF_TYPE) != TYPE_LOCAL: + if not (local_data := entry.runtime_data.local_data): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="not_local_entry", @@ -33,8 +33,6 @@ async def _set_time(service_call: ServiceCall) -> None: if time is None: time_to_send = datetime.now() - local_data: LocalData = hass.data[DOMAIN][entry.entry_id] - await local_data.system.set_time(time_to_send) hass.services.async_register( diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py index 547dedd393312a..f6e5a058224624 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -7,33 +7,29 @@ from pyrisco.common import Zone from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import LocalData, is_local -from .const import DATA_COORDINATOR, DOMAIN from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudZoneEntity, RiscoLocalZoneEntity +from .models import RiscoConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco switch.""" - if is_local(config_entry): - local_data: LocalData = hass.data[DOMAIN][config_entry.entry_id] + risco_data = config_entry.runtime_data + if local_data := risco_data.local_data: async_add_entities( RiscoLocalSwitch(local_data.system.id, zone_id, zone) for zone_id, zone in local_data.system.zones.items() ) - else: - coordinator: RiscoDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][DATA_COORDINATOR] + elif cloud_data := risco_data.cloud_data: + coordinator = cloud_data.coordinator async_add_entities( RiscoCloudSwitch(coordinator, zone_id, zone) for zone_id, zone in coordinator.data.zones.items() diff --git a/tests/components/risco/test_services.py b/tests/components/risco/test_services.py index e6681a34c29a80..6f51530b29a369 100644 --- a/tests/components/risco/test_services.py +++ b/tests/components/risco/test_services.py @@ -5,8 +5,8 @@ import pytest -from homeassistant.components.risco import DOMAIN -from homeassistant.components.risco.const import SERVICE_SET_TIME +from homeassistant.components.risco.const import DOMAIN, SERVICE_SET_TIME +from homeassistant.components.risco.models import CloudData, RiscoData from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME from homeassistant.core import HomeAssistant @@ -99,6 +99,9 @@ async def test_set_time_service_with_cloud_entry( ) cloud_entry.add_to_hass(hass) cloud_entry.mock_state(hass, ConfigEntryState.LOADED) + cloud_entry.runtime_data = RiscoData( + cloud_data=CloudData(coordinator=None, events_coordinator=None) # type: ignore[arg-type] + ) data = { ATTR_CONFIG_ENTRY_ID: cloud_entry.entry_id, From 6cf5bbe2f58a62606f4ad9f526da3e4e9640cefb Mon Sep 17 00:00:00 2001 From: Kurt Chrisford <92524101+kclif9@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:06:48 +1000 Subject: [PATCH 0593/1707] Bump actronneoapi to 0.5.0 (#167669) --- .../components/actron_air/__init__.py | 14 +++++--------- homeassistant/components/actron_air/climate.py | 12 ++++++------ .../components/actron_air/config_flow.py | 8 ++++---- .../components/actron_air/coordinator.py | 8 ++++---- homeassistant/components/actron_air/entity.py | 9 +++++++-- .../components/actron_air/manifest.json | 2 +- homeassistant/components/actron_air/switch.py | 6 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/actron_air/conftest.py | 18 +++++++++++------- 10 files changed, 43 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/actron_air/__init__.py b/homeassistant/components/actron_air/__init__.py index 456c34ff6fb08f..f8b460dd027972 100644 --- a/homeassistant/components/actron_air/__init__.py +++ b/homeassistant/components/actron_air/__init__.py @@ -1,11 +1,7 @@ """The Actron Air integration.""" -from actron_neo_api import ( - ActronAirACSystem, - ActronAirAPI, - ActronAirAPIError, - ActronAirAuthError, -) +from actron_neo_api import ActronAirAPI, ActronAirAPIError, ActronAirAuthError +from actron_neo_api.models.system import ActronAirSystemInfo from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant @@ -25,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> """Set up Actron Air integration from a config entry.""" api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN]) - systems: list[ActronAirACSystem] = [] + systems: list[ActronAirSystemInfo] = [] try: systems = await api.get_ac_systems() @@ -44,9 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> system_coordinators: dict[str, ActronAirSystemCoordinator] = {} for system in systems: coordinator = ActronAirSystemCoordinator(hass, entry, api, system) - _LOGGER.debug("Setting up coordinator for system: %s", system["serial"]) + _LOGGER.debug("Setting up coordinator for system: %s", system.serial) await coordinator.async_config_entry_first_refresh() - system_coordinators[system["serial"]] = coordinator + system_coordinators[system.serial] = coordinator entry.runtime_data = ActronAirRuntimeData( api=api, diff --git a/homeassistant/components/actron_air/climate.py b/homeassistant/components/actron_air/climate.py index 8c928fcc5a99d1..9284065bebf3b6 100644 --- a/homeassistant/components/actron_air/climate.py +++ b/homeassistant/components/actron_air/climate.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator -from .entity import ActronAirAcEntity, ActronAirZoneEntity, handle_actron_api_errors +from .entity import ActronAirAcEntity, ActronAirZoneEntity, actron_air_command PARALLEL_UPDATES = 0 @@ -136,19 +136,19 @@ def target_temperature(self) -> float: """Return the target temperature.""" return self._status.user_aircon_settings.temperature_setpoint_cool_c - @handle_actron_api_errors + @actron_air_command async def async_set_fan_mode(self, fan_mode: str) -> None: """Set a new fan mode.""" api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode) await self._status.user_aircon_settings.set_fan_mode(api_fan_mode) - @handle_actron_api_errors + @actron_air_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode.""" ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode) await self._status.ac_system.set_system_mode(ac_mode) - @handle_actron_api_errors + @actron_air_command async def async_set_temperature(self, **kwargs: Any) -> None: """Set the temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) @@ -212,13 +212,13 @@ def target_temperature(self) -> float | None: """Return the target temperature.""" return self._zone.temperature_setpoint_cool_c - @handle_actron_api_errors + @actron_air_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode.""" is_enabled = hvac_mode != HVACMode.OFF await self._zone.enable(is_enabled) - @handle_actron_api_errors + @actron_air_command async def async_set_temperature(self, **kwargs: Any) -> None: """Set the temperature.""" await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE)) diff --git a/homeassistant/components/actron_air/config_flow.py b/homeassistant/components/actron_air/config_flow.py index 3faefe7590fa47..4bbbadb296c74c 100644 --- a/homeassistant/components/actron_air/config_flow.py +++ b/homeassistant/components/actron_air/config_flow.py @@ -38,10 +38,10 @@ async def async_step_user( _LOGGER.error("OAuth2 flow failed: %s", err) return self.async_abort(reason="oauth2_error") - self._device_code = device_code_response["device_code"] - self._user_code = device_code_response["user_code"] - self._verification_uri = device_code_response["verification_uri_complete"] - self._expires_minutes = str(device_code_response["expires_in"] // 60) + self._device_code = device_code_response.device_code + self._user_code = device_code_response.user_code + self._verification_uri = device_code_response.verification_uri_complete + self._expires_minutes = str(device_code_response.expires_in // 60) async def _wait_for_authorization() -> None: """Wait for the user to authorize the device.""" diff --git a/homeassistant/components/actron_air/coordinator.py b/homeassistant/components/actron_air/coordinator.py index a69f7ab56b06dd..f23486a84f9397 100644 --- a/homeassistant/components/actron_air/coordinator.py +++ b/homeassistant/components/actron_air/coordinator.py @@ -6,12 +6,12 @@ from datetime import timedelta from actron_neo_api import ( - ActronAirACSystem, ActronAirAPI, ActronAirAPIError, ActronAirAuthError, ActronAirStatus, ) +from actron_neo_api.models.system import ActronAirSystemInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -38,7 +38,7 @@ class ActronAirRuntimeData: type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData] -class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]): +class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]): """System coordinator for Actron Air integration.""" def __init__( @@ -46,7 +46,7 @@ def __init__( hass: HomeAssistant, entry: ActronAirConfigEntry, api: ActronAirAPI, - system: ActronAirACSystem, + system: ActronAirSystemInfo, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -57,7 +57,7 @@ def __init__( config_entry=entry, ) self.system = system - self.serial_number = system["serial"] + self.serial_number = system.serial self.api = api self.status = self.api.state_manager.get_status(self.serial_number) self.last_seen = dt_util.utcnow() diff --git a/homeassistant/components/actron_air/entity.py b/homeassistant/components/actron_air/entity.py index 7f62c53516e2ab..008d00aa4914d6 100644 --- a/homeassistant/components/actron_air/entity.py +++ b/homeassistant/components/actron_air/entity.py @@ -14,10 +14,14 @@ from .coordinator import ActronAirSystemCoordinator -def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P]( +def actron_air_command[_EntityT: ActronAirEntity, **_P]( func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: - """Decorate Actron Air API calls to handle ActronAirAPIError exceptions.""" + """Decorator for Actron Air API calls. + + Handles ActronAirAPIError exceptions, and requests a coordinator update + to update the status of the devices as soon as possible. + """ @wraps(func) async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: @@ -30,6 +34,7 @@ async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: translation_key="api_error", translation_placeholders={"error": str(err)}, ) from err + self.coordinator.async_set_updated_data(self.coordinator.data) return wrapper diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json index 724ff101cb96ee..1fdf7ad1aa6cbe 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["actron-neo-api==0.4.1"] + "requirements": ["actron-neo-api==0.5.0"] } diff --git a/homeassistant/components/actron_air/switch.py b/homeassistant/components/actron_air/switch.py index 44efe6c9f7461a..113be86171dcf6 100644 --- a/homeassistant/components/actron_air/switch.py +++ b/homeassistant/components/actron_air/switch.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator -from .entity import ActronAirAcEntity, handle_actron_api_errors +from .entity import ActronAirAcEntity, actron_air_command PARALLEL_UPDATES = 0 @@ -105,12 +105,12 @@ def is_on(self) -> bool: """Return true if the switch is on.""" return self.entity_description.is_on_fn(self.coordinator) - @handle_actron_api_errors + @actron_air_command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.entity_description.set_fn(self.coordinator, True) - @handle_actron_api_errors + @actron_air_command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.entity_description.set_fn(self.coordinator, False) diff --git a/requirements_all.txt b/requirements_all.txt index eddfa5dc31d5cd..983c75143ae615 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -133,7 +133,7 @@ WSDiscovery==2.1.2 accuweather==5.1.0 # homeassistant.components.actron_air -actron-neo-api==0.4.1 +actron-neo-api==0.5.0 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e12b89a1cde73..bccb5316c439ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -124,7 +124,7 @@ WSDiscovery==2.1.2 accuweather==5.1.0 # homeassistant.components.actron_air -actron-neo-api==0.4.1 +actron-neo-api==0.5.0 # homeassistant.components.adax adax==0.4.0 diff --git a/tests/components/actron_air/conftest.py b/tests/components/actron_air/conftest.py index 0b4f2002938934..f17be5782d1090 100644 --- a/tests/components/actron_air/conftest.py +++ b/tests/components/actron_air/conftest.py @@ -4,6 +4,8 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch +from actron_neo_api.models.auth import ActronAirDeviceCode +from actron_neo_api.models.system import ActronAirSystemInfo import pytest from homeassistant.components.actron_air.const import DOMAIN @@ -31,12 +33,14 @@ def mock_actron_api() -> Generator[AsyncMock]: api = mock_api.return_value # Mock device code request - api.request_device_code.return_value = { - "device_code": "test_device_code", - "user_code": "ABC123", - "verification_uri_complete": "https://example.com/device", - "expires_in": 1800, - } + api.request_device_code.return_value = ActronAirDeviceCode( + device_code="test_device_code", + user_code="ABC123", + verification_uri="https://example.com", + verification_uri_complete="https://example.com/device", + expires_in=1800, + interval=5, + ) # Mock successful token polling (with a small delay to test progress) async def slow_poll_for_token(device_code): @@ -58,7 +62,7 @@ async def slow_poll_for_token(device_code): # Mock get_ac_systems api.get_ac_systems = AsyncMock( - return_value=[{"serial": "123456", "name": "Test System"}] + return_value=[ActronAirSystemInfo(serial="123456")] ) # Mock state manager From b697b3a54eb96709c6bf75d9aafe802ca576dcfd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Apr 2026 12:10:22 +0200 Subject: [PATCH 0594/1707] Extract version template function into a version Jinja2 extension (#167172) Co-authored-by: Joostlek --- homeassistant/helpers/template/__init__.py | 10 +----- .../helpers/template/extensions/__init__.py | 2 ++ .../helpers/template/extensions/version.py | 35 +++++++++++++++++++ .../template/extensions/test_version.py | 28 +++++++++++++++ tests/helpers/template/test_init.py | 18 ---------- 5 files changed, 66 insertions(+), 27 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/version.py create mode 100644 tests/helpers/template/extensions/test_version.py diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 728f11bc365832..5976cfe88e33e5 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Any, Concatenate, Literal, NoReturn, Self, overload import weakref -from awesomeversion import AwesomeVersion import jinja2 from jinja2 import pass_context, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext @@ -1456,11 +1455,6 @@ def add(value, amount, default=_SENTINEL): return default -def version(value): - """Filter and function to get version object of the value.""" - return AwesomeVersion(value) - - def make_logging_undefined( strict: bool | None, log_fn: Callable[[int, str], None] | None ) -> type[jinja2.Undefined]: @@ -1611,13 +1605,11 @@ def __init__( self.add_extension( "homeassistant.helpers.template.extensions.TypeCastExtension" ) - - self.globals["version"] = version + self.add_extension("homeassistant.helpers.template.extensions.VersionExtension") self.filters["add"] = add self.filters["multiply"] = multiply self.filters["round"] = forgiving_round - self.filters["version"] = version if hass is None: return diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index c2c9755d06b989..65792528adc1a4 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -15,6 +15,7 @@ from .serialization import SerializationExtension from .string import StringExtension from .type_cast import TypeCastExtension +from .version import VersionExtension __all__ = [ "AreaExtension", @@ -32,4 +33,5 @@ "SerializationExtension", "StringExtension", "TypeCastExtension", + "VersionExtension", ] diff --git a/homeassistant/helpers/template/extensions/version.py b/homeassistant/helpers/template/extensions/version.py new file mode 100644 index 00000000000000..e5b6133b53083e --- /dev/null +++ b/homeassistant/helpers/template/extensions/version.py @@ -0,0 +1,35 @@ +"""Version functions for Home Assistant templates.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from awesomeversion import AwesomeVersion + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class VersionExtension(BaseTemplateExtension): + """Jinja2 extension for version functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the version extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "version", + self.version, + as_global=True, + as_filter=True, + ), + ], + ) + + @staticmethod + def version(value: str) -> AwesomeVersion: + """Filter and function to get version object of the value.""" + return AwesomeVersion(value) diff --git a/tests/helpers/template/extensions/test_version.py b/tests/helpers/template/extensions/test_version.py new file mode 100644 index 00000000000000..d03fbfbc2991cf --- /dev/null +++ b/tests/helpers/template/extensions/test_version.py @@ -0,0 +1,28 @@ +"""Test version functions for Home Assistant templates.""" + +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError + +from tests.helpers.template.helpers import render + + +def test_version(hass: HomeAssistant) -> None: + """Test version filter and function.""" + filter_result = render(hass, "{{ '2099.9.9' | version}}") + function_result = render(hass, "{{ version('2099.9.9')}}") + assert filter_result == function_result == "2099.9.9" + + filter_result = render(hass, "{{ '2099.9.9' | version < '2099.9.10' }}") + function_result = render(hass, "{{ version('2099.9.9') < '2099.9.10' }}") + assert filter_result is function_result is True + + filter_result = render(hass, "{{ '2099.9.9' | version == '2099.9.9' }}") + function_result = render(hass, "{{ version('2099.9.9') == '2099.9.9' }}") + assert filter_result is function_result is True + + with pytest.raises(TemplateError): + render(hass, "{{ version(None) < '2099.9.10' }}") diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 15dd73126fa183..da1460409d34c4 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -954,24 +954,6 @@ def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: assert result == "15 days" -def test_version(hass: HomeAssistant) -> None: - """Test version filter and function.""" - filter_result = render(hass, "{{ '2099.9.9' | version}}") - function_result = render(hass, "{{ version('2099.9.9')}}") - assert filter_result == function_result == "2099.9.9" - - filter_result = render(hass, "{{ '2099.9.9' | version < '2099.9.10' }}") - function_result = render(hass, "{{ version('2099.9.9') < '2099.9.10' }}") - assert filter_result is function_result is True - - filter_result = render(hass, "{{ '2099.9.9' | version == '2099.9.9' }}") - function_result = render(hass, "{{ version('2099.9.9') == '2099.9.9' }}") - assert filter_result is function_result is True - - with pytest.raises(TemplateError): - render(hass, "{{ version(None) < '2099.9.10' }}") - - def test_distance_function_with_1_state(hass: HomeAssistant) -> None: """Test distance function with 1 state.""" _set_up_units(hass) From 82202ee1c2b4cc4d8ce542339ab0464936d306bc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:13:26 +0200 Subject: [PATCH 0595/1707] Use runtime_data in ruckus_unleashed integration (#167662) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/ruckus_unleashed/__init__.py | 29 ++++++------------- .../ruckus_unleashed/coordinator.py | 14 +++++++-- .../ruckus_unleashed/device_tracker.py | 22 ++++---------- .../ruckus_unleashed/test_device_tracker.py | 2 +- .../components/ruckus_unleashed/test_init.py | 4 +-- 5 files changed, 30 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index 8e9219985ce70a..89eeb97e73b41a 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -5,7 +5,6 @@ from aioruckus import AjaxSession from aioruckus.exceptions import AuthenticationError, SchemaError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -18,18 +17,18 @@ API_AP_MODEL, API_SYS_SYSINFO, API_SYS_SYSINFO_VERSION, - COORDINATOR, DOMAIN, MANUFACTURER, PLATFORMS, - UNDO_UPDATE_LISTENERS, ) -from .coordinator import RuckusDataUpdateCoordinator +from .coordinator import RuckusDataUpdateCoordinator, RuckusUnleashedConfigEntry _LOGGER = logging.getLogger(__package__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: RuckusUnleashedConfigEntry +) -> bool: """Set up Ruckus from a config entry.""" ruckus = AjaxSession.async_create( @@ -69,25 +68,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR: coordinator, - UNDO_UPDATE_LISTENERS: [], - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: RuckusUnleashedConfigEntry +) -> bool: """Unload a config entry.""" - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - for listener in hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS]: - listener() - await hass.data[DOMAIN][entry.entry_id][COORDINATOR].ruckus.close() - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py index 7ffaab2e977776..860d035bed604d 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -13,16 +13,21 @@ from .const import API_CLIENT_MAC, DOMAIN, KEY_SYS_CLIENTS, SCAN_INTERVAL +type RuckusUnleashedConfigEntry = ConfigEntry[RuckusDataUpdateCoordinator] + _LOGGER = logging.getLogger(__package__) class RuckusDataUpdateCoordinator(DataUpdateCoordinator): """Coordinator to manage data from Ruckus client.""" - config_entry: ConfigEntry + config_entry: RuckusUnleashedConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, ruckus: AjaxSession + self, + hass: HomeAssistant, + config_entry: RuckusUnleashedConfigEntry, + ruckus: AjaxSession, ) -> None: """Initialize global Ruckus data updater.""" self.ruckus = ruckus @@ -41,6 +46,11 @@ async def _fetch_clients(self) -> dict: _LOGGER.debug("fetched %d active clients", len(clients)) return {client[API_CLIENT_MAC]: client for client in clients} + async def async_shutdown(self) -> None: + """Close the Ruckus session on shutdown.""" + await super().async_shutdown() + await self.ruckus.close() + async def _async_update_data(self) -> dict: """Fetch Ruckus data.""" try: diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 890148ec25cc4d..141cd6198267a6 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -5,32 +5,24 @@ import logging from homeassistant.components.device_tracker import ScannerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - API_CLIENT_HOSTNAME, - API_CLIENT_IP, - COORDINATOR, - DOMAIN, - KEY_SYS_CLIENTS, - UNDO_UPDATE_LISTENERS, -) -from .coordinator import RuckusDataUpdateCoordinator +from .const import API_CLIENT_HOSTNAME, API_CLIENT_IP, DOMAIN, KEY_SYS_CLIENTS +from .coordinator import RuckusDataUpdateCoordinator, RuckusUnleashedConfigEntry _LOGGER = logging.getLogger(__package__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RuckusUnleashedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Ruckus component.""" - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + coordinator = entry.runtime_data tracked: set[str] = set() @@ -41,9 +33,7 @@ def router_update(): router_update() - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS].append( - coordinator.async_add_listener(router_update) - ) + entry.async_on_unload(coordinator.async_add_listener(router_update)) registry = er.async_get(hass) restore_entities(registry, coordinator, entry, async_add_entities, tracked) @@ -70,7 +60,7 @@ def add_new_entities(coordinator, async_add_entities, tracked): def restore_entities( registry: er.EntityRegistry, coordinator: RuckusDataUpdateCoordinator, - entry: ConfigEntry, + entry: RuckusUnleashedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], ) -> None: diff --git a/tests/components/ruckus_unleashed/test_device_tracker.py b/tests/components/ruckus_unleashed/test_device_tracker.py index 460c64c9651a81..60626277d79778 100644 --- a/tests/components/ruckus_unleashed/test_device_tracker.py +++ b/tests/components/ruckus_unleashed/test_device_tracker.py @@ -6,7 +6,7 @@ from aioruckus.const import ERROR_CONNECT_EOF, ERROR_LOGIN_INCORRECT from aioruckus.exceptions import AuthenticationError -from homeassistant.components.ruckus_unleashed import DOMAIN +from homeassistant.components.ruckus_unleashed.const import DOMAIN from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/ruckus_unleashed/test_init.py b/tests/components/ruckus_unleashed/test_init.py index a7514677f20114..570ff74466cab6 100644 --- a/tests/components/ruckus_unleashed/test_init.py +++ b/tests/components/ruckus_unleashed/test_init.py @@ -5,13 +5,14 @@ from aioruckus.const import ERROR_CONNECT_TIMEOUT, ERROR_LOGIN_INCORRECT from aioruckus.exceptions import AuthenticationError -from homeassistant.components.ruckus_unleashed import DOMAIN, MANUFACTURER from homeassistant.components.ruckus_unleashed.const import ( API_AP_DEVNAME, API_AP_MAC, API_AP_MODEL, API_SYS_SYSINFO, API_SYS_SYSINFO_VERSION, + DOMAIN, + MANUFACTURER, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -89,4 +90,3 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) From a560967861413c23076d31d9e189d81f9a4b6405 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:13:44 +0200 Subject: [PATCH 0596/1707] Use runtime_data in roomba integration (#167667) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/roomba/__init__.py | 24 ++++++++++--------- .../components/roomba/binary_sensor.py | 8 +++---- .../components/roomba/config_flow.py | 10 +++----- homeassistant/components/roomba/models.py | 4 ++++ homeassistant/components/roomba/sensor.py | 8 +++---- homeassistant/components/roomba/vacuum.py | 8 +++---- tests/components/roomba/conftest.py | 2 +- 7 files changed, 30 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index f811a2afe03b8a..e8adc9d787ab66 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -9,7 +9,6 @@ from roombapy import Roomba, RoombaConnectionError, RoombaFactory from homeassistant import exceptions -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DELAY, CONF_HOST, @@ -19,13 +18,15 @@ ) from homeassistant.core import HomeAssistant -from .const import CONF_BLID, CONF_CONTINUOUS, DOMAIN, PLATFORMS, ROOMBA_SESSION -from .models import RoombaData +from .const import CONF_BLID, CONF_CONTINUOUS, PLATFORMS, ROOMBA_SESSION +from .models import RoombaConfigEntry, RoombaData _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: RoombaConfigEntry +) -> bool: """Set the config entry up.""" # Set up roomba platforms with config entry @@ -62,8 +63,7 @@ async def _async_disconnect_roomba(event): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect_roomba) ) - domain_data = RoombaData(roomba, config_entry.data[CONF_BLID]) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = domain_data + config_entry.runtime_data = RoombaData(roomba, config_entry.data[CONF_BLID]) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -108,20 +108,22 @@ async def async_disconnect_or_timeout(hass: HomeAssistant, roomba: Roomba) -> No await hass.async_add_executor_job(roomba.disconnect) -async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_update_options( + hass: HomeAssistant, config_entry: RoombaConfigEntry +) -> None: """Update options.""" await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: RoombaConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) if unload_ok: - domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] - await async_disconnect_or_timeout(hass, roomba=domain_data.roomba) - hass.data[DOMAIN].pop(config_entry.entry_id) + await async_disconnect_or_timeout(hass, roomba=config_entry.runtime_data.roomba) return unload_ok diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index ba362914b6d4f6..b4c5765f53a34b 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -1,23 +1,21 @@ """Roomba binary sensor entities.""" from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import roomba_reported_state -from .const import DOMAIN from .entity import IRobotEntity -from .models import RoombaData +from .models import RoombaConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoombaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + domain_data = config_entry.runtime_data roomba = domain_data.roomba blid = domain_data.blid status = roomba_reported_state(roomba).get("bin", {}) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index b7d259e3131ac2..5173f989e4c09e 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -11,12 +11,7 @@ from roombapy.getpassword import RoombaPassword import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -31,6 +26,7 @@ DOMAIN, ROOMBA_SESSION, ) +from .models import RoombaConfigEntry ROOMBA_DISCOVERY_LOCK = "roomba_discovery_lock" ALL_ATTEMPTS = 2 @@ -90,7 +86,7 @@ def __init__(self) -> None: @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RoombaConfigEntry, ) -> RoombaOptionsFlowHandler: """Get the options flow for this handler.""" return RoombaOptionsFlowHandler() diff --git a/homeassistant/components/roomba/models.py b/homeassistant/components/roomba/models.py index 350495cae7bc00..999be8fd514474 100644 --- a/homeassistant/components/roomba/models.py +++ b/homeassistant/components/roomba/models.py @@ -6,6 +6,10 @@ from roombapy import Roomba +from homeassistant.config_entries import ConfigEntry + +type RoombaConfigEntry = ConfigEntry[RoombaData] + @dataclass class RoombaData: diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 67c33698ff1da2..6aa05b8af309e9 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -11,15 +11,13 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN from .entity import IRobotEntity, roomba_reported_state -from .models import RoombaData +from .models import RoombaConfigEntry @dataclass(frozen=True, kw_only=True) @@ -142,11 +140,11 @@ class RoombaSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoombaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + domain_data = config_entry.runtime_data roomba = domain_data.roomba blid = domain_data.blid diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 6abc1d52398c48..f2c5a91c4c9f30 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -12,16 +12,14 @@ VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM from . import roomba_reported_state -from .const import DOMAIN from .entity import IRobotEntity -from .models import RoombaData +from .models import RoombaConfigEntry SUPPORT_IROBOT = ( VacuumEntityFeature.PAUSE @@ -87,11 +85,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoombaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + domain_data = config_entry.runtime_data roomba = domain_data.roomba blid = domain_data.blid diff --git a/tests/components/roomba/conftest.py b/tests/components/roomba/conftest.py index aa89ff9f56a8d6..e5d33f95a18fd2 100644 --- a/tests/components/roomba/conftest.py +++ b/tests/components/roomba/conftest.py @@ -6,7 +6,7 @@ import pytest from roombapy import Roomba -from homeassistant.components.roomba import CONF_BLID, CONF_CONTINUOUS, DOMAIN +from homeassistant.components.roomba.const import CONF_BLID, CONF_CONTINUOUS, DOMAIN from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD from tests.common import MockConfigEntry From 7f49ecffd3c88b84091101a6bc16a0cc20c269a8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:14:29 +0200 Subject: [PATCH 0597/1707] Use runtime_data in romy integration (#167665) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/romy/__init__.py | 17 +++++++---------- homeassistant/components/romy/binary_sensor.py | 8 +++----- homeassistant/components/romy/coordinator.py | 6 ++++-- homeassistant/components/romy/sensor.py | 8 +++----- homeassistant/components/romy/vacuum.py | 11 ++++------- 5 files changed, 21 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/romy/__init__.py b/homeassistant/components/romy/__init__.py index be22764512274e..a067100bc184ed 100644 --- a/homeassistant/components/romy/__init__.py +++ b/homeassistant/components/romy/__init__.py @@ -2,15 +2,14 @@ import romy -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DOMAIN, LOGGER, PLATFORMS -from .coordinator import RomyVacuumCoordinator +from .const import LOGGER, PLATFORMS +from .coordinator import RomyConfigEntry, RomyVacuumCoordinator -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: RomyConfigEntry) -> bool: """Initialize the ROMY platform via config entry.""" new_romy = await romy.create_romy( @@ -20,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = RomyVacuumCoordinator(hass, config_entry, new_romy) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -29,14 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RomyConfigEntry) -> bool: """Handle removal of an entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, config_entry: RomyConfigEntry) -> None: """Handle options update.""" LOGGER.debug("update_listener") await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/romy/binary_sensor.py b/homeassistant/components/romy/binary_sensor.py index 599c0fe023e54f..f454efacdbc70a 100644 --- a/homeassistant/components/romy/binary_sensor.py +++ b/homeassistant/components/romy/binary_sensor.py @@ -5,12 +5,10 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RomyVacuumCoordinator +from .coordinator import RomyConfigEntry, RomyVacuumCoordinator from .entity import RomyEntity BINARY_SENSORS: list[BinarySensorEntityDescription] = [ @@ -38,12 +36,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RomyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" - coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( RomyBinarySensor(coordinator, entity_description) diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py index de5352191d7526..f72b388c3ca2a0 100644 --- a/homeassistant/components/romy/coordinator.py +++ b/homeassistant/components/romy/coordinator.py @@ -8,14 +8,16 @@ from .const import DOMAIN, LOGGER, UPDATE_INTERVAL +type RomyConfigEntry = ConfigEntry[RomyVacuumCoordinator] + class RomyVacuumCoordinator(DataUpdateCoordinator[None]): """ROMY Vacuum Coordinator.""" - config_entry: ConfigEntry + config_entry: RomyConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, romy: RomyRobot + self, hass: HomeAssistant, config_entry: RomyConfigEntry, romy: RomyRobot ) -> None: """Initialize.""" super().__init__( diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py index 85bf0df8f64721..8318924c28aa59 100644 --- a/homeassistant/components/romy/sensor.py +++ b/homeassistant/components/romy/sensor.py @@ -6,7 +6,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -18,8 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RomyVacuumCoordinator +from .coordinator import RomyConfigEntry, RomyVacuumCoordinator from .entity import RomyEntity SENSORS: list[SensorEntityDescription] = [ @@ -76,12 +74,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RomyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" - coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( RomySensor(coordinator, entity_description) diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py index 0e9dd13ffe19c5..e959ea32453cf1 100644 --- a/homeassistant/components/romy/vacuum.py +++ b/homeassistant/components/romy/vacuum.py @@ -11,12 +11,11 @@ VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER -from .coordinator import RomyVacuumCoordinator +from .const import LOGGER +from .coordinator import RomyConfigEntry, RomyVacuumCoordinator from .entity import RomyEntity FAN_SPEED_NONE = "default" @@ -50,13 +49,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RomyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" - - coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([RomyVacuumEntity(coordinator)]) + async_add_entities([RomyVacuumEntity(config_entry.runtime_data)]) class RomyVacuumEntity(RomyEntity, StateVacuumEntity): From 8994f501f1743d514bd6cb6f1f013738a8fd6abd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:15:34 +0200 Subject: [PATCH 0598/1707] Use runtime_data in rympro integration (#167663) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/rympro/__init__.py | 16 +++++----------- homeassistant/components/rympro/coordinator.py | 6 ++++-- homeassistant/components/rympro/sensor.py | 7 +++---- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py index 20d208cca69604..c16e9e0799dc56 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -6,20 +6,18 @@ from pyrympro import CannotConnectError, RymPro, UnauthorizedError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import RymProDataUpdateCoordinator +from .coordinator import RymProConfigEntry, RymProDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RymProConfigEntry) -> bool: """Set up Read Your Meter Pro from a config entry.""" data = entry.data rympro = RymPro(async_get_clientsession(hass)) @@ -41,17 +39,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = RymProDataUpdateCoordinator(hass, entry, rympro) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RymProConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index 6b49a065d35b7a..407210b7587fae 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -13,6 +13,8 @@ from .const import DOMAIN +type RymProConfigEntry = ConfigEntry[RymProDataUpdateCoordinator] + SCAN_INTERVAL = 60 * 60 _LOGGER = logging.getLogger(__name__) @@ -21,10 +23,10 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): """Class to manage fetching RYM Pro data.""" - config_entry: ConfigEntry + config_entry: RymProConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, rympro: RymPro + self, hass: HomeAssistant, config_entry: RymProConfigEntry, rympro: RymPro ) -> None: """Initialize global RymPro data updater.""" self.rympro = rympro diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 66ed41a4ce981d..59df04620bc2f9 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -10,7 +10,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -18,7 +17,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import RymProDataUpdateCoordinator +from .coordinator import RymProConfigEntry, RymProDataUpdateCoordinator @dataclass(kw_only=True, frozen=True) @@ -61,11 +60,11 @@ class RymProSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RymProConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" - coordinator: RymProDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( RymProSensor(coordinator, meter_id, description, config_entry.entry_id) for meter_id, meter in coordinator.data.items() From 5be48affcf0ba2d53d6119acdd34fd998cbc1127 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:15:59 +0200 Subject: [PATCH 0599/1707] Use runtime_data in rova integration (#167661) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/rova/__init__.py | 15 +++++---------- homeassistant/components/rova/coordinator.py | 6 ++++-- homeassistant/components/rova/sensor.py | 7 +++---- tests/components/rova/test_init.py | 2 +- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/rova/__init__.py b/homeassistant/components/rova/__init__.py index ecde0578772b48..dc78ec6610419a 100644 --- a/homeassistant/components/rova/__init__.py +++ b/homeassistant/components/rova/__init__.py @@ -5,19 +5,18 @@ from requests.exceptions import ConnectTimeout, HTTPError from rova.rova import Rova -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_HOUSE_NUMBER, CONF_HOUSE_NUMBER_SUFFIX, CONF_ZIP_CODE, DOMAIN -from .coordinator import RovaCoordinator +from .coordinator import RovaConfigEntry, RovaCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RovaConfigEntry) -> bool: """Set up ROVA from a config entry.""" api = Rova( @@ -50,15 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RovaConfigEntry) -> bool: """Unload ROVA config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rova/coordinator.py b/homeassistant/components/rova/coordinator.py index a48048d32c38c6..4240d4f3a4656d 100644 --- a/homeassistant/components/rova/coordinator.py +++ b/homeassistant/components/rova/coordinator.py @@ -11,16 +11,18 @@ from .const import DOMAIN, LOGGER +type RovaConfigEntry = ConfigEntry[RovaCoordinator] + EUROPE_AMSTERDAM_ZONE_INFO = get_time_zone("Europe/Amsterdam") class RovaCoordinator(DataUpdateCoordinator[dict[str, datetime]]): """Class to manage fetching Rova data.""" - config_entry: ConfigEntry + config_entry: RovaConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: Rova + self, hass: HomeAssistant, config_entry: RovaConfigEntry, api: Rova ) -> None: """Initialize.""" super().__init__( diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 59f9f28f8f5b6e..a14e7016bb020c 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -9,14 +9,13 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import RovaCoordinator +from .coordinator import RovaConfigEntry, RovaCoordinator ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=rova"} @@ -42,11 +41,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RovaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Rova entry.""" - coordinator: RovaCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data assert entry.unique_id unique_id = entry.unique_id diff --git a/tests/components/rova/test_init.py b/tests/components/rova/test_init.py index 5441a730bf6fc2..cccfb32a08308f 100644 --- a/tests/components/rova/test_init.py +++ b/tests/components/rova/test_init.py @@ -6,7 +6,7 @@ from requests import ConnectTimeout from syrupy.assertion import SnapshotAssertion -from homeassistant.components.rova import DOMAIN +from homeassistant.components.rova.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant From 13f1a42d695a2505ca666ed69a701ccaee84d699 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:16:32 +0200 Subject: [PATCH 0600/1707] Use runtime_data in roon integration (#167660) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/roon/__init__.py | 13 ++++++------- homeassistant/components/roon/event.py | 6 +++--- homeassistant/components/roon/media_player.py | 6 +++--- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/roon/__init__.py b/homeassistant/components/roon/__init__.py index 462437df449fc5..4b5226bf260b51 100644 --- a/homeassistant/components/roon/__init__.py +++ b/homeassistant/components/roon/__init__.py @@ -10,6 +10,8 @@ from .server import RoonServer from .services import async_setup_services +type RoonConfigEntry = ConfigEntry[RoonServer] + CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER] @@ -20,10 +22,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RoonConfigEntry) -> bool: """Set up a roonserver from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - # fallback to using host for compatibility with older configs name = entry.data.get(CONF_ROON_NAME, entry.data[CONF_HOST]) @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not await roonserver.async_setup(): return False - hass.data[DOMAIN][entry.entry_id] = roonserver + entry.runtime_data = roonserver device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -47,10 +47,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RoonConfigEntry) -> bool: """Unload a config entry.""" if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return False - roonserver = hass.data[DOMAIN].pop(entry.entry_id) - return await roonserver.async_reset() + return await entry.runtime_data.async_reset() diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index b2a491c8d28f4e..c18a67613b55d0 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -4,12 +4,12 @@ from typing import cast from homeassistant.components.event import EventDeviceClass, EventEntity -from homeassistant.config_entries import ConfigEntry 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 . import RoonConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -17,11 +17,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roon Event from Config Entry.""" - roon_server = hass.data[DOMAIN][config_entry.entry_id] + roon_server = config_entry.runtime_data event_entities = set() @callback diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 804fb0244b5934..8a4603a6b26bfe 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -15,7 +15,6 @@ MediaType, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -27,6 +26,7 @@ from homeassistant.util import convert from homeassistant.util.dt import utcnow +from . import RoonConfigEntry from .const import DOMAIN from .media_browser import browse_media @@ -45,11 +45,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RoonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roon MediaPlayer from Config Entry.""" - roon_server = hass.data[DOMAIN][config_entry.entry_id] + roon_server = config_entry.runtime_data media_players = set() @callback From 65e4b260069faf2bb1af26f15e51fe6835e50395 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:32:26 +0200 Subject: [PATCH 0601/1707] Use suggested uom in Renault charging power sensor (#167646) --- homeassistant/components/renault/sensor.py | 14 +++----------- .../components/renault/snapshots/test_sensor.ambr | 9 +++++++++ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index d5791e0c6aff17..641b5b1847a03f 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -88,15 +88,6 @@ def native_value(self) -> StateType | datetime: return self.entity_description.value_lambda(self) -def _get_charging_power( - entity: RenaultSensor[KamereonVehicleBatteryStatusData], -) -> StateType: - """Return the charging_power of this entity.""" - if (data := entity.coordinator.data.chargingInstantaneousPower) is None: - return None - return data / 1000 - - def _get_charge_state_formatted( entity: RenaultSensor[KamereonVehicleBatteryStatusData], ) -> str | None: @@ -190,9 +181,10 @@ def _get_charging_settings_mode_formatted( condition_lambda=lambda a: a.details.reports_charging_power_in_watts(), coordinator="battery", device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.KILO_WATT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, - value_lambda=_get_charging_power, + value_lambda=lambda e: e.coordinator.data.chargingInstantaneousPower, translation_key="charging_power", ), RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index f45ad0a4a7596f..3d0d6b708dc17f 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -391,6 +391,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1205,6 +1208,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -5115,6 +5121,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, From 3e5132bf85889f5bdb3075f3b854eaeadaa2c2d7 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:58:16 +0300 Subject: [PATCH 0602/1707] Victron GX reauthentication-flow (#167614) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .../components/victron_gx/config_flow.py | 62 ++++++- .../components/victron_gx/quality_scale.yaml | 2 +- .../components/victron_gx/strings.json | 15 ++ .../components/victron_gx/test_config_flow.py | 152 ++++++++++++++++++ 4 files changed, 229 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/victron_gx/config_flow.py b/homeassistant/components/victron_gx/config_flow.py index 04437e676a0ce4..e58c27fa37008d 100644 --- a/homeassistant/components/victron_gx/config_flow.py +++ b/homeassistant/components/victron_gx/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse @@ -54,6 +55,16 @@ } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME, default=""): selector.TextSelector(), + vol.Optional(CONF_PASSWORD, default=""): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_SSL): selector.BooleanSelector(), + } +) + async def validate_input(data: dict[str, Any]) -> str: """Validate the user input allows us to connect. @@ -270,5 +281,54 @@ async def async_step_ssdp_auth( STEP_SSDP_AUTH_DATA_SCHEMA, user_input ), errors=errors, - description_placeholders={"host": self.hostname}, + description_placeholders={CONF_HOST: self.hostname}, + ) + + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: + """Handle reauthentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + updates = { + CONF_USERNAME: user_input.get(CONF_USERNAME) or None, + CONF_PASSWORD: user_input.get(CONF_PASSWORD) or None, + CONF_SSL: user_input.get( + CONF_SSL, reauth_entry.data.get(CONF_SSL, False) + ), + } + try: + await validate_input({**reauth_entry.data, **updates}) + except AuthenticationError: + errors["base"] = "invalid_auth" + except CannotConnectError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during reauthentication") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=updates, + ) + + suggested_values = { + CONF_USERNAME: reauth_entry.data.get(CONF_USERNAME, None), + CONF_SSL: reauth_entry.data.get(CONF_SSL, False), + } + if user_input is not None: + suggested_values.update(user_input) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + STEP_REAUTH_DATA_SCHEMA, suggested_values + ), + description_placeholders={CONF_HOST: reauth_entry.data[CONF_HOST]}, + errors=errors, ) diff --git a/homeassistant/components/victron_gx/quality_scale.yaml b/homeassistant/components/victron_gx/quality_scale.yaml index af3275f1d21bf0..48bc8e1f3f62da 100644 --- a/homeassistant/components/victron_gx/quality_scale.yaml +++ b/homeassistant/components/victron_gx/quality_scale.yaml @@ -37,7 +37,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index cb7eadda9c028c..4d37c1516f7302 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { @@ -11,6 +12,20 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::victron_gx::config::step::user::data_description::password%]", + "ssl": "[%key:component::victron_gx::config::step::user::data_description::ssl%]", + "username": "[%key:component::victron_gx::config::step::user::data_description::username%]" + }, + "description": "Please re-authenticate with {host}.", + "title": "[%key:common::config_flow::title::reauth%]" + }, "ssdp_auth": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/tests/components/victron_gx/test_config_flow.py b/tests/components/victron_gx/test_config_flow.py index 1a11dffffad0da..1eadc6c11009a3 100644 --- a/tests/components/victron_gx/test_config_flow.py +++ b/tests/components/victron_gx/test_config_flow.py @@ -543,3 +543,155 @@ async def test_user_flow_missing_installation_id( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_reauth_flow_success( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauthentication flow with successful credentials.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-user", + CONF_PASSWORD: "new-password", + CONF_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_USERNAME] == "new-user" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + assert mock_config_entry.data[CONF_SSL] is True + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_reauth_flow_preserves_ssl_when_omitted( + hass: HomeAssistant, +) -> None: + """Test reauth preserves existing SSL value when key is omitted from input.""" + ssl_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_INSTALLATION_ID, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_USERNAME: "old-user", + CONF_PASSWORD: "old-pass", + CONF_SSL: True, + CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, + CONF_MODEL: MOCK_MODEL, + CONF_SERIAL: MOCK_SERIAL, + }, + title=f"Victron OS {MOCK_INSTALLATION_ID} ({MOCK_HOST}:{DEFAULT_PORT})", + ) + ssl_entry.add_to_hass(hass) + result = await ssl_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-user", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert ssl_entry.data[CONF_USERNAME] == "new-user" + assert ssl_entry.data[CONF_PASSWORD] == "new-password" + assert ssl_entry.data[CONF_SSL] is True + + +@pytest.mark.usefixtures("mock_victron_hub") +async def test_reauth_flow_clears_credentials( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauthentication flow clears credentials when submitted empty.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_USERNAME] is None + assert mock_config_entry.data[CONF_PASSWORD] is None + assert mock_config_entry.data[CONF_SSL] is False + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthenticationError("Invalid credentials"), "invalid_auth"), + (CannotConnectError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_reauth_flow_error_and_recover( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_victron_hub: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test reauthentication flow handles errors and allows recovery.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_victron_hub.return_value.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "wrong-user", + CONF_PASSWORD: "wrong-password", + CONF_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Recover from error + mock_victron_hub.return_value.connect.side_effect = None + mock_victron_hub.return_value.installation_id = MOCK_INSTALLATION_ID + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-user", + CONF_PASSWORD: "new-password", + CONF_SSL: True, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_USERNAME] == "new-user" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + assert mock_config_entry.data[CONF_SSL] is True From 8d3d4a1b5cae4b0717bba95c511561dbce41f9e6 Mon Sep 17 00:00:00 2001 From: Kurt Chrisford <92524101+kclif9@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:12:56 +1000 Subject: [PATCH 0603/1707] Add diagnostics to Actron Air (#167145) --- .../components/actron_air/diagnostics.py | 35 ++++++++ .../components/actron_air/quality_scale.yaml | 2 +- tests/components/actron_air/conftest.py | 79 ++++++++----------- .../actron_air/fixtures/status.json | 41 ++++++++++ .../actron_air/snapshots/test_climate.ambr | 10 +-- .../snapshots/test_diagnostics.ambr | 55 +++++++++++++ tests/components/actron_air/test_climate.py | 3 - .../components/actron_air/test_diagnostics.py | 28 +++++++ tests/components/actron_air/test_switch.py | 5 +- 9 files changed, 204 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/actron_air/diagnostics.py create mode 100644 tests/components/actron_air/fixtures/status.json create mode 100644 tests/components/actron_air/snapshots/test_diagnostics.ambr create mode 100644 tests/components/actron_air/test_diagnostics.py diff --git a/homeassistant/components/actron_air/diagnostics.py b/homeassistant/components/actron_air/diagnostics.py new file mode 100644 index 00000000000000..0cfa668a37c128 --- /dev/null +++ b/homeassistant/components/actron_air/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for Actron Air.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import ActronAirConfigEntry + +TO_REDACT = {CONF_API_TOKEN, "master_serial", "serial_number", "serial"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: ActronAirConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinators: dict[int, Any] = {} + for idx, coordinator in enumerate(entry.runtime_data.system_coordinators.values()): + coordinators[idx] = { + "system": async_redact_data( + coordinator.system.model_dump(mode="json"), TO_REDACT + ), + "status": async_redact_data( + coordinator.data.model_dump(mode="json", exclude={"last_known_state"}), + TO_REDACT, + ), + } + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "coordinators": coordinators, + } diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml index 35107d899df010..982050be3bdb4c 100644 --- a/homeassistant/components/actron_air/quality_scale.yaml +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -41,7 +41,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update. diff --git a/tests/components/actron_air/conftest.py b/tests/components/actron_air/conftest.py index f17be5782d1090..6bd7a606454139 100644 --- a/tests/components/actron_air/conftest.py +++ b/tests/components/actron_air/conftest.py @@ -2,10 +2,13 @@ import asyncio from collections.abc import Generator +import json from unittest.mock import AsyncMock, MagicMock, patch from actron_neo_api.models.auth import ActronAirDeviceCode -from actron_neo_api.models.system import ActronAirSystemInfo +from actron_neo_api.models.settings import ActronAirUserAirconSettings +from actron_neo_api.models.status import ActronAirStatus +from actron_neo_api.models.system import ActronAirACSystem, ActronAirSystemInfo import pytest from homeassistant.components.actron_air.const import DOMAIN @@ -14,7 +17,7 @@ from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture @pytest.fixture @@ -29,6 +32,27 @@ def mock_actron_api() -> Generator[AsyncMock]: "homeassistant.components.actron_air.config_flow.ActronAirAPI", new=mock_api, ), + patch.object(ActronAirACSystem, "set_system_mode", new_callable=AsyncMock), + patch.object( + ActronAirUserAirconSettings, "set_away_mode", new_callable=AsyncMock + ), + patch.object( + ActronAirUserAirconSettings, + "set_continuous_mode", + new_callable=AsyncMock, + ), + patch.object( + ActronAirUserAirconSettings, "set_quiet_mode", new_callable=AsyncMock + ), + patch.object( + ActronAirUserAirconSettings, "set_turbo_mode", new_callable=AsyncMock + ), + patch.object( + ActronAirUserAirconSettings, "set_temperature", new_callable=AsyncMock + ), + patch.object( + ActronAirUserAirconSettings, "set_fan_mode", new_callable=AsyncMock + ), ): api = mock_api.return_value @@ -65,47 +89,15 @@ async def slow_poll_for_token(device_code): return_value=[ActronAirSystemInfo(serial="123456")] ) - # Mock state manager + # Build status from fixture JSON + status = ActronAirStatus.model_validate( + json.loads(load_fixture("status.json", DOMAIN)) + ) + status.set_api(api) + + # Mock state manager to return our real pydantic status api.state_manager = MagicMock() - status = api.state_manager.get_status.return_value - status.master_info.live_temp_c = 22.0 - status.master_info.live_humidity_pc = 50.0 - status.ac_system.system_name = "Test System" - status.ac_system.serial_number = "123456" - status.ac_system.master_wc_model = "Test Model" - status.ac_system.master_wc_firmware_version = "1.0.0" - status.ac_system.set_system_mode = AsyncMock() - status.remote_zone_info = [] - status.zones = {} - status.min_temp = 16 - status.max_temp = 30 - status.aircon_system.mode = "OFF" - status.fan_mode = "LOW" - status.set_point = 24 - status.room_temp = 25 - status.is_on = False - - # Mock user_aircon_settings for the switch and climate platforms - settings = status.user_aircon_settings - settings.away_mode = False - settings.continuous_fan_enabled = False - settings.quiet_mode_enabled = False - settings.turbo_enabled = False - settings.turbo_supported = True - settings.is_on = False - settings.mode = "COOL" - settings.base_fan_mode = "LOW" - settings.temperature_setpoint_cool_c = 24.0 - - settings.set_away_mode = AsyncMock() - settings.set_continuous_mode = AsyncMock() - settings.set_quiet_mode = AsyncMock() - settings.set_turbo_mode = AsyncMock() - settings.set_temperature = AsyncMock() - settings.set_fan_mode = AsyncMock() - - # Mock ac_system methods for climate platform - status.ac_system.set_system_mode = AsyncMock() + api.state_manager.get_status.return_value = status yield api @@ -126,7 +118,7 @@ def mock_zone() -> MagicMock: """Return a mocked zone.""" zone = MagicMock() zone.exists = True - zone.zone_id = 1 + zone.zone_id = 0 zone.zone_name = "Test Zone" zone.title = "Living Room" zone.live_temp_c = 22.0 @@ -160,7 +152,6 @@ async def init_integration_with_zone( """Set up the Actron Air integration with zone for testing.""" status = mock_actron_api.state_manager.get_status.return_value status.remote_zone_info = [mock_zone] - status.zones = {1: mock_zone} with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/actron_air/fixtures/status.json b/tests/components/actron_air/fixtures/status.json new file mode 100644 index 00000000000000..fdb5c01dabca9a --- /dev/null +++ b/tests/components/actron_air/fixtures/status.json @@ -0,0 +1,41 @@ +{ + "isOnline": true, + "lastKnownState": { + "AirconSystem": { + "MasterWCModel": "Test Model", + "MasterSerial": "123456", + "MasterWCFirmwareVersion": "1.0.0", + "SystemName": "Test System" + }, + "NV_SystemSettings": { + "SystemName": "Test System" + }, + "UserAirconSettings": { + "isOn": false, + "Mode": "COOL", + "FanMode": "LOW", + "AwayMode": false, + "TemperatureSetpoint_Cool_oC": 24.0, + "TemperatureSetpoint_Heat_oC": 20.0, + "EnabledZones": [], + "QuietModeEnabled": false, + "TurboMode": { + "Enabled": false, + "Supported": true + } + }, + "MasterInfo": { + "LiveTemp_oC": 22.0, + "LiveHumidity_pc": 50.0, + "LiveOutdoorTemp_oC": 0.0 + }, + "NV_Limits": { + "UserSetpoint_oC": { + "setCool_Min": 16.0, + "setCool_Max": 30.0 + } + }, + "RemoteZoneInfo": [] + }, + "serial_number": "123456" +} diff --git a/tests/components/actron_air/snapshots/test_climate.ambr b/tests/components/actron_air/snapshots/test_climate.ambr index d2081870db3922..86f28beadb8f6e 100644 --- a/tests/components/actron_air/snapshots/test_climate.ambr +++ b/tests/components/actron_air/snapshots/test_climate.ambr @@ -42,7 +42,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '123456_zone_1', + 'unique_id': '123456_zone_0', 'unit_of_measurement': None, }) # --- @@ -92,8 +92,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 16, + 'max_temp': 30.0, + 'min_temp': 16.0, }), 'config_entry_id': , 'config_subentry_id': , @@ -145,8 +145,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 16, + 'max_temp': 30.0, + 'min_temp': 16.0, 'supported_features': , 'temperature': 24.0, }), diff --git a/tests/components/actron_air/snapshots/test_diagnostics.ambr b/tests/components/actron_air/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..83970f76149c35 --- /dev/null +++ b/tests/components/actron_air/snapshots/test_diagnostics.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'coordinators': dict({ + '0': dict({ + 'status': dict({ + 'ac_system': dict({ + 'master_serial': '**REDACTED**', + 'master_wc_firmware_version': '1.0.0', + 'master_wc_model': 'Test Model', + 'outdoor_unit': None, + 'system_name': 'Test System', + }), + 'alerts': None, + 'is_online': True, + 'live_aircon': None, + 'master_info': dict({ + 'live_humidity_pc': 50.0, + 'live_outdoor_temp_c': 0.0, + 'live_temp_c': 22.0, + }), + 'peripherals': list([ + ]), + 'remote_zone_info': list([ + ]), + 'serial_number': '**REDACTED**', + 'user_aircon_settings': dict({ + 'away_mode': False, + 'enabled_zones': list([ + ]), + 'fan_mode': 'LOW', + 'is_on': False, + 'mode': 'COOL', + 'quiet_mode_enabled': False, + 'temperature_setpoint_cool_c': 24.0, + 'temperature_setpoint_heat_c': 20.0, + 'turbo_mode_enabled': dict({ + 'Enabled': False, + 'Supported': True, + }), + }), + }), + 'system': dict({ + 'links': dict({ + }), + 'serial': '**REDACTED**', + 'type': None, + }), + }), + }), + 'entry_data': dict({ + 'api_token': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/actron_air/test_climate.py b/tests/components/actron_air/test_climate.py index 61262dcc8501d5..5afd3261f60263 100644 --- a/tests/components/actron_air/test_climate.py +++ b/tests/components/actron_air/test_climate.py @@ -36,7 +36,6 @@ async def test_climate_entities( """Test climate entities.""" status = mock_actron_api.state_manager.get_status.return_value status.remote_zone_info = [mock_zone] - status.zones = {1: mock_zone} with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) @@ -289,7 +288,6 @@ async def test_zone_hvac_mode_unmapped( status = mock_actron_api.state_manager.get_status.return_value status.remote_zone_info = [mock_zone] - status.zones = {1: mock_zone} with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) @@ -309,7 +307,6 @@ async def test_zone_hvac_mode_inactive( status = mock_actron_api.state_manager.get_status.return_value status.remote_zone_info = [mock_zone] - status.zones = {1: mock_zone} with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/actron_air/test_diagnostics.py b/tests/components/actron_air/test_diagnostics.py new file mode 100644 index 00000000000000..c38b463b44a5bd --- /dev/null +++ b/tests/components/actron_air/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for Actron Air diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_actron_api: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/actron_air/test_switch.py b/tests/components/actron_air/test_switch.py index ef4b4e2f2926ed..d2f723bfbb5711 100644 --- a/tests/components/actron_air/test_switch.py +++ b/tests/components/actron_air/test_switch.py @@ -83,7 +83,10 @@ async def test_turbo_mode_not_supported( ) -> None: """Test turbo mode switch is not created when not supported.""" status = mock_actron_api.state_manager.get_status.return_value - status.user_aircon_settings.turbo_supported = False + status.user_aircon_settings.turbo_mode_enabled = { + "Enabled": False, + "Supported": False, + } await setup_integration(hass, mock_config_entry) From 2e6137325c916a7113e0afdbe5aef775a42c7766 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:14:23 +0200 Subject: [PATCH 0604/1707] Use runtime_data in ridwell integration (#167658) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/ridwell/__init__.py | 19 +++++++++---------- homeassistant/components/ridwell/calendar.py | 10 ++++------ .../components/ridwell/config_flow.py | 5 +++-- .../components/ridwell/coordinator.py | 6 ++++-- .../components/ridwell/diagnostics.py | 10 +++------- homeassistant/components/ridwell/sensor.py | 9 ++++----- homeassistant/components/ridwell/switch.py | 8 +++----- 7 files changed, 30 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 84c389e05d61fd..2778cdcfda1877 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -9,17 +9,17 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, LOGGER, SENSOR_TYPE_NEXT_PICKUP -from .coordinator import RidwellDataUpdateCoordinator +from .const import LOGGER, SENSOR_TYPE_NEXT_PICKUP +from .coordinator import RidwellConfigEntry, RidwellDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RidwellConfigEntry) -> bool: """Set up Ridwell from a config entry.""" coordinator = RidwellDataUpdateCoordinator(hass, entry) await coordinator.async_initialize() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(options_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -27,17 +27,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def options_update_listener( + hass: HomeAssistant, entry: RidwellConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RidwellConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py index f1c5e6bc427e3b..d882e5a1e2ee13 100644 --- a/homeassistant/components/ridwell/calendar.py +++ b/homeassistant/components/ridwell/calendar.py @@ -7,7 +7,6 @@ from aioridwell.model import PickupCategory, RidwellAccount, RidwellPickupEvent from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -16,15 +15,14 @@ CALENDAR_TITLE_ROTATING, CALENDAR_TITLE_STATUS, CONF_CALENDAR_TITLE, - DOMAIN, ) -from .coordinator import RidwellDataUpdateCoordinator +from .coordinator import RidwellConfigEntry, RidwellDataUpdateCoordinator from .entity import RidwellEntity @callback def async_get_calendar_event_from_pickup_event( - pickup_event: RidwellPickupEvent, config_entry: ConfigEntry + pickup_event: RidwellPickupEvent, config_entry: RidwellConfigEntry ) -> CalendarEvent: """Get a HASS CalendarEvent from an aioridwell PickupEvent.""" pickup_items = [] @@ -66,11 +64,11 @@ def async_get_calendar_event_from_pickup_event( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RidwellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell calendars based on a config entry.""" - coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( RidwellCalendar(coordinator, account) diff --git a/homeassistant/components/ridwell/config_flow.py b/homeassistant/components/ridwell/config_flow.py index de7201c5f9a3fc..22f61a68cc40bc 100644 --- a/homeassistant/components/ridwell/config_flow.py +++ b/homeassistant/components/ridwell/config_flow.py @@ -9,7 +9,7 @@ from aioridwell.errors import InvalidCredentialsError, RidwellError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv, selector @@ -19,6 +19,7 @@ ) from .const import CALENDAR_TITLE_OPTIONS, CONF_CALENDAR_TITLE, DOMAIN, LOGGER +from .coordinator import RidwellConfigEntry STEP_REAUTH_CONFIRM_DATA_SCHEMA = vol.Schema( { @@ -107,7 +108,7 @@ async def _async_validate( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RidwellConfigEntry, ) -> SchemaOptionsFlowHandler: """Get options flow for this handler.""" try: diff --git a/homeassistant/components/ridwell/coordinator.py b/homeassistant/components/ridwell/coordinator.py index 336a71bc67f1e8..6472f631966668 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -19,6 +19,8 @@ from .const import LOGGER +type RidwellConfigEntry = ConfigEntry[RidwellDataUpdateCoordinator] + UPDATE_INTERVAL = timedelta(hours=1) @@ -27,9 +29,9 @@ class RidwellDataUpdateCoordinator( ): """Class to manage fetching data from single endpoint.""" - config_entry: ConfigEntry + config_entry: RidwellConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: RidwellConfigEntry) -> None: """Initialize.""" # These will be filled in by async_initialize; we give them these defaults to # avoid arduous typing checks down the line: diff --git a/homeassistant/components/ridwell/diagnostics.py b/homeassistant/components/ridwell/diagnostics.py index 0eff7583311a27..785be65ce18af9 100644 --- a/homeassistant/components/ridwell/diagnostics.py +++ b/homeassistant/components/ridwell/diagnostics.py @@ -6,12 +6,10 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RidwellDataUpdateCoordinator +from .coordinator import RidwellConfigEntry CONF_TITLE = "title" @@ -25,17 +23,15 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RidwellConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data( { "entry": entry.as_dict(), "data": [ dataclasses.asdict(event) - for events in coordinator.data.values() + for events in entry.runtime_data.data.values() for event in events ], }, diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 30f97ecaea8deb..e9cea7b7676e17 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -13,12 +13,11 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, SENSOR_TYPE_NEXT_PICKUP -from .coordinator import RidwellDataUpdateCoordinator +from .const import SENSOR_TYPE_NEXT_PICKUP +from .coordinator import RidwellConfigEntry, RidwellDataUpdateCoordinator from .entity import RidwellEntity ATTR_CATEGORY = "category" @@ -35,11 +34,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RidwellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell sensors based on a config entry.""" - coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( RidwellSensor(coordinator, account, SENSOR_DESCRIPTION) diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index e3be9ea5368ea2..fdf1bf0b1f262c 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -8,13 +8,11 @@ from aioridwell.model import EventState, RidwellAccount from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RidwellDataUpdateCoordinator +from .coordinator import RidwellConfigEntry, RidwellDataUpdateCoordinator from .entity import RidwellEntity SWITCH_DESCRIPTION = SwitchEntityDescription( @@ -25,11 +23,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RidwellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell sensors based on a config entry.""" - coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( RidwellSwitch(coordinator, account, SWITCH_DESCRIPTION) From 1eead15c240b35e91409896e3a93cd07ef7f25d0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:15:46 +0200 Subject: [PATCH 0605/1707] Use runtime_data in Rabbit Air (#167649) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/rabbitair/__init__.py | 19 ++++++------------- .../components/rabbitair/coordinator.py | 6 ++++-- homeassistant/components/rabbitair/entity.py | 5 ++--- homeassistant/components/rabbitair/fan.py | 11 ++++------- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/rabbitair/__init__.py b/homeassistant/components/rabbitair/__init__.py index d6530b322b0dd8..fbae3b5c3cd638 100644 --- a/homeassistant/components/rabbitair/__init__.py +++ b/homeassistant/components/rabbitair/__init__.py @@ -5,21 +5,17 @@ from rabbitair import Client, UdpClient from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RabbitAirDataUpdateCoordinator +from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.FAN] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RabbitAirConfigEntry) -> bool: """Set up Rabbit Air from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - host: str = entry.data[CONF_HOST] token: str = entry.data[CONF_ACCESS_TOKEN] @@ -30,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -39,14 +35,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RabbitAirConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: RabbitAirConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/rabbitair/coordinator.py b/homeassistant/components/rabbitair/coordinator.py index 75453fe4d2445e..ccc9566a6229dc 100644 --- a/homeassistant/components/rabbitair/coordinator.py +++ b/homeassistant/components/rabbitair/coordinator.py @@ -12,6 +12,8 @@ from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +type RabbitAirConfigEntry = ConfigEntry[RabbitAirDataUpdateCoordinator] + _LOGGER = logging.getLogger(__name__) @@ -43,10 +45,10 @@ def has_pending_call(self) -> bool: class RabbitAirDataUpdateCoordinator(DataUpdateCoordinator[State]): """Class to manage fetching data from single endpoint.""" - config_entry: ConfigEntry + config_entry: RabbitAirConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: Client + self, hass: HomeAssistant, config_entry: RabbitAirConfigEntry, device: Client ) -> None: """Initialize global data updater.""" self.device = device diff --git a/homeassistant/components/rabbitair/entity.py b/homeassistant/components/rabbitair/entity.py index 47a1b7db3eb41c..dc5e69ed7a5268 100644 --- a/homeassistant/components/rabbitair/entity.py +++ b/homeassistant/components/rabbitair/entity.py @@ -7,13 +7,12 @@ from rabbitair import Model -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import RabbitAirDataUpdateCoordinator +from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -31,7 +30,7 @@ class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]): def __init__( self, coordinator: RabbitAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: RabbitAirConfigEntry, ) -> None: """Initialize the entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py index 4c13f3a8b02f2b..8494253605439b 100644 --- a/homeassistant/components/rabbitair/fan.py +++ b/homeassistant/components/rabbitair/fan.py @@ -7,7 +7,6 @@ from rabbitair import Mode, Model, Speed from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -15,8 +14,7 @@ percentage_to_ordered_list_item, ) -from .const import DOMAIN -from .coordinator import RabbitAirDataUpdateCoordinator +from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator from .entity import RabbitAirBaseEntity SPEED_LIST = [ @@ -40,12 +38,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RabbitAirConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator: RabbitAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([RabbitAirFanEntity(coordinator, entry)]) + async_add_entities([RabbitAirFanEntity(entry.runtime_data, entry)]) class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): @@ -61,7 +58,7 @@ class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): def __init__( self, coordinator: RabbitAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: RabbitAirConfigEntry, ) -> None: """Initialize the entity.""" super().__init__(coordinator, entry) From d6342d51cc6070ef5c6c343cba2d56d3df505da0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:16:21 +0200 Subject: [PATCH 0606/1707] Use runtime_data in radiotherm (#167650) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/radiotherm/__init__.py | 19 ++++++++----------- .../components/radiotherm/climate.py | 9 +++------ .../components/radiotherm/coordinator.py | 6 ++++-- homeassistant/components/radiotherm/switch.py | 9 +++------ 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index 1c5f7f571a659b..54ddda99bc6f14 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -8,13 +8,11 @@ from radiotherm.validate import RadiothermTstatError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import RadioThermUpdateCoordinator +from .coordinator import RadioThermConfigEntry, RadioThermUpdateCoordinator from .data import async_get_init_data from .util import async_set_time @@ -38,7 +36,7 @@ async def _async_call_or_raise_not_ready[_T]( raise ConfigEntryNotReady(msg) from ex -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RadioThermConfigEntry) -> bool: """Set up Radio Thermostat from a config entry.""" host = entry.data[CONF_HOST] init_coro = async_get_init_data(hass, host) @@ -54,21 +52,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: time_coro = async_set_time(hass, init_data.tstat) await _async_call_or_raise_not_ready(time_coro, host) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: RadioThermConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RadioThermConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index 8ede90f2718685..920523c2f43753 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -17,13 +17,11 @@ HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN -from .coordinator import RadioThermUpdateCoordinator +from .coordinator import RadioThermConfigEntry, RadioThermUpdateCoordinator from .entity import RadioThermostatEntity ATTR_FAN_ACTION = "fan_action" @@ -101,12 +99,11 @@ def round_temp(temperature): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RadioThermConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate for a radiotherm device.""" - coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([RadioThermostat(coordinator)]) + async_add_entities([RadioThermostat(entry.runtime_data)]) class RadioThermostat(RadioThermostatEntity, ClimateEntity): diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py index 7d483426c83e2d..2ed913bfb035af 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -14,6 +14,8 @@ from .data import RadioThermInitData, RadioThermUpdate, async_get_data +type RadioThermConfigEntry = ConfigEntry[RadioThermUpdateCoordinator] + _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = timedelta(seconds=15) @@ -22,12 +24,12 @@ class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): """DataUpdateCoordinator to gather data for radio thermostats.""" - config_entry: ConfigEntry + config_entry: RadioThermConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RadioThermConfigEntry, init_data: RadioThermInitData, ) -> None: """Initialize DataUpdateCoordinator.""" diff --git a/homeassistant/components/radiotherm/switch.py b/homeassistant/components/radiotherm/switch.py index 2952e1e58176f7..eaced4b4386136 100644 --- a/homeassistant/components/radiotherm/switch.py +++ b/homeassistant/components/radiotherm/switch.py @@ -5,12 +5,10 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import RadioThermUpdateCoordinator +from .coordinator import RadioThermConfigEntry, RadioThermUpdateCoordinator from .entity import RadioThermostatEntity PARALLEL_UPDATES = 1 @@ -18,12 +16,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RadioThermConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for a radiotherm device.""" - coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([RadioThermHoldSwitch(coordinator)]) + async_add_entities([RadioThermHoldSwitch(entry.runtime_data)]) class RadioThermHoldSwitch(RadioThermostatEntity, SwitchEntity): From f82b8cb7c73139146e2b7ab82602185a11d77493 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 8 Apr 2026 04:17:45 -0700 Subject: [PATCH 0607/1707] Bump pylutron-caseta to 0.28.0 (#167642) --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index f163307a782a97..d5318742516111 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -10,7 +10,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.27.0"], + "requirements": ["pylutron-caseta==0.28.0"], "zeroconf": [ { "properties": { diff --git a/requirements_all.txt b/requirements_all.txt index 983c75143ae615..2405f2acad1429 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2263,7 +2263,7 @@ pylitejet==0.6.3 pylitterbot==2025.2.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.27.0 +pylutron-caseta==0.28.0 # homeassistant.components.lutron pylutron==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bccb5316c439ef..c9d02509f165fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1940,7 +1940,7 @@ pylitejet==0.6.3 pylitterbot==2025.2.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.27.0 +pylutron-caseta==0.28.0 # homeassistant.components.lutron pylutron==0.4.1 From b98aa0ad91a71d57a36d30f0a38825e3e88a7de8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:18:26 +0200 Subject: [PATCH 0608/1707] Use runtime_data in rdw integration (#167654) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/rdw/__init__.py | 15 +++++---------- homeassistant/components/rdw/binary_sensor.py | 7 +++---- homeassistant/components/rdw/coordinator.py | 6 ++++-- homeassistant/components/rdw/diagnostics.py | 9 +++------ homeassistant/components/rdw/sensor.py | 7 +++---- tests/components/rdw/test_init.py | 2 -- 6 files changed, 18 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/rdw/__init__.py b/homeassistant/components/rdw/__init__.py index 7a2cfbf6df3962..f668f1abceefb3 100644 --- a/homeassistant/components/rdw/__init__.py +++ b/homeassistant/components/rdw/__init__.py @@ -2,30 +2,25 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RDWDataUpdateCoordinator +from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: RDWConfigEntry) -> bool: """Set up RDW from a config entry.""" coordinator = RDWDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RDWConfigEntry) -> bool: """Unload RDW config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index d407cfc1b87ee8..855bd2eec4162c 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -12,14 +12,13 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import RDWDataUpdateCoordinator +from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -46,11 +45,11 @@ class RDWBinarySensorEntityDescription(BinarySensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RDWConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW binary sensors based on a config entry.""" - coordinator: RDWDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( RDWBinarySensorEntity( coordinator=coordinator, diff --git a/homeassistant/components/rdw/coordinator.py b/homeassistant/components/rdw/coordinator.py index 2b9bb866790c71..aecd3116d426cf 100644 --- a/homeassistant/components/rdw/coordinator.py +++ b/homeassistant/components/rdw/coordinator.py @@ -11,13 +11,15 @@ from .const import CONF_LICENSE_PLATE, DOMAIN, LOGGER, SCAN_INTERVAL +type RDWConfigEntry = ConfigEntry[RDWDataUpdateCoordinator] + class RDWDataUpdateCoordinator(DataUpdateCoordinator[Vehicle]): """Class to manage fetching RDW data.""" - config_entry: ConfigEntry + config_entry: RDWConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: RDWConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/rdw/diagnostics.py b/homeassistant/components/rdw/diagnostics.py index bf5f8fbd904467..0f79a5f19649d4 100644 --- a/homeassistant/components/rdw/diagnostics.py +++ b/homeassistant/components/rdw/diagnostics.py @@ -4,17 +4,14 @@ from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import RDWDataUpdateCoordinator +from .coordinator import RDWConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RDWConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: RDWDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - data: dict[str, Any] = coordinator.data.to_dict() + data: dict[str, Any] = entry.runtime_data.data.to_dict() return data diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index 08e7d772d15f39..9dd393bd21393f 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -13,14 +13,13 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_LICENSE_PLATE, DOMAIN -from .coordinator import RDWDataUpdateCoordinator +from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -48,11 +47,11 @@ class RDWSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RDWConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW sensors based on a config entry.""" - coordinator: RDWDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( RDWSensorEntity( coordinator=coordinator, diff --git a/tests/components/rdw/test_init.py b/tests/components/rdw/test_init.py index c9c25a15de67eb..121de64f91e362 100644 --- a/tests/components/rdw/test_init.py +++ b/tests/components/rdw/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock, MagicMock, patch -from homeassistant.components.rdw.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,7 +23,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From 726edf3a3bfb176b56b02959e9809695c03c19cb Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:21:54 +0200 Subject: [PATCH 0609/1707] Unifi access protect api key hint (#167404) Co-authored-by: RaHehl --- .../components/unifi_access/config_flow.py | 6 +- .../components/unifi_access/strings.json | 1 + tests/components/unifi_access/conftest.py | 1 + .../unifi_access/test_config_flow.py | 174 ++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi_access/config_flow.py b/homeassistant/components/unifi_access/config_flow.py index 81f99f4473ecab..e88510f1bbdcfb 100644 --- a/homeassistant/components/unifi_access/config_flow.py +++ b/homeassistant/components/unifi_access/config_flow.py @@ -44,7 +44,11 @@ async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: try: await client.authenticate() except ApiAuthError: - errors["base"] = "invalid_auth" + try: + is_protect = await client.is_protect_api_key() + except Exception: # noqa: BLE001 + is_protect = False + errors["base"] = "protect_api_key" if is_protect else "invalid_auth" except ApiConnectionError: errors["base"] = "cannot_connect" except Exception: diff --git a/homeassistant/components/unifi_access/strings.json b/homeassistant/components/unifi_access/strings.json index 44cf6dd921b7e6..592a2fe58464ed 100644 --- a/homeassistant/components/unifi_access/strings.json +++ b/homeassistant/components/unifi_access/strings.json @@ -8,6 +8,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "protect_api_key": "This API key is associated with UniFi Protect, not UniFi Access. Please generate a new API key from the UniFi Access application settings.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { diff --git a/tests/components/unifi_access/conftest.py b/tests/components/unifi_access/conftest.py index c28e628ab4d158..40917a279ae423 100644 --- a/tests/components/unifi_access/conftest.py +++ b/tests/components/unifi_access/conftest.py @@ -105,6 +105,7 @@ def mock_client() -> Generator[MagicMock]: ): client = client_mock.return_value client.authenticate = AsyncMock() + client.is_protect_api_key = AsyncMock(return_value=False) client.get_doors = AsyncMock(return_value=MOCK_DOORS) client.get_emergency_status = AsyncMock( return_value=EmergencyStatus(evacuation=False, lockdown=False) diff --git a/tests/components/unifi_access/test_config_flow.py b/tests/components/unifi_access/test_config_flow.py index d42e70d6a45340..abfe671b082160 100644 --- a/tests/components/unifi_access/test_config_flow.py +++ b/tests/components/unifi_access/test_config_flow.py @@ -398,3 +398,177 @@ async def test_user_flow_ssl_context( _, call_kwargs = patched_client.call_args assert isinstance(call_kwargs["ssl_context"], expected_ssl_context_type) + + +async def test_user_flow_protect_api_key( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, +) -> None: + """Test user config flow shows specific error when a Protect API key is used.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_client.authenticate.side_effect = ApiAuthError() + mock_client.is_protect_api_key.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "protect_api_key"} + + # Test recovery + mock_client.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: "correct-access-api-key", + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_protect_api_key_unreachable( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, +) -> None: + """Test user config flow falls back to invalid_auth when Protect is unreachable.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_client.authenticate.side_effect = ApiAuthError() + mock_client.is_protect_api_key.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_protect_api_key_check_raises( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, +) -> None: + """Test user config flow falls back to invalid_auth when protect check raises.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_client.authenticate.side_effect = ApiAuthError() + mock_client.is_protect_api_key.side_effect = Exception("unexpected") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_flow_protect_api_key( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow shows specific error when a Protect API key is used.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_client.authenticate.side_effect = ApiAuthError() + mock_client.is_protect_api_key.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "protect-api-key"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "protect_api_key"} + + # Test recovery + mock_client.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_TOKEN: "correct-access-api-key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow_protect_api_key( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow shows specific error when a Protect API key is used.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_client.authenticate.side_effect = ApiAuthError() + mock_client.is_protect_api_key.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "protect-api-key", + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "protect_api_key"} + + # Test recovery + mock_client.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: "correct-access-api-key", + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" From e4aeee9d85d3c093af609921be0eb71caa111470 Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 8 Apr 2026 13:22:25 +0200 Subject: [PATCH 0610/1707] Fix ProxmoxVE migration causing reauthentication (#167624) --- .../components/proxmoxve/__init__.py | 8 ++++++ tests/components/proxmoxve/test_init.py | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 6512b1761cd928..3e680f212a2de5 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -189,6 +189,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> # Migration for additional configuration options added to support API tokens if entry.version < 3: data = dict(entry.data) + # If CONF_REALM wasn't there yet, extract from username + if CONF_REALM not in data: + data[CONF_REALM] = DEFAULT_REALM + if "@" in data.get(CONF_USERNAME, ""): + username, realm = data[CONF_USERNAME].split("@", 1) + data[CONF_USERNAME] = username + data[CONF_REALM] = realm.lower() + realm = data[CONF_REALM].lower() # If the realm is one of the base providers, set the provider to match the realm. diff --git a/tests/components/proxmoxve/test_init.py b/tests/components/proxmoxve/test_init.py index 205e54f1e3345a..a3f7b21181aea3 100644 --- a/tests/components/proxmoxve/test_init.py +++ b/tests/components/proxmoxve/test_init.py @@ -281,6 +281,33 @@ async def test_migration_v2_to_v3( assert entry.data[CONF_REALM] == AUTH_PAM +async def test_migration_v2_to_v3_without_realm( + hass: HomeAssistant, +) -> None: + """Test migration from version 2 to 3.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id="1", + data={ + CONF_HOST: "http://test_host", + CONF_PORT: 8006, + CONF_USERNAME: "test_user@pam", + CONF_PASSWORD: "test_password", + CONF_VERIFY_SSL: True, + }, + ) + entry.add_to_hass(hass) + assert entry.version == 2 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 3 + assert entry.data[CONF_AUTH_METHOD] == AUTH_PAM + assert entry.data[CONF_REALM] == AUTH_PAM + + async def test_new_vm_creates_entity( hass: HomeAssistant, mock_proxmox_client: MagicMock, From a48a770ca492736857e7551a6a8f30635dc29caf Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 8 Apr 2026 21:35:48 +1000 Subject: [PATCH 0611/1707] Add Infrared platform to SMLIGHT (#167568) --- homeassistant/components/smlight/__init__.py | 1 + homeassistant/components/smlight/infrared.py | 62 +++++++ homeassistant/components/smlight/strings.json | 5 + tests/components/smlight/test_infrared.py | 170 ++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 homeassistant/components/smlight/infrared.py create mode 100644 tests/components/smlight/test_infrared.py diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index a6d7bbd14ea305..8c949bf6aeecaf 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -18,6 +18,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.INFRARED, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py new file mode 100644 index 00000000000000..662a403296b81b --- /dev/null +++ b/homeassistant/components/smlight/infrared.py @@ -0,0 +1,62 @@ +"""Infrared platform for SLZB-Ultima.""" + +from __future__ import annotations + +from pysmlight.exceptions import SmlightError +from pysmlight.models import IRPayload + +from homeassistant.components.infrared import InfraredCommand, InfraredEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator +from .entity import SmEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize infrared for SLZB-Ultima device.""" + coordinator = entry.runtime_data.data + + if coordinator.data.info.has_peripherals: + async_add_entities([SmInfraredEntity(coordinator)]) + + +class SmInfraredEntity(SmEntity, InfraredEntity): + """Representation of a SLZB-Ultima infrared.""" + + _attr_translation_key = "infrared_emitter" + + def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: + """Initialize the SLZB-Ultima infrared.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.unique_id}-infrared-emitter" + + async def async_send_command(self, command: InfraredCommand) -> None: + """Send an IR command.""" + timings = [ + interval + for timing in command.get_raw_timings() + for interval in (timing.high_us, timing.low_us) + ] + + freq = command.modulation + + try: + await self.coordinator.async_execute_command( + self.coordinator.client.actions.send_ir_code, + IRPayload.from_raw_timings(timings, freq=freq), + ) + except (SmlightError, ValueError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_ir_code_failed", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 6fbac239207976..7ac6ae74da1b7b 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -79,6 +79,11 @@ "name": "Zigbee restart" } }, + "infrared": { + "infrared_emitter": { + "name": "IR Emitter" + } + }, "light": { "ambilight": { "name": "Ambilight" diff --git a/tests/components/smlight/test_infrared.py b/tests/components/smlight/test_infrared.py new file mode 100644 index 00000000000000..14677d44cc1acc --- /dev/null +++ b/tests/components/smlight/test_infrared.py @@ -0,0 +1,170 @@ +"""Tests for SLZB-Ultima infrared entity.""" + +from unittest.mock import MagicMock + +from infrared_protocols import Command, Timing +from pysmlight import Info +from pysmlight.exceptions import SmlightError +from pysmlight.models import IRPayload +import pytest + +from homeassistant.components.infrared import async_send_command +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import setup_integration + +from tests.common import MockConfigEntry + + +class MockCommand(Command): + """Mock InfraredCommand.""" + + def __init__(self) -> None: + """Initialize with fixed 38kHz modulation.""" + super().__init__(modulation=38000) + + def get_raw_timings(self) -> list[Timing]: + """Return some fake timings.""" + return [Timing(high_us=9000, low_us=4500), Timing(high_us=560, low_us=1690)] + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms, which should be loaded during the test.""" + return [Platform.INFRARED] + + +MOCK_ULTIMA = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-Ultima3", +) + + +async def test_infrared_setup_ultima( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test infrared entity is created for Ultima devices.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("infrared.mock_title_ir_emitter") + assert state is not None + + +async def test_infrared_not_created_non_ultima( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test infrared entity is not created for non-Ultima devices.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-MR1", + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("infrared.mock_title_ir_emitter") + assert state is None + + +async def test_infrared_send_command( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test sending IR command.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "infrared.mock_title_ir_emitter" + state = hass.states.get(entity_id) + assert state is not None + + await async_send_command( + hass, + entity_id, + MockCommand(), + ) + + mock_smlight_client.actions.send_ir_code.assert_called_once_with( + IRPayload.from_raw_timings([9000, 4500, 560, 1690], freq=38000) + ) + + +async def test_infrared_send_command_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test connection error handling.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "infrared.mock_title_ir_emitter" + state = hass.states.get(entity_id) + assert state is not None + + mock_smlight_client.actions.send_ir_code.side_effect = SmlightError("Failed") + + with pytest.raises(HomeAssistantError) as exc_info: + await async_send_command( + hass, + entity_id, + MockCommand(), + ) + assert exc_info.value.translation_key == "send_ir_code_failed" + + +async def test_infrared_send_empty_command_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test ValueError from pysmlight is surfaced as HomeAssistantError.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "infrared.mock_title_ir_emitter" + state = hass.states.get(entity_id) + assert state is not None + + mock_smlight_client.actions.send_ir_code.side_effect = ValueError("empty payload") + + with pytest.raises(HomeAssistantError) as exc_info: + await async_send_command( + hass, + entity_id, + MockCommand(), + ) + assert exc_info.value.translation_key == "send_ir_code_failed" + + +@pytest.mark.freeze_time("2025-09-03T22:00:00+00:00") +async def test_infrared_state_updated_after_send( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test that entity state is updated with a timestamp after a successful send.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "infrared.mock_title_ir_emitter" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + await async_send_command(hass, entity_id, MockCommand()) + + state = hass.states.get(entity_id) + assert state.state == "2025-09-03T22:00:00.000+00:00" From 1a4d518ef2bdacca1ea3ef9c2a9a3e1e0ef0671c Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:51:15 -0400 Subject: [PATCH 0612/1707] Update template fan tests to use new framework (#167625) --- tests/components/template/conftest.py | 7 +- tests/components/template/test_fan.py | 1217 +++++++------------------ 2 files changed, 348 insertions(+), 876 deletions(-) diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 50040940cda9e3..3ed5b666a55d19 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -67,14 +67,15 @@ def assert_action( calls: list[ServiceCall], expected_calls: int, expected_action: str, + index: int = -1, **kwargs, ) -> None: """Validate the action was properly called.""" assert len(calls) == expected_calls - assert calls[-1].data["action"] == expected_action - assert calls[-1].data["caller"] == platform_setup.entity_id + assert calls[index].data["action"] == expected_action + assert calls[index].data["caller"] == platform_setup.entity_id for key, value in kwargs.items(): - assert calls[-1].data[key] == value + assert calls[index].data[key] == value async def async_trigger( diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index b810fac47156a1..7cc9fadee95956 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -20,86 +20,57 @@ from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - -from .conftest import ConfigurationStyle, async_get_flow_preview_state - -from tests.common import MockConfigEntry, assert_setup_component +from homeassistant.helpers.typing import ConfigType + +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + assert_action, + async_get_flow_preview_state, + async_trigger, + make_test_action, + make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, +) + +from tests.common import MockConfigEntry from tests.components.fan import common from tests.typing import WebSocketGenerator -TEST_OBJECT_ID = "test_fan" -TEST_ENTITY_ID = f"fan.{TEST_OBJECT_ID}" - -# Represent for fan's state -_STATE_INPUT_BOOLEAN = "input_boolean.state" -# Represent for fan's percent -_STATE_TEST_SENSOR = "sensor.test_sensor" -# Represent for fan's availability -_STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" - -TEST_STATE_TRIGGER = { - "trigger": { - "trigger": "state", - "entity_id": [ - TEST_ENTITY_ID, - _STATE_INPUT_BOOLEAN, - _STATE_AVAILABILITY_BOOLEAN, - _STATE_TEST_SENSOR, - ], - }, - "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, - "action": [ - {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} - ], -} +TEST_INPUT_BOOLEAN = "input_boolean.state" +TEST_STATE_ENTITY_ID = "sensor.test_sensor" +TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" + +TEST_FAN = TemplatePlatformSetup( + fan.DOMAIN, + "fans", + "test_fan", + make_test_trigger( + TEST_INPUT_BOOLEAN, TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY + ), +) + +ON_ACTION = make_test_action("turn_on") +OFF_ACTION = make_test_action("turn_off") OPTIMISTIC_ON_OFF_ACTIONS = { - "turn_on": { - "service": "test.automation", - "data": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, - }, - "turn_off": { - "service": "test.automation", - "data": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, - }, -} -NAMED_ON_OFF_ACTIONS = { - **OPTIMISTIC_ON_OFF_ACTIONS, - "name": TEST_OBJECT_ID, + **ON_ACTION, + **OFF_ACTION, } -PERCENTAGE_ACTION = { - "set_percentage": { - "action": "test.automation", - "data": { - "action": "set_percentage", - "percentage": "{{ percentage }}", - "caller": "{{ this.entity_id }}", - }, - }, -} +PERCENTAGE_ACTION = make_test_action( + "set_percentage", {"percentage": "{{ percentage }}"} +) OPTIMISTIC_PERCENTAGE_CONFIG = { **OPTIMISTIC_ON_OFF_ACTIONS, **PERCENTAGE_ACTION, } -PRESET_MODE_ACTION = { - "set_preset_mode": { - "action": "test.automation", - "data": { - "action": "set_preset_mode", - "preset_mode": "{{ preset_mode }}", - "caller": "{{ this.entity_id }}", - }, - }, -} +PRESET_MODE_ACTION = make_test_action( + "set_preset_mode", {"preset_mode": "{{ preset_mode }}"} +) OPTIMISTIC_PRESET_MODE_CONFIG = { **OPTIMISTIC_ON_OFF_ACTIONS, **PRESET_MODE_ACTION, @@ -109,39 +80,19 @@ "preset_modes": ["auto", "low", "medium", "high"], } -OSCILLATE_ACTION = { - "set_oscillating": { - "action": "test.automation", - "data": { - "action": "set_oscillating", - "oscillating": "{{ oscillating }}", - "caller": "{{ this.entity_id }}", - }, - }, -} +OSCILLATE_ACTION = make_test_action( + "set_oscillating", {"oscillating": "{{ oscillating }}"} +) OPTIMISTIC_OSCILLATE_CONFIG = { **OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION, } -DIRECTION_ACTION = { - "set_direction": { - "action": "test.automation", - "data": { - "action": "set_direction", - "direction": "{{ direction }}", - "caller": "{{ this.entity_id }}", - }, - }, -} +DIRECTION_ACTION = make_test_action("set_direction", {"direction": "{{ direction }}"}) OPTIMISTIC_DIRECTION_CONFIG = { **OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION, } -UNIQUE_ID_CONFIG = { - **OPTIMISTIC_ON_OFF_ACTIONS, - "unique_id": "not-so-unique-anymore", -} def _verify( @@ -153,7 +104,7 @@ def _verify( expected_preset_mode: str | None = None, ) -> None: """Verify fan's state, speed and osc.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) attributes = state.attributes assert state.state == str(expected_state) assert attributes.get(ATTR_PERCENTAGE) == expected_percentage @@ -162,94 +113,16 @@ def _verify( assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode -async def async_setup_legacy_format( - hass: HomeAssistant, count: int, fan_config: dict[str, Any] -) -> None: - """Do setup of fan integration via legacy format.""" - config = {"fan": {"platform": "template", "fans": fan_config}} - - with assert_setup_component(count, fan.DOMAIN): - assert await async_setup_component( - hass, - fan.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_modern_format( - hass: HomeAssistant, count: int, fan_config: dict[str, Any] -) -> None: - """Do setup of fan integration via modern format.""" - config = {"template": {"fan": fan_config}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_trigger_format( - hass: HomeAssistant, count: int, fan_config: dict[str, Any] -) -> None: - """Do setup of fan integration via trigger format.""" - config = {"template": {"fan": fan_config, **TEST_STATE_TRIGGER}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - @pytest.fixture async def setup_fan( hass: HomeAssistant, count: int, style: ConfigurationStyle, - fan_config: dict[str, Any], + config: ConfigType, + extra_config: ConfigType, ) -> None: """Do setup of fan integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, fan_config) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format(hass, count, fan_config) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format(hass, count, fan_config) - - -@pytest.fixture -async def setup_named_fan( - hass: HomeAssistant, - count: int, - style: ConfigurationStyle, - fan_config: dict[str, Any], -) -> None: - """Do setup of fan integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, {TEST_OBJECT_ID: fan_config}) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, count, {"name": TEST_OBJECT_ID, **fan_config} - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, count, {"name": TEST_OBJECT_ID, **fan_config} - ) + await setup_entity(hass, TEST_FAN, style, count, config, extra_config=extra_config) @pytest.fixture @@ -258,60 +131,10 @@ async def setup_state_fan( count: int, style: ConfigurationStyle, state_template: str, + extra_config: ConfigType, ): """Do setup of fan integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **OPTIMISTIC_ON_OFF_ACTIONS, - "value_template": state_template, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **NAMED_ON_OFF_ACTIONS, - "state": state_template, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **NAMED_ON_OFF_ACTIONS, - "state": state_template, - }, - ) - - -@pytest.fixture -async def setup_test_fan_with_extra_config( - hass: HomeAssistant, - count: int, - style: ConfigurationStyle, - fan_config: dict[str, Any], - extra_config: dict[str, Any], -) -> None: - """Do setup of fan integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, count, {TEST_OBJECT_ID: {**fan_config, **extra_config}} - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, count, {"name": TEST_OBJECT_ID, **fan_config, **extra_config} - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, count, {"name": TEST_OBJECT_ID, **fan_config, **extra_config} - ) + await setup_entity(hass, TEST_FAN, style, count, extra_config, state_template) @pytest.fixture @@ -322,37 +145,9 @@ async def setup_optimistic_fan_attribute( extra_config: dict, ) -> None: """Do setup of a non-optimistic fan with an optimistic attribute.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **extra_config, - "value_template": "{{ 1 == 1 }}", - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **extra_config, - "state": "{{ 1 == 1 }}", - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **extra_config, - "state": "{{ 1 == 1 }}", - }, - ) + await setup_entity( + hass, TEST_FAN, style, count, {}, "{{ 1==1 }}", extra_config=extra_config + ) @pytest.fixture @@ -366,45 +161,21 @@ async def setup_single_attribute_state_fan( extra_config: dict, ) -> None: """Do setup of fan integration testing a single attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **OPTIMISTIC_ON_OFF_ACTIONS, - "value_template": state_template, - **extra, - **extra_config, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **NAMED_ON_OFF_ACTIONS, - "state": state_template, - **extra, - **extra_config, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **NAMED_ON_OFF_ACTIONS, - "state": state_template, - **extra, - **extra_config, - }, - ) + await setup_entity( + hass, + TEST_FAN, + style, + count, + {attribute: attribute_template} if attribute and attribute_template else {}, + state_template, + {**OPTIMISTIC_ON_OFF_ACTIONS, **extra_config}, + ) -@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 'on' }}")]) +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", OPTIMISTIC_ON_OFF_ACTIONS)], +) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], @@ -412,6 +183,7 @@ async def setup_single_attribute_state_fan( @pytest.mark.usefixtures("setup_state_fan") async def test_missing_optional_config(hass: HomeAssistant) -> None: """Test: missing optional template is ok.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") _verify(hass, STATE_ON, None, None, None, None) @@ -421,26 +193,18 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None: [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( - "fan_config", - [ - { - "value_template": "{{ 'on' }}", - "turn_off": {"service": "script.fan_off"}, - }, - { - "value_template": "{{ 'on' }}", - "turn_on": {"service": "script.fan_on"}, - }, - ], + "extra_config", + [OFF_ACTION, ON_ACTION], ) -@pytest.mark.usefixtures("setup_fan") +@pytest.mark.usefixtures("setup_optimistic_fan_attribute") async def test_wrong_template_config(hass: HomeAssistant) -> None: """Test: missing 'turn_on' or 'turn_off' will fail.""" assert hass.states.async_all("fan") == [] @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ is_state('input_boolean.state', 'on') }}")] + ("count", "state_template", "extra_config"), + [(1, "{{ is_state('input_boolean.state', 'on') }}", OPTIMISTIC_ON_OFF_ACTIONS)], ) @pytest.mark.parametrize( "style", @@ -449,20 +213,17 @@ async def test_wrong_template_config(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_state_fan") async def test_state_template(hass: HomeAssistant) -> None: """Test state template.""" + await async_trigger(hass, TEST_INPUT_BOOLEAN, STATE_OFF) _verify(hass, STATE_OFF, None, None, None, None) - hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) - await hass.async_block_till_done() - + await async_trigger(hass, TEST_INPUT_BOOLEAN, STATE_ON) _verify(hass, STATE_ON, None, None, None, None) - hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_OFF) - await hass.async_block_till_done() - + await async_trigger(hass, TEST_INPUT_BOOLEAN, STATE_OFF) _verify(hass, STATE_OFF, None, None, None, None) -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_ON_OFF_ACTIONS)]) @pytest.mark.parametrize( ("state_template", "expected"), [ @@ -489,6 +250,7 @@ async def test_state_template(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_state_fan") async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: """Test state template.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") _verify(hass, expected, None, None, None, None) @@ -511,13 +273,14 @@ async def test_state_template_states(hass: HomeAssistant, expected: str) -> None @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_picture_template(hass: HomeAssistant) -> None: """Test picture template.""" - state = hass.states.get(TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_FAN.entity_id) assert state.attributes.get("entity_picture") == "" - hass.states.async_set(_STATE_TEST_SENSOR, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) assert state.attributes["entity_picture"] == "/local/switch.png" @@ -540,13 +303,14 @@ async def test_picture_template(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_icon_template(hass: HomeAssistant) -> None: """Test icon template.""" - state = hass.states.get(TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_FAN.entity_id) assert state.attributes.get("icon") == "" - hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_INPUT_BOOLEAN, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) assert state.attributes["icon"] == "mdi:eye" @@ -581,11 +345,10 @@ async def test_icon_template(hass: HomeAssistant) -> None: ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_percentage_template( - hass: HomeAssistant, percent: str, expected: int, calls: list[ServiceCall] + hass: HomeAssistant, percent: str, expected: int ) -> None: """Test templates with fan percentages from other entities.""" - hass.states.async_set(_STATE_TEST_SENSOR, percent) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, percent) _verify(hass, STATE_ON, expected, None, None, None) @@ -622,8 +385,7 @@ async def test_preset_mode_template( hass: HomeAssistant, preset_mode: str, expected: int ) -> None: """Test preset_mode template.""" - hass.states.async_set(_STATE_TEST_SENSOR, preset_mode) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, preset_mode) _verify(hass, STATE_ON, None, None, None, expected) @@ -658,8 +420,7 @@ async def test_oscillating_template( hass: HomeAssistant, oscillating: str, expected: bool | None ) -> None: """Test oscillating template.""" - hass.states.async_set(_STATE_TEST_SENSOR, oscillating) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, oscillating) _verify(hass, STATE_ON, None, expected, None, None) @@ -694,20 +455,19 @@ async def test_direction_template( hass: HomeAssistant, direction: str, expected: bool | None ) -> None: """Test direction template.""" - hass.states.async_set(_STATE_TEST_SENSOR, direction) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, direction) _verify(hass, STATE_ON, None, None, expected, None) -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "extra_config"), [(1, {})]) @pytest.mark.parametrize( - ("style", "fan_config"), + ("style", "config"), [ ( ConfigurationStyle.LEGACY, { "availability_template": ( - "{{ is_state('availability_boolean.state', 'on') }}" + "{{ is_state('binary_sensor.availability', 'on') }}" ), "value_template": "{{ 'on' }}", "oscillating_template": "{{ 1 == 1 }}", @@ -719,7 +479,7 @@ async def test_direction_template( ( ConfigurationStyle.MODERN, { - "availability": ("{{ is_state('availability_boolean.state', 'on') }}"), + "availability": ("{{ is_state('binary_sensor.availability', 'on') }}"), "state": "{{ 'on' }}", "oscillating": "{{ 1 == 1 }}", "direction": "{{ 'forward' }}", @@ -730,7 +490,7 @@ async def test_direction_template( ( ConfigurationStyle.TRIGGER, { - "availability": ("{{ is_state('availability_boolean.state', 'on') }}"), + "availability": ("{{ is_state('binary_sensor.availability', 'on') }}"), "state": "{{ 'on' }}", "oscillating": "{{ 1 == 1 }}", "direction": "{{ 'forward' }}", @@ -740,20 +500,19 @@ async def test_direction_template( ), ], ) -@pytest.mark.usefixtures("setup_named_fan") +@pytest.mark.usefixtures("setup_fan") async def test_availability_template_with_entities(hass: HomeAssistant) -> None: """Test availability tempalates with values from other entities.""" for state, test_assert in ((STATE_ON, True), (STATE_OFF, False)): - hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, state) - await hass.async_block_till_done() + await async_trigger(hass, TEST_AVAILABILITY_ENTITY, state) assert ( - hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + hass.states.get(TEST_FAN.entity_id).state != STATE_UNAVAILABLE ) == test_assert -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "extra_config"), [(1, {})]) @pytest.mark.parametrize( - ("style", "fan_config", "states"), + ("style", "config", "states"), [ ( ConfigurationStyle.LEGACY, @@ -898,15 +657,16 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: ), ], ) -@pytest.mark.usefixtures("setup_named_fan") +@pytest.mark.usefixtures("setup_fan") async def test_template_with_unavailable_entities(hass: HomeAssistant, states) -> None: """Test unavailability with value_template.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") _verify(hass, states[0], states[1], states[2], states[3], None) -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "extra_config"), [(1, {})]) @pytest.mark.parametrize( - ("style", "fan_config"), + ("style", "config"), [ ( ConfigurationStyle.LEGACY, @@ -946,50 +706,35 @@ async def test_template_with_unavailable_entities(hass: HomeAssistant, states) - ), ], ) -@pytest.mark.usefixtures("setup_named_fan") +@pytest.mark.usefixtures("setup_fan") async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" # Ensure trigger entities update. - hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_INPUT_BOOLEAN, STATE_ON) - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + assert hass.states.get(TEST_FAN.entity_id).state != STATE_UNAVAILABLE err = "'x' is undefined" assert err in caplog_setup_text or err in caplog.text -@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_ON_OFF_ACTIONS)]) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'off' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'off' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'off' }}", - }, - ), - ], + ("count", "state_template", "extra_config"), + [(1, "{{ 'off' }}", OPTIMISTIC_ON_OFF_ACTIONS)], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_state_fan") async def test_on_off(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test turn on and turn off.""" - state = hass.states.get(TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + state = hass.states.get(TEST_FAN.entity_id) assert state.state == STATE_OFF for expected_calls, (func, action) in enumerate( @@ -998,15 +743,13 @@ async def test_on_off(hass: HomeAssistant, calls: list[ServiceCall]) -> None: (common.async_turn_off, "turn_off"), ] ): - await func(hass, TEST_ENTITY_ID) + await func(hass, TEST_FAN.entity_id) - assert len(calls) == expected_calls + 1 - assert calls[-1].data["action"] == action - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, expected_calls + 1, action) @pytest.mark.parametrize( - ("count", "extra_config"), + ("count", "extra_config", "state_template"), [ ( 1, @@ -1015,380 +758,236 @@ async def test_on_off(hass: HomeAssistant, calls: list[ServiceCall]) -> None: **OPTIMISTIC_PRESET_MODE_CONFIG2, **OPTIMISTIC_PERCENTAGE_CONFIG, }, + "{{ 'off' }}", ) ], ) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'off' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'off' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'off' }}", - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_on_with_extra_attributes( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test turn on and turn off.""" - state = hass.states.get(TEST_ENTITY_ID) - assert state.state == STATE_OFF - - await common.async_turn_on(hass, TEST_ENTITY_ID, 100) - - assert len(calls) == 2 - assert calls[-2].data["action"] == "turn_on" - assert calls[-2].data["caller"] == TEST_ENTITY_ID + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") - assert calls[-1].data["action"] == "set_percentage" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["percentage"] == 100 - - await common.async_turn_off(hass, TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) + assert state.state == STATE_OFF - assert len(calls) == 3 - assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + await common.async_turn_on(hass, TEST_FAN.entity_id, 100) - await common.async_turn_on(hass, TEST_ENTITY_ID, None, "auto") + assert_action(TEST_FAN, calls, 2, "turn_on", index=-2) + assert_action(TEST_FAN, calls, 2, "set_percentage", percentage=100) - assert len(calls) == 5 - assert calls[-2].data["action"] == "turn_on" - assert calls[-2].data["caller"] == TEST_ENTITY_ID + await common.async_turn_off(hass, TEST_FAN.entity_id) - assert calls[-1].data["action"] == "set_preset_mode" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["preset_mode"] == "auto" + assert_action(TEST_FAN, calls, 3, "turn_off") - await common.async_turn_off(hass, TEST_ENTITY_ID) + await common.async_turn_on(hass, TEST_FAN.entity_id, None, "auto") - assert len(calls) == 6 - assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 5, "turn_on", index=-2) + assert_action(TEST_FAN, calls, 5, "set_preset_mode", preset_mode="auto") - await common.async_turn_on(hass, TEST_ENTITY_ID, 50, "high") + await common.async_turn_off(hass, TEST_FAN.entity_id) - assert len(calls) == 9 - assert calls[-3].data["action"] == "turn_on" - assert calls[-3].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 6, "turn_off") - assert calls[-2].data["action"] == "set_preset_mode" - assert calls[-2].data["caller"] == TEST_ENTITY_ID - assert calls[-2].data["preset_mode"] == "high" + await common.async_turn_on(hass, TEST_FAN.entity_id, 50, "high") - assert calls[-1].data["action"] == "set_percentage" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["percentage"] == 50 + assert_action(TEST_FAN, calls, 9, "turn_on", index=-3) + assert_action(TEST_FAN, calls, 9, "set_preset_mode", index=-2, preset_mode="high") + assert_action(TEST_FAN, calls, 9, "set_percentage", percentage=50) - await common.async_turn_off(hass, TEST_ENTITY_ID) + await common.async_turn_off(hass, TEST_FAN.entity_id) - assert len(calls) == 10 - assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 10, "turn_off") @pytest.mark.parametrize( - ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})], ) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_set_invalid_direction_from_initial_stage(hass: HomeAssistant) -> None: """Test set invalid direction when fan is in initial state.""" - await common.async_set_direction(hass, TEST_ENTITY_ID, "invalid") + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + await common.async_set_direction(hass, TEST_FAN.entity_id, "invalid") _verify(hass, STATE_ON, None, None, None, None) @pytest.mark.parametrize( - ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})], ) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_set_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set oscillating.""" expected_calls = 0 - await common.async_turn_on(hass, TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + await common.async_turn_on(hass, TEST_FAN.entity_id) expected_calls += 1 for state in (True, False): - await common.async_oscillate(hass, TEST_ENTITY_ID, state) + await common.async_oscillate(hass, TEST_FAN.entity_id, state) _verify(hass, STATE_ON, None, state, None, None) expected_calls += 1 - assert len(calls) == expected_calls - assert calls[-1].data["action"] == "set_oscillating" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["oscillating"] == state + assert_action( + TEST_FAN, calls, expected_calls, "set_oscillating", oscillating=state + ) @pytest.mark.parametrize( - ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})], ) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_set_direction(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid direction.""" expected_calls = 0 - await common.async_turn_on(hass, TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + await common.async_turn_on(hass, TEST_FAN.entity_id) expected_calls += 1 for direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): - await common.async_set_direction(hass, TEST_ENTITY_ID, direction) + await common.async_set_direction(hass, TEST_FAN.entity_id, direction) _verify(hass, STATE_ON, None, None, direction, None) expected_calls += 1 - assert len(calls) == expected_calls - assert calls[-1].data["action"] == "set_direction" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["direction"] == direction + assert_action( + TEST_FAN, calls, expected_calls, "set_direction", direction=direction + ) @pytest.mark.parametrize( - ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})] + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", {**OPTIMISTIC_ON_OFF_ACTIONS, **DIRECTION_ACTION})], ) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_set_invalid_direction( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid direction when fan has valid direction.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + expected_calls = 1 for direction in (DIRECTION_FORWARD, "invalid"): - await common.async_set_direction(hass, TEST_ENTITY_ID, direction) + await common.async_set_direction(hass, TEST_FAN.entity_id, direction) _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD, None) - assert len(calls) == expected_calls - assert calls[-1].data["action"] == "set_direction" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["direction"] == DIRECTION_FORWARD + assert_action( + TEST_FAN, + calls, + expected_calls, + "set_direction", + direction=DIRECTION_FORWARD, + ) @pytest.mark.parametrize( - ("count", "extra_config"), [(1, OPTIMISTIC_PRESET_MODE_CONFIG2)] + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", OPTIMISTIC_PRESET_MODE_CONFIG2)], ) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_preset_modes(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test preset_modes.""" - expected_calls = 0 - valid_modes = OPTIMISTIC_PRESET_MODE_CONFIG2["preset_modes"] - for mode in ("auto", "low", "medium", "high", "invalid", "smart"): - if mode not in valid_modes: - with pytest.raises(NotValidPresetModeError): - await common.async_set_preset_mode(hass, TEST_ENTITY_ID, mode) - else: - await common.async_set_preset_mode(hass, TEST_ENTITY_ID, mode) - expected_calls += 1 + for cnt, mode in enumerate(("auto", "low", "medium", "high")): + await common.async_set_preset_mode(hass, TEST_FAN.entity_id, mode) + assert_action(TEST_FAN, calls, cnt + 1, "set_preset_mode", preset_mode=mode) + - assert len(calls) == expected_calls - assert calls[-1].data["action"] == "set_preset_mode" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["preset_mode"] == mode +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", OPTIMISTIC_PRESET_MODE_CONFIG2)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_state_fan") +async def test_invalid_preset_modes( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test invalid preset_modes.""" + for mode in ("invalid", "smart"): + with pytest.raises(NotValidPresetModeError): + await common.async_set_preset_mode(hass, TEST_FAN.entity_id, mode) -@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_PERCENTAGE_CONFIG)]) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", OPTIMISTIC_PERCENTAGE_CONFIG)], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_state_fan") async def test_set_percentage(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid speed percentage.""" expected_calls = 0 - await common.async_turn_on(hass, TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + await common.async_turn_on(hass, TEST_FAN.entity_id) expected_calls += 1 for state, value in ( (STATE_ON, 100), (STATE_ON, 66), (STATE_ON, 0), ): - await common.async_set_percentage(hass, TEST_ENTITY_ID, value) + await common.async_set_percentage(hass, TEST_FAN.entity_id, value) _verify(hass, state, value, None, None, None) expected_calls += 1 - assert len(calls) == expected_calls - assert calls[-1].data["action"] == "set_percentage" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["percentage"] == value + assert_action( + TEST_FAN, calls, expected_calls, "set_percentage", percentage=value + ) - await common.async_turn_on(hass, TEST_ENTITY_ID, percentage=50) + await common.async_turn_on(hass, TEST_FAN.entity_id, percentage=50) _verify(hass, STATE_ON, 50, None, None, None) @pytest.mark.parametrize( - ("count", "extra_config"), [(1, {"speed_count": 3, **OPTIMISTIC_PERCENTAGE_CONFIG})] + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", {"speed_count": 3, **OPTIMISTIC_PERCENTAGE_CONFIG})], ) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_increase_decrease_speed( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set valid increase and decrease speed.""" - await common.async_turn_on(hass, TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + await common.async_turn_on(hass, TEST_FAN.entity_id) for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 66), @@ -1396,12 +995,12 @@ async def test_increase_decrease_speed( (common.async_decrease_speed, None, STATE_ON, 0), (common.async_increase_speed, None, STATE_ON, 33), ): - await func(hass, TEST_ENTITY_ID, extra) + await func(hass, TEST_FAN.entity_id, extra) _verify(hass, state, value, None, None, None) @pytest.mark.parametrize( - ("count", "fan_config"), + ("count", "config", "extra_config"), [ ( 1, @@ -1413,6 +1012,7 @@ async def test_increase_decrease_speed( **OSCILLATE_ACTION, **DIRECTION_ACTION, }, + {}, ) ], ) @@ -1420,71 +1020,51 @@ async def test_increase_decrease_speed( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_named_fan") +@pytest.mark.usefixtures("setup_fan") async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test a fan without a value_template.""" - await common.async_turn_on(hass, TEST_ENTITY_ID) + await common.async_turn_on(hass, TEST_FAN.entity_id) _verify(hass, STATE_ON) - assert len(calls) == 1 - assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 1, "turn_on") - await common.async_turn_off(hass, TEST_ENTITY_ID) + await common.async_turn_off(hass, TEST_FAN.entity_id) _verify(hass, STATE_OFF) - assert len(calls) == 2 - assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 2, "turn_off") percent = 100 - await common.async_set_percentage(hass, TEST_ENTITY_ID, percent) + await common.async_set_percentage(hass, TEST_FAN.entity_id, percent) _verify(hass, STATE_ON, percent) - assert len(calls) == 3 - assert calls[-1].data["action"] == "set_percentage" - assert calls[-1].data["percentage"] == 100 - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 3, "set_percentage", percentage=percent) - await common.async_turn_off(hass, TEST_ENTITY_ID) + await common.async_turn_off(hass, TEST_FAN.entity_id) _verify(hass, STATE_OFF, percent) - assert len(calls) == 4 - assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 4, "turn_off") preset = "auto" - await common.async_set_preset_mode(hass, TEST_ENTITY_ID, preset) + await common.async_set_preset_mode(hass, TEST_FAN.entity_id, preset) _verify(hass, STATE_ON, percent, None, None, preset) - assert len(calls) == 5 - assert calls[-1].data["action"] == "set_preset_mode" - assert calls[-1].data["preset_mode"] == preset - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 5, "set_preset_mode", preset_mode=preset) - await common.async_turn_off(hass, TEST_ENTITY_ID) + await common.async_turn_off(hass, TEST_FAN.entity_id) _verify(hass, STATE_OFF, percent, None, None, preset) - assert len(calls) == 6 - assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 6, "turn_off") - await common.async_set_direction(hass, TEST_ENTITY_ID, DIRECTION_FORWARD) + await common.async_set_direction(hass, TEST_FAN.entity_id, DIRECTION_FORWARD) _verify(hass, STATE_OFF, percent, None, DIRECTION_FORWARD, preset) - assert len(calls) == 7 - assert calls[-1].data["action"] == "set_direction" - assert calls[-1].data["direction"] == DIRECTION_FORWARD - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 7, "set_direction", direction=DIRECTION_FORWARD) - await common.async_oscillate(hass, TEST_ENTITY_ID, True) + await common.async_oscillate(hass, TEST_FAN.entity_id, True) _verify(hass, STATE_OFF, percent, True, DIRECTION_FORWARD, preset) - assert len(calls) == 8 - assert calls[-1].data["action"] == "set_oscillating" - assert calls[-1].data["oscillating"] is True - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 8, "set_oscillating", oscillating=True) @pytest.mark.parametrize("count", [1]) @@ -1541,45 +1121,29 @@ async def test_optimistic_attributes( ) -> None: """Test setting percentage with optimistic template.""" - await coro(hass, TEST_ENTITY_ID, value) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + + await coro(hass, TEST_FAN.entity_id, value) _verify(hass, STATE_ON, **{verify_attr: value}) - assert len(calls) == 1 - assert calls[-1].data["action"] == action - assert calls[-1].data[attribute] == value - assert calls[-1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_FAN, calls, 1, action, **{attribute: value}) -@pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_PERCENTAGE_CONFIG)]) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", OPTIMISTIC_PERCENTAGE_CONFIG)], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_increase_decrease_speed_default_speed_count( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set valid increase and decrease speed.""" - await common.async_turn_on(hass, TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + await common.async_turn_on(hass, TEST_FAN.entity_id) for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 99), @@ -1587,131 +1151,80 @@ async def test_increase_decrease_speed_default_speed_count( (common.async_decrease_speed, 31, STATE_ON, 67), (common.async_decrease_speed, None, STATE_ON, 66), ): - await func(hass, TEST_ENTITY_ID, extra) + await func(hass, TEST_FAN.entity_id, extra) _verify(hass, state, value, None, None, None) @pytest.mark.parametrize( - ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})], ) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_set_invalid_osc_from_initial_state( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid oscillating when fan is in initial state.""" - await common.async_turn_on(hass, TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + await common.async_turn_on(hass, TEST_FAN.entity_id) with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, TEST_ENTITY_ID, "invalid") + await common.async_oscillate(hass, TEST_FAN.entity_id, "invalid") _verify(hass, STATE_ON, None, None, None, None) @pytest.mark.parametrize( - ("count", "extra_config"), [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})] + ("count", "state_template", "extra_config"), + [(1, "{{ 'on' }}", {**OPTIMISTIC_ON_OFF_ACTIONS, **OSCILLATE_ACTION})], ) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "value_template": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.MODERN, - { - "state": "{{ 'on' }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "state": "{{ 'on' }}", - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_state_fan") async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set invalid oscillating when fan has valid osc.""" - await common.async_turn_on(hass, TEST_ENTITY_ID) - await common.async_oscillate(hass, TEST_ENTITY_ID, True) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + await common.async_turn_on(hass, TEST_FAN.entity_id) + await common.async_oscillate(hass, TEST_FAN.entity_id, True) _verify(hass, STATE_ON, None, True, None, None) - await common.async_oscillate(hass, TEST_ENTITY_ID, False) + await common.async_oscillate(hass, TEST_FAN.entity_id, False) _verify(hass, STATE_ON, None, False, None, None) with pytest.raises(vol.Invalid): - await common.async_oscillate(hass, TEST_ENTITY_ID, None) + await common.async_oscillate(hass, TEST_FAN.entity_id, None) _verify(hass, STATE_ON, None, False, None, None) -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize("config", [OPTIMISTIC_ON_OFF_ACTIONS]) @pytest.mark.parametrize( - ("fan_config", "style"), - [ - ( - { - "test_template_fan_01": UNIQUE_ID_CONFIG, - "test_template_fan_02": UNIQUE_ID_CONFIG, - }, - ConfigurationStyle.LEGACY, - ), - ( - [ - { - "name": "test_template_fan_01", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_fan_02", - **UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.MODERN, - ), - ( - [ - { - "name": "test_template_fan_01", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_fan_02", - **UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.TRIGGER, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_fan") -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, style: ConfigurationStyle, config: ConfigType +) -> None: """Test unique_id option only creates one fan per id.""" - assert len(hass.states.async_all()) == 1 + await setup_and_test_unique_id(hass, TEST_FAN, style, config) + + +@pytest.mark.parametrize("config", [OPTIMISTIC_ON_OFF_ACTIONS]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +async def test_nested_unique_id( + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + entity_registry: er.EntityRegistry, +) -> None: + """Test a template unique_id propagates to fan unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_FAN, style, entity_registry, config + ) @pytest.mark.parametrize( @@ -1723,40 +1236,41 @@ async def test_unique_id(hass: HomeAssistant) -> None: [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( - ("fan_config", "percentage_step"), + ("config", "percentage_step"), [({"speed_count": 0}, 1), ({"speed_count": 100}, 1), ({"speed_count": 3}, 100 / 3)], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_fan") async def test_speed_percentage_step(hass: HomeAssistant, percentage_step) -> None: """Test a fan that implements percentage.""" assert len(hass.states.async_all()) == 1 - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) attributes = state.attributes assert attributes["percentage_step"] == percentage_step assert attributes.get("supported_features") & FanEntityFeature.SET_SPEED @pytest.mark.parametrize( - ("count", "fan_config"), - [(1, {**OPTIMISTIC_ON_OFF_ACTIONS, **OPTIMISTIC_PRESET_MODE_CONFIG2})], + ("count", "config", "extra_config"), + [(1, OPTIMISTIC_ON_OFF_ACTIONS, OPTIMISTIC_PRESET_MODE_CONFIG2)], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_named_fan") +@pytest.mark.usefixtures("setup_fan") async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: """Test a fan that implements preset_mode.""" assert len(hass.states.async_all()) == 1 - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) attributes = state.attributes assert attributes.get("supported_features") & FanEntityFeature.PRESET_MODE @pytest.mark.parametrize( - ("count", "fan_config"), [(1, {"turn_on": [], "turn_off": []})] + ("count", "config"), + [(1, {"turn_on": [], "turn_off": []})], ) @pytest.mark.parametrize( "style", @@ -1791,74 +1305,30 @@ async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: ), ], ) -@pytest.mark.usefixtures("setup_test_fan_with_extra_config") +@pytest.mark.usefixtures("setup_fan") async def test_empty_action_config( hass: HomeAssistant, supported_features: FanEntityFeature, ) -> None: """Test configuration with empty script.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) assert state.attributes["supported_features"] == ( FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON | supported_features ) -async def test_nested_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test a template unique_id propagates to switch unique_ids.""" - with assert_setup_component(1, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - { - "template": { - "unique_id": "x", - "fan": [ - { - **OPTIMISTIC_ON_OFF_ACTIONS, - "name": "test_a", - "unique_id": "a", - "state": "{{ true }}", - }, - { - **OPTIMISTIC_ON_OFF_ACTIONS, - "name": "test_b", - "unique_id": "b", - "state": "{{ true }}", - }, - ], - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("fan")) == 2 - - entry = entity_registry.async_get("fan.test_a") - assert entry - assert entry.unique_id == "x-a" - - entry = entity_registry.async_get("fan.test_b") - assert entry - assert entry.unique_id == "x-b" - - @pytest.mark.parametrize( - ("count", "fan_config"), + ("count", "config", "extra_config"), [ ( 1, { - "name": TEST_OBJECT_ID, "state": "{{ is_state('sensor.test_sensor', 'on') }}", "turn_on": [], "turn_off": [], "optimistic": True, }, + {}, ) ], ) @@ -1869,44 +1339,42 @@ async def test_nested_unique_id( @pytest.mark.usefixtures("setup_fan") async def test_optimistic_option(hass: HomeAssistant) -> None: """Test optimistic yaml option.""" - hass.states.async_set(_STATE_TEST_SENSOR, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) assert state.state == STATE_OFF await hass.services.async_call( fan.DOMAIN, "turn_on", - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_FAN.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) assert state.state == STATE_ON - hass.states.async_set(_STATE_TEST_SENSOR, STATE_ON) - await hass.async_block_till_done() - - hass.states.async_set(_STATE_TEST_SENSOR, STATE_OFF) - await hass.async_block_till_done() + # The double trigger is needed because the state machine + # suppresses 'off' -> 'off' state changes for TEST_STATE_ENTITY_ID + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) assert state.state == STATE_OFF @pytest.mark.parametrize( - ("count", "fan_config"), + ("count", "config", "extra_config"), [ ( 1, { - "name": TEST_OBJECT_ID, "state": "{{ is_state('sensor.test_sensor', 'on') }}", "turn_on": [], "turn_off": [], "optimistic": False, }, + {}, ) ], ) @@ -1917,14 +1385,17 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_fan") async def test_not_optimistic(hass: HomeAssistant) -> None: """Test optimistic yaml option set to false.""" + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.services.async_call( fan.DOMAIN, "turn_on", - {"entity_id": TEST_ENTITY_ID}, + {"entity_id": TEST_FAN.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_FAN.entity_id) assert state.state == STATE_OFF From ea4d85f96c1fb863241b6c30dcd91bd021de6de9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Apr 2026 14:27:22 +0200 Subject: [PATCH 0613/1707] Extract arithmetic template filters into the math Jinja2 extension (#167309) Co-authored-by: Joostlek --- homeassistant/helpers/template/__init__.py | 51 +------------- .../helpers/template/extensions/math.py | 50 +++++++++++++ .../helpers/template/extensions/test_math.py | 70 +++++++++++++++++++ tests/helpers/template/test_init.py | 70 ------------------- 4 files changed, 121 insertions(+), 120 deletions(-) diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 5976cfe88e33e5..700f90dd70a138 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -10,7 +10,6 @@ from enum import Enum from functools import cache, lru_cache, partial, wraps import logging -import math import pathlib import re import sys @@ -67,7 +66,7 @@ template_context_manager, template_cv, ) -from .helpers import raise_no_default, result_as_boolean as result_as_boolean +from .helpers import result_as_boolean as result_as_boolean from .render_info import RenderInfo, render_info_cv if TYPE_CHECKING: @@ -1411,50 +1410,6 @@ def has_value(hass: HomeAssistant, entity_id: str) -> bool: ) -def forgiving_round(value, precision=0, method="common", default=_SENTINEL): - """Filter to round a value.""" - try: - # support rounding methods like jinja - multiplier = float(10**precision) - if method == "ceil": - value = math.ceil(float(value) * multiplier) / multiplier - elif method == "floor": - value = math.floor(float(value) * multiplier) / multiplier - elif method == "half": - value = round(float(value) * 2) / 2 - else: - # if method is common or something else, use common rounding - value = round(float(value), precision) - return int(value) if precision == 0 else value - except ValueError, TypeError: - # If value can't be converted to float - if default is _SENTINEL: - raise_no_default("round", value) - return default - - -def multiply(value, amount, default=_SENTINEL): - """Filter to convert value to float and multiply it.""" - try: - return float(value) * amount - except ValueError, TypeError: - # If value can't be converted to float - if default is _SENTINEL: - raise_no_default("multiply", value) - return default - - -def add(value, amount, default=_SENTINEL): - """Filter to convert value to float and add it.""" - try: - return float(value) + amount - except ValueError, TypeError: - # If value can't be converted to float - if default is _SENTINEL: - raise_no_default("add", value) - return default - - def make_logging_undefined( strict: bool | None, log_fn: Callable[[int, str], None] | None ) -> type[jinja2.Undefined]: @@ -1607,10 +1562,6 @@ def __init__( ) self.add_extension("homeassistant.helpers.template.extensions.VersionExtension") - self.filters["add"] = add - self.filters["multiply"] = multiply - self.filters["round"] = forgiving_round - if hass is None: return diff --git a/homeassistant/helpers/template/extensions/math.py b/homeassistant/helpers/template/extensions/math.py index 3ecd910f902f72..903e68a308e8b7 100644 --- a/homeassistant/helpers/template/extensions/math.py +++ b/homeassistant/helpers/template/extensions/math.py @@ -77,6 +77,10 @@ def __init__(self, environment: TemplateEnvironment) -> None: TemplateFunction( "bitwise_xor", self.bitwise_xor, as_global=True, as_filter=True ), + # Arithmetic filters + TemplateFunction("add", self.add, as_filter=True), + TemplateFunction("multiply", self.multiply, as_filter=True), + TemplateFunction("round", self.forgiving_round, as_filter=True), # Value constraint functions (as globals and filters) TemplateFunction("clamp", self.clamp, as_global=True, as_filter=True), TemplateFunction("wrap", self.wrap, as_global=True, as_filter=True), @@ -332,6 +336,52 @@ def bitwise_xor(first_value: Any, second_value: Any) -> Any: """Perform a bitwise xor operation.""" return first_value ^ second_value + @staticmethod + def add(value: Any, amount: Any, default: Any = _SENTINEL) -> Any: + """Filter to convert value to float and add it.""" + try: + return float(value) + amount + except ValueError, TypeError: + if default is _SENTINEL: + raise_no_default("add", value) + return default + + @staticmethod + def multiply(value: Any, amount: Any, default: Any = _SENTINEL) -> Any: + """Filter to convert value to float and multiply it.""" + try: + return float(value) * amount + except ValueError, TypeError: + if default is _SENTINEL: + raise_no_default("multiply", value) + return default + + @staticmethod + def forgiving_round( + value: Any, + precision: int = 0, + method: str = "common", + default: Any = _SENTINEL, + ) -> Any: + """Filter to round a value.""" + try: + # support rounding methods like jinja + multiplier = float(10**precision) + if method == "ceil": + value = math.ceil(float(value) * multiplier) / multiplier + elif method == "floor": + value = math.floor(float(value) * multiplier) / multiplier + elif method == "half": + value = round(float(value) * 2) / 2 + else: + # if method is common or something else, use common rounding + value = round(float(value), precision) + return int(value) if precision == 0 else value + except ValueError, TypeError: + if default is _SENTINEL: + raise_no_default("round", value) + return default + @staticmethod def clamp(value: Any, min_value: Any, max_value: Any) -> Any: """Filter and function to clamp a value between min and max bounds. diff --git a/tests/helpers/template/extensions/test_math.py b/tests/helpers/template/extensions/test_math.py index 035e0adfbdce8e..af62fa9cb55adb 100644 --- a/tests/helpers/template/extensions/test_math.py +++ b/tests/helpers/template/extensions/test_math.py @@ -544,3 +544,73 @@ def test_remap_with_mirror(hass: HomeAssistant) -> None: assert MathExtension.remap(12, 0, 10, 100, 0, edges="mirror") == 20.0 # Test without remapping assert MathExtension.remap(-0.1, 0, 1, 0, 1, edges="mirror") == pytest.approx(0.1) + + +def test_rounding_value(hass: HomeAssistant) -> None: + """Test rounding value.""" + hass.states.async_set("sensor.temperature", 12.78) + + assert render(hass, "{{ states.sensor.temperature.state | round(1) }}") == 12.8 + + assert ( + render(hass, "{{ states.sensor.temperature.state | multiply(10) | round }}") + == 128 + ) + + assert ( + render(hass, '{{ states.sensor.temperature.state | round(1, "floor") }}') + == 12.7 + ) + + assert ( + render(hass, '{{ states.sensor.temperature.state | round(1, "ceil") }}') == 12.8 + ) + + assert ( + render(hass, '{{ states.sensor.temperature.state | round(1, "half") }}') == 13.0 + ) + + +def test_rounding_value_on_error(hass: HomeAssistant) -> None: + """Test rounding value handling of error.""" + # Test handling of invalid input + with pytest.raises(TemplateError): + render(hass, "{{ None | round }}") + + with pytest.raises(TemplateError): + render(hass, '{{ "no_number" | round }}') + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | round(default=1) }}") == 1 + + +def test_multiply(hass: HomeAssistant) -> None: + """Test multiply.""" + tests = {10: 100} + + for inp, out in tests.items(): + assert render(hass, f"{{{{ {inp} | multiply(10) | round }}}}") == out + + # Test handling of invalid input + with pytest.raises(TemplateError): + render(hass, "{{ abcd | multiply(10) }}") + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | multiply(10, 1) }}") == 1 + assert render(hass, "{{ 'no_number' | multiply(10, default=1) }}") == 1 + + +def test_add(hass: HomeAssistant) -> None: + """Test add.""" + tests = {10: 42} + + for inp, out in tests.items(): + assert render(hass, f"{{{{ {inp} | add(32) | round }}}}") == out + + # Test handling of invalid input + with pytest.raises(TemplateError): + render(hass, "{{ abcd | add(10) }}") + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | add(10, 1) }}") == 1 + assert render(hass, "{{ 'no_number' | add(10, default=1) }}") == 1 diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index da1460409d34c4..6f1ed335f830f1 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -304,76 +304,6 @@ def test_converting_datetime_to_iterable(hass: HomeAssistant) -> None: render(hass, "{{ set(value) }}", {"value": dt_}) -def test_rounding_value(hass: HomeAssistant) -> None: - """Test rounding value.""" - hass.states.async_set("sensor.temperature", 12.78) - - assert render(hass, "{{ states.sensor.temperature.state | round(1) }}") == 12.8 - - assert ( - render(hass, "{{ states.sensor.temperature.state | multiply(10) | round }}") - == 128 - ) - - assert ( - render(hass, '{{ states.sensor.temperature.state | round(1, "floor") }}') - == 12.7 - ) - - assert ( - render(hass, '{{ states.sensor.temperature.state | round(1, "ceil") }}') == 12.8 - ) - - assert ( - render(hass, '{{ states.sensor.temperature.state | round(1, "half") }}') == 13.0 - ) - - -def test_rounding_value_on_error(hass: HomeAssistant) -> None: - """Test rounding value handling of error.""" - # Test handling of invalid input - with pytest.raises(TemplateError): - render(hass, "{{ None | round }}") - - with pytest.raises(TemplateError): - render(hass, '{{ "no_number" | round }}') - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | round(default=1) }}") == 1 - - -def test_multiply(hass: HomeAssistant) -> None: - """Test multiply.""" - tests = {10: 100} - - for inp, out in tests.items(): - assert render(hass, f"{{{{ {inp} | multiply(10) | round }}}}") == out - - # Test handling of invalid input - with pytest.raises(TemplateError): - render(hass, "{{ abcd | multiply(10) }}") - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | multiply(10, 1) }}") == 1 - assert render(hass, "{{ 'no_number' | multiply(10, default=1) }}") == 1 - - -def test_add(hass: HomeAssistant) -> None: - """Test add.""" - tests = {10: 42} - - for inp, out in tests.items(): - assert render(hass, f"{{{{ {inp} | add(32) | round }}}}") == out - - # Test handling of invalid input - with pytest.raises(TemplateError): - render(hass, "{{ abcd | add(10) }}") - - # Test handling of default return value - assert render(hass, "{{ 'no_number' | add(10, 1) }}") == 1 - assert render(hass, "{{ 'no_number' | add(10, default=1) }}") == 1 - - def test_passing_vars_as_keywords(hass: HomeAssistant) -> None: """Test passing variables as keywords.""" assert render(hass, "{{ hello }}", hello=127) == 127 From 462e9965d7597a3845b5edbd2f56ced412361994 Mon Sep 17 00:00:00 2001 From: David Bishop Date: Wed, 8 Apr 2026 06:18:55 -0700 Subject: [PATCH 0614/1707] Mark Govee local devices unavailable when they stop responding (#167566) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/govee_light_local/const.py | 4 + .../components/govee_light_local/light.py | 20 ++- .../govee_light_local/test_light.py | 163 +++++++++++++++++- 3 files changed, 181 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/govee_light_local/const.py b/homeassistant/components/govee_light_local/const.py index a90a1ff1ff1a5c..41ae13d7563167 100644 --- a/homeassistant/components/govee_light_local/const.py +++ b/homeassistant/components/govee_light_local/const.py @@ -11,4 +11,8 @@ CONF_DISCOVERY_INTERVAL_DEFAULT = 60 SCAN_INTERVAL = timedelta(seconds=30) +# A device is considered unavailable if we have not heard a status response +# from it for three consecutive poll cycles. This tolerates a single dropped +# UDP response plus some jitter before flapping the entity state. +DEVICE_TIMEOUT = SCAN_INTERVAL * 3 DISCOVERY_TIMEOUT = 5 diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 0f6ec98814ab2b..8cbb5eb9f0fe07 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime import logging from typing import Any @@ -22,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DEVICE_TIMEOUT, DOMAIN, MANUFACTURER from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry _LOGGER = logging.getLogger(__name__) @@ -118,6 +119,19 @@ def __init__( serial_number=device.fingerprint, ) + @property + def available(self) -> bool: + """Return if the device is reachable. + + The underlying library updates ``lastseen`` whenever the device + replies to a status request. The coordinator polls every + ``SCAN_INTERVAL``, so if we have not heard back within + ``DEVICE_TIMEOUT`` we consider the device offline. + """ + if not super().available: + return False + return datetime.now() - self._device.lastseen < DEVICE_TIMEOUT + @property def is_on(self) -> bool: """Return true if device is on (brightness above 0).""" @@ -205,8 +219,8 @@ async def async_will_remove_from_hass(self) -> None: @callback def _update_callback(self, device: GoveeDevice) -> None: - if self.hass: - self.async_write_ha_state() + """Handle device state updates pushed by the library.""" + self.async_write_ha_state() def _save_last_color_state(self) -> None: color_mode = self.color_mode diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 6ea36766c827b5..a3e7d39ee97ded 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -3,10 +3,16 @@ from errno import EADDRINUSE, ENETDOWN from unittest.mock import AsyncMock, MagicMock, call, patch +from freezegun.api import FrozenDateTimeFactory from govee_local_api import GoveeDevice +from govee_local_api.message import DevStatusResponse import pytest -from homeassistant.components.govee_light_local.const import DOMAIN +from homeassistant.components.govee_light_local.const import ( + DEVICE_TIMEOUT, + DOMAIN, + SCAN_INTERVAL, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, @@ -18,12 +24,18 @@ ColorMode, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_light_known_device( @@ -755,3 +767,148 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non assert light.state == "on" assert light.attributes[ATTR_EFFECT] is None mock_govee_api.set_scene.assert_not_called() + + +def _status_response( + *, + is_on: bool = False, + brightness: int = 0, + r: int = 0, + g: int = 0, + b: int = 0, + color_temp: int = 0, +) -> DevStatusResponse: + """Build a DevStatusResponse matching the library's wire format. + + Driving availability tests through the library's public ``device.update`` + keeps the test honest about the contract we depend on: ``lastseen`` is + refreshed whenever a status response is applied. + """ + return DevStatusResponse( + { + "onOff": 1 if is_on else 0, + "brightness": brightness, + "color": {"r": r, "g": g, "b": b}, + "colorTemInKelvin": color_temp, + } + ) + + +async def test_device_becomes_unavailable_after_timeout( + hass: HomeAssistant, + mock_govee_api: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a device goes unavailable when no status response arrives.""" + device = GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITIES, + ) + mock_govee_api.devices = [device] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.H615A") + assert state is not None + assert state.state == STATE_OFF + + # Advance past DEVICE_TIMEOUT without firing any status responses, and + # tick the coordinator forward so a state write occurs. + freezer.tick(DEVICE_TIMEOUT + SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get("light.H615A") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_device_recovers_after_status_response( + hass: HomeAssistant, + mock_govee_api: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that an unavailable device recovers when it responds again.""" + device = GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITIES, + ) + mock_govee_api.devices = [device] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Drive it unavailable first. + freezer.tick(DEVICE_TIMEOUT + SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get("light.H615A") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # A status response refreshes lastseen and fires the entity callback. + device.update(_status_response()) + await hass.async_block_till_done() + + state = hass.states.get("light.H615A") + assert state is not None + assert state.state == STATE_OFF + + +async def test_one_silent_device_does_not_affect_others( + hass: HomeAssistant, + mock_govee_api: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that one silent device does not pull the others unavailable.""" + silent = GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="silent_device", + sku="H615A", + capabilities=DEFAULT_CAPABILITIES, + ) + chatty = GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.101", + fingerprint="chatty_device", + sku="H615B", + capabilities=DEFAULT_CAPABILITIES, + ) + mock_govee_api.devices = [silent, chatty] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Tick past the timeout, but have the chatty device reply along the way. + freezer.tick(SCAN_INTERVAL) + chatty.update(_status_response()) + freezer.tick(DEVICE_TIMEOUT) + chatty.update(_status_response()) + + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + silent_state = hass.states.get("light.H615A") + chatty_state = hass.states.get("light.H615B") + assert silent_state is not None + assert chatty_state is not None + assert silent_state.state == STATE_UNAVAILABLE + assert chatty_state.state == STATE_OFF From 018c1309880de2653b03d212abf8405d4639ab94 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:18:19 +0200 Subject: [PATCH 0615/1707] Update UniFi Access quality scale: mark documentation rules as done (#166898) Co-authored-by: RaHehl --- .../components/unifi_access/quality_scale.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index 72d0bb8590da33..568b0422484f18 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -44,13 +44,13 @@ rules: diagnostics: done discovery-update-info: todo discovery: todo - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done From 038b583888528d4d9a2bba6bc304f00804c7ad40 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:20:57 +0200 Subject: [PATCH 0616/1707] Update types packages (#167700) --- homeassistant/components/habitica/calendar.py | 4 +++- requirements_test.txt | 22 +++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index 7dd5d5b4675cfd..e0e42359833f29 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -98,7 +98,9 @@ def get_recurrence_dates( start_date, end_date - timedelta(days=1), inc=True ) # if no end_date is given, return only the next recurrence - return [recurrences.after(start_date, inc=True)] + if (next_date := recurrences.after(start_date, inc=True)) is None: + return [] + return [next_date] class HabiticaTodosCalendarEntity(HabiticaCalendarEntity): diff --git a/requirements_test.txt b/requirements_test.txt index 56681afbe2b158..35ce1d9b1998f1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -38,17 +38,17 @@ syrupy==5.0.0 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 -types-croniter==6.0.0.20250809 +types-croniter==6.2.2.20260408 types-caldav==1.3.0.20250516 types-chardet==0.1.5 -types-decorator==5.2.0.20251101 -types-pexpect==4.9.0.20250916 -types-protobuf==6.30.2.20250914 -types-psutil==7.2.2.20260402 -types-pyserial==3.5.0.20251001 -types-python-dateutil==2.9.0.20260124 +types-decorator==5.2.0.20260408 +types-pexpect==4.9.0.20260408 +types-protobuf==6.32.1.20260221 +types-psutil==7.2.2.20260408 +types-pyserial==3.5.0.20260408 +types-python-dateutil==2.9.0.20260408 types-python-slugify==8.0.2.20240310 -types-pytz==2025.2.0.20251108 -types-PyYAML==6.0.12.20250915 -types-requests==2.32.4.20260107 -types-xmltodict==1.0.1.20260113 +types-pytz==2026.1.1.20260408 +types-PyYAML==6.0.12.20260408 +types-requests==2.33.0.20260408 +types-xmltodict==1.0.1.20260408 From b0511519a1fb8cc85fa3e8d61064e5abaa68a896 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:29:27 -0400 Subject: [PATCH 0617/1707] Expose async serial port scanning helper in USB integration (#167706) --- .../homeassistant_sky_connect/__init__.py | 4 +-- homeassistant/components/usb/__init__.py | 5 ++-- homeassistant/components/usb/utils.py | 8 ++++++ homeassistant/components/zha/config_flow.py | 8 ++++-- .../homeassistant_sky_connect/test_init.py | 2 +- tests/components/usb/__init__.py | 2 +- tests/components/usb/test_init.py | 27 ++++++++++++------- tests/components/zha/test_config_flow.py | 10 +++---- 8 files changed, 43 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index a386a49894ad32..317f8eacf72d53 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -13,7 +13,7 @@ from homeassistant.components.usb import ( USBDevice, async_register_port_event_callback, - scan_serial_ports, + async_scan_serial_ports, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -163,7 +163,7 @@ async def async_migrate_entry( key not in config_entry.data for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER) ): - serial_ports = await hass.async_add_executor_job(scan_serial_ports) + serial_ports = await async_scan_serial_ports(hass) serial_ports_info = {port.device: port for port in serial_ports} device = config_entry.data[DEVICE] diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 0ffba6bdb92058..f21f24228f2d6e 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -37,7 +37,8 @@ USBDevice, ) from .utils import ( - scan_serial_ports, + async_scan_serial_ports, + scan_serial_ports, # noqa: F401 usb_device_from_path, # noqa: F401 usb_device_from_port, # noqa: F401 usb_device_matches_matcher, @@ -433,7 +434,7 @@ async def _async_scan_serial(self) -> None: # Only consider USB-serial ports for discovery usb_ports = [ p - for p in await self.hass.async_add_executor_job(scan_serial_ports) + for p in await async_scan_serial_ports(self.hass) if isinstance(p, USBDevice) ] diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index d9481ff8ba9594..783b603aeae09d 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -10,6 +10,7 @@ from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo +from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.loader import USBMatcher @@ -76,6 +77,13 @@ def scan_serial_ports() -> Sequence[USBDevice | SerialDevice]: return serial_ports +async def async_scan_serial_ports( + hass: HomeAssistant, +) -> Sequence[USBDevice | SerialDevice]: + """Scan serial ports and return USB and other serial devices, async.""" + return await hass.async_add_executor_job(scan_serial_ports) + + def usb_device_from_path(device_path: str) -> USBDevice | None: """Get USB device info from a device path.""" diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 2342023540b4d0..d5a183a34a5239 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -26,7 +26,11 @@ ZigbeeFlowStrategy, ) from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware -from homeassistant.components.usb import SerialDevice, USBDevice, scan_serial_ports +from homeassistant.components.usb import ( + SerialDevice, + USBDevice, + async_scan_serial_ports, +) from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_ZEROCONF, @@ -155,7 +159,7 @@ def _format_serial_port_choice( async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice | SerialDevice]: """List all serial ports, including the Yellow radio and the multi-PAN addon.""" ports: list[USBDevice | SerialDevice] = [] - ports.extend(await hass.async_add_executor_job(scan_serial_ports)) + ports.extend(await async_scan_serial_ports(hass)) # Add useful info to the Yellow's serial port selection screen try: diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 37039a968fb38d..90c0cbbcc28092 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -265,7 +265,7 @@ async def test_bad_config_entry_fixing(hass: HomeAssistant) -> None: fixable_entry.add_to_hass(hass) with patch( - "homeassistant.components.homeassistant_sky_connect.scan_serial_ports", + "homeassistant.components.homeassistant_sky_connect.async_scan_serial_ports", return_value=[ USBDevice( device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_4f5f3b26d59f8714a78b599690741999-if00-port0", diff --git a/tests/components/usb/__init__.py b/tests/components/usb/__init__.py index 6db0cea1ffee02..b849acdf0a989b 100644 --- a/tests/components/usb/__init__.py +++ b/tests/components/usb/__init__.py @@ -21,7 +21,7 @@ def force_usb_polling_watcher(): def patch_scanned_serial_ports(**kwargs) -> None: """Patch the USB integration's list of scanned serial ports.""" - return patch("homeassistant.components.usb.scan_serial_ports", **kwargs) + return patch("homeassistant.components.usb.utils.scan_serial_ports", **kwargs) async def async_request_scan(hass: HomeAssistant) -> None: diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 6945c80ce07ebc..dfa0232f46d671 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -12,7 +12,10 @@ from homeassistant.components import usb from homeassistant.components.usb import DOMAIN from homeassistant.components.usb.models import SerialDevice, USBDevice -from homeassistant.components.usb.utils import scan_serial_ports, usb_device_from_path +from homeassistant.components.usb.utils import ( + async_scan_serial_ports, + usb_device_from_path, +) from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -1293,8 +1296,10 @@ async def test_register_port_event_callback_failure( assert "Failure 2" in caplog.text -def test_scan_serial_ports_with_unique_symlinks() -> None: - """Test scan_serial_ports returns devices with unique /dev/serial/by-id paths.""" +async def test_async_scan_serial_ports_with_unique_symlinks( + hass: HomeAssistant, +) -> None: + """Test async_scan_serial_ports returns devices with unique /dev/serial/by-id paths.""" entry1 = MagicMock(spec_set=os.DirEntry) entry1.is_symlink.return_value = True entry1.path = "/dev/serial/by-id/usb-device1" @@ -1335,7 +1340,7 @@ def mock_realpath(path: str) -> str: return_value=[mock_port1, mock_port2], ), ): - devices = scan_serial_ports() + devices = await async_scan_serial_ports(hass) assert len(devices) == 2 assert devices[0].device == "/dev/serial/by-id/usb-device1" @@ -1344,8 +1349,10 @@ def mock_realpath(path: str) -> str: assert devices[1].vid == "ABCD" -def test_scan_serial_ports_without_unique_symlinks() -> None: - """Test scan_serial_ports returns devices with original paths when no symlinks exist.""" +async def test_async_scan_serial_ports_without_unique_symlinks( + hass: HomeAssistant, +) -> None: + """Test async_scan_serial_ports returns devices with original paths when no symlinks exist.""" mock_port = MagicMock() mock_port.device = "/dev/ttyUSB0" mock_port.vid = 0x1234 @@ -1362,15 +1369,15 @@ def test_scan_serial_ports_without_unique_symlinks() -> None: return_value=[mock_port], ), ): - devices = scan_serial_ports() + devices = await async_scan_serial_ports(hass) assert len(devices) == 1 assert devices[0].device == "/dev/ttyUSB0" assert devices[0].vid == "1234" -def test_scan_serial_ports_no_vid_pid() -> None: - """Test scan_serial_ports returns devices without VID:PID.""" +async def test_async_scan_serial_ports_no_vid_pid(hass: HomeAssistant) -> None: + """Test async_scan_serial_ports returns devices without VID:PID.""" mock_port = MagicMock() mock_port.device = "/dev/ttyAMA1" mock_port.vid = None @@ -1387,7 +1394,7 @@ def test_scan_serial_ports_no_vid_pid() -> None: return_value=[mock_port], ), ): - devices = scan_serial_ports() + devices = await async_scan_serial_ports(hass) assert len(devices) == 1 assert isinstance(devices[0], SerialDevice) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 3fce8b9ebf3d51..ed35f04213d31b 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -2844,7 +2844,7 @@ async def test_config_flow_port_yellow_port_name( with ( patch("homeassistant.components.zha.config_flow.yellow_hardware.async_info"), patch( - "homeassistant.components.zha.config_flow.scan_serial_ports", + "homeassistant.components.zha.config_flow.async_scan_serial_ports", return_value=[port], ), ): @@ -2866,7 +2866,7 @@ async def test_config_flow_ports_no_hassio(hass: HomeAssistant) -> None: with ( patch("homeassistant.components.zha.config_flow.is_hassio", return_value=False), patch( - "homeassistant.components.zha.config_flow.scan_serial_ports", + "homeassistant.components.zha.config_flow.async_scan_serial_ports", return_value=[], ), ): @@ -2884,7 +2884,7 @@ async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info" ) as async_get_addon_info, patch( - "homeassistant.components.zha.config_flow.scan_serial_ports", + "homeassistant.components.zha.config_flow.async_scan_serial_ports", return_value=[], ), ): @@ -2909,7 +2909,7 @@ async def test_config_flow_port_no_multiprotocol(hass: HomeAssistant) -> None: side_effect=AddonError, ), patch( - "homeassistant.components.zha.config_flow.scan_serial_ports", + "homeassistant.components.zha.config_flow.async_scan_serial_ports", return_value=[], ), ): @@ -2974,7 +2974,7 @@ async def test_list_serial_ports_ignored_devices(hass: HomeAssistant) -> None: with ( patch("homeassistant.components.zha.config_flow.is_hassio", return_value=False), patch( - "homeassistant.components.zha.config_flow.scan_serial_ports", + "homeassistant.components.zha.config_flow.async_scan_serial_ports", return_value=mock_ports, ), ): From 4c8a660b2d717ceff382d39fd404d6c244c0ace8 Mon Sep 17 00:00:00 2001 From: Oluwatobi Mustapha Date: Wed, 8 Apr 2026 20:17:26 +0100 Subject: [PATCH 0618/1707] Redact Z-Wave add-on options sensitive error details (#167239) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/zwave_js/addon.py | 58 ++++++++++++++++++- tests/components/zwave_js/common.py | 1 + tests/components/zwave_js/test_config_flow.py | 23 +++++++- tests/components/zwave_js/test_init.py | 42 ++++++++++++++ 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 12d81146c03abf..8c167979cf5ec5 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -2,17 +2,69 @@ from __future__ import annotations -from homeassistant.components.hassio import AddonManager +from typing import Any + +from homeassistant.components.hassio import AddonError, AddonManager from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.redact import async_redact_data from homeassistant.helpers.singleton import singleton -from .const import ADDON_SLUG, DOMAIN, LOGGER +from .const import ( + ADDON_SLUG, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, + CONF_ADDON_NETWORK_KEY, + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, + DOMAIN, + LOGGER, +) DATA_ADDON_MANAGER = f"{DOMAIN}_addon_manager" +REDACT_ADDON_OPTION_KEYS = { + CONF_ADDON_S0_LEGACY_KEY, + CONF_ADDON_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_S2_AUTHENTICATED_KEY, + CONF_ADDON_S2_UNAUTHENTICATED_KEY, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, + CONF_ADDON_NETWORK_KEY, +} + + +def _redact_sensitive_option_values(message: str, config: dict[str, Any]) -> str: + """Redact sensitive add-on option values in an error string.""" + redacted_config = async_redact_data(config, REDACT_ADDON_OPTION_KEYS) + + for key in REDACT_ADDON_OPTION_KEYS: + option_value = config.get(key) + if not isinstance(option_value, str) or not option_value: + continue + redacted_value = redacted_config.get(key) + if not isinstance(redacted_value, str): + continue + message = message.replace(option_value, redacted_value) + + return message + + +class ZwaveAddonManager(AddonManager): + """Addon manager for Z-Wave JS with redacted option errors.""" + + async def async_set_addon_options(self, config: dict[str, Any]) -> None: + """Set add-on options.""" + try: + await super().async_set_addon_options(config) + except AddonError as err: + raise AddonError( + _redact_sensitive_option_values(str(err), config) + ) from None @singleton(DATA_ADDON_MANAGER) @callback def get_addon_manager(hass: HomeAssistant) -> AddonManager: """Get the add-on manager.""" - return AddonManager(hass, LOGGER, "Z-Wave JS", ADDON_SLUG) + return ZwaveAddonManager(hass, LOGGER, "Z-Wave JS", ADDON_SLUG) diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 6f08e89830b109..21da6671f9153c 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -46,6 +46,7 @@ DEHUMIDIFIER_ADC_T3000_ENTITY = "humidifier.adc_t3000_dehumidifier" PROPERTY_ULTRAVIOLET = "Ultraviolet" +TEST_SENSITIVE_NETWORK_KEY = "00112233445566778899AABBCCDDEEFF" def replace_value_of_zwave_value( diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 4517d22d661c87..5a26b3509ab9ab 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -38,11 +38,14 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.redact import REDACTED from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .common import TEST_SENSITIVE_NETWORK_KEY + from tests.common import MockConfigEntry, async_capture_events ADDON_DISCOVERY_INFO = { @@ -2542,13 +2545,23 @@ async def test_addon_installed_failures( @pytest.mark.usefixtures("supervisor", "addon_installed", "addon_info") -@pytest.mark.parametrize("set_addon_options_side_effect", [SupervisorError()]) +@pytest.mark.parametrize( + "set_addon_options_side_effect", + [ + SupervisorError( + "not a valid value for dictionary value @ data['options']. " + f"Got {{'s0_legacy_key': '{TEST_SENSITIVE_NETWORK_KEY}'}}" + ) + ], +) async def test_addon_installed_set_options_failure( hass: HomeAssistant, set_addon_options: AsyncMock, start_addon: AsyncMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test all failures when add-on is installed.""" + secret = TEST_SENSITIVE_NETWORK_KEY result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -2594,7 +2607,7 @@ async def test_addon_installed_set_options_failure( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "s0_legacy_key": "new123", + "s0_legacy_key": secret, "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", @@ -2608,7 +2621,7 @@ async def test_addon_installed_set_options_failure( AddonsOptions( config={ "device": "/test", - "s0_legacy_key": "new123", + "s0_legacy_key": secret, "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", @@ -2622,6 +2635,10 @@ async def test_addon_installed_set_options_failure( assert result["reason"] == "addon_set_config_failed" assert start_addon.call_count == 0 + assert "Failed to set the Z-Wave JS app options" in caplog.text + assert "not a valid value for dictionary value" in caplog.text + assert REDACTED in caplog.text + assert secret not in caplog.text @pytest.mark.usefixtures("supervisor", "addon_installed") diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 035d657bbeb371..c8966a412a4133 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -34,12 +34,14 @@ entity_registry as er, issue_registry as ir, ) +from homeassistant.helpers.redact import REDACTED from homeassistant.setup import async_setup_component from .common import ( AIR_TEMPERATURE_SENSOR, BULB_6_MULTI_COLOR_LIGHT_ENTITY, EATON_RF9640_ENTITY, + TEST_SENSITIVE_NETWORK_KEY, ) from tests.common import ( @@ -933,6 +935,46 @@ async def test_start_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed", "addon_info") +@pytest.mark.parametrize( + "set_addon_options_side_effect", + [ + SupervisorError( + "not a valid value for dictionary value @ data['options']. " + f"Got {{'s0_legacy_key': '{TEST_SENSITIVE_NETWORK_KEY}'}}" + ) + ], +) +async def test_start_addon_redacts_set_options_error( + hass: HomeAssistant, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test startup redacts add-on options backend error details.""" + device = "/test" + secret = TEST_SENSITIVE_NETWORK_KEY + entry = MockConfigEntry( + domain=DOMAIN, + title="Z-Wave JS", + data={"use_addon": True, "usb_path": device, "network_key": secret}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert install_addon.call_count == 0 + assert set_addon_options.call_count == 1 + assert start_addon.call_count == 0 + assert "Failed to set the Z-Wave JS app options" in caplog.text + assert "not a valid value for dictionary value" in caplog.text + assert REDACTED in caplog.text + assert secret not in caplog.text + + @pytest.mark.usefixtures("addon_info") async def test_install_addon( hass: HomeAssistant, From 57568fdc2c4f2ba581b5db16145ea5d293bd1cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 9 Apr 2026 00:02:05 +0100 Subject: [PATCH 0619/1707] Add standard event type for doorbell event entities (#167630) --- homeassistant/components/event/__init__.py | 18 +++++- homeassistant/components/event/const.py | 8 +++ homeassistant/components/event/strings.json | 9 ++- tests/components/event/test_init.py | 67 +++++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index 4ed5a0f1378bf6..4a4914abf8bb0a 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -20,7 +20,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey -from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN +from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN, DoorbellEventType _LOGGER = logging.getLogger(__name__) DATA_COMPONENT: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN) @@ -44,6 +44,7 @@ class EventDeviceClass(StrEnum): "DOMAIN", "PLATFORM_SCHEMA", "PLATFORM_SCHEMA_BASE", + "DoorbellEventType", "EventDeviceClass", "EventEntity", "EventEntityDescription", @@ -189,6 +190,21 @@ def state_attributes(self) -> dict[str, Any]: async def async_internal_added_to_hass(self) -> None: """Call when the event entity is added to hass.""" await super().async_internal_added_to_hass() + + if ( + self.device_class == EventDeviceClass.DOORBELL + and DoorbellEventType.RING not in self.event_types + ): + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s is a doorbell event entity but does not support " + "the '%s' event type. This will stop working in " + "Home Assistant 2027.4, please %s", + self.entity_id, + DoorbellEventType.RING, + report_issue, + ) + if ( (state := await self.async_get_last_state()) and state.state is not None diff --git a/homeassistant/components/event/const.py b/homeassistant/components/event/const.py index cd6a8b96f7a3bd..5bab5875052635 100644 --- a/homeassistant/components/event/const.py +++ b/homeassistant/components/event/const.py @@ -1,5 +1,13 @@ """Provides the constants needed for the component.""" +from enum import StrEnum + DOMAIN = "event" ATTR_EVENT_TYPE = "event_type" ATTR_EVENT_TYPES = "event_types" + + +class DoorbellEventType(StrEnum): + """Standard event types for doorbell device class.""" + + RING = "ring" diff --git a/homeassistant/components/event/strings.json b/homeassistant/components/event/strings.json index bdf9144761cda5..1b5e349b8f3523 100644 --- a/homeassistant/components/event/strings.json +++ b/homeassistant/components/event/strings.json @@ -15,7 +15,14 @@ "name": "Button" }, "doorbell": { - "name": "Doorbell" + "name": "Doorbell", + "state_attributes": { + "event_type": { + "state": { + "ring": "Ring" + } + } + } }, "motion": { "name": "Motion" diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 0cd1f39228fc8b..0df0b152d4260a 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -10,6 +10,7 @@ ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN, + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -34,6 +35,7 @@ mock_platform, mock_restore_cache, mock_restore_cache_with_extra_data, + setup_test_component_platform, ) @@ -344,3 +346,68 @@ async def async_setup_entry_platform( "device_class": "doorbell", "friendly_name": "Doorbell", } + + +@pytest.mark.usefixtures("config_flow_fixture") +async def test_doorbell_missing_ring_event_type( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test warning when a doorbell entity does not include the standard ring event type.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.EVENT] + ) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Doorbell entity WITHOUT the standard "ring" event type + entity_without_ring = EventEntity() + entity_without_ring._attr_event_types = ["ding"] + entity_without_ring._attr_device_class = EventDeviceClass.DOORBELL + entity_without_ring._attr_has_entity_name = True + entity_without_ring.entity_id = "event.doorbell_without_ring" + + # Doorbell entity WITH the standard "ring" event type + entity_with_ring = EventEntity() + entity_with_ring._attr_event_types = [DoorbellEventType.RING, "ding"] + entity_with_ring._attr_device_class = EventDeviceClass.DOORBELL + entity_with_ring._attr_has_entity_name = True + entity_with_ring.entity_id = "event.doorbell_with_ring" + + # Non-doorbell entity should not warn + entity_button = EventEntity() + entity_button._attr_event_types = ["press"] + entity_button._attr_device_class = EventDeviceClass.BUTTON + entity_button._attr_has_entity_name = True + entity_button.entity_id = "event.button" + + setup_test_component_platform( + hass, + DOMAIN, + [entity_without_ring, entity_with_ring, entity_button], + from_config_entry=True, + ) + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + "Entity event.doorbell_without_ring is a doorbell event entity " + "but does not support the 'ring' event type" + ) in caplog.text + assert "event.doorbell_with_ring" not in caplog.text + assert "event.button" not in caplog.text From 19ae7e722e72967077afc72ad690594b204f4912 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:51:03 +0200 Subject: [PATCH 0620/1707] Bump pybotvac to 0.0.29 (#167758) --- homeassistant/components/neato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index 577a515bf4df6f..37886a921a7475 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pybotvac"], - "requirements": ["pybotvac==0.0.28"] + "requirements": ["pybotvac==0.0.29"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2405f2acad1429..5c68be58b47d6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1995,7 +1995,7 @@ pyblackbird==0.6 pyblu==2.0.6 # homeassistant.components.neato -pybotvac==0.0.28 +pybotvac==0.0.29 # homeassistant.components.braviatv pybravia==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9d02509f165fb..e4adec2411e2a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1729,7 +1729,7 @@ pyblackbird==0.6 pyblu==2.0.6 # homeassistant.components.neato -pybotvac==0.0.28 +pybotvac==0.0.29 # homeassistant.components.braviatv pybravia==0.4.1 From 86d443f8c6761231a5f5b690404eaee02feaeeca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:29:18 +0200 Subject: [PATCH 0621/1707] Bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0 (#167648) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ff2257f9cdb08c..d906d3c209ebbd 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -499,7 +499,7 @@ jobs: python -m build - name: Upload package to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: skip-existing: true From 65bc7c9ea7a7f0ed4e75c12ff9f54185549cfd5c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 9 Apr 2026 11:16:05 +0200 Subject: [PATCH 0622/1707] Allow force alarm actions for Comelit (#167202) --- .../components/comelit/alarm_control_panel.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index de2186cf7f3b10..aa6af33c50b8df 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -112,7 +112,7 @@ def _area(self) -> ComelitVedoAreaObject: @property def available(self) -> bool: """Return True if alarm is available.""" - if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]: + if self._area.human_status == AlarmAreaState.UNKNOWN: return False return super().available @@ -151,7 +151,7 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: if code != str(self.coordinator.api.device_pin): return await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[DISABLE] + self._area.index, ALARM_ACTIONS[DISABLE], self._area.anomaly ) await self._async_update_state( AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE] @@ -160,7 +160,7 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[AWAY] + self._area.index, ALARM_ACTIONS[AWAY], self._area.anomaly ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY] @@ -169,7 +169,7 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None: async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[HOME] + self._area.index, ALARM_ACTIONS[HOME], self._area.anomaly ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1] @@ -178,7 +178,7 @@ async def async_alarm_arm_home(self, code: str | None = None) -> None: async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[NIGHT] + self._area.index, ALARM_ACTIONS[NIGHT], self._area.anomaly ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT] From 326799209c10d5e99a99d138469d277330c35474 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 9 Apr 2026 11:49:56 +0200 Subject: [PATCH 0623/1707] Extract config entry template functions into a config entry Jinja2 extension (#167360) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- homeassistant/helpers/template/__init__.py | 82 +--------- .../helpers/template/extensions/__init__.py | 2 + .../template/extensions/config_entries.py | 109 +++++++++++++ .../extensions/test_config_entries.py | 153 ++++++++++++++++++ tests/helpers/template/test_init.py | 140 ---------------- 5 files changed, 267 insertions(+), 219 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/config_entries.py create mode 100644 tests/helpers/template/extensions/test_config_entries.py diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 700f90dd70a138..b799ba63ec646f 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -1156,72 +1156,6 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: return list(found.values()) -def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: - """Get entity ids for entities tied to an integration/domain. - - Provide entry_name as domain to get all entity id's for a integration/domain - or provide a config entry title for filtering between instances of the same - integration. - """ - - # Don't allow searching for config entries without title - if not entry_name: - return [] - - # first try if there are any config entries with a matching title - entities: list[str] = [] - ent_reg = er.async_get(hass) - for entry in hass.config_entries.async_entries(): - if entry.title != entry_name: - continue - entries = er.async_entries_for_config_entry(ent_reg, entry.entry_id) - entities.extend(entry.entity_id for entry in entries) - if entities: - return entities - - # fallback to just returning all entities for a domain - from homeassistant.helpers.entity import entity_sources # noqa: PLC0415 - - return [ - entity_id - for entity_id, info in entity_sources(hass).items() - if info["domain"] == entry_name - ] - - -def config_entry_id(hass: HomeAssistant, entity_id: str) -> str | None: - """Get an config entry ID from an entity ID.""" - entity_reg = er.async_get(hass) - if entity := entity_reg.async_get(entity_id): - return entity.config_entry_id - return None - - -def config_entry_attr( - hass: HomeAssistant, config_entry_id_: str, attr_name: str -) -> Any: - """Get config entry specific attribute.""" - if not isinstance(config_entry_id_, str): - raise TemplateError("Must provide a config entry ID") - - if attr_name not in ( - "domain", - "title", - "state", - "source", - "disabled_by", - "pref_disable_polling", - ): - raise TemplateError("Invalid config entry attribute") - - config_entry = hass.config_entries.async_get_entry(config_entry_id_) - - if config_entry is None: - return None - - return getattr(config_entry, attr_name) - - def closest(hass: HomeAssistant, *args: Any) -> State | None: """Find closest entity. @@ -1540,6 +1474,9 @@ def __init__( self.add_extension( "homeassistant.helpers.template.extensions.CollectionExtension" ) + self.add_extension( + "homeassistant.helpers.template.extensions.ConfigEntryExtension" + ) self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension") self.add_extension( "homeassistant.helpers.template.extensions.DateTimeExtension" @@ -1587,19 +1524,6 @@ def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: return jinja_context(wrapper) - # Integration extensions - - self.globals["integration_entities"] = hassfunction(integration_entities) - self.filters["integration_entities"] = self.globals["integration_entities"] - - # Config entry extensions - - self.globals["config_entry_attr"] = hassfunction(config_entry_attr) - self.filters["config_entry_attr"] = self.globals["config_entry_attr"] - - self.globals["config_entry_id"] = hassfunction(config_entry_id) - self.filters["config_entry_id"] = self.globals["config_entry_id"] - if limited: def unsupported(name: str) -> Callable[[], NoReturn]: diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 65792528adc1a4..9dfaaf715059a0 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -3,6 +3,7 @@ from .areas import AreaExtension from .base64 import Base64Extension from .collection import CollectionExtension +from .config_entries import ConfigEntryExtension from .crypto import CryptoExtension from .datetime import DateTimeExtension from .devices import DeviceExtension @@ -21,6 +22,7 @@ "AreaExtension", "Base64Extension", "CollectionExtension", + "ConfigEntryExtension", "CryptoExtension", "DateTimeExtension", "DeviceExtension", diff --git a/homeassistant/helpers/template/extensions/config_entries.py b/homeassistant/helpers/template/extensions/config_entries.py new file mode 100644 index 00000000000000..dfc4cf9a0697cd --- /dev/null +++ b/homeassistant/helpers/template/extensions/config_entries.py @@ -0,0 +1,109 @@ +"""Config entry functions for Home Assistant templates.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import entity_registry as er + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class ConfigEntryExtension(BaseTemplateExtension): + """Jinja2 extension for config entry functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the config entry extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "integration_entities", + self.integration_entities, + as_global=True, + as_filter=True, + requires_hass=True, + ), + TemplateFunction( + "config_entry_id", + self.config_entry_id, + as_global=True, + as_filter=True, + requires_hass=True, + ), + TemplateFunction( + "config_entry_attr", + self.config_entry_attr, + as_global=True, + as_filter=True, + requires_hass=True, + ), + ], + ) + + def integration_entities(self, entry_name: str) -> Iterable[str]: + """Get entity IDs for entities tied to an integration/domain. + + Provide entry_name as domain to get all entity IDs for an integration/domain + or provide a config entry title for filtering between instances of the same + integration. + """ + # Don't allow searching for config entries without title + if not entry_name: + return [] + + hass = self.hass + + # first try if there are any config entries with a matching title + entities: list[str] = [] + ent_reg = er.async_get(hass) + for entry in hass.config_entries.async_entries(): + if entry.title != entry_name: + continue + entries = er.async_entries_for_config_entry(ent_reg, entry.entry_id) + entities.extend(entry.entity_id for entry in entries) + if entities: + return entities + + # fallback to just returning all entities for a domain + from homeassistant.helpers.entity import entity_sources # noqa: PLC0415 + + return [ + entity_id + for entity_id, info in entity_sources(hass).items() + if info["domain"] == entry_name + ] + + def config_entry_id(self, entity_id: str) -> str | None: + """Get a config entry ID from an entity ID.""" + entity_reg = er.async_get(self.hass) + if entity := entity_reg.async_get(entity_id): + return entity.config_entry_id + return None + + def config_entry_attr(self, config_entry_id: str, attr_name: str) -> Any: + """Get config entry specific attribute.""" + if not isinstance(config_entry_id, str): + raise TemplateError("Must provide a config entry ID") + + if attr_name not in ( + "domain", + "title", + "state", + "source", + "disabled_by", + "pref_disable_polling", + ): + raise TemplateError("Invalid config entry attribute") + + config_entry = self.hass.config_entries.async_get_entry(config_entry_id) + + if config_entry is None: + return None + + return getattr(config_entry, attr_name) diff --git a/tests/helpers/template/extensions/test_config_entries.py b/tests/helpers/template/extensions/test_config_entries.py new file mode 100644 index 00000000000000..510cfaafa7c854 --- /dev/null +++ b/tests/helpers/template/extensions/test_config_entries.py @@ -0,0 +1,153 @@ +"""Test config entry functions for Home Assistant templates.""" + +from __future__ import annotations + +from datetime import timedelta +import json +import logging + +import pytest + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import entity, entity_registry as er +from homeassistant.helpers.entity_platform import EntityPlatform + +from tests.common import MockConfigEntry +from tests.helpers.template.helpers import assert_result_info, render, render_to_info + + +async def test_integration_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test integration_entities function.""" + # test entities for untitled config entry + config_entry = MockConfigEntry(domain="mock", title="") + config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + "sensor", "mock", "untitled", config_entry=config_entry + ) + info = render_to_info(hass, "{{ integration_entities('') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # test entities for given config entry title + config_entry = MockConfigEntry(domain="mock", title="Mock bridge 2") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "sensor", "mock", "test", config_entry=config_entry + ) + info = render_to_info(hass, "{{ integration_entities('Mock bridge 2') }}") + assert_result_info(info, [entity_entry.entity_id]) + assert info.rate_limit is None + + # test entities for given non unique config entry title + config_entry = MockConfigEntry(domain="mock", title="Not unique") + config_entry.add_to_hass(hass) + entity_entry_not_unique_1 = entity_registry.async_get_or_create( + "sensor", "mock", "not_unique_1", config_entry=config_entry + ) + config_entry = MockConfigEntry(domain="mock", title="Not unique") + config_entry.add_to_hass(hass) + entity_entry_not_unique_2 = entity_registry.async_get_or_create( + "sensor", "mock", "not_unique_2", config_entry=config_entry + ) + info = render_to_info(hass, "{{ integration_entities('Not unique') }}") + assert_result_info( + info, [entity_entry_not_unique_1.entity_id, entity_entry_not_unique_2.entity_id] + ) + assert info.rate_limit is None + + # test integration entities not in entity registry + mock_entity = entity.Entity() + mock_entity.hass = hass + mock_entity.entity_id = "light.test_entity" + mock_entity.platform = EntityPlatform( + hass=hass, + logger=logging.getLogger(__name__), + domain="light", + platform_name="entryless_integration", + platform=None, + scan_interval=timedelta(seconds=30), + entity_namespace=None, + ) + await mock_entity.async_internal_added_to_hass() + info = render_to_info(hass, "{{ integration_entities('entryless_integration') }}") + assert_result_info(info, ["light.test_entity"]) + assert info.rate_limit is None + + # Test non existing integration/entry title + info = render_to_info(hass, "{{ integration_entities('abc123') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + +async def test_config_entry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test config_entry_id function.""" + config_entry = MockConfigEntry(domain="light", title="Some integration") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "sensor", "test", "test", suggested_object_id="test", config_entry=config_entry + ) + + info = render_to_info(hass, "{{ 'sensor.fail' | config_entry_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 56 | config_entry_id }}") + assert_result_info(info, None) + + info = render_to_info(hass, "{{ 'not_a_real_entity_id' | config_entry_id }}") + assert_result_info(info, None) + + info = render_to_info( + hass, f"{{{{ config_entry_id('{entity_entry.entity_id}') }}}}" + ) + assert_result_info(info, config_entry.entry_id) + assert info.rate_limit is None + + +async def test_config_entry_attr(hass: HomeAssistant) -> None: + """Test config entry attr.""" + info = { + "domain": "mock_light", + "title": "mock title", + "source": config_entries.SOURCE_BLUETOOTH, + "disabled_by": config_entries.ConfigEntryDisabler.USER, + "pref_disable_polling": True, + } + config_entry = MockConfigEntry(**info) + config_entry.add_to_hass(hass) + + info["state"] = config_entries.ConfigEntryState.NOT_LOADED + + for key, value in info.items(): + assert render( + hass, + "{{ config_entry_attr('" + config_entry.entry_id + "', '" + key + "') }}", + parse_result=False, + ) == str(value) + + for config_entry_id, key in ( + (config_entry.entry_id, "invalid_key"), + (56, "domain"), + ): + with pytest.raises(TemplateError): + render( + hass, + "{{ config_entry_attr(" + + json.dumps(config_entry_id) + + ", '" + + key + + "') }}", + ) + + assert ( + render( + hass, "{{ config_entry_attr('invalid_id', 'domain') }}", parse_result=False + ) + == "None" + ) diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 6f1ed335f830f1..d8178cc25ddbbe 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -4,15 +4,12 @@ from collections.abc import Iterable from datetime import datetime, timedelta -import json -import logging from unittest.mock import patch from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import group from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -31,12 +28,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import ( device_registry as dr, - entity, entity_registry as er, template, translation, ) -from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps from homeassistant.helpers.template.render_info import ( ALL_STATES_RATE_LIMIT, @@ -1327,141 +1322,6 @@ async def test_expand(hass: HomeAssistant) -> None: ) -async def test_integration_entities( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test integration_entities function.""" - # test entities for untitled config entry - config_entry = MockConfigEntry(domain="mock", title="") - config_entry.add_to_hass(hass) - entity_registry.async_get_or_create( - "sensor", "mock", "untitled", config_entry=config_entry - ) - info = render_to_info(hass, "{{ integration_entities('') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # test entities for given config entry title - config_entry = MockConfigEntry(domain="mock", title="Mock bridge 2") - config_entry.add_to_hass(hass) - entity_entry = entity_registry.async_get_or_create( - "sensor", "mock", "test", config_entry=config_entry - ) - info = render_to_info(hass, "{{ integration_entities('Mock bridge 2') }}") - assert_result_info(info, [entity_entry.entity_id]) - assert info.rate_limit is None - - # test entities for given non unique config entry title - config_entry = MockConfigEntry(domain="mock", title="Not unique") - config_entry.add_to_hass(hass) - entity_entry_not_unique_1 = entity_registry.async_get_or_create( - "sensor", "mock", "not_unique_1", config_entry=config_entry - ) - config_entry = MockConfigEntry(domain="mock", title="Not unique") - config_entry.add_to_hass(hass) - entity_entry_not_unique_2 = entity_registry.async_get_or_create( - "sensor", "mock", "not_unique_2", config_entry=config_entry - ) - info = render_to_info(hass, "{{ integration_entities('Not unique') }}") - assert_result_info( - info, [entity_entry_not_unique_1.entity_id, entity_entry_not_unique_2.entity_id] - ) - assert info.rate_limit is None - - # test integration entities not in entity registry - mock_entity = entity.Entity() - mock_entity.hass = hass - mock_entity.entity_id = "light.test_entity" - mock_entity.platform = EntityPlatform( - hass=hass, - logger=logging.getLogger(__name__), - domain="light", - platform_name="entryless_integration", - platform=None, - scan_interval=timedelta(seconds=30), - entity_namespace=None, - ) - await mock_entity.async_internal_added_to_hass() - info = render_to_info(hass, "{{ integration_entities('entryless_integration') }}") - assert_result_info(info, ["light.test_entity"]) - assert info.rate_limit is None - - # Test non existing integration/entry title - info = render_to_info(hass, "{{ integration_entities('abc123') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - -async def test_config_entry_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test config_entry_id function.""" - config_entry = MockConfigEntry(domain="light", title="Some integration") - config_entry.add_to_hass(hass) - entity_entry = entity_registry.async_get_or_create( - "sensor", "test", "test", suggested_object_id="test", config_entry=config_entry - ) - - info = render_to_info(hass, "{{ 'sensor.fail' | config_entry_id }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 56 | config_entry_id }}") - assert_result_info(info, None) - - info = render_to_info(hass, "{{ 'not_a_real_entity_id' | config_entry_id }}") - assert_result_info(info, None) - - info = render_to_info( - hass, f"{{{{ config_entry_id('{entity_entry.entity_id}') }}}}" - ) - assert_result_info(info, config_entry.entry_id) - assert info.rate_limit is None - - -async def test_config_entry_attr(hass: HomeAssistant) -> None: - """Test config entry attr.""" - info = { - "domain": "mock_light", - "title": "mock title", - "source": config_entries.SOURCE_BLUETOOTH, - "disabled_by": config_entries.ConfigEntryDisabler.USER, - "pref_disable_polling": True, - } - config_entry = MockConfigEntry(**info) - config_entry.add_to_hass(hass) - - info["state"] = config_entries.ConfigEntryState.NOT_LOADED - - for key, value in info.items(): - assert render( - hass, - "{{ config_entry_attr('" + config_entry.entry_id + "', '" + key + "') }}", - parse_result=False, - ) == str(value) - - for config_entry_id, key in ( - (config_entry.entry_id, "invalid_key"), - (56, "domain"), - ): - with pytest.raises(TemplateError): - render( - hass, - "{{ config_entry_attr(" - + json.dumps(config_entry_id) - + ", '" - + key - + "') }}", - ) - - assert ( - render( - hass, "{{ config_entry_attr('invalid_id', 'domain') }}", parse_result=False - ) - == "None" - ) - - def test_closest_function_to_coord(hass: HomeAssistant) -> None: """Test closest function to coord.""" hass.states.async_set( From 949c9074074b2d8f08451f99599308809bec881d Mon Sep 17 00:00:00 2001 From: wollew Date: Thu, 9 Apr 2026 11:55:32 +0200 Subject: [PATCH 0624/1707] Bump pyvlx to 0.2.33 (#167764) --- homeassistant/components/velux/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 9ebe6ff6062f7e..820442830e7050 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pyvlx"], "quality_scale": "silver", - "requirements": ["pyvlx==0.2.32"] + "requirements": ["pyvlx==0.2.33"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5c68be58b47d6d..f7e9b0780bc249 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2742,7 +2742,7 @@ pyvesync==3.4.1 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.32 +pyvlx==0.2.33 # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4adec2411e2a1..b4220f71a45cdb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2332,7 +2332,7 @@ pyvesync==3.4.1 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.32 +pyvlx==0.2.33 # homeassistant.components.volumio pyvolumio==0.1.5 From 075b47b5f9ca62a9883dfd6aeb21bfbfec33d282 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:46:34 +0200 Subject: [PATCH 0625/1707] Set proper state for the internet_access switches in FRITZ!Box Tools (#167767) --- homeassistant/components/fritz/coordinator.py | 9 ++++--- tests/components/fritz/test_switch.py | 26 ++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 55743af5e8362d..f6a0ae355973f0 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -453,10 +453,13 @@ async def _async_update_hosts_info(self) -> dict[str, Device]: if not attributes.get("MACAddress"): continue + wan_access_result = None if (wan_access := attributes.get("X_AVM-DE_WANAccess")) is not None: - wan_access_result = "granted" in wan_access - else: - wan_access_result = None + # wan_access can be "granted", "denied", "unknown" or "error" + if "granted" in wan_access: + wan_access_result = True + elif "denied" in wan_access: + wan_access_result = False hosts[attributes["MACAddress"]] = Device( name=attributes["HostName"], diff --git a/tests/components/fritz/test_switch.py b/tests/components/fritz/test_switch.py index 2bf53065f1ee85..a5fb5a48bd768b 100644 --- a/tests/components/fritz/test_switch.py +++ b/tests/components/fritz/test_switch.py @@ -333,30 +333,42 @@ async def test_switch_no_mesh_wifi_uplink( await hass.async_block_till_done(wait_background_tasks=True) -async def test_switch_device_no_wan_access( +@pytest.mark.parametrize( + ("wan_access_data", "expected_state"), + [ + (None, STATE_UNAVAILABLE), + ("unknown", STATE_UNAVAILABLE), + ("error", STATE_UNAVAILABLE), + ("granted", STATE_ON), + ("denied", STATE_OFF), + ], +) +async def test_switch_device_wan_access( hass: HomeAssistant, fc_class_mock, fh_class_mock, fs_class_mock, + wan_access_data: str | None, + expected_state: str, ) -> None: - """Test Fritz!Tools switches when device has no WAN access.""" + """Test Fritz!Tools switches have proper WAN access state.""" entity_id = "switch.printer_internet_access" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) entry.add_to_hass(hass) - attributes = [ - {k: v for k, v in host.items() if k != "X_AVM-DE_WANAccess"} - for host in MOCK_HOST_ATTRIBUTES_DATA - ] + attributes = deepcopy(MOCK_HOST_ATTRIBUTES_DATA) + for host in attributes: + host["X_AVM-DE_WANAccess"] = wan_access_data + fh_class_mock.get_hosts_attributes = MagicMock(return_value=attributes) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert (state := hass.states.get(entity_id)) - assert state.state == STATE_UNAVAILABLE + assert state.state == expected_state async def test_switch_device_no_ip_address( From 8e430d9f26592bba3b154515373fcf0f904d9cc6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 9 Apr 2026 13:19:05 +0200 Subject: [PATCH 0626/1707] Bump brother to 6.1.0 (#167768) --- homeassistant/components/brother/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 1f95fefc66e2e8..af519876eb8963 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==6.0.0"], + "requirements": ["brother==6.1.0"], "zeroconf": [ { "name": "brother*", diff --git a/requirements_all.txt b/requirements_all.txt index f7e9b0780bc249..28e0f08086f9e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -702,7 +702,7 @@ bring-api==1.1.1 broadlink==0.19.0 # homeassistant.components.brother -brother==6.0.0 +brother==6.1.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4220f71a45cdb..e53aea0d64838f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -632,7 +632,7 @@ bring-api==1.1.1 broadlink==0.19.0 # homeassistant.components.brother -brother==6.0.0 +brother==6.1.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 From 3ea15f274341b8da42f4cb2389db5d7500f931be Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 9 Apr 2026 21:20:01 +1000 Subject: [PATCH 0627/1707] Refactor Ultima fixtures to reduce duplication (#167731) --- tests/components/smlight/conftest.py | 13 +++ tests/components/smlight/test_infrared.py | 38 ++------ tests/components/smlight/test_light.py | 106 ++++++++-------------- 3 files changed, 59 insertions(+), 98 deletions(-) diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 982ccc3b78621d..c25ff2abc066f6 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -124,6 +124,19 @@ def get_firmware_side_effect(*args, **kwargs) -> list[Firmware]: yield api +MOCK_ULTIMA = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-Ultima3", +) + + +@pytest.fixture +def mock_ultima_client(mock_smlight_client: MagicMock) -> MagicMock: + """Configure api client to return an Ultima device.""" + mock_smlight_client.get_info.side_effect = lambda *arg, **kwargs: MOCK_ULTIMA + return mock_smlight_client + + async def setup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/smlight/test_infrared.py b/tests/components/smlight/test_infrared.py index 14677d44cc1acc..6c9a7c87806749 100644 --- a/tests/components/smlight/test_infrared.py +++ b/tests/components/smlight/test_infrared.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock from infrared_protocols import Command, Timing -from pysmlight import Info from pysmlight.exceptions import SmlightError from pysmlight.models import IRPayload import pytest @@ -36,20 +35,12 @@ def platforms() -> list[Platform]: return [Platform.INFRARED] -MOCK_ULTIMA = Info( - MAC="AA:BB:CC:DD:EE:FF", - model="SLZB-Ultima3", -) - - async def test_infrared_setup_ultima( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test infrared entity is created for Ultima devices.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) state = hass.states.get("infrared.mock_title_ir_emitter") @@ -62,11 +53,6 @@ async def test_infrared_not_created_non_ultima( mock_smlight_client: MagicMock, ) -> None: """Test infrared entity is not created for non-Ultima devices.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info( - MAC="AA:BB:CC:DD:EE:FF", - model="SLZB-MR1", - ) await setup_integration(hass, mock_config_entry) state = hass.states.get("infrared.mock_title_ir_emitter") @@ -76,11 +62,9 @@ async def test_infrared_not_created_non_ultima( async def test_infrared_send_command( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test sending IR command.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "infrared.mock_title_ir_emitter" @@ -93,7 +77,7 @@ async def test_infrared_send_command( MockCommand(), ) - mock_smlight_client.actions.send_ir_code.assert_called_once_with( + mock_ultima_client.actions.send_ir_code.assert_called_once_with( IRPayload.from_raw_timings([9000, 4500, 560, 1690], freq=38000) ) @@ -101,18 +85,16 @@ async def test_infrared_send_command( async def test_infrared_send_command_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test connection error handling.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "infrared.mock_title_ir_emitter" state = hass.states.get(entity_id) assert state is not None - mock_smlight_client.actions.send_ir_code.side_effect = SmlightError("Failed") + mock_ultima_client.actions.send_ir_code.side_effect = SmlightError("Failed") with pytest.raises(HomeAssistantError) as exc_info: await async_send_command( @@ -126,18 +108,16 @@ async def test_infrared_send_command_error( async def test_infrared_send_empty_command_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test ValueError from pysmlight is surfaced as HomeAssistantError.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "infrared.mock_title_ir_emitter" state = hass.states.get(entity_id) assert state is not None - mock_smlight_client.actions.send_ir_code.side_effect = ValueError("empty payload") + mock_ultima_client.actions.send_ir_code.side_effect = ValueError("empty payload") with pytest.raises(HomeAssistantError) as exc_info: await async_send_command( @@ -152,11 +132,9 @@ async def test_infrared_send_empty_command_error( async def test_infrared_state_updated_after_send( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test that entity state is updated with a timestamp after a successful send.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "infrared.mock_title_ir_emitter" diff --git a/tests/components/smlight/test_light.py b/tests/components/smlight/test_light.py index 7a5a9d8f130bb3..b19c291304b01a 100644 --- a/tests/components/smlight/test_light.py +++ b/tests/components/smlight/test_light.py @@ -3,7 +3,6 @@ from collections.abc import Awaitable, Callable from unittest.mock import MagicMock -from pysmlight import Info from pysmlight.const import AmbiEffect from pysmlight.exceptions import SmlightConnectionError from pysmlight.models import AmbilightPayload @@ -40,17 +39,11 @@ def platforms() -> Platform | list[Platform]: return [Platform.LIGHT] -MOCK_ULTIMA = Info( - MAC="AA:BB:CC:DD:EE:FF", - model="SLZB-Ultima3", -) - - def _build_fire_sse_ambilight( - hass: HomeAssistant, mock_smlight_client: MagicMock + hass: HomeAssistant, mock_ultima_client: MagicMock ) -> Callable[[dict[str, object]], Awaitable[None]]: """Build helper to push ambilight SSE events and wait for state updates.""" - page_callback = mock_smlight_client.sse.register_page_cb.call_args[0][1] + page_callback = mock_ultima_client.sse.register_page_cb.call_args[0][1] async def fire_ambi(changes: dict[str, object]) -> None: page_callback(changes) @@ -63,12 +56,10 @@ async def test_light_setup_ultima( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test light entity is created for Ultima devices.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA entry = await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) @@ -82,11 +73,6 @@ async def test_light_not_created_non_ultima( mock_smlight_client: MagicMock, ) -> None: """Test light entity is not created for non-Ultima devices.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info( - MAC="AA:BB:CC:DD:EE:FF", - model="SLZB-MR1", - ) await setup_integration(hass, mock_config_entry) state = hass.states.get("light.mock_title_ambilight") @@ -96,20 +82,18 @@ async def test_light_not_created_non_ultima( async def test_light_turn_on_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test turning light on and off.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "light.mock_title_ambilight" state = hass.states.get(entity_id) assert state.state != STATE_UNAVAILABLE - fire_ambi = _build_fire_sse_ambilight(hass, mock_smlight_client) + fire_ambi = _build_fire_sse_ambilight(hass, mock_ultima_client) - mock_smlight_client.actions.ambilight.reset_mock() + mock_ultima_client.actions.ambilight.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -117,7 +101,7 @@ async def test_light_turn_on_off( blocking=True, ) - mock_smlight_client.actions.ambilight.assert_called_once_with( + mock_ultima_client.actions.ambilight.assert_called_once_with( AmbilightPayload(ultLedMode=AmbiEffect.WSULT_SOLID) ) await fire_ambi({"ultLedMode": 0, "ultLedBri": 158, "ultLedColor": 0x7FACFF}) @@ -125,7 +109,7 @@ async def test_light_turn_on_off( state = hass.states.get(entity_id) assert state.state == STATE_ON - mock_smlight_client.actions.ambilight.reset_mock() + mock_ultima_client.actions.ambilight.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -133,7 +117,7 @@ async def test_light_turn_on_off( blocking=True, ) - mock_smlight_client.actions.ambilight.assert_called_once_with( + mock_ultima_client.actions.ambilight.assert_called_once_with( AmbilightPayload(ultLedMode=AmbiEffect.WSULT_OFF) ) await fire_ambi({"ultLedMode": 1}) @@ -145,20 +129,18 @@ async def test_light_turn_on_off( async def test_light_brightness( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test setting brightness.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "light.mock_title_ambilight" - fire_ambi = _build_fire_sse_ambilight(hass, mock_smlight_client) + fire_ambi = _build_fire_sse_ambilight(hass, mock_ultima_client) # Seed current state as on so brightness-only update does not force solid mode. await fire_ambi({"ultLedMode": 0, "ultLedBri": 158, "ultLedColor": 0x7FACFF}) - mock_smlight_client.actions.ambilight.reset_mock() + mock_ultima_client.actions.ambilight.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -167,7 +149,7 @@ async def test_light_brightness( blocking=True, ) - mock_smlight_client.actions.ambilight.assert_called_once_with( + mock_ultima_client.actions.ambilight.assert_called_once_with( AmbilightPayload(ultLedBri=200) ) await fire_ambi({"ultLedMode": 0, "ultLedBri": 200, "ultLedColor": 0x7FACFF}) @@ -180,18 +162,16 @@ async def test_light_brightness( async def test_light_rgb_color( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test setting RGB color.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "light.mock_title_ambilight" - fire_ambi = _build_fire_sse_ambilight(hass, mock_smlight_client) + fire_ambi = _build_fire_sse_ambilight(hass, mock_ultima_client) - mock_smlight_client.actions.ambilight.reset_mock() + mock_ultima_client.actions.ambilight.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -199,7 +179,7 @@ async def test_light_rgb_color( blocking=True, ) - mock_smlight_client.actions.ambilight.assert_called_once_with( + mock_ultima_client.actions.ambilight.assert_called_once_with( AmbilightPayload(ultLedMode=AmbiEffect.WSULT_SOLID, ultLedColor="#ff8040") ) await fire_ambi({"ultLedMode": 0, "ultLedColor": 0xFF8040}) @@ -212,19 +192,17 @@ async def test_light_rgb_color( async def test_light_effect( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test setting effect.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "light.mock_title_ambilight" - fire_ambi = _build_fire_sse_ambilight(hass, mock_smlight_client) + fire_ambi = _build_fire_sse_ambilight(hass, mock_ultima_client) # Test Rainbow effect - mock_smlight_client.actions.ambilight.reset_mock() + mock_ultima_client.actions.ambilight.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -232,13 +210,13 @@ async def test_light_effect( blocking=True, ) - mock_smlight_client.actions.ambilight.assert_called_once_with( + mock_ultima_client.actions.ambilight.assert_called_once_with( AmbilightPayload(ultLedMode=AmbiEffect.WSULT_RAINBOW) ) await fire_ambi({"ultLedMode": 3}) # Test Blur effect - mock_smlight_client.actions.ambilight.reset_mock() + mock_ultima_client.actions.ambilight.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -246,7 +224,7 @@ async def test_light_effect( blocking=True, ) - mock_smlight_client.actions.ambilight.assert_called_once_with( + mock_ultima_client.actions.ambilight.assert_called_once_with( AmbilightPayload(ultLedMode=AmbiEffect.WSULT_BLUR) ) await fire_ambi({"ultLedMode": 2}) @@ -259,16 +237,14 @@ async def test_light_effect( async def test_light_invalid_effect( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test handling of invalid effect name is ignored.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "light.mock_title_ambilight" - mock_smlight_client.actions.ambilight.reset_mock() + mock_ultima_client.actions.ambilight.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -276,25 +252,23 @@ async def test_light_invalid_effect( blocking=True, ) - mock_smlight_client.actions.ambilight.assert_not_called() + mock_ultima_client.actions.ambilight.assert_not_called() async def test_light_turn_on_when_on_is_noop( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test calling turn_on with no attributes does nothing when already on.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "light.mock_title_ambilight" - fire_ambi = _build_fire_sse_ambilight(hass, mock_smlight_client) + fire_ambi = _build_fire_sse_ambilight(hass, mock_ultima_client) await fire_ambi({"ultLedMode": 0}) - mock_smlight_client.actions.ambilight.reset_mock() + mock_ultima_client.actions.ambilight.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -302,21 +276,19 @@ async def test_light_turn_on_when_on_is_noop( blocking=True, ) - mock_smlight_client.actions.ambilight.assert_not_called() + mock_ultima_client.actions.ambilight.assert_not_called() async def test_light_state_handles_invalid_attributes_from_sse( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test state update gracefully handles invalid mode and invalid hex color.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "light.mock_title_ambilight" - fire_ambi = _build_fire_sse_ambilight(hass, mock_smlight_client) + fire_ambi = _build_fire_sse_ambilight(hass, mock_ultima_client) await fire_ambi({"ultLedMode": None, "ultLedColor": "#GG0000"}) @@ -335,18 +307,16 @@ async def test_light_state_handles_invalid_attributes_from_sse( async def test_ambilight_connection_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test connection error handling.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = MOCK_ULTIMA await setup_integration(hass, mock_config_entry) entity_id = "light.mock_title_ambilight" state = hass.states.get(entity_id) assert state.state != STATE_UNAVAILABLE - mock_smlight_client.actions.ambilight.side_effect = SmlightConnectionError + mock_ultima_client.actions.ambilight.side_effect = SmlightConnectionError with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -356,10 +326,10 @@ async def test_ambilight_connection_error( blocking=True, ) - mock_smlight_client.actions.ambilight.side_effect = None - mock_smlight_client.actions.ambilight.reset_mock() + mock_ultima_client.actions.ambilight.side_effect = None + mock_ultima_client.actions.ambilight.reset_mock() - fire_ambi = _build_fire_sse_ambilight(hass, mock_smlight_client) + fire_ambi = _build_fire_sse_ambilight(hass, mock_ultima_client) await hass.services.async_call( LIGHT_DOMAIN, @@ -368,7 +338,7 @@ async def test_ambilight_connection_error( blocking=True, ) - mock_smlight_client.actions.ambilight.assert_called_once_with( + mock_ultima_client.actions.ambilight.assert_called_once_with( AmbilightPayload(ultLedMode=AmbiEffect.WSULT_SOLID) ) From db589f7318c4eb1f6f2a72e2604add96ddbf3236 Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:20:13 +0200 Subject: [PATCH 0628/1707] Bump lunatone-rest-api-client to 0.9.0 (#167762) --- homeassistant/components/lunatone/__init__.py | 2 +- homeassistant/components/lunatone/light.py | 10 ---------- homeassistant/components/lunatone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lunatone/test_init.py | 2 ++ 6 files changed, 6 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py index c650fca92eca59..3c1ae4b2f82e17 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -69,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> """Set up Lunatone from a config entry.""" auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL]) info_api = Info(auth_api) - devices_api = Devices(auth_api) + devices_api = Devices(info_api) coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api) await coordinator_info.async_config_entry_first_refresh() diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index 72243ae713a095..fa2a9c1873fcd5 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from typing import Any from lunatone_rest_api_client import DALIBroadcast @@ -28,7 +27,6 @@ ) PARALLEL_UPDATES = 0 -STATUS_UPDATE_DELAY = 0.04 async def async_setup_entry( @@ -149,8 +147,6 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) else: await self._device.switch_on() - - await asyncio.sleep(STATUS_UPDATE_DELAY) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: @@ -161,8 +157,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self._device.fade_to_brightness(0) else: await self._device.switch_off() - - await asyncio.sleep(STATUS_UPDATE_DELAY) await self.coordinator.async_refresh() @@ -221,13 +215,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: await self._broadcast.fade_to_brightness( brightness_to_value(self.BRIGHTNESS_SCALE, kwargs.get(ATTR_BRIGHTNESS, 255)) ) - - await asyncio.sleep(STATUS_UPDATE_DELAY) await self._coordinator_devices.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the line to turn off.""" await self._broadcast.fade_to_brightness(0) - - await asyncio.sleep(STATUS_UPDATE_DELAY) await self._coordinator_devices.async_refresh() diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index 33ca0382fbb23e..d32b5dfaa1be89 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.7.0"] + "requirements": ["lunatone-rest-api-client==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 28e0f08086f9e8..93866ce916d98d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ loqedAPI==2.1.11 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.7.0 +lunatone-rest-api-client==0.9.0 # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e53aea0d64838f..e0027ecc1d2697 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1289,7 +1289,7 @@ loqedAPI==2.1.11 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.7.0 +lunatone-rest-api-client==0.9.0 # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py index b3073feca091be..ab3da3c5925743 100644 --- a/tests/components/lunatone/test_init.py +++ b/tests/components/lunatone/test_init.py @@ -95,6 +95,7 @@ async def test_config_entry_not_ready_devices_api_fail( async def test_config_entry_not_ready_no_info_data( hass: HomeAssistant, mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test the Lunatone configuration entry not ready due to missing info data.""" @@ -125,6 +126,7 @@ async def test_config_entry_not_ready_no_devices_data( async def test_config_entry_not_ready_no_serial_number( hass: HomeAssistant, mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test the Lunatone configuration entry not ready due to a missing serial number.""" From 047500af42e16a01138581129c3d31e8c064a0ef Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 9 Apr 2026 14:33:02 +0300 Subject: [PATCH 0629/1707] Anthropic pretty device model name (#167772) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/anthropic/config_flow.py | 26 +++------- .../components/anthropic/coordinator.py | 51 ++++++++++++++++--- homeassistant/components/anthropic/entity.py | 11 ++-- .../components/anthropic/test_conversation.py | 27 +++++++++- 4 files changed, 85 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 8b91bd4cc42576..f99cee25710759 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -5,7 +5,6 @@ from collections.abc import Mapping import json import logging -import re from typing import TYPE_CHECKING, Any, cast import anthropic @@ -71,6 +70,7 @@ WEB_SEARCH_UNSUPPORTED_MODELS, PromptCaching, ) +from .coordinator import model_alias if TYPE_CHECKING: from . import AnthropicConfigEntry @@ -112,25 +112,13 @@ async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionD except anthropic.AnthropicError: models = [] _LOGGER.debug("Available models: %s", models) - model_options: list[SelectOptionDict] = [] - short_form = re.compile(r"[^\d]-\d$") - for model_info in models: - # Resolve alias from versioned model name: - model_alias = ( - model_info.id[:-9] - if model_info.id != "claude-3-haiku-20240307" - and model_info.id[-2:-1] != "-" - else model_info.id + return [ + SelectOptionDict( + label=model_info.display_name, + value=model_alias(model_info.id), ) - if short_form.search(model_alias): - model_alias += "-0" - model_options.append( - SelectOptionDict( - label=model_info.display_name, - value=model_alias, - ) - ) - return model_options + for model_info in models + ] class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/anthropic/coordinator.py b/homeassistant/components/anthropic/coordinator.py index 3a7a209d7c6de3..5dc7ddfd4a3a6a 100644 --- a/homeassistant/components/anthropic/coordinator.py +++ b/homeassistant/components/anthropic/coordinator.py @@ -2,7 +2,8 @@ from __future__ import annotations -from datetime import timedelta +import datetime +import re import anthropic @@ -15,13 +16,28 @@ from .const import DOMAIN, LOGGER -UPDATE_INTERVAL_CONNECTED = timedelta(hours=12) -UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1) +UPDATE_INTERVAL_CONNECTED = datetime.timedelta(hours=12) +UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1) type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator] -class AnthropicCoordinator(DataUpdateCoordinator[None]): +_model_short_form = re.compile(r"[^\d]-\d$") + + +@callback +def model_alias(model_id: str) -> str: + """Resolve alias from versioned model name.""" + if model_id == "claude-3-haiku-20240307" or model_id.endswith("-preview"): + return model_id + if model_id[-2:-1] != "-": + model_id = model_id[:-9] + if _model_short_form.search(model_id): + return model_id + "-0" + return model_id + + +class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]): """DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates.""" client: anthropic.AsyncAnthropic @@ -42,16 +58,16 @@ def __init__(self, hass: HomeAssistant, config_entry: AnthropicConfigEntry) -> N ) @callback - def async_set_updated_data(self, data: None) -> None: + def async_set_updated_data(self, data: list[anthropic.types.ModelInfo]) -> None: """Manually update data, notify listeners and update refresh interval.""" self.update_interval = UPDATE_INTERVAL_CONNECTED super().async_set_updated_data(data) - async def async_update_data(self) -> None: + async def async_update_data(self) -> list[anthropic.types.ModelInfo]: """Fetch data from the API.""" try: self.update_interval = UPDATE_INTERVAL_DISCONNECTED - await self.client.models.list(timeout=10.0) + result = await self.client.models.list(timeout=10.0) self.update_interval = UPDATE_INTERVAL_CONNECTED except anthropic.APITimeoutError as err: raise TimeoutError(err.message or str(err)) from err @@ -67,6 +83,7 @@ async def async_update_data(self) -> None: translation_key="api_error", translation_placeholders={"message": err.message}, ) from err + return result.data def mark_connection_error(self) -> None: """Mark the connection as having an error and reschedule background check.""" @@ -76,3 +93,23 @@ def mark_connection_error(self) -> None: self.async_update_listeners() if self._listeners and not self.hass.is_stopping: self._schedule_refresh() + + @callback + def get_model_info(self, model_id: str) -> anthropic.types.ModelInfo: + """Get model info for a given model ID.""" + # First try: exact name match + for model in self.data or []: + if model.id == model_id: + return model + # Second try: match by alias + alias = model_alias(model_id) + for model in self.data or []: + if model_alias(model.id) == alias: + return model + # Model not found, return safe defaults + return anthropic.types.ModelInfo( + type="model", + id=model_id, + created_at=datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC), + display_name=model_id, + ) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index a2b9cfaa697d4b..15b8fe30435099 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -689,12 +689,17 @@ def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> Non super().__init__(entry.runtime_data) self.entry = entry self.subentry = subentry + coordinator = entry.runtime_data + self.model_info = coordinator.get_model_info( + subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]) + ) self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Anthropic", - model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]), + model=self.model_info.display_name, + model_id=self.model_info.id, entry_type=dr.DeviceEntryType.SERVICE, ) @@ -969,7 +974,7 @@ async def _async_handle_chat_log( # noqa: C901 ) from err except anthropic.AnthropicError as err: # Non-connection error, mark connection as healthy - coordinator.async_set_updated_data(None) + coordinator.async_set_updated_data(coordinator.data) LOGGER.error("Error while talking to Anthropic: %s", err) raise HomeAssistantError( translation_domain=DOMAIN, @@ -982,7 +987,7 @@ async def _async_handle_chat_log( # noqa: C901 ) from err if not chat_log.unresponded_tool_results: - coordinator.async_set_updated_data(None) + coordinator.async_set_updated_data(coordinator.data) break diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index d4a73ad7491120..92bf2bf77f33a0 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -46,12 +46,19 @@ CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DOMAIN, ) from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, entity_registry as er, intent, llm +from homeassistant.helpers import ( + chat_session, + device_registry as dr, + entity_registry as er, + intent, + llm, +) from homeassistant.setup import async_setup_component from homeassistant.util import ulid as ulid_util @@ -99,6 +106,24 @@ async def test_entity( ) +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test device parameters.""" + subentry = next(iter(mock_config_entry.subentries.values())) + device = device_registry.async_get_device({(DOMAIN, subentry.subentry_id)}) + + assert device is not None + assert device.name == "Claude conversation" + assert device.manufacturer == "Anthropic" + assert device.model == "Claude Haiku 4.5" + assert device.model_id == "claude-haiku-4-5-20251001" + assert device.entry_type == dr.DeviceEntryType.SERVICE + + async def test_translation_key( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From f634525798cf55734b7662b25de46390e86948c9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:34:42 +0200 Subject: [PATCH 0630/1707] Use runtime_data in syncthing integration (#167748) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/syncthing/__init__.py | 22 ++++++------------- homeassistant/components/syncthing/sensor.py | 7 +++--- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/syncthing/__init__.py b/homeassistant/components/syncthing/__init__.py index 091b9b0c949340..73850904a7b413 100644 --- a/homeassistant/components/syncthing/__init__.py +++ b/homeassistant/components/syncthing/__init__.py @@ -18,26 +18,19 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ( - DOMAIN, - EVENTS, - RECONNECT_INTERVAL, - SERVER_AVAILABLE, - SERVER_UNAVAILABLE, -) +from .const import EVENTS, RECONNECT_INTERVAL, SERVER_AVAILABLE, SERVER_UNAVAILABLE PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type SyncthingConfigEntry = ConfigEntry[SyncthingClient] + -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SyncthingConfigEntry) -> bool: """Set up syncthing from a config entry.""" data = entry.data - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - client = aiosyncthing.Syncthing( data[CONF_TOKEN], url=data[CONF_URL], @@ -54,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: syncthing = SyncthingClient(hass, client, server_id) syncthing.subscribe() - hass.data[DOMAIN][entry.entry_id] = syncthing + entry.runtime_data = syncthing await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -69,12 +62,11 @@ async def cancel_listen_task(event: Event) -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SyncthingConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - syncthing = hass.data[DOMAIN].pop(entry.entry_id) - await syncthing.unsubscribe() + await entry.runtime_data.unsubscribe() return unload_ok diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index d57da2b30ca3ba..5304f1e8f3c688 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -6,7 +6,6 @@ import aiosyncthing from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -14,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from . import SyncthingClient +from . import SyncthingClient, SyncthingConfigEntry from .const import ( DOMAIN, FOLDER_PAUSED_RECEIVED, @@ -28,11 +27,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncthingConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Syncthing sensors.""" - syncthing = hass.data[DOMAIN][config_entry.entry_id] + syncthing = config_entry.runtime_data try: config = await syncthing.system.config() From aa50822a8225e5bf291ca893b2dcc2174e01c0c5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:36:45 +0200 Subject: [PATCH 0631/1707] Use runtime_data in Subaru integration (#167747) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/subaru/__init__.py | 30 ++++++++----------- .../components/subaru/config_flow.py | 10 ++----- homeassistant/components/subaru/const.py | 5 ---- .../components/subaru/coordinator.py | 16 ++++++++-- .../components/subaru/device_tracker.py | 19 ++++-------- .../components/subaru/diagnostics.py | 15 +++++----- homeassistant/components/subaru/lock.py | 13 ++++---- homeassistant/components/subaru/sensor.py | 12 +++----- tests/components/subaru/test_sensor.py | 2 +- 9 files changed, 52 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 247618a8dcd869..8ecf33e8f48309 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -4,7 +4,6 @@ from subarulink import Controller as SubaruAPI, InvalidCredentials, SubaruException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_COUNTRY, CONF_DEVICE_ID, @@ -19,9 +18,6 @@ from .const import ( DOMAIN, - ENTRY_CONTROLLER, - ENTRY_COORDINATOR, - ENTRY_VEHICLES, FETCH_INTERVAL, MANUFACTURER, PLATFORMS, @@ -37,12 +33,16 @@ VEHICLE_NAME, VEHICLE_VIN, ) -from .coordinator import SubaruDataUpdateCoordinator +from .coordinator import ( + SubaruConfigEntry, + SubaruDataUpdateCoordinator, + SubaruRuntimeData, +) _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SubaruConfigEntry) -> bool: """Set up Subaru from a config entry.""" config = entry.data websession = aiohttp_client.async_create_clientsession(hass) @@ -77,24 +77,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - ENTRY_CONTROLLER: controller, - ENTRY_COORDINATOR: coordinator, - ENTRY_VEHICLES: vehicle_info, - } + entry.runtime_data = SubaruRuntimeData( + controller=controller, + coordinator=coordinator, + vehicles=vehicle_info, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SubaruConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def get_vehicle_info(controller, vin): diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 0ef4ed29941f1a..035931677c0a66 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -15,12 +15,7 @@ from subarulink.const import COUNTRY_CAN, COUNTRY_USA import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_COUNTRY, CONF_DEVICE_ID, @@ -32,6 +27,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import CONF_UPDATE_ENABLED, DOMAIN +from .coordinator import SubaruConfigEntry _LOGGER = logging.getLogger(__name__) CONF_CONTACT_METHOD = "contact_method" @@ -103,7 +99,7 @@ async def async_step_user( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index d8692e6a8bc509..c9a02e09f62762 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -9,11 +9,6 @@ UPDATE_INTERVAL = 7200 CONF_UPDATE_ENABLED = "update_enabled" -# entry fields -ENTRY_CONTROLLER = "controller" -ENTRY_COORDINATOR = "coordinator" -ENTRY_VEHICLES = "vehicles" - # update coordinator name COORDINATOR_NAME = "subaru_data" diff --git a/homeassistant/components/subaru/coordinator.py b/homeassistant/components/subaru/coordinator.py index 73aec22250af10..c23c6eef506851 100644 --- a/homeassistant/components/subaru/coordinator.py +++ b/homeassistant/components/subaru/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging import time @@ -23,16 +24,27 @@ _LOGGER = logging.getLogger(__name__) +type SubaruConfigEntry = ConfigEntry[SubaruRuntimeData] + + +@dataclass +class SubaruRuntimeData: + """Runtime data for Subaru.""" + + controller: SubaruAPI + coordinator: SubaruDataUpdateCoordinator + vehicles: dict[str, dict[str, Any]] + class SubaruDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Subaru data.""" - config_entry: ConfigEntry + config_entry: SubaruConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, *, controller: SubaruAPI, vehicle_info: dict[str, dict[str, Any]], diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index 3c5d6487cb5225..fa3dc95f354952 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -7,32 +7,23 @@ from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import get_device_info -from .const import ( - DOMAIN, - ENTRY_COORDINATOR, - ENTRY_VEHICLES, - VEHICLE_HAS_REMOTE_SERVICE, - VEHICLE_STATUS, - VEHICLE_VIN, -) -from .coordinator import SubaruDataUpdateCoordinator +from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS, VEHICLE_VIN +from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru device tracker by config_entry.""" - entry: dict = hass.data[DOMAIN][config_entry.entry_id] - coordinator: SubaruDataUpdateCoordinator = entry[ENTRY_COORDINATOR] - vehicle_info: dict = entry[ENTRY_VEHICLES] + coordinator = config_entry.runtime_data.coordinator + vehicle_info = config_entry.runtime_data.vehicles async_add_entities( SubaruDeviceTracker(vehicle, coordinator) for vehicle in vehicle_info.values() diff --git a/homeassistant/components/subaru/diagnostics.py b/homeassistant/components/subaru/diagnostics.py index eec5b01ab56c9a..60f54b993e57f7 100644 --- a/homeassistant/components/subaru/diagnostics.py +++ b/homeassistant/components/subaru/diagnostics.py @@ -13,23 +13,23 @@ ) from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntry -from .const import DOMAIN, ENTRY_CONTROLLER, ENTRY_COORDINATOR, VEHICLE_VIN +from .const import VEHICLE_VIN +from .coordinator import SubaruConfigEntry CONFIG_FIELDS_TO_REDACT = [CONF_USERNAME, CONF_PASSWORD, CONF_PIN, CONF_DEVICE_ID] DATA_FIELDS_TO_REDACT = [VEHICLE_VIN, VEHICLE_NAME, LATITUDE, LONGITUDE, ODOMETER] async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: SubaruConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] + coordinator = config_entry.runtime_data.coordinator return { "config_entry": async_redact_data(config_entry.data, CONFIG_FIELDS_TO_REDACT), @@ -42,12 +42,11 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: SubaruConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - entry = hass.data[DOMAIN][config_entry.entry_id] - coordinator = entry[ENTRY_COORDINATOR] - controller = entry[ENTRY_CONTROLLER] + coordinator = config_entry.runtime_data.coordinator + controller = config_entry.runtime_data.controller vin = next(iter(device.identifiers))[1] diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 07caa0d63678c1..8af699fb45ffb9 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -6,17 +6,14 @@ import voluptuous as vol from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, get_device_info +from . import get_device_info from .const import ( ATTR_DOOR, - ENTRY_CONTROLLER, - ENTRY_VEHICLES, SERVICE_UNLOCK_SPECIFIC_DOOR, UNLOCK_DOOR_ALL, UNLOCK_VALID_DOORS, @@ -24,6 +21,7 @@ VEHICLE_NAME, VEHICLE_VIN, ) +from .coordinator import SubaruConfigEntry from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) @@ -31,13 +29,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru locks by config_entry.""" - entry = hass.data[DOMAIN][config_entry.entry_id] - controller = entry[ENTRY_CONTROLLER] - vehicle_info = entry[ENTRY_VEHICLES] + controller = config_entry.runtime_data.controller + vehicle_info = config_entry.runtime_data.vehicles async_add_entities( SubaruLock(vehicle, controller) for vehicle in vehicle_info.values() diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 880e0043fa8a92..1d9d50dc020760 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -26,15 +26,12 @@ from .const import ( API_GEN_2, API_GEN_3, - DOMAIN, - ENTRY_COORDINATOR, - ENTRY_VEHICLES, VEHICLE_API_GEN, VEHICLE_HAS_EV, VEHICLE_STATUS, VEHICLE_VIN, ) -from .coordinator import SubaruDataUpdateCoordinator +from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -138,13 +135,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SubaruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru sensors by config_entry.""" - entry = hass.data[DOMAIN][config_entry.entry_id] - coordinator = entry[ENTRY_COORDINATOR] - vehicle_info = entry[ENTRY_VEHICLES] + coordinator = config_entry.runtime_data.coordinator + vehicle_info = config_entry.runtime_data.vehicles entities = [] await _async_migrate_entries(hass, config_entry) for info in vehicle_info.values(): diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py index f133b46d3d3fbf..73c06b4d86ed33 100644 --- a/tests/components/subaru/test_sensor.py +++ b/tests/components/subaru/test_sensor.py @@ -6,9 +6,9 @@ import pytest from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.subaru.const import DOMAIN from homeassistant.components.subaru.sensor import ( API_GEN_2_SENSORS, - DOMAIN, EV_SENSORS, SAFETY_SENSORS, ) From 0c98f01b0708cae1bb6e1c86ea36abe19202b714 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:37:59 +0200 Subject: [PATCH 0632/1707] Use runtime_data in starline integration (#167746) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/starline/__init__.py | 21 +++++++++++-------- .../components/starline/binary_sensor.py | 7 +++---- homeassistant/components/starline/button.py | 7 +++---- .../components/starline/device_tracker.py | 7 +++---- homeassistant/components/starline/lock.py | 7 +++---- homeassistant/components/starline/sensor.py | 7 +++---- homeassistant/components/starline/switch.py | 7 +++---- 7 files changed, 30 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/starline/__init__.py b/homeassistant/components/starline/__init__.py index 17f3b7dc504345..21b790ea1188b9 100644 --- a/homeassistant/components/starline/__init__.py +++ b/homeassistant/components/starline/__init__.py @@ -22,8 +22,10 @@ SERVICE_UPDATE_STATE, ) +type StarlineConfigEntry = ConfigEntry[StarlineAccount] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: StarlineConfigEntry) -> bool: """Set up the StarLine device from a config entry.""" account = StarlineAccount(hass, entry) await account.update() @@ -31,9 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not account.api.available: raise ConfigEntryNotReady - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - hass.data[DOMAIN][entry.entry_id] = account + entry.runtime_data = account device_registry = dr.async_get(hass) for device in account.api.devices.values(): @@ -92,20 +92,23 @@ async def async_update(call: ServiceCall | None = None) -> None: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: StarlineConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) - account: StarlineAccount = hass.data[DOMAIN][config_entry.entry_id] - account.unload() + config_entry.runtime_data.unload() return unload_ok -async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_options_updated( + hass: HomeAssistant, config_entry: StarlineConfigEntry +) -> None: """Triggered by config entry options updates.""" - account: StarlineAccount = hass.data[DOMAIN][config_entry.entry_id] + account = config_entry.runtime_data scan_interval = config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) scan_obd_interval = config_entry.options.get( CONF_SCAN_OBD_INTERVAL, DEFAULT_SCAN_OBD_INTERVAL diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index faec8974ed1ca2..d9452e8c0dee4a 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -7,13 +7,12 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -71,11 +70,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine sensors.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data entities = [ sensor for device in account.api.devices.values() diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py index fd449607f5293a..4bc06f41240ba7 100644 --- a/homeassistant/components/starline/button.py +++ b/homeassistant/components/starline/button.py @@ -3,12 +3,11 @@ from __future__ import annotations from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( @@ -35,11 +34,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine button.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data async_add_entities( StarlineButton(account, device, description) for device in account.api.devices.values() diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py index d6e12b4ecd9154..cb9444d579a3bc 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -3,23 +3,22 @@ from typing import Any from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up StarLine entry.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data async_add_entities( StarlineDeviceTracker(account, device) for device in account.api.devices.values() diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 43886d63962515..19329090abe2ec 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -5,22 +5,21 @@ from typing import Any from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine lock.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data entities = [] for device in account.api.devices.values(): if device.support_state: diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 5fff61144dc3a7..ef513d6b4ddf6e 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -10,7 +10,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -23,8 +22,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -91,11 +90,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine sensors.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data entities = [ sensor for device in account.api.devices.values() diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 3a457c6ffdee45..b21bdb4a777e67 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -5,12 +5,11 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import StarlineConfigEntry from .account import StarlineAccount, StarlineDevice -from .const import DOMAIN from .entity import StarlineEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( @@ -35,11 +34,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StarlineConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine switch.""" - account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + account = entry.runtime_data entities = [ switch for device in account.api.devices.values() From 4b820a0204084a35abc404bf9ddbca6951e6fd08 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:39:19 +0200 Subject: [PATCH 0633/1707] Use runtime_data in somfy_mylink integration (#167745) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/somfy_mylink/__init__.py | 34 ++++++++++++------- .../components/somfy_mylink/config_flow.py | 13 ++++--- .../components/somfy_mylink/const.py | 2 -- .../components/somfy_mylink/cover.py | 17 +++------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index fdbaaf9f4274fa..4e7028ec6c9eba 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -1,6 +1,8 @@ """Component for the Somfy MyLink device supporting the Synergy API.""" +from dataclasses import dataclass import logging +from typing import Any from somfy_mylink_synergy import SomfyMyLinkSynergy @@ -9,15 +11,23 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_SYSTEM_ID, DATA_SOMFY_MYLINK, DOMAIN, MYLINK_STATUS, PLATFORMS +from .const import CONF_SYSTEM_ID, PLATFORMS _LOGGER = logging.getLogger(__name__) +type SomfyMyLinkConfigEntry = ConfigEntry[SomfyMyLinkRuntimeData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Somfy MyLink from a config entry.""" - hass.data.setdefault(DOMAIN, {}) +@dataclass +class SomfyMyLinkRuntimeData: + """Runtime data for Somfy MyLink.""" + + somfy_mylink: SomfyMyLinkSynergy + mylink_status: dict[str, Any] + + +async def async_setup_entry(hass: HomeAssistant, entry: SomfyMyLinkConfigEntry) -> bool: + """Set up Somfy MyLink from a config entry.""" config = entry.data somfy_mylink = SomfyMyLinkSynergy( config[CONF_SYSTEM_ID], config[CONF_HOST], config[CONF_PORT] @@ -42,18 +52,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if "result" not in mylink_status: raise ConfigEntryNotReady("The Somfy MyLink device returned an empty result") - hass.data[DOMAIN][entry.entry_id] = { - DATA_SOMFY_MYLINK: somfy_mylink, - MYLINK_STATUS: mylink_status, - } + entry.runtime_data = SomfyMyLinkRuntimeData( + somfy_mylink=somfy_mylink, + mylink_status=mylink_status, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SomfyMyLinkConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 91cfae87347912..fc3cc476933a8e 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -10,7 +10,6 @@ import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -22,6 +21,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from . import SomfyMyLinkConfigEntry from .const import ( CONF_REVERSE, CONF_REVERSED_TARGET_IDS, @@ -30,7 +30,6 @@ CONF_TARGET_NAME, DEFAULT_PORT, DOMAIN, - MYLINK_STATUS, ) _LOGGER = logging.getLogger(__name__) @@ -119,7 +118,7 @@ async def async_step_user( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SomfyMyLinkConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -128,7 +127,9 @@ def async_get_options_flow( class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for somfy_mylink.""" - def __init__(self, config_entry: ConfigEntry) -> None: + config_entry: SomfyMyLinkConfigEntry + + def __init__(self, config_entry: SomfyMyLinkConfigEntry) -> None: """Initialize options flow.""" self.options = deepcopy(dict(config_entry.options)) self._target_id: str | None = None @@ -136,9 +137,7 @@ def __init__(self, config_entry: ConfigEntry) -> None: @callback def _async_callback_targets(self): """Return the list of targets.""" - return self.hass.data[DOMAIN][self.config_entry.entry_id][MYLINK_STATUS][ - "result" - ] + return self.config_entry.runtime_data.mylink_status["result"] @callback def _async_get_target_name(self, target_id) -> str: diff --git a/homeassistant/components/somfy_mylink/const.py b/homeassistant/components/somfy_mylink/const.py index 8669c73fb9b7c3..a4740ba4b55891 100644 --- a/homeassistant/components/somfy_mylink/const.py +++ b/homeassistant/components/somfy_mylink/const.py @@ -10,8 +10,6 @@ DEFAULT_PORT = 44100 -DATA_SOMFY_MYLINK = "somfy_mylink_data" -MYLINK_STATUS = "mylink_status" DOMAIN = "somfy_mylink" PLATFORMS = [Platform.COVER] diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index 5b888ea4b960e3..e731bbac698e1f 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -4,19 +4,13 @@ from typing import Any from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import ( - CONF_REVERSED_TARGET_IDS, - DATA_SOMFY_MYLINK, - DOMAIN, - MANUFACTURER, - MYLINK_STATUS, -) +from . import SomfyMyLinkConfigEntry +from .const import CONF_REVERSED_TARGET_IDS, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -28,15 +22,14 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SomfyMyLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover and configure Somfy covers.""" reversed_target_ids = config_entry.options.get(CONF_REVERSED_TARGET_IDS, {}) - data = hass.data[DOMAIN][config_entry.entry_id] - mylink_status = data[MYLINK_STATUS] - somfy_mylink = data[DATA_SOMFY_MYLINK] + mylink_status = config_entry.runtime_data.mylink_status + somfy_mylink = config_entry.runtime_data.somfy_mylink cover_list = [] for cover in mylink_status["result"]: From 11c34c7ddf3a9b830fcb0b87e84be10d64e84b8e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:40:11 +0200 Subject: [PATCH 0634/1707] Use runtime_data in snapcast integration (#167744) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/snapcast/__init__.py | 12 +++++------- homeassistant/components/snapcast/coordinator.py | 6 ++++-- homeassistant/components/snapcast/media_player.py | 8 +++----- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index 0888f339a7dc3c..e56d28fa7bd190 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1,6 +1,5 @@ """Snapcast Integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -8,7 +7,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS -from .coordinator import SnapcastUpdateCoordinator +from .coordinator import SnapcastConfigEntry, SnapcastUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -20,7 +19,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SnapcastConfigEntry) -> bool: """Set up Snapcast from a config entry.""" coordinator = SnapcastUpdateCoordinator(hass, entry) @@ -32,16 +31,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" ) from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SnapcastConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - snapcast_data = hass.data[DOMAIN].pop(entry.entry_id) # disconnect from server - await snapcast_data.disconnect() + await entry.runtime_data.disconnect() return unload_ok diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py index 963f12887fcf2c..15d7c154bd8acd 100644 --- a/homeassistant/components/snapcast/coordinator.py +++ b/homeassistant/components/snapcast/coordinator.py @@ -13,13 +13,15 @@ _LOGGER = logging.getLogger(__name__) +type SnapcastConfigEntry = ConfigEntry[SnapcastUpdateCoordinator] + class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for pushed data from Snapcast server.""" - config_entry: ConfigEntry + config_entry: SnapcastConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SnapcastConfigEntry) -> None: """Initialize coordinator.""" host = config_entry.data[CONF_HOST] port = config_entry.data[CONF_PORT] diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index bccded10176a1b..bd73ed70ae8dee 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -17,14 +17,13 @@ MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CLIENT_PREFIX, CLIENT_SUFFIX, DOMAIN -from .coordinator import SnapcastUpdateCoordinator +from .coordinator import SnapcastConfigEntry, SnapcastUpdateCoordinator from .entity import SnapcastCoordinatorEntity STREAM_STATUS = { @@ -38,13 +37,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SnapcastConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the snapcast config entry.""" - # Fetch coordinator from global data - coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data _known_client_ids: set[str] = set() From efb0e80577708675ea52b3065fb1e5de1912160f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:44:34 +0200 Subject: [PATCH 0635/1707] Use runtime_data in smart_meter_texas integration (#167743) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/smart_meter_texas/__init__.py | 28 +++++++++---------- .../components/smart_meter_texas/const.py | 3 -- .../smart_meter_texas/coordinator.py | 16 ++++++----- .../components/smart_meter_texas/sensor.py | 18 ++++-------- 4 files changed, 27 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index d55c44824df6a1..5aa7996407082a 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -5,20 +5,24 @@ from smart_meter_texas import Account from smart_meter_texas.exceptions import SmartMeterTexasAuthError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DATA_COORDINATOR, DATA_SMART_METER, DOMAIN -from .coordinator import SmartMeterTexasCoordinator, SmartMeterTexasData +from .coordinator import ( + SmartMeterTexasConfigEntry, + SmartMeterTexasCoordinator, + SmartMeterTexasData, +) _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: SmartMeterTexasConfigEntry +) -> bool: """Set up Smart Meter Texas from a config entry.""" username = entry.data[CONF_USERNAME] @@ -43,11 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # too long to update. coordinator = SmartMeterTexasCoordinator(hass, entry, smart_meter_texas_data) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_SMART_METER: smart_meter_texas_data, - } + entry.runtime_data = coordinator entry.async_create_background_task( hass, coordinator.async_refresh(), "smart_meter_texas-coordinator-refresh" @@ -58,10 +58,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SmartMeterTexasConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/smart_meter_texas/const.py b/homeassistant/components/smart_meter_texas/const.py index defe49f0be4aed..9c499811f104de 100644 --- a/homeassistant/components/smart_meter_texas/const.py +++ b/homeassistant/components/smart_meter_texas/const.py @@ -5,9 +5,6 @@ SCAN_INTERVAL = timedelta(hours=1) DEBOUNCE_COOLDOWN = 1800 # Seconds -DATA_COORDINATOR = "coordinator" -DATA_SMART_METER = "smart_meter_data" - DOMAIN = "smart_meter_texas" METER_NUMBER = "meter_number" diff --git a/homeassistant/components/smart_meter_texas/coordinator.py b/homeassistant/components/smart_meter_texas/coordinator.py index b489c0db01ed04..b1a26a6ee53475 100644 --- a/homeassistant/components/smart_meter_texas/coordinator.py +++ b/homeassistant/components/smart_meter_texas/coordinator.py @@ -52,15 +52,18 @@ async def read_meters(self) -> list[Meter]: return self.meters -class SmartMeterTexasCoordinator(DataUpdateCoordinator[SmartMeterTexasData]): +type SmartMeterTexasConfigEntry = ConfigEntry[SmartMeterTexasCoordinator] + + +class SmartMeterTexasCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Smart Meter Texas data.""" - config_entry: ConfigEntry + config_entry: SmartMeterTexasConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SmartMeterTexasConfigEntry, smart_meter_texas_data: SmartMeterTexasData, ) -> None: """Initialize the coordinator.""" @@ -74,10 +77,9 @@ def __init__( hass, _LOGGER, cooldown=DEBOUNCE_COOLDOWN, immediate=True ), ) - self._smart_meter_texas_data = smart_meter_texas_data + self.smart_meter_texas_data = smart_meter_texas_data - async def _async_update_data(self) -> SmartMeterTexasData: + async def _async_update_data(self) -> None: """Fetch latest data.""" _LOGGER.debug("Fetching latest data") - await self._smart_meter_texas_data.read_meters() - return self._smart_meter_texas_data + await self.smart_meter_texas_data.read_meters() diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index ecddd5c80c456d..80318d85d20807 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -9,32 +9,24 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DATA_COORDINATOR, - DATA_SMART_METER, - DOMAIN, - ELECTRIC_METER, - ESIID, - METER_NUMBER, -) -from .coordinator import SmartMeterTexasCoordinator +from .const import ELECTRIC_METER, ESIID, METER_NUMBER +from .coordinator import SmartMeterTexasConfigEntry, SmartMeterTexasCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SmartMeterTexasConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smart Meter Texas sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - meters = hass.data[DOMAIN][config_entry.entry_id][DATA_SMART_METER].meters + coordinator = config_entry.runtime_data + meters = coordinator.smart_meter_texas_data.meters async_add_entities( [SmartMeterTexasSensor(meter, coordinator) for meter in meters], False From 87f44a67beef77ecb3161c4ef7370d8ad4363111 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:45:44 +0200 Subject: [PATCH 0636/1707] Use runtime_data in sharkiq integration (#167741) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/sharkiq/__init__.py | 18 +++++++++--------- .../components/sharkiq/coordinator.py | 6 ++++-- homeassistant/components/sharkiq/vacuum.py | 7 +++---- tests/components/sharkiq/test_vacuum.py | 4 +++- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index 4fc53614fa2a4b..4a903cf378654d 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -13,7 +13,6 @@ ) from homeassistant import exceptions -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -28,7 +27,7 @@ SHARKIQ_REGION_DEFAULT, SHARKIQ_REGION_EUROPE, ) -from .coordinator import SharkIqUpdateCoordinator +from .coordinator import SharkIqConfigEntry, SharkIqUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -60,7 +59,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: SharkIqConfigEntry +) -> bool: """Initialize the sharkiq platform via config entry.""" if CONF_REGION not in config_entry.data: hass.config_entries.async_update_entry( @@ -93,8 +94,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -116,15 +116,15 @@ async def async_update_options(hass: HomeAssistant, config_entry): await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: SharkIqConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) if unload_ok: - domain_data = hass.data[DOMAIN][config_entry.entry_id] with suppress(SharkIqAuthError): - await async_disconnect_or_timeout(coordinator=domain_data) - hass.data[DOMAIN].pop(config_entry.entry_id) + await async_disconnect_or_timeout(coordinator=config_entry.runtime_data) return unload_ok diff --git a/homeassistant/components/sharkiq/coordinator.py b/homeassistant/components/sharkiq/coordinator.py index 1a4a819cdf6bb2..dd82e3758c06a8 100644 --- a/homeassistant/components/sharkiq/coordinator.py +++ b/homeassistant/components/sharkiq/coordinator.py @@ -20,16 +20,18 @@ from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL +type SharkIqConfigEntry = ConfigEntry[SharkIqUpdateCoordinator] + class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): """Define a wrapper class to update Shark IQ data.""" - config_entry: ConfigEntry + config_entry: SharkIqConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SharkIqConfigEntry, ayla_api: AylaApi, shark_vacs: list[SharkIqVacuum], ) -> None: diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 3856bf73554fb1..6ccc95f29c2795 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -12,7 +12,6 @@ VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo @@ -20,7 +19,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_ROOMS, DOMAIN, LOGGER, SHARK -from .coordinator import SharkIqUpdateCoordinator +from .coordinator import SharkIqConfigEntry, SharkIqUpdateCoordinator OPERATING_STATE_MAP = { OperatingModes.PAUSE: VacuumActivity.PAUSED, @@ -46,11 +45,11 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SharkIqConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Shark IQ vacuum cleaner.""" - coordinator: SharkIqUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data devices: Iterable[SharkIqVacuum] = coordinator.shark_vacs.values() device_names = [d.name for d in devices] LOGGER.debug( diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index b8004d01081a15..0c25312df0cda0 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -290,7 +290,9 @@ async def test_coordinator_updates( hass: HomeAssistant, side_effect: Exception | None, success: bool ) -> None: """Test the update coordinator update functions.""" - coordinator = hass.data[DOMAIN][ENTRY_ID] + entry = hass.config_entries.async_get_entry(ENTRY_ID) + assert entry is not None + coordinator = entry.runtime_data await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) From d292aa2e90898281cbb1eaf8a8dd8e86c2d477b7 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 9 Apr 2026 21:50:30 +1000 Subject: [PATCH 0637/1707] Add missing exception string from smlight IR platform (#167784) --- homeassistant/components/smlight/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 7ac6ae74da1b7b..ad67987899ff6f 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -164,6 +164,9 @@ }, "firmware_update_failed": { "message": "Firmware update failed for {device_name}." + }, + "send_ir_code_failed": { + "message": "Failed to send IR code: {error}." } }, "issues": { From eb31499e78783924cca691e32adf71a006fa53c3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 9 Apr 2026 14:19:46 +0200 Subject: [PATCH 0638/1707] Bump aiotractive to 1.0.2 (#167783) --- .../components/tractive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../tractive/test_device_tracker.py | 21 +++++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index a66edb985ac45b..96abbf24adecba 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_push", "loggers": ["aiotractive"], - "requirements": ["aiotractive==1.0.1"] + "requirements": ["aiotractive==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 93866ce916d98d..42cc325b8a5be3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ aiotankerkoenig==0.5.1 aiotedee==0.3.0 # homeassistant.components.tractive -aiotractive==1.0.1 +aiotractive==1.0.2 # homeassistant.components.unifi aiounifi==90 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0027ecc1d2697..066219a2f57b29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -413,7 +413,7 @@ aiotankerkoenig==0.5.1 aiotedee==0.3.0 # homeassistant.components.tractive -aiotractive==1.0.1 +aiotractive==1.0.2 # homeassistant.components.unifi aiounifi==90 diff --git a/tests/components/tractive/test_device_tracker.py b/tests/components/tractive/test_device_tracker.py index 6fdbc245662aec..ee30ca4a1f5b08 100644 --- a/tests/components/tractive/test_device_tracker.py +++ b/tests/components/tractive/test_device_tracker.py @@ -87,3 +87,24 @@ async def test_source_type_gps( hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"] is SourceType.GPS ) + + +async def test_device_tracker_with_empty_hw_info( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the device tracker sets up correctly when hw_info is empty.""" + mock_tractive_client.tracker.return_value.hw_info = AsyncMock(return_value={}) + + with patch( + "homeassistant.components.tractive.PLATFORMS", [Platform.DEVICE_TRACKER] + ): + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_position_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.test_pet_tracker") + assert state is not None + assert state.attributes.get("battery_level") is None From a9f0cd203cdd1b26cc97a53701be41a8eb1b6df5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:20:59 +0200 Subject: [PATCH 0639/1707] Update pytest warnings filter (#167703) --- pyproject.toml | 52 +++++++++++++++++++++----------------------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cdafd430aaaca8..883072535e373e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -473,7 +473,7 @@ filterwarnings = [ "ignore:.*a temporary mapping .* from `updated_parsed` to `published_parsed` if `updated_parsed` doesn't exist:DeprecationWarning:feedparser.util", # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.11/elkm1_lib/util.py#L8-L19 + # https://github.com/gwww/elkm1/blob/2.2.13/elkm1_lib/util.py#L8-L19 "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", @@ -489,8 +489,13 @@ filterwarnings = [ # -- fixed, waiting for release / update # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/BerriAI/litellm/pull/17657 - >1.80.9 - "ignore:Support for class-based `config` is deprecated, use ConfigDict instead:DeprecationWarning:litellm.types.llms.anthropic", + # https://github.com/httplib2/httplib2/pull/253 - >=0.31.1 + "ignore:'(addParseAction|delimitedList|leaveWhitespace|setName|setParseAction)' deprecated:DeprecationWarning:httplib2.auth", + # https://github.com/lawtancool/pyControl4/pull/47 - >=1.6.0 + "ignore:with timeout\\(\\) is deprecated, use async with timeout\\(\\) instead:DeprecationWarning:pyControl4.account", + # https://pypi.org/project/pyqwikswitch/ - >=1.0 + "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", # https://github.com/allenporter/python-google-nest-sdm/pull/1229 - >9.1.2 "ignore:datetime.*utcnow\\(\\) is deprecated:DeprecationWarning:google_nest_sdm.device", # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 @@ -498,7 +503,9 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", # https://github.com/rytilahti/python-miio/pull/1993 - >0.6.0.dev0 "ignore:functools.partial will be a method descriptor in future Python versions; wrap it in enum.member\\(\\) if you want to preserve the old behavior:FutureWarning:miio.miot_device", - # https://github.com/xchwarze/samsung-tv-ws-api/pull/151 - >2.7.2 - 2024-12-06 # wrong stacklevel in aiohttp + # https://github.com/pyusb/pyusb/pull/545 - >1.3.1 + "ignore:Due to '_pack_', the '.*' Structure will use memory layout compatible with MSVC:DeprecationWarning:usb.backend.libusb0", + # https://github.com/xchwarze/samsung-tv-ws-api/pull/151 - >=3.0.0 - 2024-12-06 # wrong stacklevel in aiohttp "ignore:verify_ssl is deprecated, use ssl=False instead:DeprecationWarning:aiohttp.client", # -- other @@ -522,8 +529,8 @@ filterwarnings = [ # https://pypi.org/project/motionblindsble/ - v0.1.3 - 2024-11-12 # https://github.com/LennP/motionblindsble/blob/0.1.3/motionblindsble/device.py#L390 "ignore:Passing additional arguments for BLEDevice is deprecated and has no effect:DeprecationWarning:motionblindsble.device", - # https://pypi.org/project/pyeconet/ - v0.2.0 - 2025-10-05 - # https://github.com/w1ll1am23/pyeconet/blob/v0.2.0/src/pyeconet/api.py#L39 + # https://pypi.org/project/pyeconet/ - v0.2.2 - 2026-03-05 + # https://github.com/w1ll1am23/pyeconet/blob/v0.2.2/src/pyeconet/api.py#L39 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", # https://github.com/thecynic/pylutron - v0.2.18 - 2025-04-15 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", @@ -534,15 +541,17 @@ filterwarnings = [ # https://github.com/lextudio/pysnmp/blob/v7.1.21/pysnmp/smi/compiler.py#L23-L31 - v7.1.21 - 2025-06-19 "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysnmp.smi.compiler", - # https://github.com/Python-roborock/python-roborock/issues/305 - 2.19.0 - 2025-05-13 - "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", + # https://github.com/frenck/python-radios/blob/v0.3.2/src/radios/radio_browser.py#L76 - v0.3.2 - 2024-10-26 + "ignore:query\\(\\) is deprecated, use query_dns\\(\\) instead:DeprecationWarning:radios.radio_browser", + # https://github.com/python-telegram-bot/python-telegram-bot/blob/v22.7/src/telegram/error.py#L243 - 22.7 - 2026-03-16 + "ignore:Deprecated since version v22.2.*attribute `retry_after` will be of type `datetime.timedelta`:DeprecationWarning:telegram.error", # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", - # - SyntaxWarnings + # - SyntaxWarnings - invalid escape sequence # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 "ignore:.*invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", - # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 + # https://pypi.org/project/panasonic-viera/ - v0.4.4 - 2025-11-25 # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.4/panasonic_viera/remote_control.py#L665 "ignore:.*invalid escape sequence:SyntaxWarning:.*panasonic_viera", # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 @@ -555,11 +564,6 @@ filterwarnings = [ "ignore:.*invalid escape sequence:SyntaxWarning:.*sanix", # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 "ignore:.*invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty - # - pkg_resources - # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 - "ignore:pkg_resources is deprecated as an API:UserWarning:aiomusiccast", - # https://pypi.org/project/pybotvac/ - v0.0.28 - 2025-06-11 - "ignore:pkg_resources is deprecated as an API:UserWarning:pybotvac.version", # - SyntaxWarning - is with literal # https://github.com/majuss/lupupy/pull/15 - >0.3.2 "ignore:\"is.*\" with '.*' literal:SyntaxWarning:.*lupupy.devices.alarm", @@ -572,8 +576,6 @@ filterwarnings = [ "ignore:'return' in a 'finally' block:SyntaxWarning:.*nextcord.(gateway|player)", # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 "ignore:'return' in a 'finally' block:SyntaxWarning:.*sleekxmppfs.(roster.single|xmlstream.xmlstream)", - # https://github.com/cereal2nd/velbus-aio/pull/153 - >2025.11.0 - "ignore:'return' in a 'finally' block:SyntaxWarning:.*velbusaio.vlp_reader", # -- New in Python 3.13 # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib @@ -589,8 +591,6 @@ filterwarnings = [ "ignore:Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater:UserWarning:elevenlabs.core.pydantic_utilities", # https://github.com/Lektrico/lektricowifi - v0.1 - 2025-05-19 "ignore:Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater:UserWarning:lektricowifi.models", - # https://github.com/bang-olufsen/mozart-open-api - v4.1.1.116.4 - 2025-01-22 - "ignore:Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater:UserWarning:mozart_api.api.beolink_api", # https://github.com/sstallion/sensorpush-api - v2.1.3 - 2025-06-10 "ignore:Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater:UserWarning:sensorpush_api.api.api_api", @@ -599,28 +599,23 @@ filterwarnings = [ "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:(backoff._decorator|backoff._async)", # https://github.com/albertogeniola/elmax-api - v0.0.6.3 - 2024-11-30 "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:elmax_api.http", - # https://github.com/BerriAI/litellm - v1.80.9 - 2025-12-08 - "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:litellm.litellm_core_utils.logging_utils", # https://github.com/nextcord/nextcord/pull/1269 - >3.1.1 - 2025-08-16 "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:nextcord.member", - # https://github.com/SteveEasley/pykaleidescape/pull/7 - v2022.2.6 - 2022-03-07 - "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:kaleidescape.dispatcher", # https://github.com/svinota/pyroute2 "ignore:Due to '_pack_', the '.*' Structure will use memory layout compatible with MSVC:DeprecationWarning:pyroute2.ethtool.ioctl", # https://github.com/googleapis/python-genai + "ignore:Inheritance class AiohttpClientSession from ClientSession is discouraged:DeprecationWarning:google.genai._api_client", "ignore:'_UnionGenericAlias' is deprecated and slated for removal in Python 3.17:DeprecationWarning:google.genai.types", - # https://github.com/pyusb/pyusb/issues/535 - "ignore:Due to '_pack_', the '.*' Structure will use memory layout compatible with MSVC:DeprecationWarning:usb.backend.libusb0", # -- Websockets 14.1 # https://websockets.readthedocs.io/en/stable/howto/upgrade.html "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", - # https://github.com/graphql-python/gql/pull/543 - >=4.0.0b0 - "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", + # https://pypi.org/project/colorthief/ - v0.2.1 - 2017-02-09 + "ignore:Image.Image.getdata is deprecated and will be removed in Pillow 14.* Use get_flattened_data instead:DeprecationWarning:colorthief", # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", @@ -646,9 +641,6 @@ filterwarnings = [ "ignore:.*invalid escape sequence:SyntaxWarning:.*pydub.utils", # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 "ignore:.*invalid escape sequence:SyntaxWarning:.*pypasser.utils", - # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 - "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", ] From 4efb6b9b5609b424cece280313a0ca4e143fda70 Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:22:44 +0200 Subject: [PATCH 0640/1707] Add color modes to Lunatone light entity (#167574) --- homeassistant/components/lunatone/light.py | 61 +++++- tests/components/lunatone/__init__.py | 42 +++- tests/components/lunatone/conftest.py | 24 +++ .../lunatone/snapshots/test_diagnostics.ambr | 191 +++++++++++++++++ .../lunatone/snapshots/test_light.ambr | 195 ++++++++++++++++++ tests/components/lunatone/test_light.py | 131 +++++++++++- 6 files changed, 636 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index fa2a9c1873fcd5..bfba1f303fadd4 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -9,6 +9,9 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, ColorMode, LightEntity, brightness_supported, @@ -72,6 +75,8 @@ class LunatoneLight( _attr_has_entity_name = True _attr_name = None _attr_should_poll = False + _attr_min_color_temp_kelvin = 1000 + _attr_max_color_temp_kelvin = 10000 def __init__( self, @@ -121,7 +126,13 @@ def brightness(self) -> int | None: @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" - if self._device is not None and self._device.brightness is not None: + if self._device.rgbw_color is not None: + return ColorMode.RGBW + if self._device.rgb_color is not None: + return ColorMode.RGB + if self._device.color_temperature is not None: + return ColorMode.COLOR_TEMP + if self._device.brightness is not None: return ColorMode.BRIGHTNESS return ColorMode.ONOFF @@ -130,6 +141,32 @@ def supported_color_modes(self) -> set[ColorMode]: """Return the supported color modes.""" return {self.color_mode} + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temp of this light in kelvin.""" + return self._device.color_temperature + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the RGB color of this light.""" + rgb_color = self._device.rgb_color + return rgb_color and ( + round(rgb_color[0] * 255), + round(rgb_color[1] * 255), + round(rgb_color[2] * 255), + ) + + @property + def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Return the RGBW color of this light.""" + rgbw_color = self._device.rgbw_color + return rgbw_color and ( + round(rgbw_color[0] * 255), + round(rgbw_color[1] * 255), + round(rgbw_color[2] * 255), + round(rgbw_color[3] * 255), + ) + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -139,12 +176,24 @@ def _handle_coordinator_update(self) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" if brightness_supported(self.supported_color_modes): - await self._device.fade_to_brightness( - brightness_to_value( - self.BRIGHTNESS_SCALE, - kwargs.get(ATTR_BRIGHTNESS, self._last_brightness), + if ATTR_COLOR_TEMP_KELVIN in kwargs: + await self._device.fade_to_color_temperature( + kwargs[ATTR_COLOR_TEMP_KELVIN] + ) + if ATTR_RGB_COLOR in kwargs: + await self._device.fade_to_rgbw_color( + tuple(color / 255 for color in kwargs[ATTR_RGB_COLOR]) + ) + if ATTR_RGBW_COLOR in kwargs: + rgbw_color = tuple(color / 255 for color in kwargs[ATTR_RGBW_COLOR]) + await self._device.fade_to_rgbw_color(rgbw_color[:-1], rgbw_color[-1]) + if ATTR_BRIGHTNESS in kwargs or not self.is_on: + await self._device.fade_to_brightness( + brightness_to_value( + self.BRIGHTNESS_SCALE, + kwargs.get(ATTR_BRIGHTNESS, self._last_brightness), + ) ) - ) else: await self._device.switch_on() await self.coordinator.async_refresh() diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index 12fd58b2a80abb..0c2580b5ce40ef 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -11,7 +11,7 @@ InfoData, LineStatus, ) -from lunatone_rest_api_client.models.common import Status +from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status from lunatone_rest_api_client.models.devices import DeviceStatus from homeassistant.core import HomeAssistant @@ -126,6 +126,46 @@ def build_device_data_list() -> list[DeviceData]: address=1, line=0, ), + DeviceData( + id=3, + name="Device 3", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorKelvin=Status[int](status=1000), + ), + address=2, + line=0, + ), + DeviceData( + id=4, + name="Device 4", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + ), + address=3, + line=0, + ), + DeviceData( + id=5, + name="Device 5", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)), + ), + address=4, + line=0, + ), ] diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py index 8d279bcb80a85c..574d07e38fe9d6 100644 --- a/tests/components/lunatone/conftest.py +++ b/tests/components/lunatone/conftest.py @@ -45,6 +45,30 @@ def build_devices_mock(devices: Devices): if device.data.features.dimmable else None ) + device.color_temperature = ( + device.data.features.color_kelvin.status + if device.data.features.color_kelvin + else None + ) + device.rgb_color = ( + ( + device.data.features.color_rgb.status.red, + device.data.features.color_rgb.status.green, + device.data.features.color_rgb.status.blue, + ) + if device.data.features.color_rgb + else None + ) + device.rgbw_color = ( + ( + device.data.features.color_rgb.status.red, + device.data.features.color_rgb.status.green, + device.data.features.color_rgb.status.blue, + device.data.features.color_waf.status.white, + ) + if device.data.features.color_rgb and device.data.features.color_waf + else None + ) device_list.append(device) return device_list diff --git a/tests/components/lunatone/snapshots/test_diagnostics.ambr b/tests/components/lunatone/snapshots/test_diagnostics.ambr index ca291b9726fc1c..d5f6d613639131 100644 --- a/tests/components/lunatone/snapshots/test_diagnostics.ambr +++ b/tests/components/lunatone/snapshots/test_diagnostics.ambr @@ -114,6 +114,197 @@ 'time_signature': None, 'type': 'default', }), + dict({ + 'address': 2, + 'available': True, + 'dali_types': list([ + ]), + 'features': dict({ + 'color_kelvin': dict({ + 'status': 1000.0, + }), + 'color_kelvin_with_fade': None, + 'color_rgb': None, + 'color_rgb_with_fade': None, + 'color_waf': None, + 'color_waf_with_fade': None, + 'color_xy': None, + 'color_xy_with_fade': None, + 'dali_cmd16': None, + 'dim_down': None, + 'dim_up': None, + 'dimmable': dict({ + 'status': 0.0, + }), + 'dimmable_kelvin': None, + 'dimmable_rgb': None, + 'dimmable_waf': None, + 'dimmable_with_fade': None, + 'dimmable_xy': None, + 'fade_rate': None, + 'fade_time': None, + 'goto_last_active': None, + 'goto_last_active_with_fade': None, + 'save_to_scene': None, + 'scene': None, + 'scene_with_fade': None, + 'switchable': dict({ + 'status': False, + }), + }), + 'groups': list([ + ]), + 'id': 3, + 'line': 0, + 'name': 'Device 3', + 'scenes': list([ + ]), + 'status': dict({ + 'control_gear_failure': False, + 'fade_running': False, + 'is_unaddressed': False, + 'lamp_failure': False, + 'lamp_on': False, + 'limit_error': False, + 'power_cycle_see': False, + 'raw': 0, + 'reset_state': False, + }), + 'time_signature': None, + 'type': 'default', + }), + dict({ + 'address': 3, + 'available': True, + 'dali_types': list([ + ]), + 'features': dict({ + 'color_kelvin': None, + 'color_kelvin_with_fade': None, + 'color_rgb': dict({ + 'status': dict({ + 'blue': 0.0, + 'green': 0.0, + 'red': 0.0, + }), + }), + 'color_rgb_with_fade': None, + 'color_waf': None, + 'color_waf_with_fade': None, + 'color_xy': None, + 'color_xy_with_fade': None, + 'dali_cmd16': None, + 'dim_down': None, + 'dim_up': None, + 'dimmable': dict({ + 'status': 0.0, + }), + 'dimmable_kelvin': None, + 'dimmable_rgb': None, + 'dimmable_waf': None, + 'dimmable_with_fade': None, + 'dimmable_xy': None, + 'fade_rate': None, + 'fade_time': None, + 'goto_last_active': None, + 'goto_last_active_with_fade': None, + 'save_to_scene': None, + 'scene': None, + 'scene_with_fade': None, + 'switchable': dict({ + 'status': False, + }), + }), + 'groups': list([ + ]), + 'id': 4, + 'line': 0, + 'name': 'Device 4', + 'scenes': list([ + ]), + 'status': dict({ + 'control_gear_failure': False, + 'fade_running': False, + 'is_unaddressed': False, + 'lamp_failure': False, + 'lamp_on': False, + 'limit_error': False, + 'power_cycle_see': False, + 'raw': 0, + 'reset_state': False, + }), + 'time_signature': None, + 'type': 'default', + }), + dict({ + 'address': 4, + 'available': True, + 'dali_types': list([ + ]), + 'features': dict({ + 'color_kelvin': None, + 'color_kelvin_with_fade': None, + 'color_rgb': dict({ + 'status': dict({ + 'blue': 0.0, + 'green': 0.0, + 'red': 0.0, + }), + }), + 'color_rgb_with_fade': None, + 'color_waf': dict({ + 'status': dict({ + 'amber': 0.0, + 'free_color': 0.0, + 'white': 0.0, + }), + }), + 'color_waf_with_fade': None, + 'color_xy': None, + 'color_xy_with_fade': None, + 'dali_cmd16': None, + 'dim_down': None, + 'dim_up': None, + 'dimmable': dict({ + 'status': 0.0, + }), + 'dimmable_kelvin': None, + 'dimmable_rgb': None, + 'dimmable_waf': None, + 'dimmable_with_fade': None, + 'dimmable_xy': None, + 'fade_rate': None, + 'fade_time': None, + 'goto_last_active': None, + 'goto_last_active_with_fade': None, + 'save_to_scene': None, + 'scene': None, + 'scene_with_fade': None, + 'switchable': dict({ + 'status': False, + }), + }), + 'groups': list([ + ]), + 'id': 5, + 'line': 0, + 'name': 'Device 5', + 'scenes': list([ + ]), + 'status': dict({ + 'control_gear_failure': False, + 'fade_running': False, + 'is_unaddressed': False, + 'lamp_failure': False, + 'lamp_on': False, + 'limit_error': False, + 'power_cycle_see': False, + 'raw': 0, + 'reset_state': False, + }), + 'time_signature': None, + 'type': 'default', + }), ]), 'info': dict({ 'descriptor': dict({ diff --git a/tests/components/lunatone/snapshots/test_light.ambr b/tests/components/lunatone/snapshots/test_light.ambr index a429bbf1de66ae..8fd5c20d1bd543 100644 --- a/tests/components/lunatone/snapshots/test_light.ambr +++ b/tests/components/lunatone/snapshots/test_light.ambr @@ -240,3 +240,198 @@ 'state': 'off', }) # --- +# name: test_setup[light.device_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 10000, + 'min_color_temp_kelvin': 1000, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device3', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Device 3', + 'hs_color': None, + 'max_color_temp_kelvin': 10000, + 'min_color_temp_kelvin': 1000, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.device_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[light.device_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device4', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Device 4', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.device_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[light.device_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device5', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Device 5', + 'hs_color': None, + 'rgb_color': None, + 'rgbw_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.device_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lunatone/test_light.py b/tests/components/lunatone/test_light.py index 00fd952f761150..5e8bab55ff6041 100644 --- a/tests/components/lunatone/test_light.py +++ b/tests/components/lunatone/test_light.py @@ -4,9 +4,16 @@ from unittest.mock import AsyncMock from lunatone_rest_api_client.models import LineStatus +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + DOMAIN as LIGHT_DOMAIN, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -230,3 +237,125 @@ async def test_line_broadcast_line_present( await setup_integration(hass, mock_config_entry) assert not hass.states.async_entity_ids("light") + + +@pytest.mark.parametrize( + "color_temp_kelvin", + [10000, 5000, 1000], +) +async def test_turn_on_with_color_temperature( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, + color_temp_kelvin: int, +) -> None: + """Test the color temperature of the light can be set.""" + device_id = 3 + entity_id = f"light.device_{device_id}" + + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + device = mock_lunatone_devices.data.devices[device_id - 1] + device.features.switchable.status = True + device.features.color_kelvin.status = float(color_temp_kelvin) + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + ATTR_COLOR_TEMP_KELVIN: color_temp_kelvin, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == color_temp_kelvin + + +@pytest.mark.parametrize( + "rgb_color", + [(255, 128, 0), (0, 255, 128), (128, 0, 255)], +) +async def test_turn_on_with_rgb_color( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, + rgb_color: tuple[int, int, int], +) -> None: + """Test the RGB color of the light can be set.""" + device_id = 4 + entity_id = f"light.device_{device_id}" + + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + device = mock_lunatone_devices.data.devices[device_id - 1] + device.features.switchable.status = True + device.features.color_rgb.status.red = rgb_color[0] / 255 + device.features.color_rgb.status.green = rgb_color[1] / 255 + device.features.color_rgb.status.blue = rgb_color[2] / 255 + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: rgb_color, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_RGB_COLOR] == rgb_color + + +@pytest.mark.parametrize( + "rgbw_color", + [(255, 128, 0, 255), (0, 255, 128, 128), (128, 0, 255, 0)], +) +async def test_turn_on_with_rgbw_color( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, + rgbw_color: tuple[int, int, int, int], +) -> None: + """Test the RGBW color of the light can be set.""" + device_id = 5 + entity_id = f"light.device_{device_id}" + + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + device = mock_lunatone_devices.data.devices[device_id - 1] + device.features.switchable.status = True + device.features.color_rgb.status.red = rgbw_color[0] / 255 + device.features.color_rgb.status.green = rgbw_color[1] / 255 + device.features.color_rgb.status.blue = rgbw_color[2] / 255 + device.features.color_waf.status.white = rgbw_color[3] / 255 + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGBW_COLOR: rgbw_color, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_RGBW_COLOR] == rgbw_color From cc21c99e55a2c2c2ae3ff2299bb5a6e16d53f960 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 9 Apr 2026 23:00:09 +1000 Subject: [PATCH 0641/1707] Fix "IR emitter" sentence case in SMLIGHT string (#167684) --- homeassistant/components/smlight/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index ad67987899ff6f..31fb16650a94b7 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -81,7 +81,7 @@ }, "infrared": { "infrared_emitter": { - "name": "IR Emitter" + "name": "IR emitter" } }, "light": { From 998f24649d3972a883b16801bec8355257a0c570 Mon Sep 17 00:00:00 2001 From: wibbit Date: Thu, 9 Apr 2026 14:11:41 +0100 Subject: [PATCH 0642/1707] geniushub: add water heater platform tests (#167763) Co-authored-by: Joostlek --- .../fixtures/zones_cloud_test_data.json | 1894 ++++++++++++++--- .../snapshots/test_water_heater.ambr | 81 + .../components/geniushub/test_water_heater.py | 30 + 3 files changed, 1695 insertions(+), 310 deletions(-) create mode 100644 tests/components/geniushub/snapshots/test_water_heater.ambr create mode 100644 tests/components/geniushub/test_water_heater.py diff --git a/tests/components/geniushub/fixtures/zones_cloud_test_data.json b/tests/components/geniushub/fixtures/zones_cloud_test_data.json index 00d3109cf6e1ce..271511652e8da2 100644 --- a/tests/components/geniushub/fixtures/zones_cloud_test_data.json +++ b/tests/components/geniushub/fixtures/zones_cloud_test_data.json @@ -5,7 +5,10 @@ "output": 0, "type": "manager", "mode": "off", - "schedule": { "timer": {}, "footprint": {} } + "schedule": { + "timer": {}, + "footprint": {} + } }, { "id": 1, @@ -15,64 +18,151 @@ "mode": "off", "temperature": 20, "setpoint": 4, - "override": { "duration": 0, "setpoint": 20 }, + "override": { + "duration": 0, + "setpoint": 20 + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 68400, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 68400, "setpoint": 20 }, - { "end": 81000, "start": 75600, "setpoint": 18 } + { + "end": 68400, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 68400, + "setpoint": 20 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18 + } ] }, "monday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 68400, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 68400, "setpoint": 20 }, - { "end": 81000, "start": 75600, "setpoint": 18 } + { + "end": 68400, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 68400, + "setpoint": 20 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18 + } ] }, "tuesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 68400, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 68400, "setpoint": 20 }, - { "end": 81000, "start": 75600, "setpoint": 18 } + { + "end": 68400, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 68400, + "setpoint": 20 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18 + } ] }, "wednesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 68400, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 68400, "setpoint": 20 }, - { "end": 81000, "start": 75600, "setpoint": 18 } + { + "end": 68400, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 68400, + "setpoint": 20 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18 + } ] }, "thursday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 68400, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 68400, "setpoint": 20 }, - { "end": 81000, "start": 75600, "setpoint": 18 } + { + "end": 68400, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 68400, + "setpoint": 20 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18 + } ] }, "friday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 68400, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 68400, "setpoint": 20 }, - { "end": 81000, "start": 75600, "setpoint": 18 } + { + "end": 68400, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 68400, + "setpoint": 20 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18 + } ] }, "saturday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 68400, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 68400, "setpoint": 20 }, - { "end": 81000, "start": 75600, "setpoint": 18 } + { + "end": 68400, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 68400, + "setpoint": 20 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18 + } ] } } @@ -82,50 +172,106 @@ "sunday": { "defaultSetpoint": 17, "heatingPeriods": [ - { "end": 61200, "start": 0, "setpoint": 4 }, - { "end": 86400, "start": 80100, "setpoint": 4 } + { + "end": 61200, + "start": 0, + "setpoint": 4 + }, + { + "end": 86400, + "start": 80100, + "setpoint": 4 + } ] }, "monday": { "defaultSetpoint": 17, "heatingPeriods": [ - { "end": 61200, "start": 0, "setpoint": 4 }, - { "end": 86400, "start": 80100, "setpoint": 4 } + { + "end": 61200, + "start": 0, + "setpoint": 4 + }, + { + "end": 86400, + "start": 80100, + "setpoint": 4 + } ] }, "tuesday": { "defaultSetpoint": 17, "heatingPeriods": [ - { "end": 61200, "start": 0, "setpoint": 4 }, - { "end": 86400, "start": 80100, "setpoint": 4 } + { + "end": 61200, + "start": 0, + "setpoint": 4 + }, + { + "end": 86400, + "start": 80100, + "setpoint": 4 + } ] }, "wednesday": { "defaultSetpoint": 17, "heatingPeriods": [ - { "end": 61200, "start": 0, "setpoint": 4 }, - { "end": 86400, "start": 80100, "setpoint": 4 } + { + "end": 61200, + "start": 0, + "setpoint": 4 + }, + { + "end": 86400, + "start": 80100, + "setpoint": 4 + } ] }, "thursday": { "defaultSetpoint": 17, "heatingPeriods": [ - { "end": 61200, "start": 0, "setpoint": 4 }, - { "end": 86400, "start": 80100, "setpoint": 4 } + { + "end": 61200, + "start": 0, + "setpoint": 4 + }, + { + "end": 86400, + "start": 80100, + "setpoint": 4 + } ] }, "friday": { "defaultSetpoint": 17, "heatingPeriods": [ - { "end": 61200, "start": 0, "setpoint": 4 }, - { "end": 86400, "start": 80100, "setpoint": 4 } + { + "end": 61200, + "start": 0, + "setpoint": 4 + }, + { + "end": 86400, + "start": 80100, + "setpoint": 4 + } ] }, "saturday": { "defaultSetpoint": 17, "heatingPeriods": [ - { "end": 61200, "start": 0, "setpoint": 4 }, - { "end": 86400, "start": 80100, "setpoint": 4 } + { + "end": 61200, + "start": 0, + "setpoint": 4 + }, + { + "end": 86400, + "start": 80100, + "setpoint": 4 + } ] } } @@ -141,64 +287,151 @@ "temperature": 21, "setpoint": 4, "occupied": "False", - "override": { "duration": 0, "setpoint": 20 }, + "override": { + "duration": 0, + "setpoint": 20 + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "monday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "tuesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "wednesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "thursday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "friday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "saturday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 73800, "start": 68400, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 73800, + "start": 68400, + "setpoint": 18.5 + } ] } } @@ -208,61 +441,161 @@ "sunday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 37800, "start": 32400, "setpoint": 20 }, - { "end": 75600, "start": 56700, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 37800, + "start": 32400, + "setpoint": 20 + }, + { + "end": 75600, + "start": 56700, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "monday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 43500, "start": 31800, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 43500, + "start": 31800, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "tuesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 34200, "start": 27300, "setpoint": 20 }, - { "end": 75600, "start": 60900, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 34200, + "start": 27300, + "setpoint": 20 + }, + { + "end": 75600, + "start": 60900, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "wednesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 48300, "start": 28800, "setpoint": 20 }, - { "end": 75600, "start": 75300, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 48300, + "start": 28800, + "setpoint": 20 + }, + { + "end": 75600, + "start": 75300, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "thursday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 42000, "start": 28500, "setpoint": 20 }, - { "end": 70800, "start": 53700, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 42000, + "start": 28500, + "setpoint": 20 + }, + { + "end": 70800, + "start": 53700, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "friday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 64500, "start": 28500, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 64500, + "start": 28500, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "saturday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 63900, "start": 53100, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 63900, + "start": 53100, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] } } @@ -278,64 +611,151 @@ "temperature": 21.5, "setpoint": 4, "occupied": "False", - "override": { "duration": 0, "setpoint": 20 }, + "override": { + "duration": 0, + "setpoint": 20 + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "monday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "tuesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "wednesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "thursday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "friday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 70200, "start": 61200, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 70200, + "start": 61200, + "setpoint": 18.5 + } ] }, "saturday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 61200, "start": 29700, "setpoint": 6 }, - { "end": 73800, "start": 68400, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 61200, + "start": 29700, + "setpoint": 6 + }, + { + "end": 73800, + "start": 68400, + "setpoint": 18.5 + } ] } } @@ -345,60 +765,156 @@ "sunday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 38100, "start": 29100, "setpoint": 20 }, - { "end": 75600, "start": 56700, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 38100, + "start": 29100, + "setpoint": 20 + }, + { + "end": 75600, + "start": 56700, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "monday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 51600, "start": 32400, "setpoint": 20 }, - { "end": 74400, "start": 60600, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 51600, + "start": 32400, + "setpoint": 20 + }, + { + "end": 74400, + "start": 60600, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "tuesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 33300, "start": 27300, "setpoint": 20 }, - { "end": 75600, "start": 58800, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 33300, + "start": 27300, + "setpoint": 20 + }, + { + "end": 75600, + "start": 58800, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "wednesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 48600, "start": 28800, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 48600, + "start": 28800, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "thursday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 71400, "start": 56400, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 71400, + "start": 56400, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "friday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 74400, "start": 40800, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 74400, + "start": 40800, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "saturday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 63300, "start": 29700, "setpoint": 20 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 63300, + "start": 29700, + "setpoint": 20 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] } } @@ -414,64 +930,151 @@ "temperature": 21, "setpoint": 4, "occupied": "False", - "override": { "duration": 0, "setpoint": 28 }, + "override": { + "duration": 0, + "setpoint": 28 + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 73800, "setpoint": 16 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 73800, + "setpoint": 16 + } ] }, "monday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 73800, "setpoint": 16 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 73800, + "setpoint": 16 + } ] }, "tuesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 73800, "setpoint": 16 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 73800, + "setpoint": 16 + } ] }, "wednesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 73800, "setpoint": 16 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 73800, + "setpoint": 16 + } ] }, "thursday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 73800, "setpoint": 16 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 73800, + "setpoint": 16 + } ] }, "friday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 73800, "setpoint": 16 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 73800, + "setpoint": 16 + } ] }, "saturday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 73800, "setpoint": 16 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 73800, + "setpoint": 16 + } ] } } @@ -481,50 +1084,106 @@ "sunday": { "defaultSetpoint": 12, "heatingPeriods": [ - { "end": 28800, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 81000, "setpoint": 16 } + { + "end": 28800, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 81000, + "setpoint": 16 + } ] }, "monday": { "defaultSetpoint": 12, "heatingPeriods": [ - { "end": 28800, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 81000, "setpoint": 16 } + { + "end": 28800, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 81000, + "setpoint": 16 + } ] }, "tuesday": { "defaultSetpoint": 12, "heatingPeriods": [ - { "end": 28800, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 81000, "setpoint": 16 } + { + "end": 28800, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 81000, + "setpoint": 16 + } ] }, "wednesday": { "defaultSetpoint": 12, "heatingPeriods": [ - { "end": 28800, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 81000, "setpoint": 16 } + { + "end": 28800, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 81000, + "setpoint": 16 + } ] }, "thursday": { "defaultSetpoint": 12, "heatingPeriods": [ - { "end": 28800, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 81000, "setpoint": 16 } + { + "end": 28800, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 81000, + "setpoint": 16 + } ] }, "friday": { "defaultSetpoint": 12, "heatingPeriods": [ - { "end": 28800, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 81000, "setpoint": 16 } + { + "end": 28800, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 81000, + "setpoint": 16 + } ] }, "saturday": { "defaultSetpoint": 12, "heatingPeriods": [ - { "end": 28800, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 81000, "setpoint": 16 } + { + "end": 28800, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 81000, + "setpoint": 16 + } ] } } @@ -540,70 +1199,181 @@ "temperature": 21, "setpoint": 4, "occupied": "True", - "override": { "duration": 0, "setpoint": 20 }, + "override": { + "duration": 0, + "setpoint": 20 + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "monday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "tuesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 75600, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 75600, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "wednesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "thursday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "friday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "saturday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] } } @@ -613,50 +1383,106 @@ "sunday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "monday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "tuesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "wednesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "thursday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "friday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "saturday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] } } @@ -670,37 +1496,82 @@ "type": "on / off", "mode": "timer", "setpoint": "True", - "override": { "duration": 0, "setpoint": "True" }, + "override": { + "duration": 0, + "setpoint": "True" + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "monday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "tuesday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "wednesday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "thursday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "friday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "saturday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] } } }, @@ -714,50 +1585,81 @@ "type": "on / off", "mode": "timer", "setpoint": "True", - "override": { "duration": 0, "setpoint": "True" }, + "override": { + "duration": 0, + "setpoint": "True" + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": "False", "heatingPeriods": [ - { "end": 82800, "start": 27000, "setpoint": "True" } + { + "end": 82800, + "start": 27000, + "setpoint": "True" + } ] }, "monday": { "defaultSetpoint": "False", "heatingPeriods": [ - { "end": 82800, "start": 27000, "setpoint": "True" } + { + "end": 82800, + "start": 27000, + "setpoint": "True" + } ] }, "tuesday": { "defaultSetpoint": "False", "heatingPeriods": [ - { "end": 82800, "start": 27000, "setpoint": "True" } + { + "end": 82800, + "start": 27000, + "setpoint": "True" + } ] }, "wednesday": { "defaultSetpoint": "False", "heatingPeriods": [ - { "end": 82800, "start": 27000, "setpoint": "True" } + { + "end": 82800, + "start": 27000, + "setpoint": "True" + } ] }, "thursday": { "defaultSetpoint": "False", "heatingPeriods": [ - { "end": 82800, "start": 27000, "setpoint": "True" } + { + "end": 82800, + "start": 27000, + "setpoint": "True" + } ] }, "friday": { "defaultSetpoint": "False", "heatingPeriods": [ - { "end": 82800, "start": 27000, "setpoint": "True" } + { + "end": 82800, + "start": 27000, + "setpoint": "True" + } ] }, "saturday": { "defaultSetpoint": "False", "heatingPeriods": [ - { "end": 82800, "start": 27000, "setpoint": "True" } + { + "end": 82800, + "start": 27000, + "setpoint": "True" + } ] } } @@ -773,64 +1675,151 @@ "mode": "off", "temperature": 21.5, "setpoint": 4, - "override": { "duration": 0, "setpoint": 23.5 }, + "override": { + "duration": 0, + "setpoint": 23.5 + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 75600, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 75600, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "monday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 75600, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 75600, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "tuesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 75600, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 75600, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "wednesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 73800, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 73800, + "setpoint": 18.5 + } ] }, "thursday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 75600, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 75600, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "friday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 75600, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 75600, "setpoint": 19.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 75600, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 19.5 + } ] }, "saturday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 75600, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 75600, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] } } @@ -840,50 +1829,106 @@ "sunday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "monday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "tuesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "wednesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "thursday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "friday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "saturday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] } } @@ -899,70 +1944,181 @@ "temperature": 22, "setpoint": 4, "occupied": "False", - "override": { "duration": 0, "setpoint": 28 }, + "override": { + "duration": 0, + "setpoint": 28 + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "monday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "tuesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 75600, "start": 29700, "setpoint": 6 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 75600, + "start": 29700, + "setpoint": 6 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "wednesday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "thursday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "friday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] }, "saturday": { "defaultSetpoint": 14.5, "heatingPeriods": [ - { "end": 29700, "start": 27000, "setpoint": 18 }, - { "end": 73800, "start": 29700, "setpoint": 6 }, - { "end": 75600, "start": 73800, "setpoint": 14 }, - { "end": 81000, "start": 75600, "setpoint": 18.5 } + { + "end": 29700, + "start": 27000, + "setpoint": 18 + }, + { + "end": 73800, + "start": 29700, + "setpoint": 6 + }, + { + "end": 75600, + "start": 73800, + "setpoint": 14 + }, + { + "end": 81000, + "start": 75600, + "setpoint": 18.5 + } ] } } @@ -972,50 +2128,106 @@ "sunday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "monday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "tuesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "wednesday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "thursday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "friday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] }, "saturday": { "defaultSetpoint": 14, "heatingPeriods": [ - { "end": 23400, "start": 0, "setpoint": 16 }, - { "end": 86400, "start": 75600, "setpoint": 16 } + { + "end": 23400, + "start": 0, + "setpoint": 16 + }, + { + "end": 86400, + "start": 75600, + "setpoint": 16 + } ] } } @@ -1029,41 +2241,103 @@ "type": "on / off", "mode": "off", "setpoint": "False", - "override": { "duration": 0, "setpoint": "True" }, + "override": { + "duration": 0, + "setpoint": "True" + }, "schedule": { "timer": { "weekly": { "sunday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "monday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "tuesday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "wednesday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "thursday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "friday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] }, "saturday": { "defaultSetpoint": "False", - "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + "heatingPeriods": [ + { + "end": 86400, + "start": 0, + "setpoint": "True" + } + ] } } }, "footprint": {} } + }, + { + "id": 10, + "name": "Hot Water", + "output": 1, + "type": "hot water temperature", + "mode": "override", + "temperature": 45.5, + "setpoint": 60, + "override": { + "duration": 3600, + "setpoint": 80 + }, + "schedule": { + "timer": {}, + "footprint": {} + } } ] diff --git a/tests/components/geniushub/snapshots/test_water_heater.ambr b/tests/components/geniushub/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000000..841a8bcd23200f --- /dev/null +++ b/tests/components/geniushub/snapshots/test_water_heater.ambr @@ -0,0 +1,81 @@ +# serializer version: 1 +# name: test_cloud_all_water_heaters[water_heater.hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 80.0, + 'min_temp': 30.0, + 'operation_list': list([ + 'off', + 'auto', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.hot_water', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hot Water', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hot Water', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_10', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_water_heaters[water_heater.hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 45.5, + 'friendly_name': 'Hot Water', + 'max_temp': 80.0, + 'min_temp': 30.0, + 'operation_list': list([ + 'off', + 'auto', + 'manual', + ]), + 'operation_mode': 'manual', + 'status': dict({ + 'mode': 'override', + 'override': dict({ + 'duration': 3600, + 'setpoint': 80, + }), + 'temperature': 45.5, + 'type': 'hot water temperature', + }), + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 60, + }), + 'context': , + 'entity_id': 'water_heater.hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- diff --git a/tests/components/geniushub/test_water_heater.py b/tests/components/geniushub/test_water_heater.py new file mode 100644 index 00000000000000..d9cca180d1abbb --- /dev/null +++ b/tests/components/geniushub/test_water_heater.py @@ -0,0 +1,30 @@ +"""Tests for the Geniushub water heater platform.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_geniushub_cloud") +async def test_cloud_all_water_heaters( + hass: HomeAssistant, + mock_cloud_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation of the Genius Hub water heater entities.""" + with patch("homeassistant.components.geniushub.PLATFORMS", [Platform.WATER_HEATER]): + await setup_integration(hass, mock_cloud_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_cloud_config_entry.entry_id + ) From 872120821cbf9a4cd2471e45cd659daaa1b06e09 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:15:49 +0800 Subject: [PATCH 0643/1707] Fix SwitchBot encrypted device method selection not resetting on back (#167749) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Martin Hjelmare --- .../components/switchbot/config_flow.py | 15 +++ .../components/switchbot/test_config_flow.py | 117 ++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index d9b3ea44fe1410..4d73567d9f41e3 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -96,6 +96,7 @@ def __init__(self) -> None: self._discovered_advs: dict[str, SwitchBotAdvertisement] = {} self._cloud_username: str | None = None self._cloud_password: str | None = None + self._encryption_method_selected = False async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -197,6 +198,13 @@ async def async_step_encrypted_auth( assert self._discovered_adv is not None description_placeholders: dict[str, str] = {} + if user_input is None: + if not self._encryption_method_selected and not ( + self._cloud_username and self._cloud_password + ): + return await self.async_step_encrypted_choose_method() + self._encryption_method_selected = False + # If we have saved credentials from cloud login, try them first if user_input is None and self._cloud_username and self._cloud_password: user_input = { @@ -258,6 +266,7 @@ async def async_step_encrypted_choose_method( """Handle the SwitchBot API chose method step.""" assert self._discovered_adv is not None + self._encryption_method_selected = True return self.async_show_menu( step_id="encrypted_choose_method", menu_options=["encrypted_auth", "encrypted_key"], @@ -272,6 +281,12 @@ async def async_step_encrypted_key( """Handle the encryption key step.""" errors: dict[str, str] = {} assert self._discovered_adv is not None + + if user_input is None: + if not self._encryption_method_selected: + return await self.async_step_encrypted_choose_method() + self._encryption_method_selected = False + if user_input is not None: model: SwitchbotModel = self._discovered_adv.data["modelName"] cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model] diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index 54aa37462e8565..c2dbb8e5917226 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -221,6 +221,113 @@ async def test_bluetooth_discovery_key(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_bluetooth_discovery_encrypted_key_back_navigation( + hass: HomeAssistant, +) -> None: + """Test that resuming an abandoned encrypted_key flow resets to the method menu.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WOLOCK_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + # User selects encrypted_key + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_key"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_key" + + # Simulate user closing dialog and re-opening: call the step with no input + # (as HA does when resuming an in-progress flow) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=None + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + # User can now pick a method again and complete the flow + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_key"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_key" + + with ( + patch_async_setup_entry() as mock_setup_entry, + patch( + "switchbot.SwitchbotLock.verify_encryption_key", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_bluetooth_discovery_encrypted_auth_back_navigation( + hass: HomeAssistant, +) -> None: + """Test that resuming an abandoned encrypted_auth flow resets to the method menu.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WOLOCK_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + # User selects encrypted_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_auth"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_auth" + + # Simulate user closing dialog and re-opening: call the step with no input + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=None + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + # User can switch to encrypted_key and complete the flow + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_key"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "encrypted_key" + + with ( + patch_async_setup_entry() as mock_setup_entry, + patch( + "switchbot.SwitchbotLock.verify_encryption_key", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None: """Test discovery via bluetooth with a valid device when already setup.""" entry = MockConfigEntry( @@ -1208,12 +1315,22 @@ async def test_user_cloud_login_then_encrypted_device(hass: HomeAssistant) -> No assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encrypted_auth" + # Simulate the user navigating away and re-opening the dialog. + # The failed auto-auth cleared credentials, so calling with None now + # redirects back to the method selection menu. result = await hass.config_entries.flow.async_configure( result["flow_id"], None, ) await hass.async_block_till_done() + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "encrypted_choose_method" + + # User selects encrypted_auth again and manually enters credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "encrypted_auth"} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "encrypted_auth" From 9ea527520aa3b85d2f8b3a2d2f0011ec70341095 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 9 Apr 2026 16:18:21 +0300 Subject: [PATCH 0644/1707] Bump anthropic to 0.92.0 (#167793) --- .../components/anthropic/__init__.py | 1 + .../components/anthropic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/anthropic/__init__.py | 335 ++++++++++++++++++ tests/components/anthropic/conftest.py | 63 +--- 6 files changed, 342 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 5e43b2f1c75c98..29cb49b0627e81 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -37,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> coordinator = AnthropicCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + LOGGER.debug("Available models: %s", coordinator.data) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index caf54b71729954..6a98d72b6fb8f3 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["anthropic==0.83.0"] + "requirements": ["anthropic==0.92.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 42cc325b8a5be3..a6b213beb2d030 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -512,7 +512,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.83.0 +anthropic==0.92.0 # homeassistant.components.mcp_server anyio==4.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 066219a2f57b29..7ab86166f1cade 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -488,7 +488,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.83.0 +anthropic==0.92.0 # homeassistant.components.mcp_server anyio==4.10.0 diff --git a/tests/components/anthropic/__init__.py b/tests/components/anthropic/__init__.py index c9c0ff7747de9a..4591be89749e9c 100644 --- a/tests/components/anthropic/__init__.py +++ b/tests/components/anthropic/__init__.py @@ -1,5 +1,6 @@ """Tests for the Anthropic integration.""" +import datetime from typing import Any from anthropic.types import ( @@ -8,11 +9,16 @@ BashCodeExecutionToolResultBlock, BashCodeExecutionToolResultError, BashCodeExecutionToolResultErrorCode, + CapabilitySupport, CitationsDelta, CodeExecutionToolResultBlock, CodeExecutionToolResultBlockContent, + ContextManagementCapability, DirectCaller, + EffortCapability, InputJSONDelta, + ModelCapabilities, + ModelInfo, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawContentBlockStopEvent, @@ -25,7 +31,9 @@ TextDelta, TextEditorCodeExecutionToolResultBlock, ThinkingBlock, + ThinkingCapability, ThinkingDelta, + ThinkingTypes, ToolSearchToolResultBlock, ToolUseBlock, WebSearchResultBlock, @@ -40,6 +48,333 @@ Content as ToolSearchToolResultBlockContent, ) +model_list = [ + ModelInfo( + id="claude-sonnet-4-6", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=True), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=True), + low=CapabilitySupport(supported=True), + max=CapabilitySupport(supported=True), + medium=CapabilitySupport(supported=True), + supported=True, + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=True), + enabled=CapabilitySupport(supported=True), + ), + ), + ), + created_at=datetime.datetime(2026, 2, 17, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Sonnet 4.6", + max_input_tokens=1000000, + max_tokens=128000, + type="model", + ), + ModelInfo( + id="claude-opus-4-6", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=True), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=True), + low=CapabilitySupport(supported=True), + max=CapabilitySupport(supported=True), + medium=CapabilitySupport(supported=True), + supported=True, + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=True), + enabled=CapabilitySupport(supported=True), + ), + ), + ), + created_at=datetime.datetime(2026, 2, 4, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Opus 4.6", + max_input_tokens=1000000, + max_tokens=128000, + type="model", + ), + ModelInfo( + id="claude-opus-4-5-20251101", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=False), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=True), + low=CapabilitySupport(supported=True), + max=CapabilitySupport(supported=False), + medium=CapabilitySupport(supported=True), + supported=True, + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=False), + enabled=CapabilitySupport(supported=True), + ), + ), + ), + created_at=datetime.datetime(2025, 11, 24, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Opus 4.5", + max_input_tokens=200000, + max_tokens=64000, + type="model", + ), + ModelInfo( + id="claude-haiku-4-5-20251001", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=False), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=False), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=False), + low=CapabilitySupport(supported=False), + max=CapabilitySupport(supported=False), + medium=CapabilitySupport(supported=False), + supported=False, + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=False), + enabled=CapabilitySupport(supported=True), + ), + ), + ), + created_at=datetime.datetime(2025, 10, 15, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Haiku 4.5", + max_input_tokens=200000, + max_tokens=64000, + type="model", + ), + ModelInfo( + id="claude-sonnet-4-5-20250929", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=True), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=False), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=False), + low=CapabilitySupport(supported=False), + max=CapabilitySupport(supported=False), + medium=CapabilitySupport(supported=False), + supported=False, + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=False), + enabled=CapabilitySupport(supported=True), + ), + ), + ), + created_at=datetime.datetime(2025, 9, 29, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Sonnet 4.5", + max_input_tokens=1000000, + max_tokens=64000, + type="model", + ), + ModelInfo( + id="claude-opus-4-1-20250805", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=False), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=False), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=False), + low=CapabilitySupport(supported=False), + max=CapabilitySupport(supported=False), + medium=CapabilitySupport(supported=False), + supported=False, + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=True), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=False), + enabled=CapabilitySupport(supported=True), + ), + ), + ), + created_at=datetime.datetime(2025, 8, 5, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Opus 4.1", + max_input_tokens=200000, + max_tokens=32000, + type="model", + ), + ModelInfo( + id="claude-opus-4-20250514", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=False), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=False), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=False), + low=CapabilitySupport(supported=False), + max=CapabilitySupport(supported=False), + medium=CapabilitySupport(supported=False), + supported=False, + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=False), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=False), + enabled=CapabilitySupport(supported=True), + ), + ), + ), + created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Opus 4", + max_input_tokens=200000, + max_tokens=32000, + type="model", + ), + ModelInfo( + id="claude-sonnet-4-20250514", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=True), + code_execution=CapabilitySupport(supported=False), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=True), + clear_tool_uses_20250919=CapabilitySupport(supported=True), + compact_20260112=CapabilitySupport(supported=False), + supported=True, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=False), + low=CapabilitySupport(supported=False), + max=CapabilitySupport(supported=False), + medium=CapabilitySupport(supported=False), + supported=False, + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=True), + structured_outputs=CapabilitySupport(supported=False), + thinking=ThinkingCapability( + supported=True, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=False), + enabled=CapabilitySupport(supported=True), + ), + ), + ), + created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Sonnet 4", + max_input_tokens=1000000, + max_tokens=64000, + type="model", + ), + ModelInfo( + id="claude-3-haiku-20240307", + capabilities=ModelCapabilities( + batch=CapabilitySupport(supported=True), + citations=CapabilitySupport(supported=False), + code_execution=CapabilitySupport(supported=False), + context_management=ContextManagementCapability( + clear_thinking_20251015=CapabilitySupport(supported=False), + clear_tool_uses_20250919=CapabilitySupport(supported=False), + compact_20260112=CapabilitySupport(supported=False), + supported=False, + ), + effort=EffortCapability( + high=CapabilitySupport(supported=False), + low=CapabilitySupport(supported=False), + max=CapabilitySupport(supported=False), + medium=CapabilitySupport(supported=False), + supported=False, + ), + image_input=CapabilitySupport(supported=True), + pdf_input=CapabilitySupport(supported=False), + structured_outputs=CapabilitySupport(supported=False), + thinking=ThinkingCapability( + supported=False, + types=ThinkingTypes( + adaptive=CapabilitySupport(supported=False), + enabled=CapabilitySupport(supported=False), + ), + ), + ), + created_at=datetime.datetime(2024, 3, 7, 0, 0, tzinfo=datetime.UTC), + display_name="Claude Haiku 3", + max_input_tokens=200000, + max_tokens=4096, + type="model", + ), +] + def create_content_block( index: int, text_parts: list[str], citations: list[TextCitation] | None = None diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index 94c04c3a01c915..3c840bb3d870c7 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -9,7 +9,6 @@ Container, Message, MessageDeltaUsage, - ModelInfo, RawContentBlockStartEvent, RawMessageDeltaEvent, RawMessageStartEvent, @@ -31,6 +30,8 @@ from homeassistant.helpers import llm from homeassistant.setup import async_setup_component +from . import model_list + from tests.common import MockConfigEntry @@ -81,68 +82,10 @@ async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> AsyncGenerator[None]: """Initialize integration.""" - model_list = AsyncPage( - data=[ - ModelInfo( - id="claude-sonnet-4-6", - created_at=datetime.datetime(2026, 2, 17, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Sonnet 4.6", - type="model", - ), - ModelInfo( - id="claude-opus-4-6", - created_at=datetime.datetime(2026, 2, 4, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Opus 4.6", - type="model", - ), - ModelInfo( - id="claude-opus-4-5-20251101", - created_at=datetime.datetime(2025, 11, 1, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Opus 4.5", - type="model", - ), - ModelInfo( - id="claude-haiku-4-5-20251001", - created_at=datetime.datetime(2025, 10, 15, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Haiku 4.5", - type="model", - ), - ModelInfo( - id="claude-sonnet-4-5-20250929", - created_at=datetime.datetime(2025, 9, 29, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Sonnet 4.5", - type="model", - ), - ModelInfo( - id="claude-opus-4-1-20250805", - created_at=datetime.datetime(2025, 8, 5, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Opus 4.1", - type="model", - ), - ModelInfo( - id="claude-opus-4-20250514", - created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Opus 4", - type="model", - ), - ModelInfo( - id="claude-sonnet-4-20250514", - created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Sonnet 4", - type="model", - ), - ModelInfo( - id="claude-3-haiku-20240307", - created_at=datetime.datetime(2024, 3, 7, 0, 0, tzinfo=datetime.UTC), - display_name="Claude Haiku 3", - type="model", - ), - ] - ) with patch( "anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, - return_value=model_list, + return_value=AsyncPage(data=model_list), ): assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() From 66e35cef0626a142bad48a16aae30f52cffba2f2 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:26:37 +0300 Subject: [PATCH 0645/1707] Bump victron-mqtt to 2026.4.3 (#167787) --- .../components/victron_gx/manifest.json | 2 +- .../components/victron_gx/strings.json | 342 ++++-------------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 65 insertions(+), 283 deletions(-) diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index 4c09621e1f324a..2cb8f3a5c943be 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["victron-mqtt==2026.4.2"], + "requirements": ["victron-mqtt==2026.4.3"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 4d37c1516f7302..34010032a141de 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -90,18 +90,6 @@ "acload_voltage_phase": { "name": "Voltage on {phase}" }, - "acsystem_mode": { - "state": { - "charger_only": "Charger only", - "inverter_only": "Inverter only", - "off": "Off", - "on": "On", - "passthrough": "Passthrough" - } - }, - "alternator_charge_current_limit": { - "name": "Charge current limit" - }, "alternator_dc_current": { "name": "DC output current" }, @@ -127,14 +115,14 @@ "auto_equalize": "Auto Equalize / Recondition", "battery_safe": "Battery Safe", "bulk": "Bulk", - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "equalize": "Equalize", "external_control": "External Control", - "fault": "Fault", + "fault": "[%key:common::state::fault%]", "float": "Float", "inverting": "Inverting", "low_power": "Low Power", - "off": "Off", + "off": "[%key:common::state::off%]", "passthrough": "Passthrough", "power_assist": "Power Assist", "power_supply": "Power Supply", @@ -384,14 +372,14 @@ "auto_equalize": "Auto Equalize / Recondition", "battery_safe": "Battery Safe", "bulk": "Bulk", - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "equalize": "Equalize", "external_control": "External Control", - "fault": "Fault", + "fault": "[%key:common::state::fault%]", "float": "Float", "inverting": "Inverting", "low_power": "Low Power", - "off": "Off", + "off": "[%key:common::state::off%]", "passthrough": "Passthrough", "power_assist": "Power Assist", "power_supply": "Power Supply", @@ -429,14 +417,14 @@ "auto_equalize": "Auto Equalize / Recondition", "battery_safe": "Battery Safe", "bulk": "Bulk", - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "equalize": "Equalize", "external_control": "External Control", - "fault": "Fault", + "fault": "[%key:common::state::fault%]", "float": "Float", "inverting": "Inverting", "low_power": "Low Power", - "off": "Off", + "off": "[%key:common::state::off%]", "passthrough": "Passthrough", "power_assist": "Power Assist", "power_supply": "Power Supply", @@ -489,17 +477,17 @@ "name": "State", "state": { "alarm": "Alarm", - "closed": "Closed", - "high": "High", - "low": "Low", - "no": "No", - "off": "Off", + "closed": "[%key:common::state::closed%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "no": "[%key:common::state::no%]", + "off": "[%key:common::state::off%]", "ok": "Ok", - "on": "On", - "open": "Open", + "on": "[%key:common::state::on%]", + "open": "[%key:common::state::open%]", "running": "Running", - "stopped": "Stopped", - "yes": "Yes" + "stopped": "[%key:common::state::stopped%]", + "yes": "[%key:common::state::yes%]" } }, "digitalinput_type": { @@ -509,7 +497,7 @@ "bilge_pump": "Bilge pump", "burglar_alarm": "Burglar alarm", "co2_alarm": "CO2 alarm", - "disabled": "Disabled", + "disabled": "[%key:common::state::disabled%]", "door_alarm": "Door alarm", "fire_alarm": "Fire alarm", "generator": "Generator", @@ -527,14 +515,6 @@ "evcharger_min_set_current": { "name": "Minimum set current" }, - "evcharger_mode": { - "name": "Mode", - "state": { - "auto": "Auto", - "manual": "Manual", - "scheduled_charge": "Scheduled Charge" - } - }, "evcharger_position": { "name": "Position", "state": { @@ -558,20 +538,17 @@ "evcharger_session_time": { "name": "Last session time" }, - "evcharger_set_current": { - "name": "Set current" - }, "evcharger_status": { "name": "Status", "state": { "charged": "Charged", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "charging_limit": "Charging limit", - "connected": "Connected", + "connected": "[%key:common::state::connected%]", "cp_input_test_error": "CP input test error", - "disconnected": "Disconnected", + "disconnected": "[%key:common::state::disconnected%]", "ground_test_error": "Ground test error", - "low_soc": "Low SOC", + "low_soc": "Low SoC", "overheating_detected": "Overheating detected", "overvoltage_detected": "Overvoltage detected", "reserved15": "Reserved", @@ -593,60 +570,6 @@ "evcharger_total_energy": { "name": "Total energy" }, - "generator_gen_id_cool_down_timer": { - "name": "Generator cooldown timer" - }, - "generator_gen_id_qh_start_on_soc": { - "name": "Generator QH start on SOC" - }, - "generator_gen_id_qh_start_on_voltage": { - "name": "Generator QH start on voltage" - }, - "generator_gen_id_qh_stop_on_soc": { - "name": "Generator QH stop on SOC" - }, - "generator_gen_id_qh_stop_on_voltage": { - "name": "Generator QH stop on voltage" - }, - "generator_gen_id_service_interval": { - "name": "Generator service interval" - }, - "generator_gen_id_shut_down_timer": { - "name": "Generator shutdown timer" - }, - "generator_gen_id_start_on_soc": { - "name": "Generator start on SOC" - }, - "generator_gen_id_start_on_soc_timer": { - "name": "Generator start on SOC timer" - }, - "generator_gen_id_start_on_temp_timer": { - "name": "Generator start on temp timer" - }, - "generator_gen_id_start_on_voltage": { - "name": "Generator start on voltage" - }, - "generator_gen_id_start_on_voltage_timer": { - "name": "Generator start on voltage timer" - }, - "generator_gen_id_stop_on_soc": { - "name": "Generator stop on SOC" - }, - "generator_gen_id_stop_on_soc_timer": { - "name": "Generator stop on SOC timer" - }, - "generator_gen_id_stop_on_temp_timer": { - "name": "Generator stop on temp timer" - }, - "generator_gen_id_stop_on_voltage": { - "name": "Generator stop on voltage" - }, - "generator_gen_id_stop_on_voltage_timer": { - "name": "Generator stop on voltage timer" - }, - "generator_gen_id_warm_up_timer": { - "name": "Generator warm-up timer" - }, "generator_run_state": { "name": "Run state", "state": { @@ -656,10 +579,10 @@ "inv_overload": "Inv Overload", "inv_temp": "Inv Temp", "lost_comms": "Lost Comms", - "manual": "Manual", - "soc": "SOC", + "manual": "[%key:common::state::manual%]", + "soc": "SoC", "stop_on_ac1": "Stop On AC1", - "stopped": "Stopped", + "stopped": "[%key:common::state::stopped%]", "test_run": "Test Run" } }, @@ -772,16 +695,6 @@ "heatpump_voltage_phase": { "name": "Voltage on {phase}" }, - "hub4_ac_grid_setpoint": { - "name": "AC grid setpoint" - }, - "inverter_mode": { - "state": { - "eco": "Eco", - "inverter": "Inverter", - "off": "Off" - } - }, "inverter_output_apparent_power_phase": { "name": "Output apparent power {phase}" }, @@ -807,14 +720,14 @@ "auto_equalize": "Auto Equalize / Recondition", "battery_safe": "Battery Safe", "bulk": "Bulk", - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "equalize": "Equalize", "external_control": "External Control", - "fault": "Fault", + "fault": "[%key:common::state::fault%]", "float": "Float", "inverting": "Inverting", "low_power": "Low Power", - "off": "Off", + "off": "[%key:common::state::off%]", "passthrough": "Passthrough", "power_assist": "Power Assist", "power_supply": "Power Supply", @@ -868,22 +781,16 @@ "state": { "ac_input_1": "AC Input 1", "ac_input_2": "AC Input 2", - "disconnected": "Disconnected" + "disconnected": "[%key:common::state::disconnected%]" } }, - "multi_ess_ac_power_setpoint": { - "name": "ESS AC power setpoint" - }, - "multi_ess_min_soc_limit": { - "name": "ESS minimum SOC limit" - }, "multi_ess_mode": { "name": "ESS mode", "state": { "external_control": "External control", "keep_charged": "keep charged", - "self_consumption": "self consumption", - "self_consumption_batterylife": "self consumption (batterylife)" + "self_consumption": "Self-consumption", + "self_consumption_batterylife": "Self-consumption (batterylife)" } }, "multi_inverter_power_setpoint": { @@ -914,7 +821,7 @@ "state": { "mppt_active": "MPPT active", "not_available": "Not available", - "off": "Off", + "off": "[%key:common::state::off%]", "voltage_current_limited": "Voltage/current limited" } }, @@ -928,9 +835,6 @@ "multi_pv_power_total": { "name": "PV power total" }, - "multi_shore_current_limit": { - "name": "Shore current limit" - }, "multi_solar_to_acin1": { "name": "Solar to AC-in-1" }, @@ -947,14 +851,14 @@ "auto_equalize": "Auto Equalize / Recondition", "battery_safe": "Battery Safe", "bulk": "Bulk", - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "equalize": "Equalize", "external_control": "External Control", - "fault": "Fault", + "fault": "[%key:common::state::fault%]", "float": "Float", "inverting": "Inverting", "low_power": "Low Power", - "off": "Off", + "off": "[%key:common::state::off%]", "passthrough": "Passthrough", "power_assist": "Power Assist", "power_supply": "Power Supply", @@ -976,10 +880,6 @@ "multi_yield_yesterday": { "name": "Yield yesterday" }, - "multiplus_assist_current_boost_factor": { - "name": "Assist current boost factor", - "unit_of_measurement": "factor" - }, "platform_venus_firmware_available_version": { "name": "Available version" }, @@ -1077,7 +977,7 @@ "state": { "mppt_active": "MPPT active", "not_available": "Not available", - "off": "Off", + "off": "[%key:common::state::off%]", "voltage_current_limited": "Voltage/current limited" } }, @@ -1088,14 +988,14 @@ "auto_equalize": "Auto Equalize / Recondition", "battery_safe": "Battery Safe", "bulk": "Bulk", - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "equalize": "Equalize", "external_control": "External Control", - "fault": "Fault", + "fault": "[%key:common::state::fault%]", "float": "Float", "inverting": "Inverting", "low_power": "Low Power", - "off": "Off", + "off": "[%key:common::state::off%]", "passthrough": "Passthrough", "power_assist": "Power Assist", "power_supply": "Power Supply", @@ -1131,7 +1031,7 @@ "state": { "mppt_active": "MPPT active", "not_available": "Not available", - "off": "Off", + "off": "[%key:common::state::off%]", "voltage_current_limited": "Voltage/current limited" } }, @@ -1162,10 +1062,6 @@ "switch_output_custom_name": { "name": "{output} custom name" }, - "switch_output_dimming": { - "name": "{output} dimming", - "unit_of_measurement": "%" - }, "switchable_output_output_custom_name": { "name": "Switchable output {output} custom name" }, @@ -1179,18 +1075,9 @@ "unknown": "Unknown" } }, - "system_ac_export_limit": { - "name": "AC export limit" - }, - "system_ac_input_limit": { - "name": "AC input limit" - }, "system_ac_loads_phase": { "name": "AC loads on {phase}" }, - "system_ac_power_set_point": { - "name": "AC power setpoint" - }, "system_consumption_current_phase": { "name": "Consumption current {phase}" }, @@ -1206,10 +1093,10 @@ "name": "Consumption power {phase}" }, "system_control_active_soc_limit": { - "name": "Active SOC limit" + "name": "Active SoC limit" }, "system_control_scheduled_soc": { - "name": "Scheduled SOC" + "name": "Scheduled SoC" }, "system_critical_loads_phase": { "name": "Critical loads on {phase}" @@ -1235,9 +1122,9 @@ "system_dc_battery_state": { "name": "DC battery state", "state": { - "charging": "Charging", - "discharging": "Discharging", - "idle": "Idle" + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "idle": "[%key:common::state::idle%]" } }, "system_dc_battery_voltage": { @@ -1266,7 +1153,7 @@ "no_error": "No Error", "no_ess": "No ESS", "no_schedule": "No Matching Schedule", - "soc_low": "SOC low" + "soc_low": "SoC low" } }, "system_dynamicess_last_scheduled_end": { @@ -1276,15 +1163,15 @@ "name": "Dynamic ESS last scheduled start" }, "system_dynamicess_minimum_soc": { - "name": "Dynamic ESS minimum SOC" + "name": "Dynamic ESS minimum SoC" }, "system_dynamicess_reactive_strategy": { "name": "Dynamic ESS reactive strategy", "state": { "dess_disabled": "DESS Disabled", - "ess_low_soc": "ESS Low SOC", + "ess_low_soc": "ESS Low SoC", "idle_maintain_surplus": "Idle Maintain Surplus", - "idle_maintain_targetsoc": "Idle Maintain Target SOC", + "idle_maintain_targetsoc": "Idle Maintain Target SoC", "idle_no_opportunity": "Idle No Opportunity", "idle_scheduled_feedin": "Idle Scheduled Feed-In", "keep_battery_charged": "Keep Battery Charged", @@ -1307,7 +1194,7 @@ "selfconsume_unmapped_state": "Self-Consume Unmapped State", "selfconsume_unpredicted": "Self-Consume Unpredicted", "unknown_operating_mode": "Unknown Operating Mode", - "unscheduled_charge_catchup_targetsoc": "Unscheduled Charge Catch-Up Target SOC" + "unscheduled_charge_catchup_targetsoc": "Unscheduled Charge Catch-Up Target SoC" } }, "system_dynamicess_restrictions": { @@ -1329,85 +1216,11 @@ "probattery": "Pro Battery", "progrid": "Pro Grid", "selfconsume": "Self-Consume", - "targetsoc": "Target SOC" + "targetsoc": "Target SoC" } }, "system_dynamicess_target_soc": { - "name": "Dynamic ESS target SOC" - }, - "system_ess_batterylife_state": { - "name": "ESS BatteryLife state", - "state": { - "keep_batteries_charged": "'Keep batteries charged' mode enabled", - "recharge": "Recharge, SOC dropped 5% or more below MinSOC", - "recharge_no_battery_life": "Recharge, SOC dropped 5% or more below MinSOC (No BatteryLife)", - "self_consumption": "Self consumption", - "self_consumption_soc_above_min": "Self consumption, SoC at or above minimum SoC", - "self_consumption_soc_at_100": "Self consumption, SoC at 100%", - "self_consumption_soc_below_min": "Self consumption, SoC is below minimum SoC", - "self_consumption_soc_exceeds_85": "Self consumption, SoC exceeds 85%", - "soc_below_battery_life_dynamic_soc_limit": "SoC below BatteryLife dynamic SoC limit", - "soc_below_soc_limit_24_hours": "SoC has been below SoC limit for more than 24 hours. Charging with battery with 5amps", - "sustain": "Multi/Quattro is in sustain", - "with_battery_life": "Optimized mode with BatteryLife" - } - }, - "system_ess_max_charge_current": { - "name": "ESS max charge current" - }, - "system_ess_max_charge_power": { - "name": "ESS max charge power limit" - }, - "system_ess_max_charge_voltage": { - "name": "ESS max charge voltage" - }, - "system_ess_max_feed_in_power": { - "name": "ESS max feed-in power" - }, - "system_ess_max_inverter_power_limit": { - "name": "ESS max inverter power limit" - }, - "system_ess_min_soc_limit": { - "name": "ESS min SOC limit" - }, - "system_ess_mode": { - "name": "ESS mode (Hub4)", - "state": { - "external_control": "External control", - "phase_compensation_disabled": "Optimized mode or 'keep batteries charged' and phase compensation disabled", - "phase_compensation_enabled": "Optimized mode or 'keep batteries charged' and phase compensation enabled" - } - }, - "system_ess_schedule_charge_slot_days": { - "name": "ESS BatteryLife schedule charge {slot} days", - "state": { - "disabled_every_day": "Disabled (Every day)", - "disabled_friday": "Disabled (Friday)", - "disabled_monday": "Disabled (Monday)", - "disabled_saturday": "Disabled (Saturday)", - "disabled_sunday": "Disabled (Sunday)", - "disabled_thursday": "Disabled (Thursday)", - "disabled_tuesday": "Disabled (Tuesday)", - "disabled_wednesday": "Disabled (Wednesday)", - "disabled_weekdays": "Disabled (Weekdays)", - "disabled_weekend": "Disabled (Weekends)", - "every_day": "Every day", - "friday": "Friday", - "monday": "Monday", - "saturday": "Saturday", - "sunday": "Sunday", - "thursday": "Thursday", - "tuesday": "Tuesday", - "wednesday": "Wednesday", - "weekdays": "Weekdays", - "weekends": "Weekends" - } - }, - "system_ess_schedule_charge_slot_duration": { - "name": "ESS BatteryLife schedule charge {slot} duration" - }, - "system_ess_schedule_charge_slot_soc": { - "name": "ESS BatteryLife schedule charge {slot} SOC" + "name": "Dynamic ESS target SoC" }, "system_generator_load_phase": { "name": "Genset load {phase}" @@ -1438,16 +1251,6 @@ "system_relay_relay_custom_name": { "name": "Relay {relay} custom name" }, - "system_settings_dess_mode": { - "name": "DESS mode", - "state": { - "auto_vrm": "Auto / VRM", - "buy": "Buy", - "node_red": "Node-RED", - "off": "Off", - "sell": "Sell" - } - }, "system_state": { "name": "System state", "state": { @@ -1455,14 +1258,14 @@ "auto_equalize": "Auto Equalize / Recondition", "battery_safe": "Battery Safe", "bulk": "Bulk", - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "equalize": "Equalize", "external_control": "External Control", - "fault": "Fault", + "fault": "[%key:common::state::fault%]", "float": "Float", "inverting": "Inverting", "low_power": "Low Power", - "off": "Off", + "off": "[%key:common::state::off%]", "passthrough": "Passthrough", "power_assist": "Power Assist", "power_supply": "Power Supply", @@ -1512,21 +1315,14 @@ "name": "Humidity", "unit_of_measurement": "%" }, - "temperature_offset": { - "name": "Offset" - }, "temperature_pressure": { "name": "Pressure", "unit_of_measurement": "hPa" }, - "temperature_scale": { - "name": "Scale factor", - "unit_of_measurement": "factor" - }, "temperature_status": { "name": "Sensor status", "state": { - "disconnected": "Disconnected", + "disconnected": "[%key:common::state::disconnected%]", "ok": "Ok", "reverse_polarity": "Reverse polarity", "short_circuited": "Short circuited", @@ -1548,12 +1344,6 @@ "water_heater": "Water Heater" } }, - "transfer_switch_generator_current_limit": { - "name": "Generator AC current limit" - }, - "vebus_ac_power_setpoint_phase": { - "name": "AC power setpoint {phase}" - }, "vebus_device_device_number_input_power_l1": { "name": "{device_number} line 1 input power" }, @@ -1707,8 +1497,8 @@ "vebus_inverter_ignoreacin1_state": { "name": "State of ignore AC-in-1", "state": { - "off": "Off", - "on": "On" + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" } }, "vebus_inverter_input_apparent_power_phase": { @@ -1726,14 +1516,6 @@ "vebus_inverter_input_voltage_phase": { "name": "Input voltage {phase}" }, - "vebus_inverter_mode": { - "state": { - "charger_only": "Charger Only", - "inverter_only": "Inverter Only", - "off": "Off", - "on": "On" - } - }, "vebus_inverter_output_apparent_power_phase": { "name": "Output apparent power {phase}" }, @@ -1756,14 +1538,14 @@ "auto_equalize": "Auto Equalize / Recondition", "battery_safe": "Battery Safe", "bulk": "Bulk", - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "equalize": "Equalize", "external_control": "External Control", - "fault": "Fault", + "fault": "[%key:common::state::fault%]", "float": "Float", "inverting": "Inverting", "low_power": "Low Power", - "off": "Off", + "off": "[%key:common::state::off%]", "passthrough": "Passthrough", "power_assist": "Power Assist", "power_supply": "Power Supply", diff --git a/requirements_all.txt b/requirements_all.txt index a6b213beb2d030..b08e95fc0f9720 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3237,7 +3237,7 @@ viaggiatreno_ha==0.2.4 victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_gx -victron-mqtt==2026.4.2 +victron-mqtt==2026.4.3 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ab86166f1cade..b9765db1969f7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2740,7 +2740,7 @@ venstarcolortouch==0.21 victron-ble-ha-parser==0.6.3 # homeassistant.components.victron_gx -victron-mqtt==2026.4.2 +victron-mqtt==2026.4.3 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.8 From b2fb6c0a6834d1bb02ac77d93df2ecb2cece6eec Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:27:15 +0200 Subject: [PATCH 0646/1707] Use runtime_data in seventeentrack integration (#167737) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/seventeentrack/__init__.py | 9 +++++---- .../components/seventeentrack/config_flow.py | 5 +++-- .../components/seventeentrack/coordinator.py | 6 ++++-- .../components/seventeentrack/sensor.py | 7 +++---- .../components/seventeentrack/services.py | 20 +++++++------------ 5 files changed, 22 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index afb538c6b3257e..6a3e83e7bb230b 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -3,7 +3,6 @@ from pyseventeentrack import Client as SeventeenTrackClient from pyseventeentrack.errors import SeventeenTrackError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -12,7 +11,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import SeventeenTrackCoordinator +from .coordinator import SeventeenTrackConfigEntry, SeventeenTrackCoordinator from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -28,7 +27,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: SeventeenTrackConfigEntry +) -> bool: """Set up 17Track from a config entry.""" session = async_create_clientsession(hass) @@ -43,6 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await seventeen_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = seventeen_coordinator + entry.runtime_data = seventeen_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/seventeentrack/config_flow.py b/homeassistant/components/seventeentrack/config_flow.py index 58cffbb1303b8f..2b0d9f7f68a169 100644 --- a/homeassistant/components/seventeentrack/config_flow.py +++ b/homeassistant/components/seventeentrack/config_flow.py @@ -9,7 +9,7 @@ from pyseventeentrack.errors import SeventeenTrackError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -25,6 +25,7 @@ DEFAULT_SHOW_DELIVERED, DOMAIN, ) +from .coordinator import SeventeenTrackConfigEntry CONF_SHOW = { vol.Optional(CONF_SHOW_ARCHIVED, default=DEFAULT_SHOW_ARCHIVED): bool, @@ -54,7 +55,7 @@ class SeventeenTrackConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SeventeenTrackConfigEntry, ) -> SchemaOptionsFlowHandler: """Get options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/seventeentrack/coordinator.py b/homeassistant/components/seventeentrack/coordinator.py index 107f1d48a218a5..39a42727c51922 100644 --- a/homeassistant/components/seventeentrack/coordinator.py +++ b/homeassistant/components/seventeentrack/coordinator.py @@ -20,6 +20,8 @@ LOGGER, ) +type SeventeenTrackConfigEntry = ConfigEntry[SeventeenTrackCoordinator] + @dataclass class SeventeenTrackData: @@ -32,12 +34,12 @@ class SeventeenTrackData: class SeventeenTrackCoordinator(DataUpdateCoordinator[SeventeenTrackData]): """Class to manage fetching 17Track data.""" - config_entry: ConfigEntry + config_entry: SeventeenTrackConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SeventeenTrackConfigEntry, client: SeventeenTrackClient, ) -> None: """Initialize.""" diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index c6fd79426557fb..b0b91c3c8dae12 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -3,25 +3,24 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SeventeenTrackCoordinator from .const import ATTRIBUTION, DOMAIN +from .coordinator import SeventeenTrackConfigEntry, SeventeenTrackCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SeventeenTrackConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a 17Track sensor entry.""" - coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( SeventeenTrackSummarySensor(status, coordinator) diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 62a12b9ddcf8ea..e0cc3909678622 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -16,7 +16,6 @@ from homeassistant.helpers import config_validation as cv, selector, service from homeassistant.util import slugify -from . import SeventeenTrackCoordinator from .const import ( ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, @@ -34,6 +33,7 @@ SERVICE_ARCHIVE_PACKAGE, SERVICE_GET_PACKAGES, ) +from .coordinator import SeventeenTrackConfigEntry SERVICE_GET_PACKAGES_SCHEMA: Final = vol.Schema( { @@ -72,13 +72,11 @@ async def _get_packages(call: ServiceCall) -> ServiceResponse: """Get packages from 17Track.""" package_states = call.data.get(ATTR_PACKAGE_STATE, []) - entry = service.async_get_config_entry( + entry: SeventeenTrackConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) - seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ - entry.entry_id - ] + seventeen_coordinator = entry.runtime_data live_packages = sorted( await seventeen_coordinator.client.profile.packages( show_archived=seventeen_coordinator.show_archived @@ -99,13 +97,11 @@ async def _add_package(call: ServiceCall) -> None: tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] friendly_name = call.data[ATTR_PACKAGE_FRIENDLY_NAME] - entry = service.async_get_config_entry( + entry: SeventeenTrackConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) - seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ - entry.entry_id - ] + seventeen_coordinator = entry.runtime_data await seventeen_coordinator.client.profile.add_package( tracking_number, friendly_name @@ -115,13 +111,11 @@ async def _add_package(call: ServiceCall) -> None: async def _archive_package(call: ServiceCall) -> None: tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] - entry = service.async_get_config_entry( + entry: SeventeenTrackConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) - seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ - entry.entry_id - ] + seventeen_coordinator = entry.runtime_data await seventeen_coordinator.client.profile.archive_package(tracking_number) From 15045f55d582e012d4b35531bcaa3a874b293e31 Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Thu, 9 Apr 2026 15:42:57 +0200 Subject: [PATCH 0647/1707] Make Weheat energy output TOTAL instead of TOTAL_INCREASING (#167761) --- homeassistant/components/weheat/sensor.py | 2 +- tests/components/weheat/snapshots/test_sensor.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 960749a1aa127c..32aca22ad493dd 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -218,7 +218,7 @@ class WeHeatSensorEntityDescription(SensorEntityDescription): key="energy_output", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, value_fn=lambda status: status.energy_output, ), WeHeatSensorEntityDescription( diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index cad0c509996fce..5fb8e4385967cd 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -1348,7 +1348,7 @@ ]), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1388,7 +1388,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Test Model Total energy output', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , From 050d929d8a7f8af7ec97b518b7e36f57684f5f2a Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:28:35 +0200 Subject: [PATCH 0648/1707] Bump lunatone-rest-api-client to 0.9.1 (#167804) --- homeassistant/components/lunatone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index d32b5dfaa1be89..9337485caabeb3 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.9.0"] + "requirements": ["lunatone-rest-api-client==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b08e95fc0f9720..7e77b5291be6d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ loqedAPI==2.1.11 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.9.0 +lunatone-rest-api-client==0.9.1 # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9765db1969f7f..080387fa8c198d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1289,7 +1289,7 @@ loqedAPI==2.1.11 luftdaten==0.7.4 # homeassistant.components.lunatone -lunatone-rest-api-client==0.9.0 +lunatone-rest-api-client==0.9.1 # homeassistant.components.lupusec lupupy==0.3.2 From 5bec3d1b41cdb48d376a9157e749f06d7426e2d4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:44:18 +0200 Subject: [PATCH 0649/1707] Disable pilight integration (#167760) --- homeassistant/components/pilight/manifest.json | 1 + requirements_all.txt | 3 --- requirements_test_all.txt | 3 --- tests/components/pilight/conftest.py | 3 +++ 4 files changed, 4 insertions(+), 6 deletions(-) create mode 100644 tests/components/pilight/conftest.py diff --git a/homeassistant/components/pilight/manifest.json b/homeassistant/components/pilight/manifest.json index da07c4ee645c0d..e2764026dd8dda 100644 --- a/homeassistant/components/pilight/manifest.json +++ b/homeassistant/components/pilight/manifest.json @@ -2,6 +2,7 @@ "domain": "pilight", "name": "Pilight", "codeowners": [], + "disabled": "Pilight relies on setuptools.pkg_resources, which is no longer available in setuptools 82.0.0 and later.", "documentation": "https://www.home-assistant.io/integrations/pilight", "iot_class": "local_push", "loggers": ["pilight"], diff --git a/requirements_all.txt b/requirements_all.txt index 7e77b5291be6d3..d90b59f4dd3347 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1786,9 +1786,6 @@ phone-modem==0.1.1 # homeassistant.components.remote_rpi_gpio pigpio==1.78 -# homeassistant.components.pilight -pilight==0.1.1 - # homeassistant.components.plex plexauth==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 080387fa8c198d..6131809abdb9be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1550,9 +1550,6 @@ pescea==1.0.12 # homeassistant.components.modem_callerid phone-modem==0.1.1 -# homeassistant.components.pilight -pilight==0.1.1 - # homeassistant.components.plex plexauth==0.0.6 diff --git a/tests/components/pilight/conftest.py b/tests/components/pilight/conftest.py new file mode 100644 index 00000000000000..40f61dec17780a --- /dev/null +++ b/tests/components/pilight/conftest.py @@ -0,0 +1,3 @@ +"""Fixtures for component.""" + +collect_ignore_glob = ["test_*.py"] From 566ff6d1d5e31f3ecbd28801f9ba686c7f101e9c Mon Sep 17 00:00:00 2001 From: Lamarqe Date: Thu, 9 Apr 2026 17:16:02 +0200 Subject: [PATCH 0650/1707] Add frequency unit conversion (#167537) --- homeassistant/components/number/const.py | 2 ++ homeassistant/components/recorder/statistics.py | 2 ++ .../components/recorder/websocket_api.py | 2 ++ homeassistant/components/sensor/const.py | 2 ++ homeassistant/util/unit_conversion.py | 15 +++++++++++++++ tests/components/airos/snapshots/test_sensor.ambr | 3 +++ tests/components/sensor/test_init.py | 1 - tests/util/test_unit_conversion.py | 9 +++++++++ 8 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 5024fc8054a761..f5bdc9b6f928cb 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -60,6 +60,7 @@ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -629,6 +630,7 @@ class NumberDeviceClass(StrEnum): NumberDeviceClass.ENERGY: EnergyConverter, NumberDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter, NumberDeviceClass.ENERGY_STORAGE: EnergyConverter, + NumberDeviceClass.FREQUENCY: FrequencyConverter, NumberDeviceClass.GAS: VolumeConverter, NumberDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter, NumberDeviceClass.NITROGEN_MONOXIDE: NitrogenMonoxideConcentrationConverter, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 517bf77b282e84..b4e451a082cce4 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -57,6 +57,7 @@ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -214,6 +215,7 @@ def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]: ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 58dfd2271d2b47..42cca8cf2dfd95 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -30,6 +30,7 @@ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -90,6 +91,7 @@ vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS), + vol.Optional("frequency"): vol.In(FrequencyConverter.VALID_UNITS), vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS), vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), vol.Optional("nitrogen_dioxide"): vol.In( diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 949cc8cd5a2f02..26fde240596991 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -60,6 +60,7 @@ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -565,6 +566,7 @@ class SensorStateClass(StrEnum): SensorDeviceClass.ENERGY: EnergyConverter, SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter, SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, + SensorDeviceClass.FREQUENCY: FrequencyConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter, SensorDeviceClass.NITROGEN_MONOXIDE: NitrogenMonoxideConcentrationConverter, diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 4a5522da91cc23..1127c76f7777a3 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -23,6 +23,7 @@ UnitOfElectricPotential, UnitOfEnergy, UnitOfEnergyDistance, + UnitOfFrequency, UnitOfInformation, UnitOfLength, UnitOfMass, @@ -358,6 +359,20 @@ class ElectricCurrentConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfElectricCurrent) +class FrequencyConverter(BaseUnitConverter): + """Utility to convert frequency values.""" + + UNIT_CLASS = "frequency" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfFrequency.MILLIHERTZ: 1e3, + UnitOfFrequency.HERTZ: 1, + UnitOfFrequency.KILOHERTZ: 1 / 1e3, + UnitOfFrequency.MEGAHERTZ: 1 / 1e6, + UnitOfFrequency.GIGAHERTZ: 1 / 1e9, + } + VALID_UNITS = set(UnitOfFrequency) + + class ElectricPotentialConverter(BaseUnitConverter): """Utility to convert electric potential values.""" diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr index 1c645745e5fa1d..ce0f6489c48f30 100644 --- a/tests/components/airos/snapshots/test_sensor.ambr +++ b/tests/components/airos/snapshots/test_sensor.ambr @@ -557,6 +557,9 @@ 'name': None, 'object_id_base': 'Wireless frequency', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index cdaec70afbd3d4..e9ae2ba4f7520d 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -3112,7 +3112,6 @@ def test_device_class_converters_are_complete() -> None: SensorDeviceClass.CO2, SensorDeviceClass.DATE, SensorDeviceClass.ENUM, - SensorDeviceClass.FREQUENCY, SensorDeviceClass.HUMIDITY, SensorDeviceClass.ILLUMINANCE, SensorDeviceClass.IRRADIANCE, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 52c9a730f94a96..e3db5cc406b92d 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -23,6 +23,7 @@ UnitOfElectricPotential, UnitOfEnergy, UnitOfEnergyDistance, + UnitOfFrequency, UnitOfInformation, UnitOfLength, UnitOfMass, @@ -53,6 +54,7 @@ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -92,6 +94,7 @@ ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + FrequencyConverter, InformationConverter, MassConverter, ApparentPowerConverter, @@ -159,6 +162,7 @@ UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, 0.621371, ), + FrequencyConverter: (UnitOfFrequency.HERTZ, UnitOfFrequency.KILOHERTZ, 1000), InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8), MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473), MassVolumeConcentrationConverter: ( @@ -732,6 +736,11 @@ UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, ), ], + FrequencyConverter: [ + (5000, UnitOfFrequency.HERTZ, 5, UnitOfFrequency.KILOHERTZ), + (5, UnitOfFrequency.HERTZ, 5000, UnitOfFrequency.MILLIHERTZ), + (5, UnitOfFrequency.GIGAHERTZ, 5000, UnitOfFrequency.MEGAHERTZ), + ], InformationConverter: [ (8e3, UnitOfInformation.BITS, 8, UnitOfInformation.KILOBITS), (8e6, UnitOfInformation.BITS, 8, UnitOfInformation.MEGABITS), From 681f8bedb42a7d3d169b5abbc6ba8c5341d1b9e6 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 10 Apr 2026 01:26:53 +1000 Subject: [PATCH 0651/1707] Handle boolean charging state in Tessie sensor (#165172) --- .../components/tessie/binary_sensor.py | 7 ++++-- homeassistant/components/tessie/helpers.py | 16 ++++++++++++- homeassistant/components/tessie/sensor.py | 3 ++- homeassistant/components/tessie/switch.py | 8 +++++-- .../tessie/snapshots/test_switch.ambr | 2 +- tests/components/tessie/test_binary_sensor.py | 22 +++++++++++++++++ tests/components/tessie/test_sensor.py | 17 +++++++++++++ tests/components/tessie/test_switch.py | 24 +++++++++++++++++++ 8 files changed, 92 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 51a5c33b0d8774..0ecbf3aa2f3070 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -16,8 +16,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry -from .const import TessieState +from .const import TessieChargeStates, TessieState from .entity import TessieEnergyEntity, TessieEntity +from .helpers import charge_state_to_option from .models import TessieEnergyData, TessieVehicleData PARALLEL_UPDATES = 0 @@ -44,7 +45,9 @@ class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): TessieBinarySensorEntityDescription( key="charge_state_charging_state", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - is_on=lambda x: x == "Charging", + is_on=lambda value: ( + charge_state_to_option(value) == TessieChargeStates["Charging"] + ), entity_registry_enabled_default=False, ), TessieBinarySensorEntityDescription( diff --git a/homeassistant/components/tessie/helpers.py b/homeassistant/components/tessie/helpers.py index 321ad0d9aa0d68..82202890ca6b9a 100644 --- a/homeassistant/components/tessie/helpers.py +++ b/homeassistant/components/tessie/helpers.py @@ -7,9 +7,23 @@ from tesla_fleet_api.exceptions import TeslaFleetError from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import StateType from . import _LOGGER -from .const import DOMAIN, TRANSLATED_ERRORS +from .const import DOMAIN, TRANSLATED_ERRORS, TessieChargeStates + + +def charge_state_to_option(value: StateType) -> str | None: + """Convert Tessie charging state values into enum sensor options.""" + if isinstance(value, str): + return TessieChargeStates.get( + value, value if value in TessieChargeStates.values() else None + ) + if isinstance(value, bool): + return ( + TessieChargeStates["Charging"] if value else TessieChargeStates["Stopped"] + ) + return None async def handle_command(command: Awaitable[dict[str, Any]]) -> dict[str, Any]: diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 449cd0d7073be2..18ea9afc90fd14 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -46,6 +46,7 @@ TessieEntity, TessieWallConnectorEntity, ) +from .helpers import charge_state_to_option from .models import TessieEnergyData, TessieVehicleData @@ -71,7 +72,7 @@ class TessieSensorEntityDescription(SensorEntityDescription): key="charge_state_charging_state", options=list(TessieChargeStates.values()), device_class=SensorDeviceClass.ENUM, - value_fn=lambda value: TessieChargeStates[cast(str, value)], + value_fn=charge_state_to_option, ), TessieSensorEntityDescription( key="charge_state_usable_battery_level", diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index 41134b38fda0aa..f33c978fd4aedf 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -30,8 +30,9 @@ from homeassistant.helpers.typing import StateType from . import TessieConfigEntry +from .const import TessieChargeStates from .entity import TessieEnergyEntity, TessieEntity -from .helpers import handle_command +from .helpers import charge_state_to_option, handle_command from .models import TessieEnergyData, TessieVehicleData @@ -71,7 +72,10 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): unique_id="charge_state_charge_enable_request", on_func=lambda: start_charging, off_func=lambda: stop_charging, - value_func=lambda state: state in {"Starting", "Charging"}, + value_func=lambda state: ( + charge_state_to_option(state) + in {TessieChargeStates["Starting"], TessieChargeStates["Charging"]} + ), ), ) diff --git a/tests/components/tessie/snapshots/test_switch.ambr b/tests/components/tessie/snapshots/test_switch.ambr index ce9f9ebe10b931..2901ecf2e92740 100644 --- a/tests/components/tessie/snapshots/test_switch.ambr +++ b/tests/components/tessie/snapshots/test_switch.ambr @@ -379,6 +379,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- diff --git a/tests/components/tessie/test_binary_sensor.py b/tests/components/tessie/test_binary_sensor.py index 26d343181faaf7..84e3aae3a6e828 100644 --- a/tests/components/tessie/test_binary_sensor.py +++ b/tests/components/tessie/test_binary_sensor.py @@ -3,13 +3,35 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.tessie.binary_sensor import VEHICLE_DESCRIPTIONS from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import StateType from .common import assert_entities, setup_platform +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("Charging", True), + ("Stopped", False), + (True, True), + (False, False), + ("Unexpected", False), + ], +) +def test_charging_binary_sensor_state(value: StateType, expected: bool) -> None: + """Test charging binary sensor state conversion.""" + description = next( + description + for description in VEHICLE_DESCRIPTIONS + if description.key == "charge_state_charging_state" + ) + assert description.is_on(value) is expected + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py index 144ec06723d783..2a40a456d1ec9a 100644 --- a/tests/components/tessie/test_sensor.py +++ b/tests/components/tessie/test_sensor.py @@ -4,13 +4,30 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.tessie.helpers import charge_state_to_option from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import StateType from .common import assert_entities, setup_platform +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("Charging", "charging"), + ("charging", "charging"), + (True, "charging"), + (False, "stopped"), + ("Unexpected", None), + ], +) +def test_charge_state_to_option(value: StateType, expected: str | None) -> None: + """Test charge state conversion for enum sensor values.""" + assert charge_state_to_option(value) == expected + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index aaa9c769ff8d4e..33e293f5e5f8e8 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -10,13 +10,37 @@ SERVICE_TURN_OFF, SERVICE_TURN_ON, ) +from homeassistant.components.tessie.switch import DESCRIPTIONS from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import StateType from .common import RESPONSE_OK, assert_entities, setup_platform +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("Starting", True), + ("Charging", True), + ("Stopped", False), + (True, True), + (False, False), + ("Unexpected", False), + (None, False), + ], +) +def test_charge_switch_state(value: StateType, expected: bool) -> None: + """Test charging switch state conversion.""" + description = next( + description + for description in DESCRIPTIONS + if description.key == "charge_state_charging_state" + ) + assert description.value_func(value) is expected + + async def test_switches( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry ) -> None: From 93e9575547b745fad7523da712e021a0e965dd4f Mon Sep 17 00:00:00 2001 From: mcisk Date: Thu, 9 Apr 2026 17:30:53 +0200 Subject: [PATCH 0652/1707] Add reauthentication flow to Autoskope integration (#167688) --- .../components/autoskope/__init__.py | 5 +- .../components/autoskope/config_flow.py | 75 ++++++++++++++++--- .../components/autoskope/quality_scale.yaml | 5 +- .../components/autoskope/strings.json | 12 ++- .../components/autoskope/test_config_flow.py | 69 +++++++++++++++++ 5 files changed, 146 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/autoskope/__init__.py b/homeassistant/components/autoskope/__init__.py index a269976dc3503a..084755cb5487f9 100644 --- a/homeassistant/components/autoskope/__init__.py +++ b/homeassistant/components/autoskope/__init__.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import DEFAULT_HOST @@ -31,8 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) -> try: await api.connect() except InvalidAuth as err: - # Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed) - raise ConfigEntryError( + raise ConfigEntryAuthFailed( "Authentication failed, please check credentials" ) from err except CannotConnect as err: diff --git a/homeassistant/components/autoskope/config_flow.py b/homeassistant/components/autoskope/config_flow.py index 3f141b4663f53f..0f30fe9ada7bd3 100644 --- a/homeassistant/components/autoskope/config_flow.py +++ b/homeassistant/components/autoskope/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from autoskope_client.api import AutoskopeApi @@ -39,12 +40,39 @@ } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Autoskope.""" VERSION = 1 + async def _async_validate_credentials( + self, host: str, username: str, password: str, errors: dict[str, str] + ) -> bool: + """Validate credentials against the Autoskope API.""" + try: + async with AutoskopeApi( + host=host, + username=username, + password=password, + ): + pass + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return True + return False + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -63,18 +91,9 @@ async def async_step_user( await self.async_set_unique_id(f"{username}@{host}") self._abort_if_unique_id_configured() - try: - async with AutoskopeApi( - host=host, - username=username, - password=user_input[CONF_PASSWORD], - ): - pass - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - else: + if await self._async_validate_credentials( + host, username, user_input[CONF_PASSWORD], errors + ): return self.async_create_entry( title=f"Autoskope ({username})", data={ @@ -87,3 +106,35 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle initiation of re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication with new credentials.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + + if await self._async_validate_credentials( + reauth_entry.data[CONF_HOST], + reauth_entry.data[CONF_USERNAME], + user_input[CONF_PASSWORD], + errors, + ): + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/autoskope/quality_scale.yaml b/homeassistant/components/autoskope/quality_scale.yaml index c0af808b0996b4..264d9c35e7a638 100644 --- a/homeassistant/components/autoskope/quality_scale.yaml +++ b/homeassistant/components/autoskope/quality_scale.yaml @@ -39,10 +39,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: done - reauthentication-flow: - status: todo - comment: | - Reauthentication flow removed for initial PR, will be added in follow-up. + reauthentication-flow: done test-coverage: done # Gold devices: done diff --git a/homeassistant/components/autoskope/strings.json b/homeassistant/components/autoskope/strings.json index d3a05f9f286512..18e83c0866c35a 100644 --- a/homeassistant/components/autoskope/strings.json +++ b/homeassistant/components/autoskope/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -10,6 +11,15 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The new password for your Autoskope account." + }, + "description": "Please re-enter your password for your Autoskope account." + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/tests/components/autoskope/test_config_flow.py b/tests/components/autoskope/test_config_flow.py index de4f6b01b72a77..14aabb35a168fc 100644 --- a/tests/components/autoskope/test_config_flow.py +++ b/tests/components/autoskope/test_config_flow.py @@ -165,3 +165,72 @@ async def test_custom_host( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_HOST] == "https://custom.autoskope.server" assert result["result"].unique_id == "test_user@https://custom.autoskope.server" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_autoskope_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow updates password and reloads entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new_password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_USERNAME: "test_user", + CONF_PASSWORD: "new_password", + CONF_HOST: DEFAULT_HOST, + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidAuth("Invalid credentials"), "invalid_auth"), + (CannotConnect("Connection failed"), "cannot_connect"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_autoskope_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test reauth flow error handling with recovery.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_autoskope_client.__aenter__.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "wrong_password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Recovery: clear the error and retry + mock_autoskope_client.__aenter__.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new_password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" From f2c20fedebbe34e415e9c0887bd6fdbf4c1131a5 Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:31:01 +0200 Subject: [PATCH 0653/1707] Add zeroconf discovery to Lunatone integration (#167582) Co-authored-by: Joost Lekkerkerker --- .../components/lunatone/config_flow.py | 63 +++++++++- .../components/lunatone/manifest.json | 12 +- .../components/lunatone/strings.json | 12 +- homeassistant/generated/zeroconf.py | 8 ++ tests/components/lunatone/__init__.py | 5 +- tests/components/lunatone/test_config_flow.py | 109 ++++++++++++++++-- 6 files changed, 189 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py index bb48361299e7d6..fa9951d2ae7e94 100644 --- a/homeassistant/components/lunatone/config_flow.py +++ b/homeassistant/components/lunatone/config_flow.py @@ -5,15 +5,17 @@ import aiohttp from lunatone_rest_api_client import Auth, Info import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -28,13 +30,17 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + def __init__(self) -> None: + """Initialize the config flow.""" + self._data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" errors: dict[str, str] = {} if user_input is not None: - url = user_input[CONF_URL] + url = URL(user_input[CONF_URL]).human_repr()[:-1] data = {CONF_URL: url} self._async_abort_entries_match(data) auth_api = Auth( @@ -64,13 +70,58 @@ async def async_step_user( self._abort_if_unique_id_configured() return self.async_create_entry(title=url, data=data) return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors=errors, + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by zeroconf discovery.""" + url = URL.build(scheme="http", host=discovery_info.host).human_repr()[:-1] + uid = discovery_info.properties["uid"] + await self.async_set_unique_id(uid.replace("-", "")) + self._abort_if_unique_id_configured(updates={CONF_URL: url}) + + auth_api = Auth( + session=async_get_clientsession(self.hass), + base_url=url, + ) + info_api = Info(auth_api) + + try: + await info_api.async_update() + except aiohttp.InvalidUrlClientError: + return self.async_abort(reason="invalid_url") + except aiohttp.ClientConnectionError: + return self.async_abort(reason="cannot_connect") + + self._data[CONF_URL] = url + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the discovered device.""" + if user_input is not None: + return self.async_create_entry(title=self._data[CONF_URL], data=self._data) + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders=self._data, ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a reconfiguration flow initialized by the user.""" - return await self.async_step_user(user_input) + if user_input is not None: + return await self.async_step_user(user_input) + + entry = self._get_reconfigure_entry() + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + {vol.Required(CONF_URL, default=entry.data[CONF_URL]): cv.string}, + ), + description_placeholders={CONF_NAME: entry.title}, + ) diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index 9337485caabeb3..8f6ee96b7279f8 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -7,5 +7,15 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["lunatone-rest-api-client==0.9.1"] + "requirements": ["lunatone-rest-api-client==0.9.1"], + "zeroconf": [ + { + "properties": { + "manufacturer": "lunatone industrielle elektronik gmbh", + "type": "dali-2-*", + "uid": "*" + }, + "type": "_http._tcp.local." + } + ] } diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json index 438d67782fb4cc..76006f73eefb94 100644 --- a/homeassistant/components/lunatone/strings.json +++ b/homeassistant/components/lunatone/strings.json @@ -2,17 +2,19 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unique_id_mismatch": "Please ensure you reconfigure against the same device." }, "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_url": "Failed to connect. Check the URL and if the device is connected to power", "missing_device_info": "Failed to read device information. Check the network connection of the device" }, "step": { - "confirm": { - "description": "[%key:common::config_flow::description::confirm_setup%]" + "discovery_confirm": { + "description": "Do you want to setup the Lunatone device with {url}?" }, "reconfigure": { "data": { @@ -21,16 +23,16 @@ "data_description": { "url": "[%key:component::lunatone::config::step::user::data_description::url%]" }, - "description": "Update the URL." + "description": "Update configuration for {name}." }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]" }, "data_description": { - "url": "The URL of the Lunatone gateway device." + "url": "The URL of the Lunatone device to connect to." }, - "description": "Connect to the API of your Lunatone DALI IoT Gateway." + "description": "Enter the URL of your Lunatone device.\nHome Assistant will use this address to connect to the device API." } } } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 50bb4f31414eda..9f602f3c50147d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -615,6 +615,14 @@ "domain": "loqed", "name": "loqed*", }, + { + "domain": "lunatone", + "properties": { + "manufacturer": "lunatone industrielle elektronik gmbh", + "type": "dali-2-*", + "uid": "*", + }, + }, { "domain": "nam", "name": "nam-*", diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index 0c2580b5ce40ef..f4520ab0457eec 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -13,12 +13,15 @@ ) from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status from lunatone_rest_api_client.models.devices import DeviceStatus +from yarl import URL from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -BASE_URL: Final = "http://10.0.0.131" +BASE_IP: Final = "10.0.0.131" +BASE_URL: Final = URL.build(scheme="http", host=BASE_IP).human_repr()[:-1] +MANUFACTURER: Final = "Lunatone Industrielle Elektronik GmbH" PRODUCT_NAME: Final = "Test Product" SERIAL_NUMBER: Final = 12345 UUID: Final = "be37ca9c-47c2-4498-a38b-c62c7c711840" diff --git a/tests/components/lunatone/test_config_flow.py b/tests/components/lunatone/test_config_flow.py index f48c5179bf8868..695dad3139e0bc 100644 --- a/tests/components/lunatone/test_config_flow.py +++ b/tests/components/lunatone/test_config_flow.py @@ -1,21 +1,48 @@ """Define tests for the Lunatone config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock import aiohttp from lunatone_rest_api_client.models import InfoData import pytest +from yarl import URL from homeassistant.components.lunatone.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType - -from . import BASE_URL, INFO_DATA, LEGACY_INFO_DATA +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from . import ( + BASE_IP, + BASE_URL, + INFO_DATA, + LEGACY_INFO_DATA, + MANUFACTURER, + UUID, + setup_integration, +) from tests.common import MockConfigEntry +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address(BASE_IP), + ip_addresses=[ip_address(BASE_IP)], + hostname="dali2_display.local.", + name="DALI-2 Display._http._tcp.local.", + port=80, + type="_http._tcp.local.", + properties={ + "path": "/", + "manufacturer": MANUFACTURER.lower(), + "device": "dali-2 display", + "uid": UUID.lower(), + "type": "dali-2-display", + }, +) + @pytest.mark.parametrize(("info_data"), [INFO_DATA, LEGACY_INFO_DATA]) async def test_full_flow( @@ -128,6 +155,74 @@ async def test_user_step_fail_with_error( assert result["data"] == {CONF_URL: BASE_URL} +async def test_zeroconf_flow( + hass: HomeAssistant, mock_lunatone_devices: AsyncMock, mock_lunatone_info: AsyncMock +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZEROCONF_DISCOVERY + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == BASE_URL + assert result["data"] == {CONF_URL: BASE_URL} + assert result["result"].unique_id == UUID.replace("-", "") + + +async def test_zeroconf_flow_abort_duplicate( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf flow aborts with duplicate.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZEROCONF_DISCOVERY + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"), + (aiohttp.ClientConnectionError(), "cannot_connect"), + ], +) +async def test_zeroconf_flow_abort_with_error( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test zeroconf flow aborts with error.""" + + mock_lunatone_info.async_update.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZEROCONF_DISCOVERY + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + mock_lunatone_info.async_update.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZEROCONF_DISCOVERY + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + async def test_reconfigure( hass: HomeAssistant, mock_lunatone_info: AsyncMock, @@ -135,13 +230,13 @@ async def test_reconfigure( mock_config_entry: MockConfigEntry, ) -> None: """Test reconfigure flow.""" - url = "http://10.0.0.100" + url = URL.build(scheme="http", host="10.0.0.100").human_repr()[:-1] mock_config_entry.add_to_hass(hass) result = await mock_config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "reconfigure" result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_URL: url} @@ -167,7 +262,7 @@ async def test_reconfigure_fail_with_error( expected_error: str, ) -> None: """Test reconfigure flow with an error.""" - url = "http://10.0.0.100" + url = URL.build(scheme="http", host="10.0.0.100").human_repr()[:-1] mock_lunatone_info.async_update.side_effect = exception @@ -175,7 +270,7 @@ async def test_reconfigure_fail_with_error( result = await mock_config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "reconfigure" result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_URL: url} From f3d25a04f856bddc5288b04df6a9e51d7bb16777 Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Thu, 9 Apr 2026 17:50:18 +0200 Subject: [PATCH 0654/1707] Bump weheat to 2026.4.8 (#167807) --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 304494fcc3702e..98a147b72dd68d 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/weheat", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["weheat==2026.2.28"] + "requirements": ["weheat==2026.4.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index d90b59f4dd3347..ccc191193135c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3292,7 +3292,7 @@ webio-api==0.1.12 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2026.2.28 +weheat==2026.4.8 # homeassistant.components.whirlpool whirlpool-sixth-sense==1.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6131809abdb9be..3a46e842755703 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2786,7 +2786,7 @@ webio-api==0.1.12 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2026.2.28 +weheat==2026.4.8 # homeassistant.components.whirlpool whirlpool-sixth-sense==1.0.3 From 36944525e190880fa72eedc1c4bda31026387b33 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:52:51 -0400 Subject: [PATCH 0655/1707] Update template event tests to use new framework (#167808) --- tests/components/template/test_event.py | 357 +++++++----------------- 1 file changed, 106 insertions(+), 251 deletions(-) diff --git a/tests/components/template/test_event.py b/tests/components/template/test_event.py index 5f57e70348cf8a..fabcaa83ce1051 100644 --- a/tests/components/template/test_event.py +++ b/tests/components/template/test_event.py @@ -16,7 +16,16 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle, async_get_flow_preview_state +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + async_get_flow_preview_state, + async_trigger, + make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, +) from tests.common import ( MockConfigEntry, @@ -25,16 +34,14 @@ ) from tests.conftest import WebSocketGenerator -TEST_OBJECT_ID = "template_event" -TEST_ENTITY_ID = f"event.{TEST_OBJECT_ID}" -TEST_SENSOR = "sensor.event" -TEST_STATE_TRIGGER = { - "trigger": {"trigger": "state", "entity_id": TEST_SENSOR}, - "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, - "action": [ - {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} - ], -} +TEST_STATE_ENTITY_ID = "sensor.test_state" +TEST_EVENT = TemplatePlatformSetup( + event.DOMAIN, + None, + "template_event", + make_test_trigger(TEST_STATE_ENTITY_ID), +) + TEST_EVENT_TYPES_TEMPLATE = "{{ ['single', 'double', 'hold'] }}" TEST_EVENT_TYPE_TEMPLATE = "{{ 'single' }}" @@ -42,92 +49,19 @@ "event_types": TEST_EVENT_TYPES_TEMPLATE, "event_type": TEST_EVENT_TYPE_TEMPLATE, } -TEST_UNIQUE_ID_CONFIG = { - **TEST_EVENT_CONFIG, - "unique_id": "not-so-unique-anymore", -} TEST_FROZEN_INPUT = "2024-07-09 00:00:00+00:00" TEST_FROZEN_STATE = "2024-07-09T00:00:00.000+00:00" -async def async_setup_modern_format( - hass: HomeAssistant, - count: int, - event_config: dict[str, Any], - extra_config: dict[str, Any] | None, -) -> None: - """Do setup of event integration via new format.""" - extra = extra_config or {} - config = {**event_config, **extra} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - {"template": {"event": config}}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_trigger_format( - hass: HomeAssistant, - count: int, - event_config: dict[str, Any], - extra_config: dict[str, Any] | None, -) -> None: - """Do setup of event integration via trigger format.""" - extra = extra_config or {} - config = { - "template": { - **TEST_STATE_TRIGGER, - "event": {**event_config, **extra}, - } - } - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_event_config( - hass: HomeAssistant, - count: int, - style: ConfigurationStyle, - event_config: dict[str, Any], - extra_config: dict[str, Any] | None, -) -> None: - """Do setup of event integration.""" - if style == ConfigurationStyle.MODERN: - await async_setup_modern_format(hass, count, event_config, extra_config) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format(hass, count, event_config, extra_config) - - @pytest.fixture async def setup_base_event( hass: HomeAssistant, count: int, style: ConfigurationStyle, - event_config: dict[str, Any], + config: dict[str, Any], ) -> None: """Do setup of event integration.""" - await async_setup_event_config( - hass, - count, - style, - event_config, - None, - ) + await setup_entity(hass, TEST_EVENT, style, count, config) @pytest.fixture @@ -140,16 +74,16 @@ async def setup_event( extra_config: dict[str, Any] | None, ) -> None: """Do setup of event integration.""" - await async_setup_event_config( + await setup_entity( hass, - count, + TEST_EVENT, style, + count, { - "name": TEST_OBJECT_ID, "event_type": event_type_template, "event_types": event_types_template, }, - extra_config, + extra_config=extra_config, ) @@ -164,16 +98,19 @@ async def setup_single_attribute_state_event( attribute_template: str, ) -> None: """Do setup of event integration testing a single attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - config = { - "name": TEST_OBJECT_ID, - "event_type": event_type_template, - "event_types": event_types_template, - } - if style == ConfigurationStyle.MODERN: - await async_setup_modern_format(hass, count, config, extra) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format(hass, count, config, extra) + await setup_entity( + hass, + TEST_EVENT, + style, + count, + { + "event_type": event_type_template, + "event_types": event_types_template, + }, + extra_config={attribute: attribute_template} + if attribute and attribute_template + else {}, + ) async def test_legacy_platform_config(hass: HomeAssistant) -> None: @@ -182,7 +119,7 @@ async def test_legacy_platform_config(hass: HomeAssistant) -> None: assert await async_setup_component( hass, event.DOMAIN, - {"event": {"platform": "template", "events": {TEST_OBJECT_ID: {}}}}, + {"event": {"platform": "template", "events": {TEST_EVENT.object_id: {}}}}, ) await hass.async_block_till_done() @@ -198,8 +135,9 @@ async def test_setup_config_entry( ) -> None: """Test the config flow.""" - hass.states.async_set( - TEST_SENSOR, + await async_trigger( + hass, + TEST_STATE_ENTITY_ID, "single", {}, ) @@ -208,9 +146,8 @@ async def test_setup_config_entry( data={}, domain=template.DOMAIN, options={ - "name": TEST_OBJECT_ID, - "event_type": TEST_EVENT_TYPE_TEMPLATE, - "event_types": TEST_EVENT_TYPES_TEMPLATE, + "name": TEST_EVENT.object_id, + **TEST_EVENT_CONFIG, "template_type": event.DOMAIN, }, title="My template", @@ -220,7 +157,7 @@ async def test_setup_config_entry( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state is not None assert state == snapshot @@ -284,13 +221,13 @@ async def test_event_type_syntax_error( expected_state: str, ) -> None: """Test template event_type with render error.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == expected_state @pytest.mark.parametrize( ("count", "event_type_template", "event_types_template", "extra_config"), - [(1, "{{ states('sensor.event') }}", TEST_EVENT_TYPES_TEMPLATE, None)], + [(1, "{{ states('sensor.test_state') }}", TEST_EVENT_TYPES_TEMPLATE, None)], ) @pytest.mark.parametrize( "style", @@ -311,16 +248,15 @@ async def test_event_type_template( expected: str, ) -> None: """Test template event_type.""" - hass.states.async_set(TEST_SENSOR, event) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, event) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.attributes["event_type"] == expected @pytest.mark.parametrize( ("count", "event_type_template", "event_types_template", "extra_config"), - [(1, "{{ states('sensor.event') }}", TEST_EVENT_TYPES_TEMPLATE, None)], + [(1, "{{ states('sensor.test_state') }}", TEST_EVENT_TYPES_TEMPLATE, None)], ) @pytest.mark.parametrize( "style", @@ -332,24 +268,21 @@ async def test_event_type_template_updates( hass: HomeAssistant, ) -> None: """Test template event_type updates.""" - hass.states.async_set(TEST_SENSOR, "single") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "single") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == TEST_FROZEN_STATE assert state.attributes["event_type"] == "single" - hass.states.async_set(TEST_SENSOR, "double") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "double") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == TEST_FROZEN_STATE assert state.attributes["event_type"] == "double" - hass.states.async_set(TEST_SENSOR, "hold") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "hold") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == TEST_FROZEN_STATE assert state.attributes["event_type"] == "hold" @@ -376,14 +309,14 @@ async def test_event_type_invalid( hass: HomeAssistant, ) -> None: """Test template event_type.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == STATE_UNKNOWN assert state.attributes["event_type"] is None @pytest.mark.parametrize( ("count", "event_type_template", "event_types_template"), - [(1, "{{ states('sensor.event') }}", TEST_EVENT_TYPES_TEMPLATE)], + [(1, "{{ states('sensor.test_state') }}", TEST_EVENT_TYPES_TEMPLATE)], ) @pytest.mark.parametrize( "style", @@ -394,13 +327,13 @@ async def test_event_type_invalid( [ ( "picture", - "{% if is_state('sensor.event', 'double') %}something{% endif %}", + "{% if is_state('sensor.test_state', 'double') %}something{% endif %}", ATTR_ENTITY_PICTURE, "something", ), ( "icon", - "{% if is_state('sensor.event', 'double') %}mdi:something{% endif %}", + "{% if is_state('sensor.test_state', 'double') %}mdi:something{% endif %}", ATTR_ICON, "mdi:something", ), @@ -411,16 +344,14 @@ async def test_entity_picture_and_icon_templates( hass: HomeAssistant, key: str, expected: str ) -> None: """Test picture and icon template.""" - state = hass.states.async_set(TEST_SENSOR, "single") - await hass.async_block_till_done() + state = await async_trigger(hass, TEST_STATE_ENTITY_ID, "single") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.attributes.get(key) in ("", None) - state = hass.states.async_set(TEST_SENSOR, "double") - await hass.async_block_till_done() + state = await async_trigger(hass, TEST_STATE_ENTITY_ID, "double") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.attributes[key] == expected @@ -455,10 +386,9 @@ async def test_entity_picture_and_icon_templates( @pytest.mark.usefixtures("setup_event") async def test_event_types_template(hass: HomeAssistant, expected: str) -> None: """Test template event_types.""" - hass.states.async_set(TEST_SENSOR, "anything") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.attributes["event_types"] == expected @@ -467,8 +397,8 @@ async def test_event_types_template(hass: HomeAssistant, expected: str) -> None: [ ( 1, - "{{ states('sensor.event') }}", - "{{ state_attr('sensor.event', 'options') or ['unknown'] }}", + "{{ states('sensor.test_state') }}", + "{{ state_attr('sensor.test_state', 'options') or ['unknown'] }}", None, ) ], @@ -481,20 +411,22 @@ async def test_event_types_template(hass: HomeAssistant, expected: str) -> None: @pytest.mark.freeze_time(TEST_FROZEN_INPUT) async def test_event_types_template_updates(hass: HomeAssistant) -> None: """Test template event_type update with entity.""" - hass.states.async_set( - TEST_SENSOR, "single", {"options": ["single", "double", "hold"]} + await async_trigger( + hass, TEST_STATE_ENTITY_ID, "single", {"options": ["single", "double", "hold"]} ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == TEST_FROZEN_STATE assert state.attributes["event_type"] == "single" assert state.attributes["event_types"] == ["single", "double", "hold"] - hass.states.async_set(TEST_SENSOR, "double", {"options": ["double", "hold"]}) + await async_trigger( + hass, TEST_STATE_ENTITY_ID, "double", {"options": ["double", "hold"]} + ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == TEST_FROZEN_STATE assert state.attributes["event_type"] == "double" assert state.attributes["event_types"] == ["double", "hold"] @@ -511,10 +443,10 @@ async def test_event_types_template_updates(hass: HomeAssistant) -> None: [ ( 1, - "{{ states('sensor.event') }}", + "{{ states('sensor.test_state') }}", TEST_EVENT_TYPES_TEMPLATE, "availability", - "{{ states('sensor.event') in ['single', 'double', 'hold'] }}", + "{{ states('sensor.test_state') in ['single', 'double', 'hold'] }}", ) ], ) @@ -525,17 +457,15 @@ async def test_event_types_template_updates(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_single_attribute_state_event") async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" - hass.states.async_set(TEST_SENSOR, "single") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "single") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state != STATE_UNAVAILABLE assert state.attributes["event_type"] == "single" - hass.states.async_set(TEST_SENSOR, "triple") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "triple") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == STATE_UNAVAILABLE assert "event_type" not in state.attributes @@ -548,7 +478,7 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: "template": { "trigger": {"platform": "event", "event_type": "test_event"}, "event": { - "name": TEST_OBJECT_ID, + "name": TEST_EVENT.object_id, "event_type": "{{ trigger.event.data.action }}", "event_types": TEST_EVENT_TYPES_TEMPLATE, "picture": "{{ '/local/dogs.png' }}", @@ -576,7 +506,7 @@ async def test_trigger_entity_restore_state( "plus_one": 55, } fake_state = State( - TEST_ENTITY_ID, + TEST_EVENT.entity_id, "2021-01-01T23:59:59.123+00:00", restored_attributes, ) @@ -597,7 +527,7 @@ async def test_trigger_entity_restore_state( await hass.async_block_till_done() test_state = "2021-01-01T23:59:59.123+00:00" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == test_state for attr, value in restored_attributes.items(): assert state.attributes[attr] == value @@ -606,7 +536,7 @@ async def test_trigger_entity_restore_state( hass.bus.async_fire("test_event", {"action": "double", "beer": 2}) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state != test_state assert state.attributes["icon"] == "mdi:pirate" assert state.attributes["entity_picture"] == "/local/dogs.png" @@ -623,8 +553,8 @@ async def test_trigger_entity_restore_state( { "template": { "event": { - "name": TEST_OBJECT_ID, - "event_type": "{{ states('sensor.event') }}", + "name": TEST_EVENT.object_id, + "event_type": "{{ states('sensor.test_state') }}", "event_types": TEST_EVENT_TYPES_TEMPLATE, }, }, @@ -639,7 +569,7 @@ async def test_event_entity_restore_state( ) -> None: """Test restoring trigger event entities.""" fake_state = State( - TEST_ENTITY_ID, + TEST_EVENT.entity_id, "2021-01-01T23:59:59.123+00:00", {}, ) @@ -659,13 +589,12 @@ async def test_event_entity_restore_state( await hass.async_block_till_done() test_state = "2021-01-01T23:59:59.123+00:00" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state == test_state - hass.states.async_set(TEST_SENSOR, "double") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "double") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_EVENT.entity_id) assert state.state != test_state assert state.attributes["event_type"] == "double" @@ -699,109 +628,35 @@ async def test_invalid_availability_template_keeps_component_available( caplog_setup_text, ) -> None: """Test that an invalid availability keeps the device available.""" - hass.states.async_set(TEST_SENSOR, "anything") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + assert hass.states.get(TEST_EVENT.entity_id).state != STATE_UNAVAILABLE error = "UndefinedError: 'x' is undefined" assert error in caplog_setup_text or error in caplog.text -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("events", "style"), - [ - ( - [ - { - "name": "test_template_event_01", - **TEST_UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_event_02", - **TEST_UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.MODERN, - ), - ( - [ - { - "name": "test_template_event_01", - **TEST_UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_event_02", - **TEST_UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.TRIGGER, - ), - ], + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -async def test_unique_id( - hass: HomeAssistant, count: int, events: list[dict], style: ConfigurationStyle -) -> None: +async def test_unique_id(hass: HomeAssistant, style: ConfigurationStyle) -> None: """Test unique_id option only creates one event per id.""" - config = {"event": events} - if style == ConfigurationStyle.TRIGGER: - config = {**config, **TEST_STATE_TRIGGER} - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - {"template": config}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("event")) == 1 + await setup_and_test_unique_id(hass, TEST_EVENT, style, TEST_EVENT_CONFIG) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) async def test_nested_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + style: ConfigurationStyle, + entity_registry: er.EntityRegistry, ) -> None: - """Test unique_id option creates one event per nested id.""" - - with assert_setup_component(1, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - { - "template": { - "unique_id": "x", - "event": [ - { - "name": "test_a", - **TEST_EVENT_CONFIG, - "unique_id": "a", - }, - { - "name": "test_b", - **TEST_EVENT_CONFIG, - "unique_id": "b", - }, - ], - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("event")) == 2 - - entry = entity_registry.async_get("event.test_a") - assert entry - assert entry.unique_id == "x-a" - - entry = entity_registry.async_get("event.test_b") - assert entry - assert entry.unique_id == "x-b" + """Test a template unique_id propagates to event unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_EVENT, style, entity_registry, TEST_EVENT_CONFIG + ) @pytest.mark.freeze_time(TEST_FROZEN_INPUT) From 7bad7fc4f6a36a0f69e4dea8f0056eeb05cd6e90 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:53:11 -0400 Subject: [PATCH 0656/1707] Update template button tests to use new framework (#167806) --- tests/components/template/test_button.py | 365 ++++++++++++----------- 1 file changed, 185 insertions(+), 180 deletions(-) diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index 77d316ce89d472..8eb654b848a97d 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -7,10 +7,8 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.template import DOMAIN -from homeassistant.components.template.button import DEFAULT_NAME from homeassistant.components.template.const import CONF_PICTURE from homeassistant.const import ( ATTR_ENTITY_PICTURE, @@ -19,15 +17,72 @@ CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_ICON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry, assert_setup_component +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + assert_action, + async_trigger, + make_test_action, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, +) + +from tests.common import MockConfigEntry + +TEST_ATTRIBUTE_ENTITY_ID = "sensor.test_attribute" +TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" +TEST_BUTTON = TemplatePlatformSetup(BUTTON_DOMAIN, None, "template_button", {}) +PRESS_ACTION = make_test_action("press") + + +@pytest.fixture +async def setup_button(hass: HomeAssistant, count: int, config: dict[str, Any]) -> None: + """Do setup of button integration.""" + await setup_entity(hass, TEST_BUTTON, ConfigurationStyle.MODERN, count, config) -_TEST_BUTTON = "button.template_button" -_TEST_OPTIONS_BUTTON = "button.test" + +@pytest.fixture +async def setup_single_attribute_button( + hass: HomeAssistant, + attribute: str, + attribute_template: str, + config: dict, +) -> None: + """Do setup of button integration with a single attribute.""" + await setup_entity( + hass, + TEST_BUTTON, + ConfigurationStyle.MODERN, + 1, + config, + extra_config={attribute: attribute_template} + if attribute and attribute_template + else {}, + ) + + +def _verify( + hass: HomeAssistant, + expected_value: str, + attributes: dict[str, Any] | None = None, + entity_id: str = TEST_BUTTON.entity_id, +) -> None: + """Verify button's state.""" + attributes = attributes or {} + if CONF_FRIENDLY_NAME not in attributes: + attributes[CONF_FRIENDLY_NAME] = TEST_BUTTON.object_id + state = hass.states.get(entity_id) + assert state.state == expected_value + assert state.attributes == attributes @pytest.mark.parametrize( @@ -74,50 +129,20 @@ async def test_setup_config_entry( assert state == snapshot +@pytest.mark.parametrize(("count", "config"), [(1, PRESS_ACTION)]) +@pytest.mark.usefixtures("setup_button") async def test_missing_optional_config(hass: HomeAssistant) -> None: """Test: missing optional template is ok.""" - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "button": { - "press": {"service": "script.press"}, - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - _verify(hass, STATE_UNKNOWN) +@pytest.mark.parametrize(("count", "config"), [(1, {"press": []})]) +@pytest.mark.usefixtures("setup_button") async def test_missing_emtpy_press_action_config( hass: HomeAssistant, freezer: FrozenDateTimeFactory, ) -> None: """Test: missing optional template is ok.""" - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "button": { - "press": [], - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - _verify(hass, STATE_UNKNOWN) now = dt.datetime.now(dt.UTC) @@ -125,7 +150,7 @@ async def test_missing_emtpy_press_action_config( await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {CONF_ENTITY_ID: _TEST_BUTTON}, + {CONF_ENTITY_ID: TEST_BUTTON.entity_id}, blocking=True, ) @@ -135,63 +160,40 @@ async def test_missing_emtpy_press_action_config( ) +@pytest.mark.parametrize(("count", "config"), [(0, {})]) +@pytest.mark.usefixtures("setup_button") async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" - with assert_setup_component(0, "template"): - assert await setup.async_setup_component( - hass, - "template", - {"template": {"button": {}}}, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.async_all("button") == [] -async def test_all_optional_config( +@pytest.mark.parametrize( + ("count", "config"), + [ + ( + 1, + { + **PRESS_ACTION, + "device_class": "restart", + }, + ) + ], +) +@pytest.mark.usefixtures("setup_button") +async def test_device_class_option( hass: HomeAssistant, - entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, calls: list[ServiceCall], ) -> None: - """Test: including all optional templates is ok.""" - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "unique_id": "test", - "button": { - "press": { - "service": "test.automation", - "data_template": {"caller": "{{ this.entity_id }}"}, - }, - "device_class": "restart", - "unique_id": "test", - "name": "test", - "icon": "mdi:test", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - + """Test optional options is ok.""" _verify( hass, STATE_UNKNOWN, { CONF_DEVICE_CLASS: "restart", - CONF_FRIENDLY_NAME: "test", - CONF_ICON: "mdi:test", + CONF_FRIENDLY_NAME: TEST_BUTTON.object_id, }, - _TEST_OPTIONS_BUTTON, + TEST_BUTTON.entity_id, ) now = dt.datetime.now(dt.UTC) @@ -199,137 +201,101 @@ async def test_all_optional_config( await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, + {CONF_ENTITY_ID: TEST_BUTTON.entity_id}, blocking=True, ) - assert len(calls) == 1 - assert calls[0].data["caller"] == _TEST_OPTIONS_BUTTON - + assert_action(TEST_BUTTON, calls, 1, "press") _verify( hass, now.isoformat(), { CONF_DEVICE_CLASS: "restart", - CONF_FRIENDLY_NAME: "test", - CONF_ICON: "mdi:test", + CONF_FRIENDLY_NAME: TEST_BUTTON.object_id, }, - _TEST_OPTIONS_BUTTON, - ) - - assert entity_registry.async_get_entity_id("button", "template", "test-test") - - -async def test_name_template(hass: HomeAssistant) -> None: - """Test: name template.""" - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "button": { - "press": {"service": "script.press"}, - "name": "Button {{ 1 + 1 }}", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify( - hass, - STATE_UNKNOWN, - { - CONF_FRIENDLY_NAME: "Button 2", - }, - "button.button_2", + TEST_BUTTON.entity_id, ) +@pytest.mark.parametrize("config", [PRESS_ACTION]) @pytest.mark.parametrize( - ("field", "attribute", "test_template", "expected"), + ("attribute", "attribute_template", "attribute_name", "expected"), [ - (CONF_ICON, ATTR_ICON, "mdi:test{{ 1 + 1 }}", "mdi:test2"), - (CONF_PICTURE, ATTR_ENTITY_PICTURE, "test{{ 1 + 1 }}.jpg", "test2.jpg"), + ( + CONF_ICON, + "{{ 'mdi:test' if is_state('sensor.test_attribute', 'on') else '' }}", + ATTR_ICON, + "mdi:test", + ), + ( + CONF_PICTURE, + "{{ 'test.jpg' if is_state('sensor.test_attribute', 'on') else '' }}", + ATTR_ENTITY_PICTURE, + "test.jpg", + ), ], ) -async def test_templated_optional_config( +@pytest.mark.usefixtures("setup_single_attribute_button") +async def test_options_that_are_templates( hass: HomeAssistant, - field: str, - attribute: str, - test_template: str, + attribute_name: str, expected: str, + freezer: FrozenDateTimeFactory, + calls: list[ServiceCall], ) -> None: - """Test optional config templates.""" - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "button": { - "press": {"service": "script.press"}, - field: test_template, - }, - } - }, - ) + """Test button options that are templates.""" + expected_attributes = {attribute_name: expected} - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() + _verify(hass, STATE_UNKNOWN, {attribute_name: ""}) + + await async_trigger(hass, TEST_ATTRIBUTE_ENTITY_ID, STATE_ON) + _verify(hass, STATE_UNKNOWN, expected_attributes) + + now = dt.datetime.now(dt.UTC) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: TEST_BUTTON.entity_id}, + blocking=True, + ) + + assert_action(TEST_BUTTON, calls, 1, "press") + _verify(hass, now.isoformat(), expected_attributes) + + +@pytest.mark.parametrize("config", [PRESS_ACTION]) +@pytest.mark.parametrize( + ("attribute", "attribute_template"), [("name", "Button {{ 1 + 1 }}")] +) +@pytest.mark.usefixtures("setup_single_attribute_button") +async def test_name_template(hass: HomeAssistant) -> None: + """Test: name template.""" _verify( hass, STATE_UNKNOWN, { - attribute: expected, + CONF_FRIENDLY_NAME: "Button 2", }, - "button.template_button", + "button.button_2", ) async def test_unique_id(hass: HomeAssistant) -> None: - """Test: unique id is ok.""" - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "unique_id": "test", - "button": { - "press": {"service": "script.press"}, - "unique_id": "test", - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - _verify(hass, STATE_UNKNOWN) + """Test unique_id option only creates one button per id.""" + await setup_and_test_unique_id( + hass, TEST_BUTTON, ConfigurationStyle.MODERN, PRESS_ACTION + ) -def _verify( - hass: HomeAssistant, - expected_value: str, - attributes: dict[str, Any] | None = None, - entity_id: str = _TEST_BUTTON, +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Verify button's state.""" - attributes = attributes or {} - if CONF_FRIENDLY_NAME not in attributes: - attributes[CONF_FRIENDLY_NAME] = DEFAULT_NAME - state = hass.states.get(entity_id) - assert state.state == expected_value - assert state.attributes == attributes + """Test a template unique_id propagates to button unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_BUTTON, ConfigurationStyle.MODERN, entity_registry, PRESS_ACTION + ) async def test_device_id( @@ -376,3 +342,42 @@ async def test_device_id( template_entity = entity_registry.async_get("button.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("config", "attribute", "attribute_template"), + [ + ( + PRESS_ACTION, + "availability", + "{{ is_state('binary_sensor.availability', 'on') }}", + ) + ], +) +@pytest.mark.usefixtures("setup_single_attribute_button") +async def test_available_template_with_entities(hass: HomeAssistant) -> None: + """Test availability templates with values from other entities.""" + + await async_trigger(hass, TEST_AVAILABILITY_ENTITY, STATE_ON) + + # Device State should not be unavailable + assert hass.states.get(TEST_BUTTON.entity_id).state != STATE_UNAVAILABLE + + # When Availability template returns false + await async_trigger(hass, TEST_AVAILABILITY_ENTITY, STATE_OFF) + + # device state should be unavailable + assert hass.states.get(TEST_BUTTON.entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("config", "attribute", "attribute_template"), + [(PRESS_ACTION, "availability", "{{ x - 12 }}")], +) +@pytest.mark.usefixtures("setup_single_attribute_button") +async def test_invalid_availability_template_keeps_component_available( + hass: HomeAssistant, caplog_setup_text +) -> None: + """Test that an invalid availability keeps the device available.""" + assert hass.states.get(TEST_BUTTON.entity_id).state != STATE_UNAVAILABLE + assert "UndefinedError: 'x' is undefined" in caplog_setup_text From b3e7ae0fdd0982287f757177d01479964f73f707 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:56:24 -0400 Subject: [PATCH 0657/1707] Update template alarm control panel tests to use new framework (#167799) --- .../template/test_alarm_control_panel.py | 615 ++++++------------ 1 file changed, 197 insertions(+), 418 deletions(-) diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 503a63481e4e25..f29da0df556fd4 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -10,168 +10,58 @@ DOMAIN as ALARM_DOMAIN, AlarmControlPanelState, ) -from homeassistant.const import ( - ATTR_DOMAIN, - ATTR_ENTITY_ID, - ATTR_SERVICE_DATA, - EVENT_CALL_SERVICE, - STATE_ON, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.setup import async_setup_component - -from .conftest import ConfigurationStyle, async_get_flow_preview_state +from homeassistant.helpers.typing import ConfigType + +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + assert_action, + async_get_flow_preview_state, + async_trigger, + make_test_action, + make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, +) -from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache +from tests.common import MockConfigEntry, mock_restore_cache from tests.conftest import WebSocketGenerator -TEST_OBJECT_ID = "test_template_panel" -TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}" -TEST_STATE_ENTITY_ID = "alarm_control_panel.test" -TEST_SWITCH = "switch.test_state" - - -@pytest.fixture -def call_service_events(hass: HomeAssistant) -> list[Event]: - """Track service call events for alarm_control_panel.test.""" - events: list[Event] = [] - entity_id = "alarm_control_panel.test" - - @callback - def capture_events(event: Event) -> None: - if event.data[ATTR_DOMAIN] != ALARM_DOMAIN: - return - if event.data[ATTR_SERVICE_DATA][ATTR_ENTITY_ID] != [entity_id]: - return - events.append(event) - - hass.bus.async_listen(EVENT_CALL_SERVICE, capture_events) - - return events - - -OPTIMISTIC_TEMPLATE_ALARM_CONFIG = { - "arm_away": { - "service": "alarm_control_panel.alarm_arm_away", - "entity_id": "alarm_control_panel.test", - "data": {"code": "{{ this.entity_id }}"}, - }, - "arm_home": { - "service": "alarm_control_panel.alarm_arm_home", - "entity_id": "alarm_control_panel.test", - "data": {"code": "{{ this.entity_id }}"}, - }, - "arm_night": { - "service": "alarm_control_panel.alarm_arm_night", - "entity_id": "alarm_control_panel.test", - "data": {"code": "{{ this.entity_id }}"}, - }, - "arm_vacation": { - "service": "alarm_control_panel.alarm_arm_vacation", - "entity_id": "alarm_control_panel.test", - "data": {"code": "{{ this.entity_id }}"}, - }, - "arm_custom_bypass": { - "service": "alarm_control_panel.alarm_arm_custom_bypass", - "entity_id": "alarm_control_panel.test", - "data": {"code": "{{ this.entity_id }}"}, - }, - "disarm": { - "service": "alarm_control_panel.alarm_disarm", - "entity_id": "alarm_control_panel.test", - "data": {"code": "{{ this.entity_id }}"}, - }, - "trigger": { - "service": "alarm_control_panel.alarm_trigger", - "entity_id": "alarm_control_panel.test", - "data": {"code": "{{ this.entity_id }}"}, - }, -} -EMPTY_ACTIONS = { - "arm_away": [], - "arm_home": [], - "arm_night": [], - "arm_vacation": [], - "arm_custom_bypass": [], - "disarm": [], - "trigger": [], -} - - -UNIQUE_ID_CONFIG = { - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - "unique_id": "not-so-unique-anymore", -} +TEST_STATE_ENTITY_ID = "sensor.test_state" +TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" +TEST_PANEL = TemplatePlatformSetup( + ALARM_DOMAIN, + "panels", + "test_template_panel", + make_test_trigger(TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY), +) -TEMPLATE_ALARM_CONFIG = { - "value_template": "{{ states('alarm_control_panel.test') }}", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, -} -TEST_STATE_TRIGGER = { - "triggers": {"trigger": "state", "entity_id": [TEST_STATE_ENTITY_ID, TEST_SWITCH]}, - "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, - "actions": [ - {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} - ], +DATA_CODE = {"code": "{{ code }}"} +ARM_AWAY_ACTION = make_test_action("arm_away", DATA_CODE) +ARM_HOME_ACTION = make_test_action("arm_home", DATA_CODE) +ARM_NIGHT_ACTION = make_test_action("arm_night", DATA_CODE) +ARM_VACATION_ACTION = make_test_action("arm_vacation", DATA_CODE) +ARM_CUSTOM_BYPASS_ACTION = make_test_action("arm_custom_bypass", DATA_CODE) +DISARM_ACTION = make_test_action("disarm", DATA_CODE) +TRIGGER_ACTION = make_test_action("trigger", DATA_CODE) + +OPTIMISTIC_ACTIONS = { + **ARM_AWAY_ACTION, + **ARM_HOME_ACTION, + **ARM_NIGHT_ACTION, + **ARM_VACATION_ACTION, + **ARM_CUSTOM_BYPASS_ACTION, + **DISARM_ACTION, + **TRIGGER_ACTION, } - -async def async_setup_legacy_format( - hass: HomeAssistant, count: int, panel_config: dict[str, Any] -) -> None: - """Do setup of alarm control panel integration via legacy format.""" - config = {"alarm_control_panel": {"platform": "template", "panels": panel_config}} - - with assert_setup_component(count, ALARM_DOMAIN): - assert await async_setup_component( - hass, - ALARM_DOMAIN, - config, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_modern_format( - hass: HomeAssistant, count: int, panel_config: dict[str, Any] -) -> None: - """Do setup of alarm control panel integration via modern format.""" - config = {"template": {"alarm_control_panel": panel_config}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_trigger_format( - hass: HomeAssistant, count: int, panel_config: dict[str, Any] -) -> None: - """Do setup of alarm control panel integration via trigger format.""" - config = {"template": {"alarm_control_panel": panel_config, **TEST_STATE_TRIGGER}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() +EMPTY_ACTIONS = {action: [] for action in OPTIMISTIC_ACTIONS} @pytest.fixture @@ -182,12 +72,7 @@ async def setup_panel( panel_config: dict[str, Any], ) -> None: """Do setup of alarm control panel integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, panel_config) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format(hass, count, panel_config) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format(hass, count, panel_config) + await setup_entity(hass, TEST_PANEL, style, count, panel_config) async def async_setup_state_panel( @@ -197,37 +82,9 @@ async def async_setup_state_panel( state_template: str, ): """Do setup of alarm control panel integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - "value_template": state_template, - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - "state": state_template, - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - "state": state_template, - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - }, - ) + await setup_entity( + hass, TEST_PANEL, style, count, OPTIMISTIC_ACTIONS, state_template + ) @pytest.fixture @@ -238,7 +95,9 @@ async def setup_state_panel( state_template: str, ): """Do setup of alarm control panel integration using a state template.""" - await async_setup_state_panel(hass, count, style, state_template) + await setup_entity( + hass, TEST_PANEL, style, count, OPTIMISTIC_ACTIONS, state_template + ) @pytest.fixture @@ -250,35 +109,7 @@ async def setup_base_panel( panel_config: str, ): """Do setup of alarm control panel integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - extra = {"value_template": state_template} if state_template else {} - await async_setup_legacy_format( - hass, - count, - {TEST_OBJECT_ID: {**extra, **panel_config}}, - ) - elif style == ConfigurationStyle.MODERN: - extra = {"state": state_template} if state_template else {} - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **extra, - **panel_config, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - extra = {"state": state_template} if state_template else {} - await async_setup_trigger_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **extra, - **panel_config, - }, - ) + await setup_entity(hass, TEST_PANEL, style, count, panel_config, state_template) @pytest.fixture @@ -291,45 +122,19 @@ async def setup_single_attribute_state_panel( attribute_template: str, ) -> None: """Do setup of alarm control panel integration testing a single attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - "value_template": state_template, - **extra, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - "state": state_template, - **extra, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - "state": state_template, - **extra, - }, - ) + await setup_entity( + hass, + TEST_PANEL, + style, + count, + OPTIMISTIC_ACTIONS, + state_template, + {attribute: attribute_template} if attribute and attribute_template else {}, + ) @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] + ("count", "state_template"), [(1, "{{ states('sensor.test_state') }}")] ) @pytest.mark.parametrize( "style", @@ -351,14 +156,12 @@ async def test_template_state_text(hass: HomeAssistant) -> None: AlarmControlPanelState.PENDING, AlarmControlPanelState.TRIGGERED, ): - hass.states.async_set(TEST_STATE_ENTITY_ID, set_state) - await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, set_state) + state = hass.states.get(TEST_PANEL.entity_id) assert state.state == set_state - hass.states.async_set(TEST_STATE_ENTITY_ID, "invalid_state") - await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + await async_trigger(hass, TEST_STATE_ENTITY_ID, "invalid_state") + state = hass.states.get(TEST_PANEL.entity_id) assert state.state == "unknown" @@ -386,13 +189,8 @@ async def test_template_state_text(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_state_panel") async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: """Test the state template.""" - - # Force a trigger - hass.states.async_set(TEST_STATE_ENTITY_ID, None) - await hass.async_block_till_done() - - state = hass.states.get(TEST_ENTITY_ID) - + await async_trigger(hass, TEST_STATE_ENTITY_ID, None) + state = hass.states.get(TEST_PANEL.entity_id) assert state.state == expected @@ -402,7 +200,7 @@ async def test_state_template_states(hass: HomeAssistant, expected: str) -> None ( 1, "{{ 'disarmed' }}", - "{% if states.switch.test_state.state %}mdi:check{% endif %}", + "{% if states.sensor.test_state.state %}mdi:check{% endif %}", "icon", ) ], @@ -417,13 +215,12 @@ async def test_state_template_states(hass: HomeAssistant, expected: str) -> None @pytest.mark.usefixtures("setup_single_attribute_state_panel") async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None: """Test icon template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_PANEL.entity_id) assert state.attributes.get("icon") == initial_state - hass.states.async_set(TEST_SWITCH, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_PANEL.entity_id) assert state.attributes["icon"] == "mdi:check" @@ -433,7 +230,7 @@ async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None: ( 1, "{{ 'disarmed' }}", - "{% if states.switch.test_state.state %}local/panel.png{% endif %}", + "{% if states.sensor.test_state.state %}local/panel.png{% endif %}", "picture", ) ], @@ -448,13 +245,12 @@ async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None: @pytest.mark.usefixtures("setup_single_attribute_state_panel") async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None: """Test icon template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_PANEL.entity_id) assert state.attributes.get("entity_picture") == initial_state - hass.states.async_set(TEST_SWITCH, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_PANEL.entity_id) assert state.attributes["entity_picture"] == "local/panel.png" @@ -464,7 +260,7 @@ async def test_setup_config_entry( """Test the config flow.""" value_template = "{{ states('alarm_control_panel.one') }}" - hass.states.async_set("alarm_control_panel.one", "armed_away", {}) + await async_trigger(hass, "alarm_control_panel.one", "armed_away", {}) template_config_entry = MockConfigEntry( data={}, @@ -487,8 +283,7 @@ async def test_setup_config_entry( assert state is not None assert state == snapshot - hass.states.async_set("alarm_control_panel.one", "disarmed", {}) - await hass.async_block_till_done() + await async_trigger(hass, "alarm_control_panel.one", "disarmed", {}) state = hass.states.get("alarm_control_panel.my_template") assert state.state == AlarmControlPanelState.DISARMED @@ -498,14 +293,12 @@ async def test_setup_config_entry( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.parametrize( - "panel_config", [OPTIMISTIC_TEMPLATE_ALARM_CONFIG, EMPTY_ACTIONS] -) +@pytest.mark.parametrize("panel_config", [OPTIMISTIC_ACTIONS, EMPTY_ACTIONS]) @pytest.mark.usefixtures("setup_base_panel") -async def test_optimistic_states(hass: HomeAssistant) -> None: +async def test_optimistic_states(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the optimistic state.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_PANEL.entity_id) await hass.async_block_till_done() assert state.state == "unknown" @@ -521,11 +314,11 @@ async def test_optimistic_states(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, service, - {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + {"entity_id": TEST_PANEL.entity_id, "code": "1234"}, blocking=True, ) await hass.async_block_till_done() - assert hass.states.get(TEST_ENTITY_ID).state == set_state + assert hass.states.get(TEST_PANEL.entity_id).state == set_state @pytest.mark.parametrize("count", [0]) @@ -537,12 +330,12 @@ async def test_optimistic_states(hass: HomeAssistant) -> None: ("panel_config", "state_template", "msg"), [ ( - OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + OPTIMISTIC_ACTIONS, "{% if blah %}", "invalid template", ), ( - {"code_format": "bad_format", **OPTIMISTIC_TEMPLATE_ALARM_CONFIG}, + {"code_format": "bad_format", **OPTIMISTIC_ACTIONS}, "disarmed", "value must be one of ['no_code', 'number', 'text']", ), @@ -568,7 +361,7 @@ async def test_template_syntax_error( "panels": { "bad name here": { "value_template": "disarmed", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + **OPTIMISTIC_ACTIONS, } }, } @@ -608,7 +401,7 @@ async def test_legacy_template_syntax_error( @pytest.mark.parametrize( ("style", "test_entity_id"), [ - (ConfigurationStyle.LEGACY, TEST_ENTITY_ID), + (ConfigurationStyle.LEGACY, TEST_PANEL.entity_id), (ConfigurationStyle.MODERN, "alarm_control_panel.template_alarm_panel"), (ConfigurationStyle.TRIGGER, "alarm_control_panel.unnamed_device"), ], @@ -616,8 +409,7 @@ async def test_legacy_template_syntax_error( @pytest.mark.usefixtures("setup_single_attribute_state_panel") async def test_name(hass: HomeAssistant, test_entity_id: str) -> None: """Test the accessibility of the name attribute.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, "disarmed") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "disarmed") state = hass.states.get(test_entity_id) assert state is not None @@ -625,138 +417,66 @@ async def test_name(hass: HomeAssistant, test_entity_id: str) -> None: @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] + ("count", "state_template"), [(1, "{{ states('sensor.test_state') }}")] ) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( - "service", + ("service", "expected_service"), [ - "alarm_arm_home", - "alarm_arm_away", - "alarm_arm_night", - "alarm_arm_vacation", - "alarm_arm_custom_bypass", - "alarm_disarm", - "alarm_trigger", + ("alarm_arm_home", "arm_home"), + ("alarm_arm_away", "arm_away"), + ("alarm_arm_night", "arm_night"), + ("alarm_arm_vacation", "arm_vacation"), + ("alarm_arm_custom_bypass", "arm_custom_bypass"), + ("alarm_disarm", "disarm"), + ("alarm_trigger", "trigger"), ], ) @pytest.mark.usefixtures("setup_state_panel") async def test_actions( - hass: HomeAssistant, service, call_service_events: list[Event] + hass: HomeAssistant, service: str, expected_service: str, calls: list[ServiceCall] ) -> None: """Test alarm actions.""" await hass.services.async_call( ALARM_DOMAIN, service, - {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + {"entity_id": TEST_PANEL.entity_id, "code": "1234"}, blocking=True, ) await hass.async_block_till_done() - assert len(call_service_events) == 1 - assert call_service_events[0].data["service"] == service - assert call_service_events[0].data["service_data"]["code"] == TEST_ENTITY_ID + assert_action(TEST_PANEL, calls, 1, expected_service, code=1234) -@pytest.mark.parametrize("count", [1]) + +@pytest.mark.parametrize("config", [OPTIMISTIC_ACTIONS]) @pytest.mark.parametrize( - ("panel_config", "style"), - [ - ( - { - "test_template_alarm_control_panel_01": { - "value_template": "{{ true }}", - **UNIQUE_ID_CONFIG, - }, - "test_template_alarm_control_panel_02": { - "value_template": "{{ false }}", - **UNIQUE_ID_CONFIG, - }, - }, - ConfigurationStyle.LEGACY, - ), - ( - [ - { - "name": "test_template_alarm_control_panel_01", - "state": "{{ true }}", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_alarm_control_panel_02", - "state": "{{ false }}", - **UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.MODERN, - ), - ( - [ - { - "name": "test_template_alarm_control_panel_01", - "state": "{{ true }}", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_alarm_control_panel_02", - "state": "{{ false }}", - **UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.TRIGGER, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_panel") -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, style: ConfigurationStyle, config: ConfigType +) -> None: """Test unique_id option only creates one alarm control panel per id.""" - assert len(hass.states.async_all()) == 1 + await setup_and_test_unique_id(hass, TEST_PANEL, style, config) +@pytest.mark.parametrize("config", [OPTIMISTIC_ACTIONS]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) async def test_nested_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + entity_registry: er.EntityRegistry, ) -> None: - """Test a template unique_id propagates to alarm_control_panel unique_ids.""" - with assert_setup_component(1, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - { - "template": { - "unique_id": "x", - "alarm_control_panel": [ - { - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - "name": "test_a", - "unique_id": "a", - "state": "{{ true }}", - }, - { - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, - "name": "test_b", - "unique_id": "b", - "state": "{{ true }}", - }, - ], - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("alarm_control_panel")) == 2 - - entry = entity_registry.async_get("alarm_control_panel.test_a") - assert entry - assert entry.unique_id == "x-a" - - entry = entity_registry.async_get("alarm_control_panel.test_b") - assert entry - assert entry.unique_id == "x-b" + """Test a template unique_id propagates to alarm control panel unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_PANEL, style, entity_registry, config + ) @pytest.mark.parametrize(("count", "state_template"), [(1, "disarmed")]) @@ -798,13 +518,13 @@ async def test_nested_unique_id( @pytest.mark.usefixtures("setup_base_panel") async def test_code_config(hass: HomeAssistant, code_format, code_arm_required) -> None: """Test configuration options related to alarm code.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_PANEL.entity_id) assert state.attributes.get("code_format") == code_format assert state.attributes.get("code_arm_required") == code_arm_required @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] + ("count", "state_template"), [(1, "{{ states('sensor.test_state') }}")] ) @pytest.mark.parametrize( "style", @@ -931,9 +651,9 @@ async def test_flow_preview( ( 1, { - "name": TEST_OBJECT_ID, - "state": "{{ states('alarm_control_panel.test') }}", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "name": TEST_PANEL.object_id, + "state": "{{ states('sensor.test_state') }}", + **OPTIMISTIC_ACTIONS, "optimistic": True, }, ) @@ -944,25 +664,23 @@ async def test_flow_preview( [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_panel") -async def test_optimistic(hass: HomeAssistant) -> None: +async def test_optimistic(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test configuration with empty script.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.DISARMED) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, AlarmControlPanelState.DISARMED) await hass.services.async_call( ALARM_DOMAIN, "alarm_arm_away", - {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + {"entity_id": TEST_PANEL.entity_id, "code": "1234"}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_PANEL.entity_id) assert state.state == AlarmControlPanelState.ARMED_AWAY - hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.ARMED_HOME) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, AlarmControlPanelState.ARMED_HOME) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_PANEL.entity_id) assert state.state == AlarmControlPanelState.ARMED_HOME @@ -972,9 +690,9 @@ async def test_optimistic(hass: HomeAssistant) -> None: ( 1, { - "name": TEST_OBJECT_ID, - "state": "{{ states('alarm_control_panel.test') }}", - **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "name": TEST_PANEL.object_id, + "state": "{{ states('sensor.test_state') }}", + **OPTIMISTIC_ACTIONS, "optimistic": False, }, ) @@ -985,14 +703,75 @@ async def test_optimistic(hass: HomeAssistant) -> None: [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_panel") -async def test_not_optimistic(hass: HomeAssistant) -> None: +async def test_not_optimistic(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test optimistic yaml option set to false.""" await hass.services.async_call( ALARM_DOMAIN, "alarm_arm_away", - {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + {"entity_id": TEST_PANEL.entity_id, "code": "1234"}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_PANEL.entity_id) assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute", "attribute_template"), + [ + ( + 1, + "{{ 'disarmed' }}", + "availability", + "{{ is_state('binary_sensor.availability', 'on') }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_single_attribute_state_panel") +async def test_available_template_with_entities(hass: HomeAssistant) -> None: + """Test availability templates with values from other entities.""" + # When template returns true.. + hass.states.async_set(TEST_AVAILABILITY_ENTITY, STATE_ON) + await hass.async_block_till_done() + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + + # Device State should not be unavailable + assert hass.states.get(TEST_PANEL.entity_id).state != STATE_UNAVAILABLE + + # When Availability template returns false + hass.states.async_set(TEST_AVAILABILITY_ENTITY, STATE_OFF) + await hass.async_block_till_done() + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) + + # device state should be unavailable + assert hass.states.get(TEST_PANEL.entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("count", "state_template", "attribute", "attribute_template"), + [ + ( + 1, + "{{ 'disarmed' }}", + "availability", + "{{ x - 12 }}", + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_single_attribute_state_panel") +async def test_invalid_availability_template_keeps_component_available( + hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture +) -> None: + """Test that an invalid availability keeps the device available.""" + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") + assert hass.states.get(TEST_PANEL.entity_id).state != STATE_UNAVAILABLE + error = "UndefinedError: 'x' is undefined" + assert error in caplog_setup_text or error in caplog.text From fefc5a950f055fe55c85e54b3ca933b4211bf39f Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:56:55 -0400 Subject: [PATCH 0658/1707] Update template binary sensor tests to use new framework (#167704) --- .../components/template/test_binary_sensor.py | 661 ++++++------------ 1 file changed, 232 insertions(+), 429 deletions(-) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index bdff422b8d1495..4ee247e25b3470 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -28,11 +28,13 @@ from .conftest import ( ConfigurationStyle, + TemplatePlatformSetup, async_get_flow_preview_state, - async_setup_legacy_platforms, - async_setup_modern_state_format, - async_setup_modern_trigger_format, + async_trigger, make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, ) from tests.common import ( @@ -55,95 +57,20 @@ ) -TEST_OBJECT_ID = "test_binary_sensor" -TEST_ENTITY_ID = f"binary_sensor.{TEST_OBJECT_ID}" -TEST_STATE_ENTITY_ID = "binary_sensor.test_state" +TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_ATTRIBUTE_ENTITY_ID = "sensor.test_attribute" TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" -TEST_STATE_TRIGGER = make_test_trigger( - TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID, TEST_ATTRIBUTE_ENTITY_ID -) -UNIQUE_ID_CONFIG = { - "unique_id": "not-so-unique-anymore", -} - - -async def async_setup_legacy_format( - hass: HomeAssistant, count: int, config: ConfigType -) -> None: - """Do setup of binary sensor integration via legacy format.""" - await async_setup_legacy_platforms( - hass, binary_sensor.DOMAIN, "sensors", count, config - ) - - -async def async_setup_modern_format( - hass: HomeAssistant, - count: int, - config: ConfigType, - extra_config: ConfigType | None = None, -) -> None: - """Do setup of binary sensor integration via modern format.""" - await async_setup_modern_state_format( - hass, binary_sensor.DOMAIN, count, config, extra_config - ) - - -async def async_setup_trigger_format( - hass: HomeAssistant, - count: int, - config: ConfigType, - extra_config: ConfigType | None = None, -) -> None: - """Do setup of binary sensor integration via trigger format.""" - await async_setup_modern_trigger_format( - hass, binary_sensor.DOMAIN, TEST_STATE_TRIGGER, count, config, extra_config - ) - - -@pytest.fixture -async def setup_base_binary_sensor( - hass: HomeAssistant, - count: int, - style: ConfigurationStyle, - config: ConfigType | list[dict], - extra_template_options: ConfigType, -) -> None: - """Do setup of binary sensor integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, config) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format(hass, count, config, extra_template_options) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format(hass, count, config, extra_template_options) - -async def async_setup_binary_sensor( - hass: HomeAssistant, - count: int, - style: ConfigurationStyle, - state_template: str, - extra_config: ConfigType, -) -> None: - """Do setup of binary sensor integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - {TEST_OBJECT_ID: {"value_template": state_template, **extra_config}}, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - {"name": TEST_OBJECT_ID, "state": state_template, **extra_config}, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - {"name": TEST_OBJECT_ID, "state": state_template, **extra_config}, - ) +TEST_BINARY_SENSOR = TemplatePlatformSetup( + binary_sensor.DOMAIN, + "sensors", + "test_binary_sensor", + make_test_trigger( + TEST_STATE_ENTITY_ID, + TEST_ATTRIBUTE_ENTITY_ID, + TEST_AVAILABILITY_ENTITY_ID, + ), +) @pytest.fixture @@ -155,7 +82,9 @@ async def setup_binary_sensor( extra_config: dict[str, Any], ) -> None: """Do setup of binary sensor integration.""" - await async_setup_binary_sensor(hass, count, style, state_template, extra_config) + await setup_entity( + hass, TEST_BINARY_SENSOR, style, count, extra_config, state_template + ) @pytest.fixture @@ -169,41 +98,15 @@ async def setup_single_attribute_binary_sensor( extra_config: dict, ) -> None: """Do setup of binary sensor integration testing a single attribute.""" - extra = {attribute: attribute_value} if attribute and attribute_value else {} - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - "value_template": state_template, - **extra, - **extra_config, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - "state": state_template, - **extra, - **extra_config, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - "state": state_template, - **extra, - **extra_config, - }, - ) + await setup_entity( + hass, + TEST_BINARY_SENSOR, + style, + count, + {attribute: attribute_value} if attribute and attribute_value else {}, + state_template, + extra_config, + ) @pytest.mark.parametrize( @@ -216,14 +119,13 @@ async def setup_single_attribute_binary_sensor( @pytest.mark.usefixtures("setup_binary_sensor") async def test_setup_minimal(hass: HomeAssistant) -> None: """Test the setup.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state is not None - assert state.name == TEST_OBJECT_ID + assert state.name == TEST_BINARY_SENSOR.object_id assert state.state == STATE_ON - assert state.attributes == {"friendly_name": TEST_OBJECT_ID} + assert state.attributes == {"friendly_name": TEST_BINARY_SENSOR.object_id} @pytest.mark.parametrize( @@ -245,12 +147,11 @@ async def test_setup_minimal(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_binary_sensor") async def test_setup(hass: HomeAssistant) -> None: """Test the setup.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state is not None - assert state.name == TEST_OBJECT_ID + assert state.name == TEST_BINARY_SENSOR.object_id assert state.state == STATE_ON assert state.attributes["device_class"] == "motion" @@ -277,10 +178,10 @@ async def test_setup_config_entry( template_type = binary_sensor.DOMAIN for input_entity in input_entities: - hass.states.async_set( + await async_trigger( + hass, f"{template_type}.{input_entity}", input_states[input_entity], - {}, ) template_config_entry = MockConfigEntry( @@ -392,9 +293,9 @@ async def test_state( expected_result: str, ) -> None: """Test the config flow.""" - hass.states.async_set("binary_sensor.one", "on") - hass.states.async_set("binary_sensor.two", "off") - hass.states.async_set("binary_sensor.three", "unknown") + await async_trigger(hass, "binary_sensor.one", "on") + await async_trigger(hass, "binary_sensor.two", "off") + await async_trigger(hass, "binary_sensor.three", "unknown") template_config_entry = MockConfigEntry( data={}, @@ -422,7 +323,7 @@ async def test_state( ( 1, "{{ 1 == 1 }}", - "{% if is_state('binary_sensor.test_state', 'on') %}mdi:check{% endif %}", + "{% if is_state('sensor.test_state', 'on') %}mdi:check{% endif %}", {}, ) ], @@ -438,13 +339,12 @@ async def test_state( @pytest.mark.usefixtures("setup_single_attribute_binary_sensor") async def test_icon_template(hass: HomeAssistant, initial_state: str | None) -> None: """Test icon template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.attributes.get("icon") == initial_state - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.attributes["icon"] == "mdi:check" @@ -454,7 +354,7 @@ async def test_icon_template(hass: HomeAssistant, initial_state: str | None) -> ( 1, "{{ 1 == 1 }}", - "{% if is_state('binary_sensor.test_state', 'on') %}/local/sensor.png{% endif %}", + "{% if is_state('sensor.test_state', 'on') %}/local/sensor.png{% endif %}", {}, ) ], @@ -472,13 +372,12 @@ async def test_entity_picture_template( hass: HomeAssistant, initial_state: str | None ) -> None: """Test entity_picture template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.attributes.get("entity_picture") == initial_state - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.attributes["entity_picture"] == "/local/sensor.png" @@ -506,15 +405,13 @@ async def test_attribute_templates( hass: HomeAssistant, initial_value: str | None ) -> None: """Test attribute_templates template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.attributes.get("test_attribute") == initial_value - hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Works2") - await hass.async_block_till_done() - hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Works") - await hass.async_block_till_done() + await async_trigger(hass, TEST_ATTRIBUTE_ENTITY_ID, "Works2") + await async_trigger(hass, TEST_ATTRIBUTE_ENTITY_ID, "Works") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.attributes["test_attribute"] == "It Works." @@ -523,7 +420,7 @@ async def test_attribute_templates( [ ( 1, - "{{ states.binary_sensor.test_sensor }}", + "{{ states.sensor.test_state.state }}", {"test_attribute": "{{ states.binary_sensor.unknown.attributes.picture }}"}, {}, ) @@ -545,8 +442,7 @@ async def test_invalid_attribute_template( caplog: pytest.LogCaptureFixture, ) -> None: """Test that errors are logged if rendering template fails.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) assert len(hass.states.async_all()) == 2 text = ( "Template variable error: 'None' has no attribute 'attributes' when rendering" @@ -564,35 +460,32 @@ def setup_mock() -> Generator[Mock]: yield _update_state -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "extra_config"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "match_all_template_sensor": { - "value_template": ( - "{% for state in states %}" - "{% if state.entity_id == 'sensor.humidity' %}" - "{{ state.entity_id }}={{ state.state }}" - "{% endif %}" - "{% endfor %}" - ), - }, - }, - } - }, + ( + 1, + ( + "{% for state in states %}" + "{% if state.entity_id == 'sensor.humidity' %}" + "{{ state.entity_id }}={{ state.state }}" + "{% endif %}" + "{% endfor %}" + ), + {}, + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") async def test_match_all(hass: HomeAssistant, setup_mock: Mock) -> None: """Test template that is rerendered on any state lifecycle.""" init_calls = len(setup_mock.mock_calls) - hass.states.async_set("sensor.any_state", "update") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "update") assert len(setup_mock.mock_calls) == init_calls @@ -601,29 +494,25 @@ async def test_match_all(hass: HomeAssistant, setup_mock: Mock) -> None: [ ( 1, - "{{ is_state('binary_sensor.test_state', 'on') }}", + "{{ is_state('sensor.test_state', 'on') }}", {"device_class": "motion"}, ) ], ) @pytest.mark.parametrize( - ("style", "initial_state"), - [ - (ConfigurationStyle.LEGACY, STATE_OFF), - (ConfigurationStyle.MODERN, STATE_OFF), - (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_binary_sensor") -async def test_binary_sensor_state(hass: HomeAssistant, initial_state: str) -> None: +async def test_binary_sensor_state(hass: HomeAssistant) -> None: """Test the event.""" - state = hass.states.get(TEST_ENTITY_ID) - assert state.state == initial_state + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) + assert state.state == STATE_OFF - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_ON @@ -632,19 +521,15 @@ async def test_binary_sensor_state(hass: HomeAssistant, initial_state: str) -> N [ ( 1, - "{{ is_state('binary_sensor.test_state', 'on') }}", + "{{ is_state('sensor.test_state', 'on') }}", {"device_class": "motion"}, "delay_on", ) ], ) @pytest.mark.parametrize( - ("style", "initial_state"), - [ - (ConfigurationStyle.LEGACY, STATE_OFF), - (ConfigurationStyle.MODERN, STATE_OFF), - (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "attribute_value", @@ -655,45 +540,39 @@ async def test_binary_sensor_state(hass: HomeAssistant, initial_state: str) -> N ], ) @pytest.mark.usefixtures("setup_single_attribute_binary_sensor") -async def test_delay_on( - hass: HomeAssistant, initial_state: str, freezer: FrozenDateTimeFactory -) -> None: +async def test_delay_on(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test binary sensor template delay on.""" - # Ensure the initial state is not on - assert hass.states.get(TEST_ENTITY_ID).state == initial_state + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF - hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, 5) - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_ATTRIBUTE_ENTITY_ID, 5) + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - assert hass.states.get(TEST_ENTITY_ID).state == initial_state + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_ON - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF @pytest.mark.parametrize( @@ -701,7 +580,7 @@ async def test_delay_on( [ ( 1, - "{{ is_state('binary_sensor.test_state', 'on') }}", + "{{ is_state('sensor.test_state', 'on') }}", {"device_class": "motion"}, "delay_off", ) @@ -726,40 +605,36 @@ async def test_delay_on( @pytest.mark.usefixtures("setup_single_attribute_binary_sensor") async def test_delay_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test binary sensor template delay off.""" - assert hass.states.get(TEST_ENTITY_ID).state != STATE_ON + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state != STATE_ON - hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, 5) - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_ATTRIBUTE_ENTITY_ID, 5) + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_ON freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_ON - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_ON - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_ON - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_ON freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_OFF @pytest.mark.parametrize( @@ -782,7 +657,7 @@ async def test_delay_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> @pytest.mark.usefixtures("setup_binary_sensor") async def test_available_without_availability_template(hass: HomeAssistant) -> None: """Ensure availability is true without an availability_template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" @@ -813,15 +688,13 @@ async def test_available_without_availability_template(hass: HomeAssistant) -> N @pytest.mark.usefixtures("setup_single_attribute_binary_sensor") async def test_availability_template(hass: HomeAssistant) -> None: """Test availability template.""" - hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) - assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state == STATE_UNAVAILABLE - hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_AVAILABILITY_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" @@ -845,10 +718,9 @@ async def test_invalid_availability_template_keeps_component_available( ) -> None: """Test that an invalid availability keeps the device available.""" - hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + assert hass.states.get(TEST_BINARY_SENSOR.entity_id).state != STATE_UNAVAILABLE text = "UndefinedError: 'x' is undefined" assert text in caplog_setup_text or text in caplog.text @@ -860,30 +732,34 @@ async def test_no_update_template_match_all(hass: HomeAssistant) -> None: await setup.async_setup_component( hass, - binary_sensor.DOMAIN, + template.DOMAIN, { - "binary_sensor": { - "platform": "template", - "sensors": { - "all_state": {"value_template": '{{ "true" }}'}, - "all_icon": { - "value_template": "{{ states.binary_sensor.test_sensor.state }}", - "icon_template": "{{ 1 + 1 }}", - }, - "all_entity_picture": { - "value_template": "{{ states.binary_sensor.test_sensor.state }}", - "entity_picture_template": "{{ 1 + 1 }}", - }, - "all_attribute": { - "value_template": "{{ states.binary_sensor.test_sensor.state }}", - "attribute_templates": {"test_attribute": "{{ 1 + 1 }}"}, - }, - }, - } + "template": [ + { + "binary_sensor": [ + {"name": "all_state", "state": "{{ True }}"}, + { + "name": "all_icon", + "state": "{{ states('sensor.test_state') }}", + "icon": "{{ 1 + 1 }}", + }, + { + "name": "all_entity_picture", + "state": "{{ states('sensor.test_state') }}", + "picture": "{{ 1 + 1 }}", + }, + { + "name": "all_attribute", + "state": "{{ states('sensor.test_state') }}", + "attributes": {"test_attribute": "{{ 1 + 1 }}"}, + }, + ] + } + ] }, ) await hass.async_block_till_done() - hass.states.async_set("binary_sensor.test_sensor", STATE_ON) + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) assert len(hass.states.async_all()) == 5 assert hass.states.get("binary_sensor.all_state").state == STATE_UNKNOWN @@ -899,8 +775,7 @@ async def test_no_update_template_match_all(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.all_entity_picture").state == STATE_ON assert hass.states.get("binary_sensor.all_attribute").state == STATE_ON - hass.states.async_set("binary_sensor.test_sensor", STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) assert hass.states.get("binary_sensor.all_state").state == STATE_ON # Will now process because we have one valid template @@ -919,99 +794,27 @@ async def test_no_update_template_match_all(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.all_attribute").state == STATE_OFF -@pytest.mark.parametrize(("count", "extra_template_options"), [(1, {})]) @pytest.mark.parametrize( - ("config", "style"), - [ - ( - { - "test_template_01": { - "value_template": "{{ True }}", - **UNIQUE_ID_CONFIG, - }, - "test_template_02": { - "value_template": "{{ True }}", - **UNIQUE_ID_CONFIG, - }, - }, - ConfigurationStyle.LEGACY, - ), - ( - [ - { - "name": "test_template_01", - "state": "{{ True }}", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_02", - "state": "{{ True }}", - **UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.MODERN, - ), - ( - [ - { - "name": "test_template_01", - "state": "{{ True }}", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_02", - "state": "{{ True }}", - **UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.TRIGGER, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_base_binary_sensor") -async def test_unique_id(hass: HomeAssistant) -> None: - """Test unique_id option only creates one fan per id.""" - assert len(hass.states.async_all()) == 1 +async def test_unique_id(hass: HomeAssistant, style: ConfigurationStyle) -> None: + """Test unique_id option only creates one light per id.""" + await setup_and_test_unique_id(hass, TEST_BINARY_SENSOR, style, {}, "{{ 1 == 1}}") -@pytest.mark.parametrize( - ("count", "config", "extra_template_options"), - [ - ( - 1, - [ - { - "name": "test_a", - "state": "{{ True }}", - "unique_id": "a", - }, - { - "name": "test_b", - "state": "{{ True }}", - "unique_id": "b", - }, - ], - {"unique_id": "x"}, - ) - ], -) @pytest.mark.parametrize( "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] ) -@pytest.mark.usefixtures("setup_base_binary_sensor") async def test_nested_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + style: ConfigurationStyle, + entity_registry: er.EntityRegistry, ) -> None: - """Test a template unique_id propagates to switch unique_ids.""" - assert len(hass.states.async_all("binary_sensor")) == 2 - - entry = entity_registry.async_get("binary_sensor.test_a") - assert entry - assert entry.unique_id == "x-a" - - entry = entity_registry.async_get("binary_sensor.test_b") - assert entry - assert entry.unique_id == "x-b" + """Test a template unique_id propagates to binary_sensor unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_BINARY_SENSOR, style, entity_registry, {"state": "{{ 1 == 1 }}"} + ) @pytest.mark.parametrize( @@ -1031,29 +834,27 @@ async def test_template_icon_validation_error( ) -> None: """Test binary sensor template delay on.""" caplog.set_level(logging.ERROR) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.attributes.get("icon") == initial_state - hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "mdi:check") - await hass.async_block_till_done() + await async_trigger(hass, TEST_ATTRIBUTE_ENTITY_ID, "mdi:check") - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.attributes["icon"] == "mdi:check" - hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "invalid_icon") - await hass.async_block_till_done() + await async_trigger(hass, TEST_ATTRIBUTE_ENTITY_ID, "invalid_icon") assert len(caplog.records) == 1 assert caplog.records[0].message.startswith( "Error validating template result 'invalid_icon' from template" ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.attributes.get("icon") is None @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.binary_sensor.test_state.state }}")] + ("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")] ) @pytest.mark.parametrize( "style", @@ -1112,15 +913,16 @@ async def test_restore_state( ) -> None: """Test restoring template binary sensor.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, source_state) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, source_state) - fake_state = State(TEST_ENTITY_ID, restored_state, {}) + fake_state = State(TEST_BINARY_SENSOR.entity_id, restored_state, {}) mock_restore_cache(hass, (fake_state,)) - await async_setup_binary_sensor(hass, count, style, state_template, extra_config) + await setup_entity( + hass, TEST_BINARY_SENSOR, style, count, extra_config, state_template + ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == initial_state @@ -1158,7 +960,7 @@ async def test_template_with_trigger_templated_auto_off( freezer: FrozenDateTimeFactory, ) -> None: """Test binary sensor template with template auto off.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_UNKNOWN context = Context() @@ -1166,7 +968,7 @@ async def test_template_with_trigger_templated_auto_off( await hass.async_block_till_done() # State should still be unknown - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == first_state # Now wait for the on delay @@ -1174,7 +976,7 @@ async def test_template_with_trigger_templated_auto_off( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == second_state # Now wait for the auto-off @@ -1182,7 +984,7 @@ async def test_template_with_trigger_templated_auto_off( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == final_state @@ -1207,7 +1009,7 @@ async def test_template_trigger_delay_on_and_auto_off( freezer: FrozenDateTimeFactory, ) -> None: """Test binary sensor template with delay_on, auto_off, and multiple triggers.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_UNKNOWN context = Context() @@ -1215,7 +1017,7 @@ async def test_template_trigger_delay_on_and_auto_off( await hass.async_block_till_done() # State should still be unknown - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_UNKNOWN last_state = STATE_UNKNOWN @@ -1225,7 +1027,7 @@ async def test_template_trigger_delay_on_and_auto_off( hass.bus.async_fire("test_event", {"beer": 2}, context=context) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == last_state # Now wait for the on delay @@ -1233,7 +1035,7 @@ async def test_template_trigger_delay_on_and_auto_off( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_ON # Now wait for the auto-off @@ -1241,7 +1043,7 @@ async def test_template_trigger_delay_on_and_auto_off( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_OFF # Now wait to trigger again @@ -1250,7 +1052,7 @@ async def test_template_trigger_delay_on_and_auto_off( await hass.async_block_till_done() # State should still be off - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_OFF last_state = STATE_OFF @@ -1262,7 +1064,7 @@ async def test_template_trigger_delay_on_and_auto_off( ( 1, ConfigurationStyle.MODERN, - "{{ states('binary_sensor.test_state') }}", + "{{ states('sensor.test_state') }}", { "device_class": "motion", "delay_on": "00:00:02", @@ -1276,14 +1078,13 @@ async def test_template_multiple_states_delay_on( freezer: FrozenDateTimeFactory, ) -> None: """Test binary sensor template with delay_on and multiple state changes.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_OFF - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) # State should be off - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_OFF for _ in range(5): @@ -1292,19 +1093,17 @@ async def test_template_multiple_states_delay_on( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_ON freezer.tick(timedelta(seconds=1)) - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) freezer.tick(timedelta(seconds=1)) - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) # State should still be off - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_OFF @@ -1328,7 +1127,7 @@ async def test_template_with_trigger_auto_off_cancel( freezer: FrozenDateTimeFactory, ) -> None: """Test binary sensor template with template auto off.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_UNKNOWN context = Context() @@ -1336,7 +1135,7 @@ async def test_template_with_trigger_auto_off_cancel( await hass.async_block_till_done() # State should still be unknown - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_ON # Now wait for the on delay @@ -1344,7 +1143,7 @@ async def test_template_with_trigger_auto_off_cancel( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_ON hass.bus.async_fire("test_event", {}, context=context) @@ -1355,7 +1154,7 @@ async def test_template_with_trigger_auto_off_cancel( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_ON # Now wait for the auto-off @@ -1363,7 +1162,7 @@ async def test_template_with_trigger_auto_off_cancel( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_OFF @@ -1391,11 +1190,10 @@ async def test_trigger_with_negative_time_periods( hass: HomeAssistant, attribute: str, caplog: pytest.LogCaptureFixture ) -> None: """Test binary sensor template with template negative time periods.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_UNKNOWN - hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "-5") - await hass.async_block_till_done() + await async_trigger(hass, TEST_ATTRIBUTE_ENTITY_ID, "-5") assert f"Error rendering {attribute} template: " in caplog.text @@ -1425,7 +1223,7 @@ async def test_trigger_template_delay_with_multiple_triggers( """Test trigger based binary sensor with multiple triggers occurring during the delay.""" for _ in range(10): # State should still be unknown - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_UNKNOWN hass.bus.async_fire("test_event", {"beer": 2}, context=Context()) @@ -1435,7 +1233,7 @@ async def test_trigger_template_delay_with_multiple_triggers( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == delay_state @@ -1463,7 +1261,7 @@ async def test_trigger_entity_restore_state( } fake_state = State( - TEST_ENTITY_ID, + TEST_BINARY_SENSOR.entity_id, restored_state, restored_attributes, ) @@ -1471,11 +1269,11 @@ async def test_trigger_entity_restore_state( "auto_off_time": None, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - await async_setup_binary_sensor( + await setup_entity( hass, - 1, + TEST_BINARY_SENSOR, ConfigurationStyle.TRIGGER, - _BEER_TRIGGER_VALUE_TEMPLATE, + 1, { "device_class": "motion", "picture": "{{ '/local/dogs.png' }}", @@ -1485,9 +1283,10 @@ async def test_trigger_entity_restore_state( "another": "{{ trigger.event.data.uno_mas or 1 }}", }, }, + _BEER_TRIGGER_VALUE_TEMPLATE, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == initial_state for attr, value in restored_attributes.items(): if attr in initial_attributes: @@ -1499,7 +1298,7 @@ async def test_trigger_entity_restore_state( hass.bus.async_fire("test_event", {"beer": 2}) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_ON assert state.attributes["icon"] == "mdi:pirate" assert state.attributes["entity_picture"] == "/local/dogs.png" @@ -1516,7 +1315,7 @@ async def test_trigger_entity_restore_state_auto_off( """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State(TEST_ENTITY_ID, restored_state, {}) + fake_state = State(TEST_BINARY_SENSOR.entity_id, restored_state, {}) fake_extra_data = { "auto_off_time": { "__type": "", @@ -1524,15 +1323,16 @@ async def test_trigger_entity_restore_state_auto_off( }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - await async_setup_binary_sensor( + await setup_entity( hass, - 1, + TEST_BINARY_SENSOR, ConfigurationStyle.TRIGGER, - _BEER_TRIGGER_VALUE_TEMPLATE, + 1, {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + _BEER_TRIGGER_VALUE_TEMPLATE, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == restored_state # Now wait for the auto-off @@ -1540,7 +1340,7 @@ async def test_trigger_entity_restore_state_auto_off( await hass.async_block_till_done() await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_OFF @@ -1551,7 +1351,7 @@ async def test_trigger_entity_restore_state_auto_off_expired( """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) + fake_state = State(TEST_BINARY_SENSOR.entity_id, STATE_ON, {}) fake_extra_data = { "auto_off_time": { "__type": "", @@ -1559,15 +1359,16 @@ async def test_trigger_entity_restore_state_auto_off_expired( }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - await async_setup_binary_sensor( + await setup_entity( hass, - 1, + TEST_BINARY_SENSOR, ConfigurationStyle.TRIGGER, - _BEER_TRIGGER_VALUE_TEMPLATE, + 1, {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + _BEER_TRIGGER_VALUE_TEMPLATE, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_OFF @@ -1590,26 +1391,26 @@ async def test_saving_auto_off( "isoformat": "2022-02-02T02:02:02+00:00", }, } - await async_setup_binary_sensor( + await setup_entity( hass, - 1, + TEST_BINARY_SENSOR, ConfigurationStyle.TRIGGER, - "{{ True }}", + 1, { "device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', "attributes": restored_attributes, }, + "{{ True }}", ) - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) await async_mock_restore_state_shutdown_restart(hass) assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] - assert state["entity_id"] == TEST_ENTITY_ID + assert state["entity_id"] == TEST_BINARY_SENSOR.entity_id for attr, value in restored_attributes.items(): assert state["attributes"][attr] == value @@ -1626,7 +1427,7 @@ async def test_trigger_entity_restore_invalid_auto_off_time_data( """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) + fake_state = State(TEST_BINARY_SENSOR.entity_id, STATE_ON, {}) fake_extra_data = { "auto_off_time": { "_type": "", @@ -1639,15 +1440,16 @@ async def test_trigger_entity_restore_invalid_auto_off_time_data( extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] assert extra_data == fake_extra_data - await async_setup_binary_sensor( + await setup_entity( hass, - 1, + TEST_BINARY_SENSOR, ConfigurationStyle.TRIGGER, - _BEER_TRIGGER_VALUE_TEMPLATE, + 1, {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + _BEER_TRIGGER_VALUE_TEMPLATE, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_UNKNOWN @@ -1659,7 +1461,7 @@ async def test_trigger_entity_restore_invalid_auto_off_time_key( """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) + fake_state = State(TEST_BINARY_SENSOR.entity_id, STATE_ON, {}) fake_extra_data = { "auto_off_timex": { "__type": "", @@ -1673,15 +1475,16 @@ async def test_trigger_entity_restore_invalid_auto_off_time_key( assert "auto_off_timex" in extra_data assert extra_data == fake_extra_data - await async_setup_binary_sensor( + await setup_entity( hass, - 1, + TEST_BINARY_SENSOR, ConfigurationStyle.TRIGGER, - _BEER_TRIGGER_VALUE_TEMPLATE, + 1, {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + _BEER_TRIGGER_VALUE_TEMPLATE, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_BINARY_SENSOR.entity_id) assert state.state == STATE_UNKNOWN From de973e890064d23b81e91070c533221542673c41 Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Thu, 9 Apr 2026 08:59:52 -0700 Subject: [PATCH 0659/1707] iaqualink: don't return False in async_setup_entry (#167812) --- .../components/iaqualink/__init__.py | 23 ++++++++++++------- tests/components/iaqualink/test_init.py | 23 ++++++++++++++++--- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 9a745a61f1fb0f..1647e880d1107e 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -18,12 +18,19 @@ AqualinkSwitch, AqualinkThermostat, ) -from iaqualink.exception import AqualinkServiceException +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval @@ -74,11 +81,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> ) try: await aqualink.login() - except AqualinkServiceException as login_exception: - _LOGGER.error("Failed to login: %s", login_exception) + except AqualinkServiceUnauthorizedException as auth_exception: await aqualink.close() - return False - except (TimeoutError, httpx.HTTPError) as aio_exception: + raise ConfigEntryAuthFailed( + "Invalid credentials for iAqualink" + ) from auth_exception + except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as aio_exception: await aqualink.close() raise ConfigEntryNotReady( f"Error while attempting login: {aio_exception}" @@ -94,9 +102,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> systems = list(systems.values()) if not systems: - _LOGGER.error("No systems detected or supported") await aqualink.close() - return False + raise ConfigEntryError("No systems detected or supported") runtime_data = AqualinkRuntimeData( aqualink, binary_sensors=[], lights=[], sensors=[], switches=[], thermostats=[] diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index 1df199f706a4de..798fae2344cc04 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -3,7 +3,10 @@ import logging from unittest.mock import AsyncMock, patch -from iaqualink.exception import AqualinkServiceException +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) from iaqualink.systems.iaqua.device import ( IaquaAuxSwitch, IaquaBinarySensor, @@ -36,8 +39,8 @@ async def _ffwd_next_update_interval(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_setup_login_exception(hass: HomeAssistant, config_entry) -> None: - """Test setup encountering a login exception.""" +async def test_setup_login_service_exception(hass: HomeAssistant, config_entry) -> None: + """Test setup encountering a transient service exception during login.""" config_entry.add_to_hass(hass) with patch( @@ -47,6 +50,20 @@ async def test_setup_login_exception(hass: HomeAssistant, config_entry) -> None: await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_login_unauthorized(hass: HomeAssistant, config_entry) -> None: + """Test setup encountering an unauthorized exception during login.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + side_effect=AqualinkServiceUnauthorizedException, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_ERROR From e5a83106d776bb04237be6f90dd7464b1e8eae64 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 9 Apr 2026 18:14:32 +0100 Subject: [PATCH 0660/1707] Change default icon of Evohome's WaterHeater entities (#167818) --- homeassistant/components/evohome/water_heater.py | 1 - tests/components/evohome/snapshots/test_water_heater.ambr | 2 -- 2 files changed, 3 deletions(-) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 4da5a826690aad..0095f65ea20bea 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -69,7 +69,6 @@ class EvoDHW(EvoChild, WaterHeaterEntity): """Base for any evohome-compatible DHW controller.""" _attr_name = "DHW controller" - _attr_icon = "mdi:thermometer-lines" _attr_operation_list = list(HA_STATE_TO_EVO) _attr_supported_features = ( WaterHeaterEntityFeature.AWAY_MODE diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 08058fe1bdfc2b..a0742012f38a15 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -25,7 +25,6 @@ 'away_mode': 'on', 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', - 'icon': 'mdi:thermometer-lines', 'max_temp': 60.0, 'min_temp': 43.3, 'operation_list': list([ @@ -72,7 +71,6 @@ 'away_mode': 'on', 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', - 'icon': 'mdi:thermometer-lines', 'max_temp': 60.0, 'min_temp': 43.3, 'operation_list': list([ From 6510b3d1d1d4172c128000ebc5ef073e2070b7c2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 9 Apr 2026 19:36:08 +0200 Subject: [PATCH 0661/1707] Add configuration URL to Comelit (#167813) --- homeassistant/components/comelit/coordinator.py | 1 + tests/components/comelit/conftest.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 009d864c0cb2a0..0bfde07561b491 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -65,6 +65,7 @@ def __init__( ) device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( + configuration_url=self.api.base_url, config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, model=device, diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py index 040d56be9a7888..14fc27e417ebb0 100644 --- a/tests/components/comelit/conftest.py +++ b/tests/components/comelit/conftest.py @@ -50,6 +50,7 @@ def mock_serial_bridge() -> Generator[AsyncMock]: bridge.vedo_enabled.return_value = True bridge.host = BRIDGE_HOST bridge.port = BRIDGE_PORT + bridge.base_url = f"http://{BRIDGE_HOST}:{BRIDGE_PORT}" bridge.device_pin = BRIDGE_PIN yield bridge @@ -86,6 +87,7 @@ def mock_vedo() -> Generator[AsyncMock]: vedo.get_all_areas_and_zones.return_value = deepcopy(VEDO_DEVICE_QUERY) vedo.host = VEDO_HOST vedo.port = VEDO_PORT + vedo.base_url = f"http://{VEDO_HOST}:{VEDO_PORT}" vedo.device_pin = VEDO_PIN vedo.type = VEDO yield vedo From 8b37cc8719b2d372fb2bffe0620f3adb6cc497b6 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:43:13 +0800 Subject: [PATCH 0662/1707] Switchbot Cloud: Enable Webhook for Bot (#165647) --- .../components/switchbot_cloud/__init__.py | 72 ++++++++++--------- tests/components/switchbot_cloud/conftest.py | 23 ++++++ .../components/switchbot_cloud/test_button.py | 33 ++++++++- .../components/switchbot_cloud/test_switch.py | 32 ++++++++- 4 files changed, 124 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index dd47f37e7e0cb7..597157b07d2913 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -13,6 +13,7 @@ SwitchBotAPI, SwitchBotAuthenticationError, SwitchBotConnectionError, + SwitchBotDeviceOfflineError, ) from homeassistant.components import webhook @@ -202,7 +203,7 @@ async def make_device_data( if isinstance(device, Device) and device.device_type == "Bot": coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id, True ) devices_data.sensors.append((device, coordinator)) if coordinator.data is not None: @@ -409,42 +410,49 @@ async def _initialize_webhook( hass, entry.data[CONF_WEBHOOK_ID], ) - # check if webhook is configured in switchbot cloud - check_webhook_result = None - with contextlib.suppress(Exception): - check_webhook_result = await api.get_webook_configuration() - actual_webhook_urls = ( - check_webhook_result["urls"] - if check_webhook_result and "urls" in check_webhook_result - else [] - ) - need_add_webhook = ( - len(actual_webhook_urls) == 0 or webhook_url not in actual_webhook_urls - ) - need_clean_previous_webhook = ( - len(actual_webhook_urls) > 0 and webhook_url not in actual_webhook_urls - ) + try: + check_webhook_result = None + with contextlib.suppress(Exception): + check_webhook_result = await api.get_webook_configuration() - if need_clean_previous_webhook: - # it seems is impossible to register multiple webhook. - # So, if webhook already exists, we delete it - await api.delete_webhook(actual_webhook_urls[0]) - _LOGGER.debug( - "Deleted previous Switchbot cloud webhook url: %s", - actual_webhook_urls[0], + actual_webhook_urls = ( + check_webhook_result["urls"] + if check_webhook_result and "urls" in check_webhook_result + else [] + ) + need_add_webhook = ( + len(actual_webhook_urls) == 0 or webhook_url not in actual_webhook_urls + ) + need_clean_previous_webhook = ( + len(actual_webhook_urls) > 0 and webhook_url not in actual_webhook_urls ) - if need_add_webhook: - # call api for register webhookurl - await api.setup_webhook(webhook_url) - _LOGGER.debug("Registered Switchbot cloud webhook at hass: %s", webhook_url) - - for coordinator in coordinators_by_id.values(): - coordinator.webhook_subscription_listener(True) - - _LOGGER.debug("Registered Switchbot cloud webhook at: %s", webhook_url) + if need_clean_previous_webhook: + # it seems is impossible to register multiple webhook. + # So, if webhook already exists, we delete it + await api.delete_webhook(actual_webhook_urls[0]) + _LOGGER.debug( + "Deleted previous Switchbot cloud webhook url: %s", + actual_webhook_urls[0], + ) + + if need_add_webhook: + # call api for register webhookurl + await api.setup_webhook(webhook_url) + _LOGGER.debug( + "Registered Switchbot cloud webhook at hass: %s", webhook_url + ) + + for coordinator in coordinators_by_id.values(): + coordinator.webhook_subscription_listener(True) + + _LOGGER.debug("Registered Switchbot cloud webhook at: %s", webhook_url) + except SwitchBotDeviceOfflineError as e: + _LOGGER.error("Failed to connect Switchbot cloud device: %s", e) + except SwitchBotConnectionError as e: + _LOGGER.error("Failed to connect Switchbot cloud device: %s", e) def _create_handle_webhook( diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py index 93a46ec3bbe2eb..7b116baa28cf40 100644 --- a/tests/components/switchbot_cloud/conftest.py +++ b/tests/components/switchbot_cloud/conftest.py @@ -32,6 +32,29 @@ def mock_get_status(): yield mock_get_status +@pytest.fixture +def mock_setup_webhook(): + """Mock setup_webhook.""" + with patch.object(SwitchBotAPI, "setup_webhook") as mock_setup_webhook: + yield mock_setup_webhook + + +@pytest.fixture +def mock_delete_webhook(): + """Mock delete_webhook.""" + with patch.object(SwitchBotAPI, "delete_webhook") as mock_delete_webhook: + yield mock_delete_webhook + + +@pytest.fixture +def mock_get_webook_configuration(): + """Mock get_webook_configuration.""" + with patch.object( + SwitchBotAPI, "get_webook_configuration" + ) as mock_get_webook_configuration: + yield mock_get_webook_configuration + + @pytest.fixture(scope="package", autouse=True) def mock_after_command_refresh(): """Mock after command refresh.""" diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py index 9c3b25b4c9ade8..018122b946019c 100644 --- a/tests/components/switchbot_cloud/test_button.py +++ b/tests/components/switchbot_cloud/test_button.py @@ -16,7 +16,11 @@ async def test_pressmode_bot( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_setup_webhook, + mock_get_webook_configuration, ) -> None: """Test press.""" mock_list_devices.return_value = [ @@ -31,6 +35,17 @@ async def test_pressmode_bot( mock_get_status.return_value = {"deviceMode": "pressMode"} + mock_setup_webhook.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + mock_get_webook_configuration.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED @@ -49,7 +64,11 @@ async def test_pressmode_bot( async def test_switchmode_bot_no_button_entity( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_setup_webhook, + mock_get_webook_configuration, ) -> None: """Test a switchMode bot isn't added as a button.""" mock_list_devices.return_value = [ @@ -63,6 +82,16 @@ async def test_switchmode_bot_no_button_entity( ] mock_get_status.return_value = {"deviceMode": "switchMode"} + mock_setup_webhook.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + mock_get_webook_configuration.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index 67d0d516713f09..34b41dc0603fbf 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -56,7 +56,11 @@ async def test_relay_switch( async def test_switchmode_bot( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_setup_webhook, + mock_get_webook_configuration, ) -> None: """Test turn on and turn off.""" mock_list_devices.return_value = [ @@ -71,6 +75,16 @@ async def test_switchmode_bot( mock_get_status.return_value = {"deviceMode": "switchMode", "power": "off"} + mock_setup_webhook.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + mock_get_webook_configuration.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED @@ -91,7 +105,11 @@ async def test_switchmode_bot( async def test_pressmode_bot_no_switch_entity( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_setup_webhook, + mock_get_webook_configuration, ) -> None: """Test a pressMode bot isn't added as a switch.""" mock_list_devices.return_value = [ @@ -105,6 +123,16 @@ async def test_pressmode_bot_no_switch_entity( ] mock_get_status.return_value = {"deviceMode": "pressMode"} + mock_setup_webhook.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + mock_get_webook_configuration.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED From 6d55c076e44d6e4462eae72c86cbbf0baf01a835 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:56:59 -0400 Subject: [PATCH 0663/1707] List serial ports via USB integration helpers (A-P) (#167695) --- .../aurora_abb_powerone/config_flow.py | 10 +- .../aurora_abb_powerone/manifest.json | 1 + .../components/crownstone/config_flow.py | 16 ++- .../components/crownstone/helpers.py | 9 +- .../components/crownstone/manifest.json | 5 +- homeassistant/components/dsmr/config_flow.py | 24 +---- homeassistant/components/dsmr/manifest.json | 1 + .../components/insteon/manifest.json | 4 +- homeassistant/components/insteon/utils.py | 26 ++--- .../landisgyr_heat_meter/config_flow.py | 27 ++--- .../components/modem_callerid/config_flow.py | 30 +++--- requirements_all.txt | 1 - requirements_test_all.txt | 1 - .../aurora_abb_powerone/test_config_flow.py | 26 +++-- .../components/crownstone/test_config_flow.py | 75 ++++++-------- tests/components/dsmr/test_config_flow.py | 99 +++++++------------ .../landisgyr_heat_meter/test_config_flow.py | 36 ++++--- tests/components/modem_callerid/__init__.py | 20 ++-- .../modem_callerid/test_config_flow.py | 27 +++-- 19 files changed, 195 insertions(+), 243 deletions(-) diff --git a/homeassistant/components/aurora_abb_powerone/config_flow.py b/homeassistant/components/aurora_abb_powerone/config_flow.py index 0b6e41257fcae8..2be9782819f474 100644 --- a/homeassistant/components/aurora_abb_powerone/config_flow.py +++ b/homeassistant/components/aurora_abb_powerone/config_flow.py @@ -7,9 +7,9 @@ from typing import TYPE_CHECKING, Any from aurorapy.client import AuroraError, AuroraSerialClient -import serial.tools.list_ports import voluptuous as vol +from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant @@ -57,9 +57,11 @@ def validate_and_connect( return ret -def scan_comports() -> tuple[list[str] | None, str | None]: +async def async_scan_comports( + hass: HomeAssistant, +) -> tuple[list[str] | None, str | None]: """Find and store available com ports for the GUI dropdown.""" - com_ports = serial.tools.list_ports.comports(include_links=True) + com_ports = await usb.async_scan_serial_ports(hass) com_ports_list = [] for port in com_ports: com_ports_list.append(port.device) @@ -87,7 +89,7 @@ async def async_step_user( errors = {} if self._com_ports_list is None: - result = await self.hass.async_add_executor_job(scan_comports) + result = await async_scan_comports(self.hass) self._com_ports_list, self._default_com_port = result if self._default_com_port is None: return self.async_abort(reason="no_serial_ports") diff --git a/homeassistant/components/aurora_abb_powerone/manifest.json b/homeassistant/components/aurora_abb_powerone/manifest.json index 8d33cc95d458ca..04728cbb47d09f 100644 --- a/homeassistant/components/aurora_abb_powerone/manifest.json +++ b/homeassistant/components/aurora_abb_powerone/manifest.json @@ -3,6 +3,7 @@ "name": "Aurora ABB PowerOne Solar PV", "codeowners": ["@davet2001"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 5f5af4f51a4647..64baf7f49a1b1b 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -10,8 +10,6 @@ CrownstoneAuthenticationError, CrownstoneUnknownError, ) -import serial.tools.list_ports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol from homeassistant.components import usb @@ -61,9 +59,11 @@ async def async_step_usb_config( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Set up a Crownstone USB dongle.""" - list_of_ports = await self.hass.async_add_executor_job( - serial.tools.list_ports.comports - ) + list_of_ports = [ + p + for p in await usb.async_scan_serial_ports(self.hass) + if isinstance(p, usb.USBDevice) + ] if self.flow_type == CONFIG_FLOW: ports_as_string = list_ports_as_str(list_of_ports) else: @@ -82,10 +82,8 @@ async def async_step_usb_config( else: index = ports_as_string.index(selection) - 1 - selected_port: ListPortInfo = list_of_ports[index] - self.usb_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, selected_port.device - ) + selected_port = list_of_ports[index] + self.usb_path = selected_port.device return await self.async_step_usb_sphere_config() return self.async_show_form( diff --git a/homeassistant/components/crownstone/helpers.py b/homeassistant/components/crownstone/helpers.py index 4da8bc8dbe75b3..829cf7354dba03 100644 --- a/homeassistant/components/crownstone/helpers.py +++ b/homeassistant/components/crownstone/helpers.py @@ -5,15 +5,14 @@ from collections.abc import Sequence import os -from serial.tools.list_ports_common import ListPortInfo - from homeassistant.components import usb +from homeassistant.components.usb import USBDevice from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST def list_ports_as_str( - serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True + serial_ports: Sequence[USBDevice], no_usb_option: bool = True ) -> list[str]: """Represent currently available serial ports as string. @@ -31,8 +30,8 @@ def list_ports_as_str( port.serial_number, port.manufacturer, port.description, - f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None, - f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None, + port.vid, + port.pid, ) for port in serial_ports ) diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json index 6168d483ab535d..7eb3dbd31ba7d8 100644 --- a/homeassistant/components/crownstone/manifest.json +++ b/homeassistant/components/crownstone/manifest.json @@ -1,9 +1,9 @@ { "domain": "crownstone", "name": "Crownstone", - "after_dependencies": ["usb"], "codeowners": ["@Crownstone", "@RicArch97"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/crownstone", "iot_class": "cloud_push", "loggers": [ @@ -15,7 +15,6 @@ "requirements": [ "crownstone-cloud==1.4.11", "crownstone-sse==2.0.5", - "crownstone-uart==2.1.0", - "pyserial==3.5" + "crownstone-uart==2.1.0" ] } diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 577def8b3ecd14..fc34f0dd6473d5 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -4,7 +4,6 @@ import asyncio from functools import partial -import os from typing import Any from dsmr_parser import obis_references as obis_ref @@ -15,9 +14,9 @@ ) from dsmr_parser.objects import DSMRObject import serial -import serial.tools.list_ports import voluptuous as vol +from homeassistant.components import usb from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -229,9 +228,7 @@ async def async_step_setup_serial( self._dsmr_version = user_input[CONF_DSMR_VERSION] return await self.async_step_setup_serial_manual_path() - dev_path = await self.hass.async_add_executor_job( - get_serial_by_id, user_selection - ) + dev_path = user_selection validate_data = { CONF_PORT: dev_path, @@ -242,9 +239,10 @@ async def async_step_setup_serial( if not errors: return self.async_create_entry(title=data[CONF_PORT], data=data) - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await usb.async_scan_serial_ports(self.hass) list_of_ports = { - port.device: f"{port}, s/n: {port.serial_number or 'n/a'}" + port.device: f"{port.device} - {port.description or 'n/a'}" + f", s/n: {port.serial_number or 'n/a'}" + (f" - {port.manufacturer}" if port.manufacturer else "") for port in ports } @@ -335,18 +333,6 @@ async def async_step_init( ) -def get_serial_by_id(dev_path: str) -> str: - """Return a /dev/serial/by-id match for given device if available.""" - by_id = "/dev/serial/by-id" - if not os.path.isdir(by_id): - return dev_path - - for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): - if os.path.realpath(path) == dev_path: - return path - return dev_path - - class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 32366c5578400c..4dd031f1ac44c4 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -3,6 +3,7 @@ "name": "DSMR Smart Meter", "codeowners": ["@Robbie1221"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/dsmr", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index b1398326de46f3..32dcdf38f096b6 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -1,10 +1,10 @@ { "domain": "insteon", "name": "Insteon", - "after_dependencies": ["panel_custom", "usb"], + "after_dependencies": ["panel_custom"], "codeowners": ["@teharris1"], "config_flow": true, - "dependencies": ["http", "websocket_api"], + "dependencies": ["http", "usb", "websocket_api"], "dhcp": [ { "macaddress": "000EF3*" diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 5f48306754edb4..229ed007d0e594 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -11,7 +11,6 @@ from pyinsteon.constants import ALDBStatus, DeviceAction from pyinsteon.device_types.device_base import Device from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT, Event -from serial.tools import list_ports from homeassistant.components import usb from homeassistant.const import CONF_ADDRESS, Platform @@ -172,35 +171,22 @@ def async_add_insteon_devices( ) -def get_usb_ports() -> dict[str, str]: +async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: """Return a dict of USB ports and their friendly names.""" - ports = list_ports.comports() port_descriptions = {} - for port in ports: - vid: str | None = None - pid: str | None = None - if port.vid is not None and port.pid is not None: - usb_device = usb.usb_device_from_port(port) - vid = usb_device.vid - pid = usb_device.pid - dev_path = usb.get_serial_by_id(port.device) + for port in await usb.async_scan_serial_ports(hass): human_name = usb.human_readable_device_name( - dev_path, + port.device, port.serial_number, port.manufacturer, port.description, - vid, - pid, + port.vid if isinstance(port, usb.USBDevice) else None, + port.pid if isinstance(port, usb.USBDevice) else None, ) - port_descriptions[dev_path] = human_name + port_descriptions[port.device] = human_name return port_descriptions -async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: - """Return a dict of USB ports and their friendly names.""" - return await hass.async_add_executor_job(get_usb_ports) - - def compute_device_name(ha_device) -> str: """Return the HA device name.""" return ha_device.name_by_user or ha_device.name diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index f7288b8a0cd586..3f573f7f1d0190 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -7,7 +7,6 @@ from typing import Any import serial -from serial.tools import list_ports import ultraheat_api import voluptuous as vol @@ -45,9 +44,7 @@ async def async_step_user( if user_input[CONF_DEVICE] == CONF_MANUAL_PATH: return await self.async_step_setup_serial_manual_path() - dev_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, user_input[CONF_DEVICE] - ) + dev_path = user_input[CONF_DEVICE] _LOGGER.debug("Using this path : %s", dev_path) try: @@ -118,23 +115,19 @@ async def validate_ultraheat(self, port: str) -> tuple[str, str]: async def get_usb_ports(hass: HomeAssistant) -> dict[str, str]: """Return a dict of USB ports and their friendly names.""" - ports = await hass.async_add_executor_job(list_ports.comports) + ports = await usb.async_scan_serial_ports(hass) port_descriptions = {} for port in ports: - # this prevents an issue with usb_device_from_port - # not working for ports without vid on RPi - if port.vid: - usb_device = usb.usb_device_from_port(port) - dev_path = usb.get_serial_by_id(usb_device.device) + if isinstance(port, usb.USBDevice): human_name = usb.human_readable_device_name( - dev_path, - usb_device.serial_number, - usb_device.manufacturer, - usb_device.description, - usb_device.vid, - usb_device.pid, + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, ) - port_descriptions[dev_path] = human_name + port_descriptions[port.device] = human_name return port_descriptions diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 237fafa69d75dd..8d9cae02a639ba 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -5,8 +5,6 @@ from typing import Any from phone_modem import PhoneModem -import serial.tools.list_ports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol from homeassistant.components import usb @@ -19,9 +17,11 @@ DATA_SCHEMA = vol.Schema({"name": str, "device": str}) -def _generate_unique_id(port: ListPortInfo) -> str: +def _generate_unique_id(port: usb.USBDevice | usb.SerialDevice) -> str: """Generate unique id from usb attributes.""" - return f"{port.vid}:{port.pid}_{port.serial_number}_{port.manufacturer}_{port.description}" + vid = port.vid if isinstance(port, usb.USBDevice) else None + pid = port.pid if isinstance(port, usb.USBDevice) else None + return f"{vid}:{pid}_{port.serial_number}_{port.manufacturer}_{port.description}" class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN): @@ -62,30 +62,28 @@ async def async_step_user( errors: dict[str, str] | None = {} if self._async_in_progress(): return self.async_abort(reason="already_in_progress") - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await usb.async_scan_serial_ports(self.hass) existing_devices = [ entry.data[CONF_DEVICE] for entry in self._async_current_entries() ] - unused_ports = [ + port_map = { usb.human_readable_device_name( port.device, port.serial_number, port.manufacturer, port.description, - port.vid, - port.pid, - ) + port.vid if isinstance(port, usb.USBDevice) else None, + port.pid if isinstance(port, usb.USBDevice) else None, + ): port for port in ports if port.device not in existing_devices - ] - if not unused_ports: + } + if not port_map: return self.async_abort(reason="no_devices_found") if user_input is not None: - port = ports[unused_ports.index(str(user_input.get(CONF_DEVICE)))] - dev_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, port.device - ) + port = port_map[user_input[CONF_DEVICE]] + dev_path = port.device errors = await self.validate_device_errors( dev_path=dev_path, unique_id=_generate_unique_id(port) ) @@ -95,7 +93,7 @@ async def async_step_user( data={CONF_DEVICE: dev_path}, ) user_input = user_input or {} - schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(list(port_map))}) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def validate_device_errors( diff --git a/requirements_all.txt b/requirements_all.txt index ccc191193135c3..404f62002db351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2466,7 +2466,6 @@ pysenz==1.0.2 pyserial-asyncio-fast==0.16 # homeassistant.components.acer_projector -# homeassistant.components.crownstone # homeassistant.components.route_b_smart_meter # homeassistant.components.usb # homeassistant.components.zwave_js diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a46e842755703..130a08060e3343 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2107,7 +2107,6 @@ pysensibo==1.2.1 pysenz==1.0.2 # homeassistant.components.acer_projector -# homeassistant.components.crownstone # homeassistant.components.route_b_smart_meter # homeassistant.components.usb # homeassistant.components.zwave_js diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index 9c27c14d6332db..0e33d971d2167b 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import patch from aurorapy.client import AuroraError, AuroraTimeoutError -from serial.tools import list_ports_common from homeassistant import config_entries, setup from homeassistant.components.aurora_abb_powerone.const import ( @@ -11,6 +10,7 @@ ATTR_MODEL, DOMAIN, ) +from homeassistant.components.usb import SerialDevice from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -23,9 +23,16 @@ async def test_form(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, "persistent_notification", {}) fakecomports = [] - fakecomports.append(list_ports_common.ListPortInfo("/dev/ttyUSB7")) + fakecomports.append( + SerialDevice( + device="/dev/ttyUSB7", + serial_number=None, + manufacturer=None, + description=None, + ) + ) with patch( - "serial.tools.list_ports.comports", + "homeassistant.components.aurora_abb_powerone.config_flow.usb.async_scan_serial_ports", return_value=fakecomports, ): result = await hass.config_entries.flow.async_init( @@ -85,7 +92,7 @@ async def test_form_no_comports(hass: HomeAssistant) -> None: fakecomports = [] with patch( - "serial.tools.list_ports.comports", + "homeassistant.components.aurora_abb_powerone.config_flow.usb.async_scan_serial_ports", return_value=fakecomports, ): result = await hass.config_entries.flow.async_init( @@ -99,9 +106,16 @@ async def test_form_invalid_com_ports(hass: HomeAssistant) -> None: """Test we display correct info when the comport is invalid..""" fakecomports = [] - fakecomports.append(list_ports_common.ListPortInfo("/dev/ttyUSB7")) + fakecomports.append( + SerialDevice( + device="/dev/ttyUSB7", + serial_number=None, + manufacturer=None, + description=None, + ) + ) with patch( - "serial.tools.list_ports.comports", + "homeassistant.components.aurora_abb_powerone.config_flow.usb.async_scan_serial_ports", return_value=fakecomports, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py index c3bb17cb6d6f49..32f6a189de7bcc 100644 --- a/tests/components/crownstone/test_config_flow.py +++ b/tests/components/crownstone/test_config_flow.py @@ -11,7 +11,6 @@ CrownstoneUnknownError, ) import pytest -from serial.tools.list_ports_common import ListPortInfo from homeassistant.components import usb from homeassistant.components.crownstone.const import ( @@ -24,6 +23,7 @@ DONT_USE_USB, MANUAL_PATH, ) +from homeassistant.components.usb import USBDevice from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -44,34 +44,24 @@ def crownstone_setup() -> MockFixture: @pytest.fixture(name="pyserial_comports") def usb_comports() -> MockFixture: - """Mock pyserial comports.""" + """Mock scan_serial_ports.""" with patch( - "serial.tools.list_ports.comports", - MagicMock(return_value=[get_mocked_com_port()]), + "homeassistant.components.crownstone.config_flow.usb.async_scan_serial_ports", + AsyncMock(return_value=[get_mocked_com_port()]), ) as comports_mock: yield comports_mock @pytest.fixture(name="pyserial_comports_none_types") def usb_comports_none_types() -> MockFixture: - """Mock pyserial comports.""" + """Mock scan_serial_ports with none types.""" with patch( - "serial.tools.list_ports.comports", - MagicMock(return_value=[get_mocked_com_port_none_types()]), + "homeassistant.components.crownstone.config_flow.usb.async_scan_serial_ports", + AsyncMock(return_value=[get_mocked_com_port_none_types()]), ) as comports_mock: yield comports_mock -@pytest.fixture(name="usb_path") -def usb_path() -> MockFixture: - """Mock usb serial path.""" - with patch( - "homeassistant.components.usb.get_serial_by_id", - return_value="/dev/serial/by-id/crownstone-usb", - ) as usb_path_mock: - yield usb_path_mock - - def get_mocked_crownstone_entry_manager(mocked_cloud: MagicMock): """Get a mocked CrownstoneEntryManager instance.""" mocked_entry_manager = MagicMock() @@ -102,30 +92,28 @@ def create_mocked_spheres(amount: int) -> dict[str, MagicMock]: return spheres -def get_mocked_com_port(): +def get_mocked_com_port() -> USBDevice: """Mock of a serial port.""" - port = ListPortInfo("/dev/ttyUSB1234") - port.device = "/dev/ttyUSB1234" - port.serial_number = "1234567" - port.manufacturer = "crownstone" - port.description = "crownstone dongle - crownstone dongle" - port.vid = 1234 - port.pid = 5678 - - return port + return USBDevice( + device="/dev/ttyUSB1234", + vid="04D2", + pid="162E", + serial_number="1234567", + manufacturer="crownstone", + description="crownstone dongle - crownstone dongle", + ) -def get_mocked_com_port_none_types(): +def get_mocked_com_port_none_types() -> USBDevice: """Mock of a serial port with NoneTypes.""" - port = ListPortInfo("/dev/ttyUSB1234") - port.device = "/dev/ttyUSB1234" - port.serial_number = None - port.manufacturer = None - port.description = "crownstone dongle - crownstone dongle" - port.vid = None - port.pid = None - - return port + return USBDevice( + device="/dev/ttyUSB1234", + vid="0000", + pid="0000", + serial_number=None, + manufacturer=None, + description="crownstone dongle - crownstone dongle", + ) def create_mocked_entry_data_conf(email: str, password: str): @@ -294,7 +282,6 @@ async def test_successful_login_no_usb( async def test_successful_login_with_usb( crownstone_setup: MockFixture, pyserial_comports_none_types: MockFixture, - usb_path: MockFixture, hass: HomeAssistant, ) -> None: """Test flow with correct login and usb configuration.""" @@ -303,7 +290,7 @@ async def test_successful_login_with_usb( password="homeassistantisawesome", ) entry_options_with_usb = create_mocked_entry_options_conf( - usb_path="/dev/serial/by-id/crownstone-usb", + usb_path="/dev/ttyUSB1234", usb_sphere="sphere_id_1", ) @@ -334,7 +321,6 @@ async def test_successful_login_with_usb( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "usb_sphere_config" assert pyserial_comports_none_types.call_count == 2 - assert usb_path.call_count == 1 # select a sphere result = await hass.config_entries.flow.async_configure( @@ -391,7 +377,7 @@ async def test_successful_login_with_manual_usb_path( async def test_options_flow_setup_usb( - pyserial_comports: MockFixture, usb_path: MockFixture, hass: HomeAssistant + pyserial_comports: MockFixture, hass: HomeAssistant ) -> None: """Test options flow init.""" configured_entry_data = create_mocked_entry_data_conf( @@ -446,8 +432,8 @@ async def test_options_flow_setup_usb( port.serial_number, port.manufacturer, port.description, - f"{hex(port.vid)[2:]:0>4}".upper(), - f"{hex(port.pid)[2:]:0>4}".upper(), + port.vid, + port.pid, ) # select a port from the list @@ -457,7 +443,6 @@ async def test_options_flow_setup_usb( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "usb_sphere_config" assert pyserial_comports.call_count == 2 - assert usb_path.call_count == 1 # select a sphere result = await hass.config_entries.options.async_configure( @@ -465,7 +450,7 @@ async def test_options_flow_setup_usb( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == create_mocked_entry_options_conf( - usb_path="/dev/serial/by-id/crownstone-usb", usb_sphere="sphere_id_1" + usb_path="/dev/ttyUSB1234", usb_sphere="sphere_id_1" ) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index c6031781c66d62..bd9076b15600f1 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -1,17 +1,15 @@ """Test the DSMR config flow.""" from itertools import chain, repeat -import os from typing import Any -from unittest.mock import DEFAULT, AsyncMock, MagicMock, patch, sentinel +from unittest.mock import DEFAULT, AsyncMock, MagicMock, patch import pytest import serial -import serial.tools.list_ports from homeassistant import config_entries -from homeassistant.components.dsmr import config_flow from homeassistant.components.dsmr.const import DOMAIN +from homeassistant.components.usb import SerialDevice from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,15 +19,14 @@ SERIAL_DATA_SWEDEN = {"serial_id": None, "serial_id_gas": None} -def com_port(): +def com_port() -> SerialDevice: """Mock of a serial port.""" - port = serial.tools.list_ports_common.ListPortInfo("/dev/ttyUSB1234") - port.serial_number = "1234" - port.manufacturer = "Virtual serial port" - port.device = "/dev/ttyUSB1234" - port.description = "Some serial port" - - return port + return SerialDevice( + device="/dev/ttyUSB1234", + serial_number="1234", + manufacturer="Virtual serial port", + description="Some serial port", + ) async def test_setup_network( @@ -195,7 +192,10 @@ async def test_setup_network_rfxtrx( ), ], ) -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.dsmr.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial( com_mock, hass: HomeAssistant, @@ -235,7 +235,10 @@ async def test_setup_serial( assert result["data"] == entry_data -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.dsmr.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial_rfxtrx( com_mock, hass: HomeAssistant, @@ -287,7 +290,10 @@ async def test_setup_serial_rfxtrx( assert result["data"] == {**entry_data, **SERIAL_DATA} -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.dsmr.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial_manual( com_mock, hass: HomeAssistant, @@ -337,7 +343,10 @@ async def test_setup_serial_manual( assert result["data"] == {**entry_data, **SERIAL_DATA} -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.dsmr.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial_fail( com_mock, hass: HomeAssistant, @@ -385,7 +394,10 @@ async def test_setup_serial_fail( assert result["errors"] == {"base": "cannot_connect"} -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.dsmr.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial_timeout( com_mock, hass: HomeAssistant, @@ -443,7 +455,10 @@ async def test_setup_serial_timeout( assert result["errors"] == {"base": "cannot_communicate"} -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.dsmr.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial_wrong_telegram( com_mock, hass: HomeAssistant, @@ -528,51 +543,3 @@ async def test_options_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.options == {"time_between_update": 15} - - -def test_get_serial_by_id_no_dir() -> None: - """Test serial by id conversion if there's no /dev/serial/by-id.""" - p1 = patch("os.path.isdir", MagicMock(return_value=False)) - p2 = patch("os.scandir") - with p1 as is_dir_mock, p2 as scan_mock: - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.path - assert is_dir_mock.call_count == 1 - assert scan_mock.call_count == 0 - - -def test_get_serial_by_id() -> None: - """Test serial by id conversion.""" - - def _realpath(path): - if path is sentinel.matched_link: - return sentinel.path - return sentinel.serial_link_path - - with ( - patch("os.path.isdir", MagicMock(return_value=True)) as is_dir_mock, - patch("os.scandir") as scan_mock, - patch("os.path.realpath", side_effect=_realpath), - ): - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.path - assert is_dir_mock.call_count == 1 - assert scan_mock.call_count == 1 - - entry1 = MagicMock(spec_set=os.DirEntry) - entry1.is_symlink.return_value = True - entry1.path = sentinel.some_path - - entry2 = MagicMock(spec_set=os.DirEntry) - entry2.is_symlink.return_value = False - entry2.path = sentinel.other_path - - entry3 = MagicMock(spec_set=os.DirEntry) - entry3.is_symlink.return_value = True - entry3.path = sentinel.matched_link - - scan_mock.return_value = [entry1, entry2, entry3] - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.matched_link - assert is_dir_mock.call_count == 2 - assert scan_mock.call_count == 2 diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index fe62d5307198d3..0fdca6a507aab0 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -5,10 +5,10 @@ import pytest import serial -import serial.tools.list_ports from homeassistant import config_entries from homeassistant.components.landisgyr_heat_meter import DOMAIN +from homeassistant.components.usb import USBDevice from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -19,17 +19,16 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") -def mock_serial_port(): +def mock_serial_port() -> USBDevice: """Mock of a serial port.""" - port = serial.tools.list_ports_common.ListPortInfo("/dev/ttyUSB1234") - port.serial_number = "1234" - port.manufacturer = "Virtual serial port" - port.device = "/dev/ttyUSB1234" - port.description = "Some serial port" - port.pid = 9876 - port.vid = 5678 - - return port + return USBDevice( + device="/dev/ttyUSB1234", + vid="162E", + pid="269C", + serial_number="1234", + manufacturer="Virtual serial port", + description="Some serial port", + ) @dataclass @@ -75,7 +74,10 @@ async def test_manual_entry(mock_heat_meter, hass: HomeAssistant) -> None: @patch(API_HEAT_METER_SERVICE) -@patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) +@patch( + "homeassistant.components.landisgyr_heat_meter.config_flow.usb.async_scan_serial_ports", + return_value=[mock_serial_port()], +) async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: """Test select from list entry.""" @@ -132,7 +134,10 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: @patch(API_HEAT_METER_SERVICE) -@patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) +@patch( + "homeassistant.components.landisgyr_heat_meter.config_flow.usb.async_scan_serial_ports", + return_value=[mock_serial_port()], +) async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: """Test select from list entry fails.""" @@ -155,7 +160,10 @@ async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) @patch(API_HEAT_METER_SERVICE) -@patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) +@patch( + "homeassistant.components.landisgyr_heat_meter.config_flow.usb.async_scan_serial_ports", + return_value=[mock_serial_port()], +) async def test_already_configured( mock_port, mock_heat_meter, hass: HomeAssistant ) -> None: diff --git a/tests/components/modem_callerid/__init__.py b/tests/components/modem_callerid/__init__.py index 419f5bc50ff9ab..045e664efdf8eb 100644 --- a/tests/components/modem_callerid/__init__.py +++ b/tests/components/modem_callerid/__init__.py @@ -3,7 +3,8 @@ from unittest.mock import patch from phone_modem import DEFAULT_PORT -from serial.tools.list_ports_common import ListPortInfo + +from homeassistant.components.usb import USBDevice def patch_init_modem(): @@ -20,12 +21,13 @@ def patch_config_flow_modem(): ) -def com_port(): +def com_port() -> USBDevice: """Mock of a serial port.""" - port = ListPortInfo(DEFAULT_PORT) - port.serial_number = "1234" - port.manufacturer = "Virtual serial port" - port.device = DEFAULT_PORT - port.description = "Some serial port" - - return port + return USBDevice( + device=DEFAULT_PORT, + vid="0572", + pid="1340", + serial_number="1234", + manufacturer="Virtual serial port", + description="Some serial port", + ) diff --git a/tests/components/modem_callerid/test_config_flow.py b/tests/components/modem_callerid/test_config_flow.py index 280deadc733899..f1ce5e742c4a6b 100644 --- a/tests/components/modem_callerid/test_config_flow.py +++ b/tests/components/modem_callerid/test_config_flow.py @@ -1,6 +1,6 @@ """Test Modem Caller ID config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, patch import phone_modem @@ -30,7 +30,10 @@ def _patch_setup(): ) -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.modem_callerid.config_flow.usb.async_scan_serial_ports", + AsyncMock(return_value=[com_port()]), +) async def test_flow_usb(hass: HomeAssistant) -> None: """Test usb discovery flow.""" with patch_config_flow_modem(), _patch_setup(): @@ -50,7 +53,10 @@ async def test_flow_usb(hass: HomeAssistant) -> None: assert result["data"] == {CONF_DEVICE: com_port().device} -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.modem_callerid.config_flow.usb.async_scan_serial_ports", + AsyncMock(return_value=[com_port()]), +) async def test_flow_usb_cannot_connect(hass: HomeAssistant) -> None: """Test usb flow connection error.""" with patch_config_flow_modem() as modemmock: @@ -62,7 +68,10 @@ async def test_flow_usb_cannot_connect(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_connect" -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.modem_callerid.config_flow.usb.async_scan_serial_ports", + AsyncMock(return_value=[com_port()]), +) async def test_flow_user(hass: HomeAssistant) -> None: """Test user initialized flow.""" port = com_port() @@ -92,7 +101,10 @@ async def test_flow_user(hass: HomeAssistant) -> None: assert result["reason"] == "no_devices_found" -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.modem_callerid.config_flow.usb.async_scan_serial_ports", + AsyncMock(return_value=[com_port()]), +) async def test_flow_user_error(hass: HomeAssistant) -> None: """Test user initialized flow with unreachable device.""" port = com_port() @@ -122,7 +134,10 @@ async def test_flow_user_error(hass: HomeAssistant) -> None: assert result["data"] == {CONF_DEVICE: port.device} -@patch("serial.tools.list_ports.comports", MagicMock()) +@patch( + "homeassistant.components.modem_callerid.config_flow.usb.async_scan_serial_ports", + AsyncMock(return_value=[]), +) async def test_flow_user_no_port_list(hass: HomeAssistant) -> None: """Test user with no list of ports.""" with patch_config_flow_modem(): From 09585a7e1c7910de3c6e8a274b0a58fc6f6b52a1 Mon Sep 17 00:00:00 2001 From: Benjamin Hudgens Date: Thu, 9 Apr 2026 13:21:14 -0500 Subject: [PATCH 0664/1707] Revert "Fix Ring snapshots" - #164337 (#167790) --- homeassistant/components/ring/camera.py | 21 ++++---- homeassistant/components/ring/strings.json | 3 ++ tests/components/ring/test_camera.py | 60 +++++++++++++--------- 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 21ce0bfb2b3815..ee4ab050aca98b 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -128,8 +128,9 @@ def _handle_coordinator_update(self) -> None: self._device = self._get_coordinator_data().get_video_device( self._device.device_api_id ) + history_data = self._device.last_history - if history_data: + if history_data and self._device.has_subscription: self._last_event = history_data[0] # will call async_update to update the attributes and get the # video url from the api @@ -154,13 +155,16 @@ async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - # For live_view cameras, get a fresh snapshot - if self.entity_description.key == "live_view": - return await self._async_get_fresh_snapshot() + if self._video_url is None: + if not self._device.has_subscription: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_subscription", + ) + return None - # For last_recording cameras, use the cached video frame key = (width, height) - if not (image := self._images.get(key)) and self._video_url is not None: + if not (image := self._images.get(key)): image = await ffmpeg.async_get_image( self.hass, self._video_url, @@ -173,11 +177,6 @@ async def async_camera_image( return image - @exception_wrap - async def _async_get_fresh_snapshot(self) -> bytes | None: - """Get a fresh snapshot from the camera.""" - return await self._device.async_get_snapshot() - async def handle_async_mjpeg_stream( self, request: web.Request ) -> web.StreamResponse | None: diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 09f36d6dd7424c..1159a8b906e690 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -151,6 +151,9 @@ "api_timeout": { "message": "Timeout communicating with Ring API" }, + "no_subscription": { + "message": "Ring Protect subscription required for snapshots" + }, "sdp_m_line_index_required": { "message": "Error negotiating stream for {device}" } diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index a40611ea2ce4c1..95ee0d4b5fd800 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -294,32 +294,10 @@ async def test_camera_image( await setup_platform(hass, Platform.CAMERA) front_camera_mock = mock_ring_devices.get_device(765432) - front_camera_mock.async_get_snapshot.return_value = SMALLEST_VALID_JPEG_BYTES state = hass.states.get("camera.front_live_view") assert state is not None - # For live_view camera, snapshot should use async_get_snapshot - image = await async_get_image(hass, "camera.front_live_view") - assert image.content == SMALLEST_VALID_JPEG_BYTES - front_camera_mock.async_get_snapshot.assert_called_once() - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_camera_last_recording_image( - hass: HomeAssistant, - mock_ring_client, - mock_ring_devices, - freezer: FrozenDateTimeFactory, -) -> None: - """Test last recording camera will return still image from video when available.""" - await setup_platform(hass, Platform.CAMERA) - - front_camera_mock = mock_ring_devices.get_device(765432) - - state = hass.states.get("camera.front_last_recording") - assert state is not None - # history not updated yet front_camera_mock.async_history.assert_not_called() front_camera_mock.async_recording_url.assert_not_called() @@ -330,23 +308,55 @@ async def test_camera_last_recording_image( ), pytest.raises(HomeAssistantError), ): - await async_get_image(hass, "camera.front_last_recording") + image = await async_get_image(hass, "camera.front_live_view") freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) # history updated so image available front_camera_mock.async_history.assert_called_once() - assert front_camera_mock.async_recording_url.call_count == 2 + front_camera_mock.async_recording_url.assert_called_once() with patch( "homeassistant.components.ring.camera.ffmpeg.async_get_image", return_value=SMALLEST_VALID_JPEG_BYTES, ): - image = await async_get_image(hass, "camera.front_last_recording") + image = await async_get_image(hass, "camera.front_live_view") assert image.content == SMALLEST_VALID_JPEG_BYTES +async def test_camera_live_view_no_subscription( + hass: HomeAssistant, + mock_ring_client, + mock_ring_devices, + freezer: FrozenDateTimeFactory, +) -> None: + """Test live view camera skips recording URL when no subscription.""" + await setup_platform(hass, Platform.CAMERA) + + front_camera_mock = mock_ring_devices.get_device(765432) + # Set device to not have subscription + front_camera_mock.has_subscription = False + + state = hass.states.get("camera.front_live_view") + assert state is not None + + # Reset mock call counts + front_camera_mock.async_recording_url.reset_mock() + + # Trigger coordinator update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # For cameras without subscription, recording URL should NOT be fetched + front_camera_mock.async_recording_url.assert_not_called() + + # Requesting an image without subscription should raise an error + with pytest.raises(HomeAssistantError): + await async_get_image(hass, "camera.front_live_view") + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_stream_attributes( hass: HomeAssistant, From 97fe7101873cd52eae12c4be0fa2c00d3ac6b0e4 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:34:02 -0400 Subject: [PATCH 0665/1707] Update template select tests to use new framework (#167825) --- tests/components/template/test_select.py | 55 +++++++++++++++--------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index e6ac5919eecf0e..7f6a629ef1d878 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -30,8 +30,10 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, + assert_action, async_get_flow_preview_state, async_trigger, + make_test_action, make_test_trigger, setup_and_test_nested_unique_id, setup_and_test_unique_id, @@ -41,7 +43,7 @@ from tests.common import MockConfigEntry, assert_setup_component from tests.conftest import WebSocketGenerator -TEST_STATE_ENTITY_ID = "select.test_state" +TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" TEST_SELECT = TemplatePlatformSetup( @@ -56,14 +58,7 @@ "select_option": [], } TEST_OPTIONS = {"state": "test", **TEST_OPTIONS_WITHOUT_STATE} -TEST_OPTION_ACTION = { - "action": "test.automation", - "data": { - "action": "select_option", - "caller": "{{ this.entity_id }}", - "option": "{{ option }}", - }, -} +TEST_OPTION_ACTION = make_test_action("select_option", {"option": "{{ option }}"}) @pytest.fixture @@ -181,9 +176,9 @@ async def test_missing_required_keys(hass: HomeAssistant) -> None: ( 1, { - "options": "{{ state_attr('select.test_state', 'options') or [] }}", - "select_option": [TEST_OPTION_ACTION], - "state": "{{ states('select.test_state') }}", + "options": "{{ state_attr('sensor.test_state', 'options') or [] }}", + **TEST_OPTION_ACTION, + "state": "{{ states('sensor.test_state') }}", }, ) ], @@ -214,10 +209,7 @@ async def test_template_select(hass: HomeAssistant, calls: list[ServiceCall]) -> ) # Check this variable can be used in set_value script - assert len(calls) == 1 - assert calls[-1].data["action"] == "select_option" - assert calls[-1].data["caller"] == TEST_SELECT.entity_id - assert calls[-1].data["option"] == "c" + assert_action(TEST_SELECT, calls, 1, "select_option", option="c") await async_trigger(hass, TEST_STATE_ENTITY_ID, "c", attributes) _verify(hass, "c", ["a", "b", "c"]) @@ -247,7 +239,7 @@ def _verify( ( { **TEST_OPTIONS, - CONF_ICON: "{% if states.select.test_state.state == 'yes' %}mdi:check{% endif %}", + CONF_ICON: "{% if states.sensor.test_state.state == 'yes' %}mdi:check{% endif %}", }, ATTR_ICON, "mdi:check", @@ -255,7 +247,7 @@ def _verify( ( { **TEST_OPTIONS, - CONF_PICTURE: "{% if states.select.test_state.state == 'yes' %}check.jpg{% endif %}", + CONF_PICTURE: "{% if states.sensor.test_state.state == 'yes' %}check.jpg{% endif %}", }, ATTR_ENTITY_PICTURE, "check.jpg", @@ -410,7 +402,7 @@ async def test_optimistic(hass: HomeAssistant) -> None: ( 1, { - "state": "{{ states('select.test_state') }}", + "state": "{{ states('sensor.test_state') }}", "optimistic": False, "options": "{{ ['test', 'yes', 'no'] }}", "select_option": [], @@ -448,7 +440,7 @@ async def test_not_optimistic(hass: HomeAssistant) -> None: { "options": "{{ ['test', 'yes', 'no'] }}", "select_option": [], - "state": "{{ states('select.test_state') }}", + "state": "{{ states('sensor.test_state') }}", "availability": "{{ is_state('binary_sensor.test_availability', 'on') }}", }, ) @@ -487,6 +479,29 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state == "yes" +@pytest.mark.parametrize( + ("count", "config"), + [ + ( + 1, + {"availability": "{{ x - 12 }}", **TEST_OPTIONS}, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_select") +async def test_invalid_availability_template_keeps_component_available( + hass: HomeAssistant, caplog_setup_text: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test that an invalid availability keeps the device available.""" + await async_trigger(hass, TEST_AVAILABILITY_ENTITY_ID, "anything") + assert hass.states.get(TEST_SELECT.entity_id).state != STATE_UNAVAILABLE + error = "UndefinedError: 'x' is undefined" + assert error in caplog_setup_text or error in caplog.text + + async def test_flow_preview( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 3a1002457b1e3886f724a26ab421f0657575f974 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:34:45 -0400 Subject: [PATCH 0666/1707] Update template number tests to use new framework (#167823) --- tests/components/template/test_number.py | 46 ++++++++++++++++-------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index d5316deed5e549..4d3ed4f7f50dba 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -32,8 +32,10 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, + assert_action, async_get_flow_preview_state, async_trigger, + make_test_action, make_test_trigger, setup_and_test_nested_unique_id, setup_and_test_unique_id, @@ -60,14 +62,7 @@ TEST_STEP_ENTITY_ID, ), ) -TEST_SET_VALUE_ACTION = { - "action": "test.automation", - "data": { - "action": "set_value", - "caller": "{{ this.entity_id }}", - "value": "{{ value }}", - }, -} +TEST_SET_VALUE_ACTION = make_test_action("set_value", {"value": "{{ value }}"}) TEST_REQUIRED = {"state": "0", "step": "1", "set_value": []} @@ -198,7 +193,7 @@ async def test_all_optional_config(hass: HomeAssistant) -> None: "step": f"{{{{ states('{TEST_STEP_ENTITY_ID}') | float(5.0) }}}}", "min": f"{{{{ states('{TEST_MINIMUM_ENTITY_ID}') | float(0.0) }}}}", "max": f"{{{{ states('{TEST_MAXIMUM_ENTITY_ID}') | float(100.0) }}}}", - "set_value": [TEST_SET_VALUE_ACTION], + **TEST_SET_VALUE_ACTION, }, ) ], @@ -237,11 +232,7 @@ async def test_template_number( blocking=True, ) - # Check this variable can be used in set_value script - assert len(calls) == 1 - assert calls[-1].data["action"] == "set_value" - assert calls[-1].data["caller"] == TEST_NUMBER.entity_id - assert calls[-1].data["value"] == 2 + assert_action(TEST_NUMBER, calls, 1, "set_value", value=2) await async_trigger(hass, TEST_STATE_ENTITY_ID, 2) _verify(hass, 2, 2, 2, 6, None) @@ -467,6 +458,33 @@ async def test_availability(hass: HomeAssistant) -> None: assert float(state.state) == 2 +@pytest.mark.parametrize( + ("count", "config"), + [ + ( + 1, + { + "set_value": [], + "state": "{{ states('number.test_state') }}", + "availability": "{{ x - 12 }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_number") +async def test_invalid_availability_template_keeps_component_available( + hass: HomeAssistant, caplog_setup_text: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test that an invalid availability keeps the device available.""" + await async_trigger(hass, TEST_AVAILABILITY_ENTITY_ID, "anything") + assert hass.states.get(TEST_NUMBER.entity_id).state != STATE_UNAVAILABLE + error = "UndefinedError: 'x' is undefined" + assert error in caplog_setup_text or error in caplog.text + + @pytest.mark.parametrize( ("count", "config"), [ From 9056e0b64f01b4ab0a4fd011cd5a162ac56779bb Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:39:12 -0400 Subject: [PATCH 0667/1707] Update template cover tests to use new framework (#167686) --- tests/components/template/test_cover.py | 1219 ++++++----------------- 1 file changed, 320 insertions(+), 899 deletions(-) diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index adf093200b3b8f..a5a2af005c8854 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -1,7 +1,5 @@ """The tests for the Template cover platform.""" -from typing import Any - import pytest from syrupy.assertion import SnapshotAssertion @@ -31,158 +29,64 @@ ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - -from .conftest import ConfigurationStyle, async_get_flow_preview_state +from homeassistant.helpers.typing import ConfigType + +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + assert_action, + async_get_flow_preview_state, + async_trigger, + make_test_action, + make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, +) -from tests.common import MockConfigEntry, assert_setup_component +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator -TEST_OBJECT_ID = "test_template_cover" -TEST_ENTITY_ID = f"cover.{TEST_OBJECT_ID}" -TEST_STATE_ENTITY_ID = "cover.test_state" - -TEST_STATE_TRIGGER = { - "trigger": { - "trigger": "state", - "entity_id": [ - "cover.test_state", - "cover.test_position", - "binary_sensor.garage_door_sensor", - ], - }, - "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, - "action": [ - {"event": "action_event", "event_data": {"what": "{{ triggering_entity}}"}} - ], -} - - -OPEN_COVER = { - "service": "test.automation", - "data_template": { - "action": "open_cover", - "caller": "{{ this.entity_id }}", - }, -} - -CLOSE_COVER = { - "service": "test.automation", - "data_template": { - "action": "close_cover", - "caller": "{{ this.entity_id }}", - }, -} - -SET_COVER_POSITION = { - "service": "test.automation", - "data_template": { - "action": "set_cover_position", - "caller": "{{ this.entity_id }}", - "position": "{{ position }}", - }, -} +TEST_STATE_ENTITY_ID = "sensor.test_state" +TEST_POSITION_ENTITY_ID = "sensor.test_position" +TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" + +TEST_COVER = TemplatePlatformSetup( + cover.DOMAIN, + "covers", + "test_template_cover", + make_test_trigger( + TEST_STATE_ENTITY_ID, + TEST_POSITION_ENTITY_ID, + TEST_AVAILABILITY_ENTITY, + ), +) -SET_COVER_TILT_POSITION = { - "service": "test.automation", - "data_template": { - "action": "set_cover_tilt_position", - "caller": "{{ this.entity_id }}", - "tilt_position": "{{ tilt }}", - }, -} +OPEN_COVER = make_test_action("open_cover") +CLOSE_COVER = make_test_action("close_cover") +STOP_COVER = make_test_action("stop_cover") +SET_COVER_POSITION = make_test_action( + "set_cover_position", {"position": "{{ position }}"} +) +SET_COVER_TILT_POSITION = make_test_action( + "set_cover_tilt_position", {"tilt_position": "{{ tilt }}"} +) COVER_ACTIONS = { - "open_cover": OPEN_COVER, - "close_cover": CLOSE_COVER, -} -NAMED_COVER_ACTIONS = { - **COVER_ACTIONS, - "name": TEST_OBJECT_ID, -} -UNIQUE_ID_CONFIG = { - **COVER_ACTIONS, - "unique_id": "not-so-unique-anymore", + **OPEN_COVER, + **CLOSE_COVER, } -async def async_setup_legacy_format( - hass: HomeAssistant, count: int, cover_config: dict[str, Any] -) -> None: - """Do setup of cover integration via legacy format.""" - config = {"cover": {"platform": "template", "covers": cover_config}} - - with assert_setup_component(count, cover.DOMAIN): - assert await async_setup_component( - hass, - cover.DOMAIN, - config, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_modern_format( - hass: HomeAssistant, count: int, cover_config: dict[str, Any] -) -> None: - """Do setup of cover integration via modern format.""" - config = {"template": {"cover": cover_config}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_trigger_format( - hass: HomeAssistant, count: int, cover_config: dict[str, Any] -) -> None: - """Do setup of cover integration via trigger format.""" - config = {"template": {**TEST_STATE_TRIGGER, "cover": cover_config}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_cover_config( - hass: HomeAssistant, - count: int, - style: ConfigurationStyle, - cover_config: dict[str, Any], -) -> None: - """Do setup of cover integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, cover_config) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format(hass, count, cover_config) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format(hass, count, cover_config) - - @pytest.fixture async def setup_cover( hass: HomeAssistant, count: int, style: ConfigurationStyle, - cover_config: dict[str, Any], + config: ConfigType, ) -> None: """Do setup of cover integration.""" - await async_setup_cover_config(hass, count, style, cover_config) + await setup_entity(hass, TEST_COVER, style, count, config) @pytest.fixture @@ -191,37 +95,10 @@ async def setup_state_cover( count: int, style: ConfigurationStyle, state_template: str, + config: ConfigType, ): """Do setup of cover integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **COVER_ACTIONS, - "value_template": state_template, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **NAMED_COVER_ACTIONS, - "state": state_template, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **NAMED_COVER_ACTIONS, - "state": state_template, - }, - ) + await setup_entity(hass, TEST_COVER, style, count, config, state_template) @pytest.fixture @@ -230,40 +107,23 @@ async def setup_position_cover( count: int, style: ConfigurationStyle, position_template: str, + config: ConfigType, ): """Do setup of cover integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **COVER_ACTIONS, - "set_cover_position": SET_COVER_POSITION, - "position_template": position_template, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **NAMED_COVER_ACTIONS, - "set_cover_position": SET_COVER_POSITION, - "position": position_template, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **NAMED_COVER_ACTIONS, - "set_cover_position": SET_COVER_POSITION, - "position": position_template, - }, - ) + position_option = ( + "position_template" if style == ConfigurationStyle.LEGACY else "position" + ) + await setup_entity( + hass, + TEST_COVER, + style, + count, + config, + extra_config={ + position_option: position_template, + **SET_COVER_POSITION, + }, + ) @pytest.fixture @@ -276,39 +136,15 @@ async def setup_single_attribute_state_cover( attribute_template: str, ) -> None: """Do setup of cover integration testing a single attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **COVER_ACTIONS, - "value_template": state_template, - **extra, - } - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **NAMED_COVER_ACTIONS, - "state": state_template, - **extra, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **NAMED_COVER_ACTIONS, - "state": state_template, - **extra, - }, - ) + await setup_entity( + hass, + TEST_COVER, + style, + count, + {attribute: attribute_template} if attribute and attribute_template else {}, + state_template, + COVER_ACTIONS, + ) @pytest.fixture @@ -319,33 +155,18 @@ async def setup_empty_action( script: str, ): """Do setup of cover integration using a empty actions template.""" - empty = { - "open_cover": [], - "close_cover": [], - script: [], - } - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - {TEST_OBJECT_ID: empty}, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - {"name": TEST_OBJECT_ID, **empty}, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - {"name": TEST_OBJECT_ID, **empty}, - ) + await setup_entity( + hass, + TEST_COVER, + style, + count, + {"open_cover": [], "close_cover": [], script: []}, + ) @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.cover.test_state.state }}")] + ("count", "state_template", "config"), + [(1, "{{ states.sensor.test_state.state }}", COVER_ACTIONS)], ) @pytest.mark.parametrize( "style", @@ -372,18 +193,17 @@ async def test_template_state_text( caplog: pytest.LogCaptureFixture, ) -> None: """Test the state text of a template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == STATE_UNKNOWN - hass.states.async_set(TEST_STATE_ENTITY_ID, set_state) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, set_state) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == test_state assert text in caplog.text -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "config"), [(1, COVER_ACTIONS)]) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], @@ -392,7 +212,13 @@ async def test_template_state_text( ("state_template", "expected"), [ ("{{ 'open' }}", CoverState.OPEN), + ("{{ 'on' }}", CoverState.OPEN), + ("{{ 1 }}", CoverState.OPEN), + ("{{ True }}", CoverState.OPEN), ("{{ 'closed' }}", CoverState.CLOSED), + ("{{ 'off' }}", CoverState.CLOSED), + ("{{ 0 }}", CoverState.CLOSED), + ("{{ False }}", CoverState.CLOSED), ("{{ 'opening' }}", CoverState.OPENING), ("{{ 'closing' }}", CoverState.CLOSING), ("{{ 'dog' }}", STATE_UNKNOWN), @@ -406,10 +232,9 @@ async def test_template_state_states( ) -> None: """Test state template states.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, None) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, None) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == expected @@ -418,8 +243,8 @@ async def test_template_state_states( [ ( 1, - "{{ states.cover.test_state.state }}", - "{{ states.cover.test_position.attributes.position }}", + "{{ states('sensor.test_state') }}", + "{{ states('sensor.test_position') }}", ) ], ) @@ -431,62 +256,58 @@ async def test_template_state_states( (ConfigurationStyle.TRIGGER, "position"), ], ) -@pytest.mark.parametrize( - "states", - [ - ( - [ - (TEST_STATE_ENTITY_ID, CoverState.OPEN, STATE_UNKNOWN, "", None), - (TEST_STATE_ENTITY_ID, CoverState.CLOSED, STATE_UNKNOWN, "", None), - ( - TEST_STATE_ENTITY_ID, - CoverState.OPENING, - CoverState.OPENING, - "", - None, - ), - ( - TEST_STATE_ENTITY_ID, - CoverState.CLOSING, - CoverState.CLOSING, - "", - None, - ), - ("cover.test_position", CoverState.CLOSED, CoverState.CLOSING, "", 0), - (TEST_STATE_ENTITY_ID, CoverState.OPEN, CoverState.CLOSED, "", None), - ("cover.test_position", CoverState.CLOSED, CoverState.OPEN, "", 10), - ( - TEST_STATE_ENTITY_ID, - "dog", - CoverState.OPEN, - "Received invalid cover state: dog", - None, - ), - ] - ) - ], -) @pytest.mark.usefixtures("setup_single_attribute_state_cover") async def test_template_state_text_with_position( hass: HomeAssistant, - states: list[tuple[str, str, str, int | None]], caplog: pytest.LogCaptureFixture, ) -> None: """Test the state of a position template in order.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == STATE_UNKNOWN - for test_entity, set_state, test_state, text, position in states: - attrs = {"position": position} if position is not None else {} + # Test the open/closed states are ignored when state template updates. + await async_trigger(hass, TEST_STATE_ENTITY_ID, CoverState.OPEN) + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == STATE_UNKNOWN - hass.states.async_set(test_entity, set_state, attrs) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, CoverState.CLOSED) + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == STATE_UNKNOWN - state = hass.states.get(TEST_ENTITY_ID) - assert state.state == test_state - if position is not None: - assert state.attributes.get("current_position") == position - assert text in caplog.text + # Test the opening/closing state are honored when state template updates. + await async_trigger(hass, TEST_STATE_ENTITY_ID, CoverState.OPENING) + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == CoverState.OPENING + + await async_trigger(hass, TEST_STATE_ENTITY_ID, CoverState.CLOSING) + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == CoverState.CLOSING + + # Test the open/closed states are honored when position template updates. + await async_trigger(hass, TEST_POSITION_ENTITY_ID, 0) + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == CoverState.CLOSING + assert state.attributes.get("current_position") == 0 + + # Test the closed state is ignored when position is already set. + await async_trigger(hass, TEST_STATE_ENTITY_ID, CoverState.OPEN) + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes.get("current_position") == 0 + + # Test the open/closed states are honored when position template updates. + await async_trigger(hass, TEST_POSITION_ENTITY_ID, 10) + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == CoverState.OPEN + assert state.attributes.get("current_position") == 10 + + assert "Received invalid cover state" not in caplog.text + + await async_trigger(hass, TEST_STATE_ENTITY_ID, "dog") + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == CoverState.OPEN + assert state.attributes.get("current_position") == 10 + assert "Received invalid cover state: dog" in caplog.text @pytest.mark.parametrize( @@ -494,8 +315,8 @@ async def test_template_state_text_with_position( [ ( 1, - "{{ states.cover.test_state.state }}", - "{{ state_attr('cover.test_state', 'position') }}", + "{{ states.sensor.test_state.state }}", + "{{ state_attr('sensor.test_state', 'position') }}", ) ], ) @@ -520,66 +341,39 @@ async def test_template_state_text_ignored_if_none_or_empty( set_state: str, ) -> None: """Test ignoring an empty state text of a template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == STATE_UNKNOWN - hass.states.async_set(TEST_STATE_ENTITY_ID, set_state) - await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) - assert state.state == STATE_UNKNOWN - - -@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) -@pytest.mark.parametrize( - "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], -) -@pytest.mark.usefixtures("setup_state_cover") -async def test_template_state_boolean(hass: HomeAssistant) -> None: - """Test the value_template attribute.""" - # This forces a trigger for trigger based entities - hass.states.async_set(TEST_STATE_ENTITY_ID, None) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, set_state) - state = hass.states.get(TEST_ENTITY_ID) - assert state.state == CoverState.OPEN + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == STATE_UNKNOWN @pytest.mark.parametrize( - ("count", "position_template"), - [(1, "{{ states.cover.test_state.attributes.position }}")], + ("count", "position_template", "config"), + [(1, "{{ states('sensor.test_state') }}", COVER_ACTIONS)], ) @pytest.mark.parametrize( "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( - ("test_state", "position", "expected"), - [ - (CoverState.CLOSED, 42, CoverState.OPEN), - (CoverState.OPEN, 0.0, CoverState.CLOSED), - (CoverState.CLOSED, None, STATE_UNKNOWN), - ], + ("position", "expected"), + [(42, CoverState.OPEN), (0.0, CoverState.CLOSED), (None, STATE_UNKNOWN)], ) @pytest.mark.usefixtures("setup_position_cover") async def test_template_position( hass: HomeAssistant, - test_state: str, position: int | None, expected: str, caplog: pytest.LogCaptureFixture, calls: list[ServiceCall], ) -> None: """Test the position_template attribute.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, CoverState.OPEN) - await hass.async_block_till_done() - - hass.states.async_set( - TEST_STATE_ENTITY_ID, test_state, attributes={"position": position} - ) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, position) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("current_position") == position assert state.state == expected assert "ValueError" not in caplog.text @@ -588,44 +382,22 @@ async def test_template_position( await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, "position": 10}, + {ATTR_ENTITY_ID: TEST_COVER.entity_id, "position": 10}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("current_position") == position assert state.state == expected -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "cover_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "test_template_cover": { - **COVER_ACTIONS, - "optimistic": False, - } - }, - ), - ( - ConfigurationStyle.MODERN, - { - **NAMED_COVER_ACTIONS, - "optimistic": False, - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - **NAMED_COVER_ACTIONS, - "optimistic": False, - }, - ), - ], + ("count", "config"), [(1, {**COVER_ACTIONS, "optimistic": False})] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_cover") async def test_template_not_optimistic( @@ -633,31 +405,31 @@ async def test_template_not_optimistic( calls: list[ServiceCall], ) -> None: """Test the is_closed attribute.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == STATE_UNKNOWN # Test to make sure optimistic is not set with only a position template. await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_COVER.entity_id}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == STATE_UNKNOWN # Test to make sure optimistic is not set with only a position template. await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_COVER.entity_id}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == STATE_UNKNOWN @@ -695,10 +467,9 @@ async def test_template_not_optimistic( async def test_template_tilt(hass: HomeAssistant, tilt_position: float | None) -> None: """Test tilt in and out-of-bound conditions.""" # This forces a trigger for trigger based entities - hass.states.async_set(TEST_STATE_ENTITY_ID, None) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, None) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("current_tilt_position") == tilt_position @@ -733,89 +504,62 @@ async def test_template_tilt(hass: HomeAssistant, tilt_position: float | None) - async def test_position_out_of_bounds(hass: HomeAssistant) -> None: """Test position out-of-bounds condition.""" # This forces a trigger for trigger based entities - hass.states.async_set(TEST_STATE_ENTITY_ID, None) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, None) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("current_position") is None -@pytest.mark.parametrize("count", [0]) +@pytest.mark.parametrize(("count", "state_template"), [(0, "{{ 1 == 1 }}")]) @pytest.mark.parametrize( - ("style", "cover_config", "error"), + ("style", "config", "error"), [ ( ConfigurationStyle.LEGACY, - { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - } - }, + {}, "Invalid config for 'cover' from integration 'template'", ), ( ConfigurationStyle.LEGACY, - { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - "open_cover": OPEN_COVER, - } - }, + OPEN_COVER, "Invalid config for 'cover' from integration 'template'", ), ( ConfigurationStyle.MODERN, - { - "name": TEST_OBJECT_ID, - "state": "{{ 1 == 1 }}", - }, + {}, "Invalid config for 'template': must contain at least one of open_cover, set_cover_position.", ), ( ConfigurationStyle.MODERN, - { - "name": TEST_OBJECT_ID, - "state": "{{ 1 == 1 }}", - "open_cover": OPEN_COVER, - }, + OPEN_COVER, "Invalid config for 'template': some but not all values in the same group of inclusion 'open_or_close'", ), ( ConfigurationStyle.TRIGGER, - { - "name": TEST_OBJECT_ID, - "state": "{{ 1 == 1 }}", - }, + {}, "Invalid config for 'template': must contain at least one of open_cover, set_cover_position.", ), ( ConfigurationStyle.TRIGGER, - { - "name": TEST_OBJECT_ID, - "state": "{{ 1 == 1 }}", - "open_cover": OPEN_COVER, - }, + OPEN_COVER, "Invalid config for 'template': some but not all values in the same group of inclusion 'open_or_close'", ), ], ) +@pytest.mark.usefixtures("setup_state_cover") async def test_template_open_or_position( hass: HomeAssistant, - count: int, - style: ConfigurationStyle, - cover_config: dict[str, Any], error: str, - caplog: pytest.LogCaptureFixture, + caplog_setup_text: str, ) -> None: """Test that at least one of open_cover or set_position is used.""" - await async_setup_cover_config(hass, count, style, cover_config) assert hass.states.async_all("cover") == [] - assert error in caplog.text + assert error in caplog_setup_text @pytest.mark.parametrize( - ("count", "position_template"), - [(1, "{{ 0 }}")], + ("count", "position_template", "config"), + [(1, "{{ 0 }}", COVER_ACTIONS)], ) @pytest.mark.parametrize( "style", @@ -826,89 +570,42 @@ async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non """Test the open_cover command.""" # This forces a trigger for trigger based entities - hass.states.async_set(TEST_STATE_ENTITY_ID, None) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, None) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == CoverState.CLOSED await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_COVER.entity_id}, blocking=True, ) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["action"] == "open_cover" - assert calls[0].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_COVER, calls, 1, "open_cover") -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "cover_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "test_template_cover": { - **COVER_ACTIONS, - "position_template": "{{ 100 }}", - "stop_cover": { - "service": "test.automation", - "data_template": { - "action": "stop_cover", - "caller": "{{ this.entity_id }}", - }, - }, - } - }, - ), - ( - ConfigurationStyle.MODERN, - { - **NAMED_COVER_ACTIONS, - "position": "{{ 100 }}", - "stop_cover": { - "service": "test.automation", - "data_template": { - "action": "stop_cover", - "caller": "{{ this.entity_id }}", - }, - }, - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - **NAMED_COVER_ACTIONS, - "position": "{{ 100 }}", - "stop_cover": { - "service": "test.automation", - "data_template": { - "action": "stop_cover", - "caller": "{{ this.entity_id }}", - }, - }, - }, - ), - ], + ("count", "state_template", "config"), + [(1, "{{ 1==1 }}", {**COVER_ACTIONS, **STOP_COVER})], ) -@pytest.mark.usefixtures("setup_cover") +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_state_cover") async def test_close_stop_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the close-cover and stop_cover commands.""" - # This forces a trigger for trigger based entities - hass.states.async_set(TEST_STATE_ENTITY_ID, None) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, None) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == CoverState.OPEN await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_COVER.entity_id}, blocking=True, ) await hass.async_block_till_done() @@ -916,163 +613,74 @@ async def test_close_stop_action(hass: HomeAssistant, calls: list[ServiceCall]) await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_COVER.entity_id}, blocking=True, ) await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[0].data["action"] == "close_cover" - assert calls[0].data["caller"] == TEST_ENTITY_ID - assert calls[1].data["action"] == "stop_cover" - assert calls[1].data["caller"] == TEST_ENTITY_ID + assert_action(TEST_COVER, calls, 2, "close_cover", index=0) + assert_action(TEST_COVER, calls, 2, "stop_cover") -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "config"), [(1, SET_COVER_POSITION)]) @pytest.mark.parametrize( - ("style", "cover_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "test_template_cover": { - "set_cover_position": SET_COVER_POSITION, - } - }, - ), - ( - ConfigurationStyle.MODERN, - { - "name": TEST_OBJECT_ID, - "set_cover_position": SET_COVER_POSITION, - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "name": TEST_OBJECT_ID, - "set_cover_position": SET_COVER_POSITION, - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_cover") async def test_set_position(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the set_position command.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == STATE_UNKNOWN - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, - blocking=True, - ) - await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("current_position") == 100.0 - assert len(calls) == 1 - assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["position"] == 100 - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, - blocking=True, - ) - await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("current_position") == 0.0 - assert len(calls) == 2 - assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["position"] == 0 - - await hass.services.async_call( - COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("current_position") == 100.0 - assert len(calls) == 3 - assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["position"] == 100 - - await hass.services.async_call( - COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True - ) - await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("current_position") == 0.0 - assert len(calls) == 4 - assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["position"] == 0 + expected_calls = 1 + for service, position, options in ( + (SERVICE_OPEN_COVER, 100, {}), + (SERVICE_CLOSE_COVER, 0, {}), + (SERVICE_TOGGLE, 100, {}), + (SERVICE_TOGGLE, 0, {}), + (SERVICE_SET_COVER_POSITION, 25, {ATTR_POSITION: 25}), + ): + await hass.services.async_call( + COVER_DOMAIN, + service, + {ATTR_ENTITY_ID: TEST_COVER.entity_id, **options}, + blocking=True, + ) + await hass.async_block_till_done() - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_POSITION: 25}, - blocking=True, - ) - await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("current_position") == 25.0 - assert len(calls) == 5 - assert calls[-1].data["action"] == "set_cover_position" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["position"] == 25 + state = hass.states.get(TEST_COVER.entity_id) + assert state.attributes.get("current_position") == position + assert_action( + TEST_COVER, calls, expected_calls, "set_cover_position", position=position + ) + expected_calls += 1 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "cover_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "test_template_cover": { - **COVER_ACTIONS, - "set_cover_tilt_position": SET_COVER_TILT_POSITION, - } - }, - ), - ( - ConfigurationStyle.MODERN, - { - **NAMED_COVER_ACTIONS, - "set_cover_tilt_position": SET_COVER_TILT_POSITION, - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - **NAMED_COVER_ACTIONS, - "set_cover_tilt_position": SET_COVER_TILT_POSITION, - }, - ), - ], + ("count", "config"), [(1, {**COVER_ACTIONS, **SET_COVER_TILT_POSITION})] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( - ("service", "attr", "tilt_position"), + ("service", "options", "tilt_position"), [ ( SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_TILT_POSITION: 42}, + {ATTR_TILT_POSITION: 42}, 42, ), - (SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, 100), - (SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, 0), + (SERVICE_OPEN_COVER_TILT, {}, 100), + (SERVICE_CLOSE_COVER_TILT, {}, 0), ], ) @pytest.mark.usefixtures("setup_cover") async def test_set_tilt_position( hass: HomeAssistant, service, - attr, + options, tilt_position, calls: list[ServiceCall], ) -> None: @@ -1080,61 +688,37 @@ async def test_set_tilt_position( await hass.services.async_call( COVER_DOMAIN, service, - attr, + {ATTR_ENTITY_ID: TEST_COVER.entity_id, **options}, blocking=True, ) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[-1].data["action"] == "set_cover_tilt_position" - assert calls[-1].data["caller"] == TEST_ENTITY_ID - assert calls[-1].data["tilt_position"] == tilt_position + assert_action( + TEST_COVER, calls, 1, "set_cover_tilt_position", tilt_position=tilt_position + ) -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "config"), [(1, SET_COVER_POSITION)]) @pytest.mark.parametrize( - ("style", "cover_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "test_template_cover": { - "set_cover_position": SET_COVER_POSITION, - } - }, - ), - ( - ConfigurationStyle.MODERN, - { - "name": TEST_OBJECT_ID, - "set_cover_position": SET_COVER_POSITION, - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "name": TEST_OBJECT_ID, - "set_cover_position": SET_COVER_POSITION, - }, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_cover") async def test_set_position_optimistic( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test optimistic position mode.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("current_position") is None await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_POSITION: 42}, + {ATTR_ENTITY_ID: TEST_COVER.entity_id, ATTR_POSITION: 42}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("current_position") == 42.0 for service, test_state in ( @@ -1144,23 +728,22 @@ async def test_set_position_optimistic( (SERVICE_TOGGLE, CoverState.OPEN), ): await hass.services.async_call( - COVER_DOMAIN, service, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True + COVER_DOMAIN, service, {ATTR_ENTITY_ID: TEST_COVER.entity_id}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == test_state @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "cover_config"), + ("style", "config"), [ ( ConfigurationStyle.TRIGGER, { - "name": TEST_OBJECT_ID, - "set_cover_position": SET_COVER_POSITION, - "picture": "{{ 'foo.png' if is_state('cover.test_state', 'open') else 'bar.png' }}", + **SET_COVER_POSITION, + "picture": "{{ 'foo.png' if is_state('sensor.test_state', 'open') else 'bar.png' }}", }, ), ], @@ -1170,81 +753,54 @@ async def test_non_optimistic_template_with_optimistic_state( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test optimistic state with non-optimistic template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert "entity_picture" not in state.attributes await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_POSITION: 42}, + {ATTR_ENTITY_ID: TEST_COVER.entity_id, ATTR_POSITION: 42}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == CoverState.OPEN assert state.attributes["current_position"] == 42.0 assert "entity_picture" not in state.attributes - hass.states.async_set(TEST_STATE_ENTITY_ID, CoverState.OPEN) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, CoverState.OPEN) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.state == CoverState.OPEN assert state.attributes["current_position"] == 42.0 assert state.attributes["entity_picture"] == "foo.png" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "cover_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "test_template_cover": { - "position_template": "{{ 100 }}", - "set_cover_position": SET_COVER_POSITION, - "set_cover_tilt_position": SET_COVER_TILT_POSITION, - } - }, - ), - ( - ConfigurationStyle.MODERN, - { - "name": TEST_OBJECT_ID, - "position": "{{ 100 }}", - "set_cover_position": SET_COVER_POSITION, - "set_cover_tilt_position": SET_COVER_TILT_POSITION, - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "name": TEST_OBJECT_ID, - "position": "{{ 100 }}", - "set_cover_position": SET_COVER_POSITION, - "set_cover_tilt_position": SET_COVER_TILT_POSITION, - }, - ), - ], + ("count", "position_template", "config"), + [(1, "{{ 100 }}", SET_COVER_TILT_POSITION)], ) -@pytest.mark.usefixtures("setup_cover") +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_position_cover") async def test_set_tilt_position_optimistic( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test the optimistic tilt_position mode.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("current_tilt_position") is None await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_TILT_POSITION: 42}, + {ATTR_ENTITY_ID: TEST_COVER.entity_id, ATTR_TILT_POSITION: 42}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("current_tilt_position") == 42.0 for service, pos in ( @@ -1254,10 +810,10 @@ async def test_set_tilt_position_optimistic( (SERVICE_TOGGLE_COVER_TILT, 100.0), ): await hass.services.async_call( - COVER_DOMAIN, service, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True + COVER_DOMAIN, service, {ATTR_ENTITY_ID: TEST_COVER.entity_id}, blocking=True ) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("current_tilt_position") == pos @@ -1266,8 +822,8 @@ async def test_set_tilt_position_optimistic( [ ( 1, - "{{ states.cover.test_state.state }}", - "{% if states.cover.test_state.state %}mdi:check{% endif %}", + "{{ states.sensor.test_state.state }}", + "{% if states.sensor.test_state.state %}mdi:check{% endif %}", ) ], ) @@ -1284,13 +840,12 @@ async def test_icon_template( hass: HomeAssistant, initial_expected_state: str | None ) -> None: """Test icon template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("icon") == initial_expected_state - state = hass.states.async_set("cover.test_state", CoverState.OPEN) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, CoverState.OPEN) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes["icon"] == "mdi:check" @@ -1300,8 +855,8 @@ async def test_icon_template( [ ( 1, - "{{ states.cover.test_state.state }}", - "{% if states.cover.test_state.state %}/local/cover.png{% endif %}", + "{{ states.sensor.test_state.state }}", + "{% if states.sensor.test_state.state %}/local/cover.png{% endif %}", ) ], ) @@ -1318,13 +873,12 @@ async def test_entity_picture_template( hass: HomeAssistant, initial_expected_state: str | None ) -> None: """Test icon template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("entity_picture") == initial_expected_state - state = hass.states.async_set("cover.test_state", CoverState.OPEN) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, CoverState.OPEN) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes["entity_picture"] == "/local/cover.png" @@ -1335,7 +889,7 @@ async def test_entity_picture_template( ( 1, "{{ 1 == 1 }}", - "{{ is_state('availability_state.state','on') }}", + "{{ is_state('binary_sensor.availability','on') }}", ) ], ) @@ -1350,79 +904,32 @@ async def test_entity_picture_template( @pytest.mark.usefixtures("setup_single_attribute_state_cover") async def test_availability_template(hass: HomeAssistant) -> None: """Test availability template.""" - hass.states.async_set("availability_state.state", STATE_OFF) - # This forces a trigger for trigger based entities - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() - - assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE + await async_trigger(hass, TEST_AVAILABILITY_ENTITY, STATE_OFF) + assert hass.states.get(TEST_COVER.entity_id).state == STATE_UNAVAILABLE - hass.states.async_set("availability_state.state", STATE_ON) - # This forces a trigger for trigger based entities - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_AVAILABILITY_ENTITY, STATE_ON) + assert hass.states.get(TEST_COVER.entity_id).state != STATE_UNAVAILABLE - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE - -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain"), + ("count", "state_template", "attribute_template"), + [(1, "{{ true }}", "{{ x - 12 }}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - ( - { - COVER_DOMAIN: { - "platform": "template", - "covers": { - "test_template_cover": { - **COVER_ACTIONS, - "availability_template": "{{ x - 12 }}", - "value_template": "open", - } - }, - } - }, - cover.DOMAIN, - ), - ( - { - "template": { - "cover": { - **NAMED_COVER_ACTIONS, - "state": "{{ true }}", - "availability": "{{ x - 12 }}", - }, - } - }, - template.DOMAIN, - ), - ( - { - "template": { - **TEST_STATE_TRIGGER, - "cover": { - **NAMED_COVER_ACTIONS, - "state": "{{ true }}", - "availability": "{{ x - 12 }}", - }, - } - }, - template.DOMAIN, - ), + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_single_attribute_state_cover") async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, caplog_setup_text ) -> None: """Test that an invalid availability keeps the device available.""" - - # This forces a trigger for trigger based entities - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() - - assert hass.states.get(TEST_ENTITY_ID) != STATE_UNAVAILABLE - + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + assert hass.states.get(TEST_COVER.entity_id) != STATE_UNAVAILABLE err = "UndefinedError: 'x' is undefined" assert err in caplog_setup_text or err in caplog.text @@ -1438,7 +945,7 @@ async def test_invalid_availability_template_keeps_component_available( @pytest.mark.usefixtures("setup_single_attribute_state_cover") async def test_device_class(hass: HomeAssistant) -> None: """Test device class.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert state.attributes.get("device_class") == "door" @@ -1453,144 +960,58 @@ async def test_device_class(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_single_attribute_state_cover") async def test_invalid_device_class(hass: HomeAssistant) -> None: """Test device class.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_COVER.entity_id) assert not state -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize("config", [COVER_ACTIONS]) @pytest.mark.parametrize( - ("cover_config", "style"), - [ - ( - { - "test_template_cover_01": UNIQUE_ID_CONFIG, - "test_template_cover_02": UNIQUE_ID_CONFIG, - }, - ConfigurationStyle.LEGACY, - ), - ( - [ - { - "name": "test_template_cover_01", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_cover_02", - **UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.MODERN, - ), - ( - [ - { - "name": "test_template_cover_01", - **UNIQUE_ID_CONFIG, - }, - { - "name": "test_template_cover_02", - **UNIQUE_ID_CONFIG, - }, - ], - ConfigurationStyle.TRIGGER, - ), - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) -@pytest.mark.usefixtures("setup_cover") -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, style: ConfigurationStyle, config: ConfigType +) -> None: """Test unique_id option only creates one cover per id.""" - assert len(hass.states.async_all()) == 1 + await setup_and_test_unique_id(hass, TEST_COVER, style, config) +@pytest.mark.parametrize("config", [COVER_ACTIONS]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) async def test_nested_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + entity_registry: er.EntityRegistry, ) -> None: - """Test a template unique_id propagates to switch unique_ids.""" - with assert_setup_component(1, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - { - "template": { - "unique_id": "x", - "cover": [ - { - **COVER_ACTIONS, - "name": "test_a", - "unique_id": "a", - "state": "{{ true }}", - }, - { - **COVER_ACTIONS, - "name": "test_b", - "unique_id": "b", - "state": "{{ true }}", - }, - ], - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("cover")) == 2 - - entry = entity_registry.async_get("cover.test_a") - assert entry - assert entry.unique_id == "x-a" - - entry = entity_registry.async_get("cover.test_b") - assert entry - assert entry.unique_id == "x-b" + """Test a template unique_id propagates to cover unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_COVER, style, entity_registry, config + ) -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "cover_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "garage_door": { - **COVER_ACTIONS, - "friendly_name": "Garage Door", - "value_template": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}", - }, - }, - ), - ( - ConfigurationStyle.MODERN, - { - "name": "Garage Door", - **COVER_ACTIONS, - "state": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}", - }, - ), - ( - ConfigurationStyle.TRIGGER, - { - "name": "Garage Door", - **COVER_ACTIONS, - "state": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}", - }, - ), - ], + ("count", "state_template", "config"), + [(1, "{{ is_state('sensor.test_state', 'off') }}", COVER_ACTIONS)], ) -@pytest.mark.usefixtures("setup_cover") +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_state_cover") async def test_state_gets_lowercased(hass: HomeAssistant) -> None: """Test True/False is lowercased.""" - hass.states.async_set("binary_sensor.garage_door_sensor", "off") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) assert len(hass.states.async_all()) == 2 - assert hass.states.get("cover.garage_door").state == CoverState.OPEN - hass.states.async_set("binary_sensor.garage_door_sensor", "on") - await hass.async_block_till_done() - assert hass.states.get("cover.garage_door").state == CoverState.CLOSED + assert hass.states.get(TEST_COVER.entity_id).state == CoverState.OPEN + + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) + assert hass.states.get(TEST_COVER.entity_id).state == CoverState.CLOSED @pytest.mark.parametrize( @@ -1598,7 +1019,7 @@ async def test_state_gets_lowercased(hass: HomeAssistant) -> None: [ ( 1, - "{{ states.cover.test_state.state }}", + "{{ states.sensor.test_state.state }}", "mdi:window-shutter{{ '-open' if is_state('cover.test_template_cover', 'open') else '' }}", ) ], @@ -1659,7 +1080,7 @@ async def test_setup_config_entry( """Tests creating a cover from a config entry.""" hass.states.async_set( - "cover.test_state", + TEST_STATE_ENTITY_ID, "open", {}, ) @@ -1669,7 +1090,7 @@ async def test_setup_config_entry( domain=template.DOMAIN, options={ "name": "My template", - "state": "{{ states('cover.test_state') }}", + "state": "{{ states('sensor.test_state') }}", "set_cover_position": [], "template_type": COVER_DOMAIN, }, From 75a4b088bc8c562ea6bed1f71a6cbf031ca77884 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 9 Apr 2026 21:41:04 +0300 Subject: [PATCH 0668/1707] Entity translation for Anthropic integration (#166725) Co-authored-by: Norbert Rittel Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/anthropic/quality_scale.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml index 6279e5eb3d62f7..28d2c0999fcedb 100644 --- a/homeassistant/components/anthropic/quality_scale.yaml +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -81,7 +81,10 @@ rules: status: exempt comment: | No entities disabled by default. - entity-translations: todo + entity-translations: + status: exempt + comment: | + Entities explicitly set `_attr_name` to `None`, so entity name translations are not used. exception-translations: done icon-translations: done reconfiguration-flow: done From 6cc05e6a280bfb7d18801d64cd2da4475701cb86 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:53:26 -0400 Subject: [PATCH 0669/1707] Fix Victron BLE false reauth triggered by unknown enum bitmask combinations (#167809) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/victron_ble/__init__.py | 32 ++++++++----- tests/components/victron_ble/fixtures.py | 14 ++++++ tests/components/victron_ble/test_sensor.py | 47 +++++++++++++++++++ 3 files changed, 80 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/victron_ble/__init__.py b/homeassistant/components/victron_ble/__init__.py index 7eff058b7b229a..7c79d7331544e1 100644 --- a/homeassistant/components/victron_ble/__init__.py +++ b/homeassistant/components/victron_ble/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from .const import REAUTH_AFTER_FAILURES +from .const import REAUTH_AFTER_FAILURES, VICTRON_IDENTIFIER _LOGGER = logging.getLogger(__name__) @@ -38,18 +38,24 @@ def _update( nonlocal consecutive_failures update = data.update(service_info) - # If the device type was recognized (devices dict populated) but - # only signal strength came back, decryption likely failed. - # Unsupported devices have an empty devices dict and won't trigger this. - if update.devices and len(update.entity_values) <= 1: - consecutive_failures += 1 - if consecutive_failures >= REAUTH_AFTER_FAILURES: - _LOGGER.debug( - "Triggering reauth for %s after %d consecutive failures", - address, - consecutive_failures, - ) - entry.async_start_reauth(hass) + # Only consider a reauth when the device type is recognised (devices + # populated) but the advertisement key fails the quick-check built into + # validate_advertisement_key. Using the key check instead of counting + # entity values avoids false positives: some devices legitimately return + # few (or zero) sensor values when in certain error or alarm states. + raw_data = service_info.manufacturer_data.get(VICTRON_IDENTIFIER) + if update.devices and raw_data is not None: + if not data.validate_advertisement_key(raw_data): + consecutive_failures += 1 + if consecutive_failures >= REAUTH_AFTER_FAILURES: + _LOGGER.debug( + "Triggering reauth for %s after %d consecutive failures", + address, + consecutive_failures, + ) + entry.async_start_reauth(hass) + consecutive_failures = 0 + else: consecutive_failures = 0 else: consecutive_failures = 0 diff --git a/tests/components/victron_ble/fixtures.py b/tests/components/victron_ble/fixtures.py index b8253c672df175..c6efeb4112b10d 100644 --- a/tests/components/victron_ble/fixtures.py +++ b/tests/components/victron_ble/fixtures.py @@ -187,6 +187,20 @@ source="local", ) +# Same DC/DC converter but with OffReason=0x81 (NO_INPUT_POWER|ENGINE_SHUTDOWN), +# a real bitmask combination that the current OffReason enum doesn't handle. +# The key check byte is valid so validate_advertisement_key passes, but +# parsing raises ValueError → sparse update (signal strength only). +VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO = BluetoothServiceInfo( + name="DC/DC Converter", + address="01:02:03:04:05:08", + rssi=-60, + manufacturer_data={0x02E1: bytes.fromhex("1000c0a304121d64ca8d442b90bbde6a8cba")}, + service_data={}, + service_uuids=[], + source="local", +) + VICTRON_VEBUS_SENSORS = { "inverter_charger_device_state": "float", "inverter_charger_battery_voltage": "14.45", diff --git a/tests/components/victron_ble/test_sensor.py b/tests/components/victron_ble/test_sensor.py index a44dabb969b990..ad8565cbe07cbd 100644 --- a/tests/components/victron_ble/test_sensor.py +++ b/tests/components/victron_ble/test_sensor.py @@ -24,6 +24,7 @@ VICTRON_BATTERY_SENSE_TOKEN, VICTRON_DC_DC_CONVERTER_SERVICE_INFO, VICTRON_DC_DC_CONVERTER_TOKEN, + VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO, VICTRON_DC_ENERGY_METER_SERVICE_INFO, VICTRON_DC_ENERGY_METER_TOKEN, VICTRON_SMART_BATTERY_PROTECT_SERVICE_INFO, @@ -208,6 +209,52 @@ async def test_reauth_triggered_only_once( assert len(flows) == 1 +@pytest.mark.usefixtures("enable_bluetooth") +async def test_reauth_not_triggered_on_unknown_enum_value( + hass: HomeAssistant, +) -> None: + """Test reauth is NOT triggered when a valid key yields a sparse update. + + Some devices report bitmask combinations for OffReason or AlarmReason that + are not in the enum (e.g. NO_INPUT_POWER|ENGINE_SHUTDOWN = 0x81 on a DC-DC + converter that stopped due to both conditions simultaneously). The parser + raises ValueError, producing a sparse update (signal strength only). + This must not be mistaken for a wrong encryption key. + + Regression test for https://github.com/home-assistant/core/issues/167105 + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "address": VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO.address, + CONF_ACCESS_TOKEN: VICTRON_DC_DC_CONVERTER_TOKEN, + }, + unique_id=VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + service_info = VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO + for idx in range(REAUTH_AFTER_FAILURES + 1): + inject_bluetooth_service_info( + hass, + BluetoothServiceInfo( + name=service_info.name, + address=service_info.address, + rssi=service_info.rssi - idx, + manufacturer_data=service_info.manufacturer_data, + service_data=service_info.service_data, + service_uuids=service_info.service_uuids, + source=service_info.source, + ), + ) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 0 + + @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.parametrize( ("payload_hex", "expected_state"), From 9ac730fb589f1f1a7414e8e5d090336ae5fb4f5c Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:07:02 -0400 Subject: [PATCH 0670/1707] Update template vacuum tests to use new framework (#167830) --- tests/components/template/test_vacuum.py | 138 +++++++---------------- 1 file changed, 42 insertions(+), 96 deletions(-) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index ca8a581827179e..5036760ef4541c 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -22,8 +22,10 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, + assert_action, async_get_flow_preview_state, async_trigger, + make_test_action, make_test_trigger, setup_and_test_nested_unique_id, setup_and_test_unique_id, @@ -34,83 +36,41 @@ from tests.components.vacuum import common from tests.typing import WebSocketGenerator -TEST_STATE_SENSOR = "sensor.test_state" -TEST_SPEED_SENSOR = "sensor.test_fan_speed" -TEST_BATTERY_LEVEL_SENSOR = "sensor.test_battery_level" -TEST_AVAILABILITY_ENTITY = "availability_state.state" +TEST_STATE_ENTITY_ID = "sensor.test_state" +TEST_ATTRIBUTE_ENTITY_ID = "sensor.test_attribute" +TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" TEST_VACUUM = TemplatePlatformSetup( vacuum.DOMAIN, "vacuums", "test_vacuum", make_test_trigger( - TEST_STATE_SENSOR, - TEST_SPEED_SENSOR, - TEST_BATTERY_LEVEL_SENSOR, + TEST_STATE_ENTITY_ID, + TEST_ATTRIBUTE_ENTITY_ID, TEST_AVAILABILITY_ENTITY, ), ) -START_ACTION = { - "start": { - "service": "test.automation", - "data": { - "caller": "{{ this.entity_id }}", - "action": "start", - }, - }, -} - +CLEAN_SPOT_ACTION = make_test_action("clean_spot") +LOCATE_ACTION = make_test_action("locate") +PAUSE_ACTION = make_test_action("pause") +RETURN_TO_BASE_ACTION = make_test_action("return_to_base") +SET_FAN_SPEED_ACTION = make_test_action( + "set_fan_speed", {"fan_speed": "{{ fan_speed }}"} +) +START_ACTION = make_test_action("start") +STOP_ACTION = make_test_action("stop") TEMPLATE_VACUUM_ACTIONS = { **START_ACTION, - "pause": { - "service": "test.automation", - "data": { - "caller": "{{ this.entity_id }}", - "action": "pause", - }, - }, - "stop": { - "service": "test.automation", - "data": { - "caller": "{{ this.entity_id }}", - "action": "stop", - }, - }, - "return_to_base": { - "service": "test.automation", - "data": { - "caller": "{{ this.entity_id }}", - "action": "return_to_base", - }, - }, - "clean_spot": { - "service": "test.automation", - "data": { - "caller": "{{ this.entity_id }}", - "action": "clean_spot", - }, - }, - "locate": { - "service": "test.automation", - "data": { - "caller": "{{ this.entity_id }}", - "action": "locate", - }, - }, - "set_fan_speed": { - "service": "test.automation", - "data": { - "caller": "{{ this.entity_id }}", - "action": "set_fan_speed", - "fan_speed": "{{ fan_speed }}", - }, - }, + **PAUSE_ACTION, + **STOP_ACTION, + **RETURN_TO_BASE_ACTION, + **CLEAN_SPOT_ACTION, + **LOCATE_ACTION, + **SET_FAN_SPEED_ACTION, } -UNIQUE_ID_CONFIG = {"unique_id": "not-so-unique-anymore", **TEMPLATE_VACUUM_ACTIONS} - def _verify( hass: HomeAssistant, @@ -344,7 +304,7 @@ async def test_valid_legacy_configs(hass: HomeAssistant, count, parm1, parm2) -> """Test: configs.""" # Ensure trigger entity templates are rendered - hass.states.async_set(TEST_STATE_SENSOR, None) + hass.states.async_set(TEST_STATE_ENTITY_ID, None) await hass.async_block_till_done() assert len(hass.states.async_all("vacuum")) == count @@ -396,7 +356,7 @@ async def test_battery_level_template( hass: HomeAssistant, expected: int | None ) -> None: """Test templates with values from other entities.""" - await async_trigger(hass, TEST_STATE_SENSOR) + await async_trigger(hass, TEST_STATE_ENTITY_ID) _verify(hass, STATE_UNKNOWN, expected) @@ -420,7 +380,7 @@ async def test_battery_level_template_repair( caplog: pytest.LogCaptureFixture, ) -> None: """Test battery_level template raises issue.""" - await async_trigger(hass, TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await async_trigger(hass, TEST_STATE_ENTITY_ID, VacuumActivity.DOCKED) assert len(issue_registry.issues) == issue_count issue = issue_registry.async_get_issue( @@ -465,7 +425,7 @@ async def test_battery_level_template_repair( @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_fan_speed_template(hass: HomeAssistant, expected: str | None) -> None: """Test templates with values from other entities.""" - await async_trigger(hass, TEST_STATE_SENSOR) + await async_trigger(hass, TEST_STATE_ENTITY_ID) _verify(hass, STATE_UNKNOWN, None, expected) @@ -494,7 +454,7 @@ async def test_icon_template(hass: HomeAssistant, expected: int) -> None: state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes.get("icon") == expected - hass.states.async_set(TEST_STATE_SENSOR, STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_VACUUM.entity_id) @@ -526,7 +486,7 @@ async def test_picture_template(hass: HomeAssistant, expected: int) -> None: state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes.get("entity_picture") == expected - hass.states.async_set(TEST_STATE_SENSOR, STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_VACUUM.entity_id) @@ -540,7 +500,7 @@ async def test_picture_template(hass: HomeAssistant, expected: int) -> None: ( 1, None, - "{{ is_state('availability_state.state', 'on') }}", + "{{ is_state('binary_sensor.availability', 'on') }}", ) ], ) @@ -595,7 +555,7 @@ async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" - await async_trigger(hass, TEST_STATE_SENSOR) + await async_trigger(hass, TEST_STATE_ENTITY_ID) assert hass.states.get(TEST_VACUUM.entity_id) != STATE_UNAVAILABLE err = "'x' is undefined" assert err in caplog_setup_text or err in caplog.text @@ -620,7 +580,7 @@ async def test_attribute_templates(hass: HomeAssistant) -> None: state = hass.states.get(TEST_VACUUM.entity_id) assert state.attributes["test_attribute"] == "It ." - hass.states.async_set(TEST_STATE_SENSOR, "Works") + hass.states.async_set(TEST_STATE_ENTITY_ID, "Works") await hass.async_block_till_done() await async_update_entity(hass, TEST_VACUUM.entity_id) state = hass.states.get(TEST_VACUUM.entity_id) @@ -647,7 +607,7 @@ async def test_invalid_attribute_template( ) -> None: """Test that errors are logged if rendering template fails.""" - hass.states.async_set(TEST_STATE_SENSOR, "Works") + hass.states.async_set(TEST_STATE_ENTITY_ID, "Works") await hass.async_block_till_done() assert len(hass.states.async_all("vacuum")) == 1 @@ -760,9 +720,7 @@ async def test_state_services( await hass.async_block_till_done() # verify - assert len(calls) == 1 - assert calls[-1].data["action"] == action - assert calls[-1].data["caller"] == TEST_VACUUM.entity_id + assert_action(TEST_VACUUM, calls, 1, action) @pytest.mark.parametrize( @@ -771,7 +729,7 @@ async def test_state_services( ( 1, "{{ states('sensor.test_state') }}", - "{{ states('sensor.test_fan_speed') }}", + "{{ states('sensor.test_attribute') }}", { "fan_speeds": ["low", "medium", "high"], }, @@ -795,20 +753,14 @@ async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> N await hass.async_block_till_done() # verify - assert len(calls) == 1 - assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == TEST_VACUUM.entity_id - assert calls[-1].data["fan_speed"] == "high" + assert_action(TEST_VACUUM, calls, 1, "set_fan_speed", fan_speed="high") # Set fan's speed to medium await common.async_set_fan_speed(hass, "medium", TEST_VACUUM.entity_id) await hass.async_block_till_done() # verify - assert len(calls) == 2 - assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == TEST_VACUUM.entity_id - assert calls[-1].data["fan_speed"] == "medium" + assert_action(TEST_VACUUM, calls, 2, "set_fan_speed", fan_speed="medium") @pytest.mark.parametrize( @@ -825,7 +777,7 @@ async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> N ( 1, "{{ states('sensor.test_state') }}", - "{{ states('sensor.test_fan_speed') }}", + "{{ states('sensor.test_attribute') }}", ) ], ) @@ -848,20 +800,14 @@ async def test_set_invalid_fan_speed( await hass.async_block_till_done() # verify - assert len(calls) == 1 - assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == TEST_VACUUM.entity_id - assert calls[-1].data["fan_speed"] == "high" + assert_action(TEST_VACUUM, calls, 1, "set_fan_speed", fan_speed="high") # Set vacuum's fan speed to 'invalid' await common.async_set_fan_speed(hass, "invalid", TEST_VACUUM.entity_id) await hass.async_block_till_done() # verify fan speed is unchanged - assert len(calls) == 1 - assert calls[-1].data["action"] == "set_fan_speed" - assert calls[-1].data["caller"] == TEST_VACUUM.entity_id - assert calls[-1].data["fan_speed"] == "high" + assert_action(TEST_VACUUM, calls, 1, "set_fan_speed", fan_speed="high") @pytest.mark.parametrize(("count", "vacuum_config"), [(1, {"start": []})]) @@ -1006,7 +952,7 @@ async def test_optimistic_option( calls: list[ServiceCall], ) -> None: """Test optimistic yaml option.""" - hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + hass.states.async_set(TEST_STATE_ENTITY_ID, VacuumActivity.DOCKED) await hass.async_block_till_done() state = hass.states.get(TEST_VACUUM.entity_id) @@ -1023,10 +969,10 @@ async def test_optimistic_option( state = hass.states.get(TEST_VACUUM.entity_id) assert state.state == expected - hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.RETURNING) + hass.states.async_set(TEST_STATE_ENTITY_ID, VacuumActivity.RETURNING) await hass.async_block_till_done() - hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + hass.states.async_set(TEST_STATE_ENTITY_ID, VacuumActivity.DOCKED) await hass.async_block_till_done() state = hass.states.get(TEST_VACUUM.entity_id) From 77c8eab698cf57a370e924d1a6dad8036beb5d5c Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:11:04 -0400 Subject: [PATCH 0671/1707] Update template update tests to use new framework (#167828) --- tests/components/template/test_update.py | 42 +++++++++++------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/tests/components/template/test_update.py b/tests/components/template/test_update.py index eaf8de093dd203..e926991ef74a9c 100644 --- a/tests/components/template/test_update.py +++ b/tests/components/template/test_update.py @@ -23,7 +23,9 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, + assert_action, async_get_flow_preview_state, + make_test_action, make_test_trigger, setup_and_test_nested_unique_id, setup_and_test_unique_id, @@ -55,17 +57,13 @@ "latest_version": TEST_LATEST_TEMPLATE, } -INSTALL_ACTION = { - "install": { - "action": "test.automation", - "data": { - "caller": "{{ this.entity_id }}", - "action": "install", - "backup": "{{ backup }}", - "specific_version": "{{ specific_version }}", - }, - } -} +INSTALL_ACTION = make_test_action( + "install", + { + "backup": "{{ backup }}", + "specific_version": "{{ specific_version }}", + }, +) @pytest.fixture @@ -425,9 +423,7 @@ async def test_install_action(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert len(calls) == 1 - assert calls[-1].data["action"] == "install" - assert calls[-1].data["caller"] == TEST_UPDATE.entity_id + assert_action(TEST_UPDATE, calls, 1, "install") hass.states.async_set(TEST_INSTALLED_SENSOR, "2.0") hass.states.async_set(TEST_LATEST_SENSOR, "2.0") @@ -444,9 +440,7 @@ async def test_install_action(hass: HomeAssistant, calls: list[ServiceCall]) -> await hass.async_block_till_done() # verify - assert len(calls) == 1 - assert calls[-1].data["action"] == "install" - assert calls[-1].data["caller"] == TEST_UPDATE.entity_id + assert_action(TEST_UPDATE, calls, 1, "install") @pytest.mark.parametrize( @@ -813,12 +807,14 @@ async def test_supported_features( await hass.async_block_till_done() # verify - assert len(calls) == 1 - data = calls[-1].data - assert data["action"] == "install" - assert data["caller"] == TEST_UPDATE.entity_id - assert data["backup"] == expected_backup - assert data["specific_version"] == expected_version + assert_action( + TEST_UPDATE, + calls, + 1, + "install", + backup=expected_backup, + specific_version=expected_version, + ) @pytest.mark.parametrize( From 89ddfff66fd8dc47b202222207fe1dfaead874d6 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:16:21 -0400 Subject: [PATCH 0672/1707] Update template switch tests to use new framework (#167826) --- tests/components/template/test_switch.py | 59 +++++++----------------- 1 file changed, 16 insertions(+), 43 deletions(-) diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 2a15d979098091..81727e004fb6d4 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -24,8 +24,10 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, + assert_action, async_get_flow_preview_state, async_trigger, + make_test_action, make_test_trigger, setup_and_test_nested_unique_id, setup_and_test_unique_id, @@ -51,24 +53,9 @@ make_test_trigger(TEST_STATE_ENTITY_ID, TEST_SENSOR), ) -SWITCH_TURN_ON = { - "service": "test.automation", - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, -} -SWITCH_TURN_OFF = { - "service": "test.automation", - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, -} -SWITCH_ACTIONS = { - "turn_on": SWITCH_TURN_ON, - "turn_off": SWITCH_TURN_OFF, -} +TURN_ON_ACTION = make_test_action("turn_on") +TURN_OFF_ACTION = make_test_action("turn_off") +SWITCH_ACTIONS = {**TURN_ON_ACTION, **TURN_OFF_ACTION} @pytest.fixture @@ -329,9 +316,7 @@ async def test_trigger_attributes_with_optimistic_state( assert state.state == STATE_ON assert state.attributes.get(attr) is None - assert len(calls) == 1 - assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == TEST_SWITCH.entity_id + assert_action(TEST_SWITCH, calls, 1, "turn_on") await hass.services.async_call( SWITCH_DOMAIN, @@ -344,9 +329,7 @@ async def test_trigger_attributes_with_optimistic_state( assert state.state == STATE_OFF assert state.attributes.get(attr) is None - assert len(calls) == 2 - assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_SWITCH.entity_id + assert_action(TEST_SWITCH, calls, 2, "turn_off") await async_trigger(hass, TEST_SENSOR, expected) @@ -365,9 +348,7 @@ async def test_trigger_attributes_with_optimistic_state( assert state.state == STATE_ON assert state.attributes.get(attr) == expected - assert len(calls) == 3 - assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == TEST_SWITCH.entity_id + assert_action(TEST_SWITCH, calls, 3, "turn_on") @pytest.mark.parametrize( @@ -513,12 +494,12 @@ async def test_no_switches_does_not_create( "config", [ { - "not_on": SWITCH_TURN_ON, - "turn_off": SWITCH_TURN_OFF, + "not_on": [], + **TURN_OFF_ACTION, }, { - "turn_on": SWITCH_TURN_ON, - "not_off": SWITCH_TURN_OFF, + **TURN_ON_ACTION, + "not_off": [], }, ], ) @@ -553,9 +534,7 @@ async def test_on_action( blocking=True, ) - assert len(calls) == 1 - assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == TEST_SWITCH.entity_id + assert_action(TEST_SWITCH, calls, 1, "turn_on") @pytest.mark.parametrize("count", [1]) @@ -584,9 +563,7 @@ async def test_on_action_optimistic( state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_ON - assert len(calls) == 1 - assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == TEST_SWITCH.entity_id + assert_action(TEST_SWITCH, calls, 1, "turn_on") @pytest.mark.parametrize( @@ -611,9 +588,7 @@ async def test_off_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None blocking=True, ) - assert len(calls) == 1 - assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_SWITCH.entity_id + assert_action(TEST_SWITCH, calls, 1, "turn_off") @pytest.mark.parametrize("count", [1]) @@ -642,9 +617,7 @@ async def test_off_action_optimistic( state = hass.states.get(TEST_SWITCH.entity_id) assert state.state == STATE_OFF - assert len(calls) == 1 - assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == TEST_SWITCH.entity_id + assert_action(TEST_SWITCH, calls, 1, "turn_off") @pytest.mark.parametrize( From a983cb7ccd10aeb581b06ac285ba9904448da52b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 9 Apr 2026 20:18:01 +0100 Subject: [PATCH 0673/1707] Tidy up Evohome code, and improve docstrings (#167827) --- homeassistant/components/evohome/climate.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 21b03844a2df51..846ed245e35258 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -189,25 +189,25 @@ def __init__( ) async def async_clear_zone_override(self) -> None: - """Clear the zone's override, if any.""" + """Clear the zone override (if any) and return to following its schedule.""" await self.coordinator.call_client_api(self._evo_device.reset()) async def async_set_zone_override( self, setpoint: float, duration: timedelta | None = None ) -> None: - """Set the zone's override (mode/setpoint).""" + """Override the zone's setpoint, either permanently or for a duration.""" temperature = max(min(setpoint, self.max_temp), self.min_temp) - if duration is not None: - if duration.total_seconds() == 0: - await self._update_schedule() - until = self.setpoints.get("next_sp_from") - else: - until = dt_util.now() + duration - else: + if duration is None: until = None # indefinitely + elif duration.total_seconds() == 0: + await self._update_schedule() + until = self.setpoints.get("next_sp_from") + else: + until = dt_util.now() + duration until = dt_util.as_utc(until) if until else None + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) From e639e983dc780a55fefe5fa3eace908bf4861498 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Thu, 9 Apr 2026 12:25:59 -0700 Subject: [PATCH 0674/1707] Use offline command for non-UTF-8 stdout test (#167466) --- tests/components/shell_command/test_init.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py index cfc36d297e1e1d..60e65ba6db81f7 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -4,6 +4,9 @@ import asyncio import os +import re +import shlex +import sys import tempfile from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -184,12 +187,16 @@ async def test_non_text_stdout_capture( mock_output, hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test handling of non-text output.""" + non_utf8_cmd = ( + f"{shlex.quote(sys.executable)} -c" + ' "import sys; sys.stdout.buffer.write(bytes([0x80, 0x81, 0x82]))"' + ) assert await async_setup_component( hass, shell_command.DOMAIN, { shell_command.DOMAIN: { - "output_image": "curl -o - https://raw.githubusercontent.com/home-assistant/assets/master/misc/loading-screen.gif" + "output_image": non_utf8_cmd, } }, ) @@ -205,7 +212,9 @@ async def test_non_text_stdout_capture( # Non-text output throws with 'return_response' with pytest.raises( HomeAssistantError, - match="Unable to handle non-utf8 output of command: `curl -o - https://raw.githubusercontent.com/home-assistant/assets/master/misc/loading-screen.gif`", + match=re.escape( + f"Unable to handle non-utf8 output of command: `{non_utf8_cmd}`" + ), ): response = await hass.services.async_call( "shell_command", "output_image", blocking=True, return_response=True From f491ec8b448855bf5761a94ff56c1adc8ac6335a Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 9 Apr 2026 21:30:17 +0200 Subject: [PATCH 0675/1707] Generate translations optimization (#166483) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- script/translations/develop.py | 54 +++++++++++++++++++++------------- script/translations/util.py | 2 +- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/script/translations/develop.py b/script/translations/develop.py index 0b49138f2d88a5..360923ce332ce7 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -1,10 +1,11 @@ """Compile the current translation strings files for testing.""" import argparse -import json from pathlib import Path import sys +import orjson + from . import download, upload from .const import INTEGRATIONS_DIR from .util import flatten_translations, get_base_arg_parser, substitute_references @@ -30,33 +31,44 @@ def get_arguments() -> argparse.Namespace: return parser.parse_args() -def run_single(translations, flattened_translations, integration): - """Run the script for a single integration.""" - print(f"Generating translations for {integration}") - - if integration not in translations["component"]: - print("Integration has no strings.json") - sys.exit(1) - - integration_strings = translations["component"][integration] - - translations["component"][integration] = substitute_references( - integration_strings, flattened_translations, fail_on_missing=True - ) - +def prepare_download_dir(): + """Ensure the download directory exists and is empty.""" if download.DOWNLOAD_DIR.is_dir(): for lang_file in download.DOWNLOAD_DIR.glob("*.json"): lang_file.unlink() else: download.DOWNLOAD_DIR.mkdir(parents=True) - (download.DOWNLOAD_DIR / "en.json").write_text( - json.dumps({"component": {integration: translations["component"][integration]}}) - ) +def run_translation(component_translations, flattened_translations): + """Run the translation process for the given components.""" + for integration in component_translations: + print(f"Generating translations for {integration}") + component_translations[integration] = substitute_references( + component_translations[integration], + flattened_translations, + fail_on_missing=True, + ) + + (download.DOWNLOAD_DIR / "en.json").write_bytes( + orjson.dumps({"component": component_translations}) + ) download.save_integrations_translations() +def run_single(translations, flattened_translations, integration): + """Run the script for a single integration.""" + component_translations = translations["component"] + if integration not in component_translations: + print("Integration has no strings.json") + sys.exit(1) + + prepare_download_dir() + run_translation( + {integration: component_translations[integration]}, flattened_translations + ) + + def run(): """Run the script.""" args = get_arguments() @@ -64,8 +76,8 @@ def run(): flattened_translations = flatten_translations(translations) if args.all: - for integration in translations["component"]: - run_single(translations, flattened_translations, integration) + prepare_download_dir() + run_translation(translations["component"], flattened_translations) print("🌎 Generated translation files for all integrations") return 0 @@ -75,7 +87,7 @@ def run(): integration = None while ( integration is None - or not Path(f"homeassistant/components/{integration}").exists() + or not Path(f"homeassistant/components/{integration}").is_dir() ): if integration is not None: print(f"Integration {integration} doesn't exist!") diff --git a/script/translations/util.py b/script/translations/util.py index bd71cc7215d28c..cbb3e9c789aeeb 100644 --- a/script/translations/util.py +++ b/script/translations/util.py @@ -64,7 +64,7 @@ def get_current_branch(): def load_json_from_path(path: pathlib.Path) -> Any: """Load JSON from path.""" try: - return json.loads(path.read_text()) + return json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as err: raise JSONDecodeErrorWithPath(err.msg, err.doc, err.pos, path) from err From 1eab08f9868db0164664332bd9e65ba0198b01c1 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:32:16 +0300 Subject: [PATCH 0676/1707] Victron GX binary_sensor platform (#167527) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/victron_gx/__init__.py | 1 + .../components/victron_gx/binary_sensor.py | 85 +++++++++++++ homeassistant/components/victron_gx/const.py | 4 + .../components/victron_gx/strings.json | 113 ++++++++++++++++++ .../victron_gx/test_binary_sensor.py | 84 +++++++++++++ 5 files changed, 287 insertions(+) create mode 100644 homeassistant/components/victron_gx/binary_sensor.py create mode 100644 tests/components/victron_gx/test_binary_sensor.py diff --git a/homeassistant/components/victron_gx/__init__.py b/homeassistant/components/victron_gx/__init__.py index 96183fb56e42ed..185848dbb19e9e 100644 --- a/homeassistant/components/victron_gx/__init__.py +++ b/homeassistant/components/victron_gx/__init__.py @@ -12,6 +12,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.SENSOR, ] diff --git a/homeassistant/components/victron_gx/binary_sensor.py b/homeassistant/components/victron_gx/binary_sensor.py new file mode 100644 index 00000000000000..72af11eeaa0792 --- /dev/null +++ b/homeassistant/components/victron_gx/binary_sensor.py @@ -0,0 +1,85 @@ +"""Support for Victron GX binary sensors.""" + +from typing import Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + MetricType, + VictronEnum, +) + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import BINARY_SENSOR_OFF_ID, BINARY_SENSOR_ON_ID +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +PARALLEL_UPDATES = 0 # There is no I/O in the entity itself. + +METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, BinarySensorDeviceClass] = { + MetricType.POWER: BinarySensorDeviceClass.POWER, + MetricType.PROBLEM: BinarySensorDeviceClass.PROBLEM, + MetricType.CONNECTIVITY: BinarySensorDeviceClass.CONNECTIVITY, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX binary sensors from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new binary sensor metric discovery.""" + async_add_entities( + [VictronBinarySensor(device, metric, device_info, installation_id)] + ) + + hub.register_new_metric_callback(MetricKind.BINARY_SENSOR, on_new_metric) + + +class VictronBinarySensor(VictronBaseEntity, BinarySensorEntity): + """Implementation of a Victron GX binary sensor.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(device, metric, device_info, installation_id) + self._attr_device_class = METRIC_TYPE_TO_DEVICE_CLASS.get(metric.metric_type) + self._attr_is_on = self._is_on(metric.value) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_is_on = self._is_on(value) + self.async_write_ha_state() + + @staticmethod + def _is_on(value: Any) -> bool | None: + """Convert a Victron binary sensor enum value to a boolean.""" + if value is None or not isinstance(value, VictronEnum): + return None + if value.id == BINARY_SENSOR_ON_ID: + return True + if value.id == BINARY_SENSOR_OFF_ID: + return False + return None diff --git a/homeassistant/components/victron_gx/const.py b/homeassistant/components/victron_gx/const.py index bd63f86410fb4d..ca806ca5249c01 100644 --- a/homeassistant/components/victron_gx/const.py +++ b/homeassistant/components/victron_gx/const.py @@ -5,3 +5,7 @@ CONF_INSTALLATION_ID = "installation_id" CONF_MODEL = "model" CONF_SERIAL = "serial" + +# Binary sensor enum ids must be "on" for on and "off" for off. +BINARY_SENSOR_ON_ID = "on" +BINARY_SENSOR_OFF_ID = "off" diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 34010032a141de..8b2bfc038ff000 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -62,6 +62,119 @@ } }, "entity": { + "binary_sensor": { + "digitalinput_settings_invert_translation": { + "name": "Invert digital input" + }, + "evcharger_charge": { + "name": "EV charging" + }, + "evcharger_connected": { + "name": "Connected" + }, + "generator_autorun": { + "name": "Auto-start enabled" + }, + "generator_gen_id_quiet_hours_enabled": { + "name": "Generator quiet hours enabled" + }, + "generator_gen_id_start_on_soc_enabled": { + "name": "Generator start on SOC enabled" + }, + "generator_gen_id_start_on_temp_enabled": { + "name": "Generator start on high temp enabled" + }, + "generator_gen_id_start_on_voltage_enabled": { + "name": "Generator start on voltage enabled" + }, + "generator_manual_start": { + "name": "Manual start" + }, + "gps_connected": { + "name": "Connected" + }, + "gps_fix": { + "name": "Fix" + }, + "inverter_alarm_high_temperature": { + "name": "High temperature alarm" + }, + "inverter_alarm_high_voltage": { + "name": "High voltage alarm" + }, + "inverter_alarm_high_voltage_ac_out": { + "name": "High voltage AC-out alarm" + }, + "inverter_alarm_low_temperature": { + "name": "Low temperature alarm" + }, + "inverter_alarm_low_voltage": { + "name": "Low voltage alarm" + }, + "inverter_alarm_low_voltage_ac_out": { + "name": "Low voltage AC-out alarm" + }, + "inverter_alarm_overload": { + "name": "Overload alarm" + }, + "inverter_alarm_ripple": { + "name": "Ripple alarm" + }, + "multi_disable_charge": { + "name": "ESS disable charge" + }, + "multi_disable_feed_in": { + "name": "ESS disable feed-in" + }, + "multi_relay0_state": { + "name": "Relay on Multi RS state" + }, + "solarcharger_load_state": { + "name": "Load state" + }, + "solarcharger_relay_state": { + "name": "Relay state" + }, + "switch_output_state": { + "name": "Switch {output} state" + }, + "switchable_output_output_state": { + "name": "Switchable output {output} state" + }, + "system_dynamicess_active": { + "name": "Dynamic ESS active" + }, + "system_dynamicess_allow_gridfeedin": { + "name": "Dynamic ESS allow grid feed-in" + }, + "system_dynamicess_available": { + "name": "Dynamic ESS available" + }, + "system_ess_battery_use": { + "name": "ESS only critical loads from battery" + }, + "system_ess_schedule_charge_slot_enabled": { + "name": "ESS BatteryLife schedule charge {slot} enabled" + }, + "system_relay_relay": { + "name": "Relay {relay} state" + }, + "system_settings_overvoltage_feedin": { + "name": "PV DC overvoltage feed-in" + }, + "vebus_device_device_number_power_assist_enabled": { + "name": "{device_number} PowerAssist enabled" + }, + "vebus_inverter_connected": { + "name": "Connected" + }, + "vebus_inverter_ignoreacin1_onoff_control": { + "name": "Control ignore AC-in-1" + }, + "vebus_inverter_setting_alarm_grid_lost": { + "name": "Grid lost alarm setting" + } + }, "sensor": { "acload_current": { "name": "Load current" diff --git a/tests/components/victron_gx/test_binary_sensor.py b/tests/components/victron_gx/test_binary_sensor.py new file mode 100644 index 00000000000000..ee3eb2b5673f24 --- /dev/null +++ b/tests/components/victron_gx/test_binary_sensor.py @@ -0,0 +1,84 @@ +"""Tests for Victron GX MQTT binary sensors.""" + +from __future__ import annotations + +import pytest +from victron_mqtt import Hub as VictronVenusHub, VictronEnum +from victron_mqtt.testing import finalize_injection, inject_message + +from homeassistant.components.victron_gx.binary_sensor import VictronBinarySensor +from homeassistant.components.victron_gx.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import MOCK_INSTALLATION_ID + +from tests.common import MockConfigEntry + + +class _TestEnum(VictronEnum): + UNKNOWN = (99, "unknown_id", "Unknown") + + +async def test_victron_binary_sensor( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test BINARY_SENSOR MetricKind - EV charger connected sensor is created and updated.""" + victron_hub, mock_config_entry = init_integration + + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/evcharger/0/Connected", + '{"value": 1}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert len(entities) == 1 + entity = entities[0] + assert entity.entity_id == "binary_sensor.ev_charging_station_connected" + assert entity.unique_id == f"{MOCK_INSTALLATION_ID}_evcharger_0_evcharger_connected" + assert entity.translation_key == "evcharger_connected" + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "on" + + # Verify device info was registered correctly + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{MOCK_INSTALLATION_ID}_evcharger_0")} + ) + assert device is not None + assert device.manufacturer == "Victron Energy" + + # Update the metric to exercise the entity update callback path. + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/evcharger/0/Connected", + '{"value": 0}', + ) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "off" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("not_an_enum", None), + (_TestEnum.UNKNOWN, None), + ], +) +def test_is_on_edge_cases(value: object, expected: bool | None) -> None: + """Test _is_on returns None for non-VictronEnum and unknown enum IDs.""" + assert VictronBinarySensor._is_on(value) is expected From b6ea61f9535faa6b930181f9e464062510825d00 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:37:16 +0200 Subject: [PATCH 0677/1707] Fix run_then_background in service intent handler (#167817) --- homeassistant/helpers/intent.py | 16 +++++----------- tests/helpers/test_intent.py | 26 +++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 85f990535571d0..223b26bc71e14e 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1181,17 +1181,11 @@ async def _run_then_background(self, task: asyncio.Task[Any]) -> None: After the timeout the task will continue to run in the background. """ - try: - await asyncio.wait({task}, timeout=self.service_timeout) - except TimeoutError: - pass - except asyncio.CancelledError: - # Task calling us was cancelled, so cancel service call task, and wait for - # it to be cancelled, within reason, before leaving. - _LOGGER.debug("Service call was cancelled: %s", task.get_name()) - task.cancel() - await asyncio.wait({task}, timeout=5) - raise + done, _ = await asyncio.wait({task}, timeout=self.service_timeout) + if done: + # Task finished within the timeout. Re-raise any exception + # (e.g. validation errors) so the caller can handle it. + task.result() class ServiceIntentHandler(DynamicServiceIntentHandler): diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 64dc35bc10f3df..c592f055111763 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -13,7 +13,8 @@ ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant, ServiceCall, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, config_validation as cv, @@ -725,6 +726,29 @@ async def mock_service(call): assert calls[0].data == {"entity_id": "light.kitchen"} +async def test_run_then_background_validation_error(hass: HomeAssistant) -> None: + """Test that a validation error within the timeout is propagated.""" + hass.states.async_set("light.kitchen", "off") + + async def mock_service(call: ServiceCall) -> None: + """Mock service that raises a validation error immediately.""" + raise HomeAssistantError("Invalid service data") + + hass.services.async_register("light", "turn_on", mock_service) + + handler = intent.ServiceIntentHandler("TestType", "light", "turn_on") + intent.async_register(hass, handler) + + # The single entity fails, so IntentHandleError is raised + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + "TestType", + slots={"name": {"value": "kitchen"}}, + ) + + async def test_invalid_area_floor_names(hass: HomeAssistant) -> None: """Test that we throw an appropriate errors with invalid area/floor names.""" handler = intent.ServiceIntentHandler("TestType", "light", "turn_on") From 4700c79ace07b5a1dc9eada2b25e2befafefe45c Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:57:18 +0300 Subject: [PATCH 0678/1707] Implement reconfiguration flow for Huum integration (#167711) --- homeassistant/components/huum/config_flow.py | 37 ++++++ .../components/huum/quality_scale.yaml | 2 +- homeassistant/components/huum/strings.json | 13 +- tests/components/huum/test_config_flow.py | 112 ++++++++++++++++++ 4 files changed, 162 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index c5cdc18107a1d5..d6c93e9dedb2b3 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -59,6 +59,43 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) + try: + huum = Huum( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + await huum.status() + except Forbidden, NotAuthenticated: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + title=user_input[CONF_USERNAME], + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + {CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]}, + ), + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/huum/quality_scale.yaml b/homeassistant/components/huum/quality_scale.yaml index 72fc2db3428b32..6422498628a0f8 100644 --- a/homeassistant/components/huum/quality_scale.yaml +++ b/homeassistant/components/huum/quality_scale.yaml @@ -64,7 +64,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: Integration has no repair scenarios. diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json index 8b50fcd5eeebfb..e7c597838071ab 100644 --- a/homeassistant/components/huum/strings.json +++ b/homeassistant/components/huum/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -20,6 +21,16 @@ "description": "The authentication for {username} is no longer valid. Please enter the current password.", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::huum::config::step::user::data_description::password%]", + "username": "[%key:component::huum::config::step::user::data_description::username%]" + } + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/tests/components/huum/test_config_flow.py b/tests/components/huum/test_config_flow.py index 511fe89636b4c3..1de9494ad8bd84 100644 --- a/tests/components/huum/test_config_flow.py +++ b/tests/components/huum/test_config_flow.py @@ -183,3 +183,115 @@ async def test_reauth_errors( assert result["reason"] == "reauth_successful" assert mock_config_entry.data[CONF_USERNAME] == TEST_USERNAME assert mock_config_entry.data[CONF_PASSWORD] == "new_password" + + +@pytest.mark.usefixtures("mock_huum_client", "mock_setup_entry") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new@sauna.org", + CONF_PASSWORD: "new_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.title == "new@sauna.org" + assert mock_config_entry.data[CONF_USERNAME] == "new@sauna.org" + assert mock_config_entry.data[CONF_PASSWORD] == "new_password" + + +@pytest.mark.usefixtures("mock_huum_client", "mock_setup_entry") +async def test_reconfigure_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow aborts when username already configured.""" + mock_config_entry.add_to_hass(hass) + + other_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "other@sauna.org", + CONF_PASSWORD: "other_password", + }, + entry_id="OTHER_ENTRY", + ) + other_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other@sauna.org", + CONF_PASSWORD: "new_password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ( + "raises", + "error_base", + ), + [ + (Exception, "unknown"), + (Forbidden, "invalid_auth"), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, + mock_huum_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + raises: Exception, + error_base: str, +) -> None: + """Test reconfiguration flow handles errors and recovers.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_huum_client.status.side_effect = raises + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: "wrong_password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_base} + + # Recover with valid credentials + mock_huum_client.status.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: "new_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_USERNAME] == TEST_USERNAME + assert mock_config_entry.data[CONF_PASSWORD] == "new_password" From 7e1f4d27e8d064dd0fa7fdaf1737f185be9b11f1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:00:39 +0200 Subject: [PATCH 0679/1707] Bump aioimmich to 0.14.0 (#167833) --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 2a0680e314ae84..ade1a5627eb8f8 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "platinum", - "requirements": ["aioimmich==0.12.1"] + "requirements": ["aioimmich==0.14.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 404f62002db351..3636df6394775a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -294,7 +294,7 @@ aiohue==4.8.1 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.12.1 +aioimmich==0.14.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 130a08060e3343..80bbc94d3a14af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -282,7 +282,7 @@ aiohue==4.8.1 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.12.1 +aioimmich==0.14.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 From f5d5ee71f5543d5e969de0f7df733f7a07100a31 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:01:02 -0400 Subject: [PATCH 0680/1707] Update template lock tests to use new framework (#164621) --- tests/components/template/test_lock.py | 519 +++++++------------------ 1 file changed, 147 insertions(+), 372 deletions(-) diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index ae3297b050b5c3..ef5fdd6c3a019d 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -19,58 +19,42 @@ ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - -from .conftest import ConfigurationStyle, async_get_flow_preview_state +from homeassistant.helpers.typing import ConfigType + +from .conftest import ( + ConfigurationStyle, + TemplatePlatformSetup, + async_get_flow_preview_state, + async_trigger, + make_test_action, + make_test_trigger, + setup_and_test_nested_unique_id, + setup_and_test_unique_id, + setup_entity, +) from tests.common import MockConfigEntry, assert_setup_component from tests.typing import WebSocketGenerator -TEST_OBJECT_ID = "test_template_lock" -TEST_ENTITY_ID = f"lock.{TEST_OBJECT_ID}" TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_AVAILABILITY_ENTITY_ID = "availability_state.state" +TEST_LOCK = TemplatePlatformSetup( + lock.DOMAIN, + None, + "test_template_lock", + make_test_trigger( + TEST_AVAILABILITY_ENTITY_ID, + TEST_STATE_ENTITY_ID, + ), +) -TEST_STATE_TRIGGER = { - "trigger": { - "trigger": "state", - "entity_id": [TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID], - }, - "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, - "action": [ - {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} - ], -} -LOCK_ACTION = { - "lock": { - "service": "test.automation", - "data_template": { - "action": "lock", - "caller": "{{ this.entity_id }}", - "code": "{{ code if code is defined else None }}", - }, - }, -} -UNLOCK_ACTION = { - "unlock": { - "service": "test.automation", - "data_template": { - "action": "unlock", - "caller": "{{ this.entity_id }}", - "code": "{{ code if code is defined else None }}", - }, - }, -} -OPEN_ACTION = { - "open": { - "service": "test.automation", - "data_template": { - "action": "open", - "caller": "{{ this.entity_id }}", - }, - }, +CODE_DATA = { + "code": "{{ code if code is defined else None }}", } +LOCK_ACTION = make_test_action("lock", CODE_DATA) +UNLOCK_ACTION = make_test_action("unlock", CODE_DATA) +OPEN_ACTION = make_test_action("open") OPTIMISTIC_LOCK = { @@ -93,78 +77,15 @@ } -async def async_setup_legacy_format( - hass: HomeAssistant, count: int, lock_config: dict[str, Any] -) -> None: - """Do setup of lock integration via legacy format.""" - config = {"lock": {"platform": "template", "name": TEST_OBJECT_ID, **lock_config}} - - with assert_setup_component(count, lock.DOMAIN): - assert await async_setup_component( - hass, - lock.DOMAIN, - config, - ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_modern_format( - hass: HomeAssistant, count: int, lock_config: dict[str, Any] -) -> None: - """Do setup of lock integration via modern format.""" - config = {"template": {"lock": {"name": TEST_OBJECT_ID, **lock_config}}} - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - -async def async_setup_trigger_format( - hass: HomeAssistant, count: int, lock_config: dict[str, Any] -) -> None: - """Do setup of lock integration via trigger format.""" - config = { - "template": { - "lock": {"name": TEST_OBJECT_ID, **lock_config}, - **TEST_STATE_TRIGGER, - } - } - - with assert_setup_component(count, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - @pytest.fixture async def setup_lock( hass: HomeAssistant, count: int, style: ConfigurationStyle, - lock_config: dict[str, Any], + config: dict[str, Any], ) -> None: """Do setup of lock integration.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format(hass, count, lock_config) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format(hass, count, lock_config) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format(hass, count, lock_config) + await setup_entity(hass, TEST_LOCK, style, count, config) @pytest.fixture @@ -175,25 +96,8 @@ async def setup_base_lock( state_template: str, extra_config: dict, ): - """Do setup of cover integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - {"value_template": state_template, **extra_config}, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - {"state": state_template, **extra_config}, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - {"state": state_template, **extra_config}, - ) + """Do setup of lock integration using a state template.""" + await setup_entity(hass, TEST_LOCK, style, count, extra_config, state_template) @pytest.fixture @@ -203,34 +107,8 @@ async def setup_state_lock( style: ConfigurationStyle, state_template: str, ): - """Do setup of cover integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - **OPTIMISTIC_LOCK, - "value_template": state_template, - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - { - **OPTIMISTIC_LOCK, - "state": state_template, - }, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - { - **OPTIMISTIC_LOCK, - "state": state_template, - }, - ) + """Do setup of lock integration using a state template.""" + await setup_entity(hass, TEST_LOCK, style, count, OPTIMISTIC_LOCK, state_template) @pytest.fixture @@ -241,25 +119,10 @@ async def setup_state_lock_with_extra_config( state_template: str, extra_config: dict, ): - """Do setup of cover integration using a state template.""" - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - {**OPTIMISTIC_LOCK, "value_template": state_template, **extra_config}, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - {**OPTIMISTIC_LOCK, "state": state_template, **extra_config}, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - {**OPTIMISTIC_LOCK, "state": state_template, **extra_config}, - ) + """Do setup of lock integration using a state template.""" + await setup_entity( + hass, TEST_LOCK, style, count, OPTIMISTIC_LOCK, state_template, extra_config + ) @pytest.fixture @@ -271,30 +134,16 @@ async def setup_state_lock_with_attribute( attribute: str, attribute_template: str, ): - """Do setup of cover integration using a state template.""" - extra = {attribute: attribute_template} if attribute else {} - if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format( - hass, - count, - { - **OPTIMISTIC_LOCK, - "value_template": state_template, - **extra, - }, - ) - elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format( - hass, - count, - {**OPTIMISTIC_LOCK, "state": state_template, **extra}, - ) - elif style == ConfigurationStyle.TRIGGER: - await async_setup_trigger_format( - hass, - count, - {**OPTIMISTIC_LOCK, "state": state_template, **extra}, - ) + """Do setup of lock integration using a state template.""" + await setup_entity( + hass, + TEST_LOCK, + style, + count, + OPTIMISTIC_LOCK, + state_template, + {attribute: attribute_template} if attribute else {}, + ) @pytest.mark.parametrize( @@ -307,28 +156,25 @@ async def setup_state_lock_with_attribute( @pytest.mark.usefixtures("setup_state_lock") async def test_template_state(hass: HomeAssistant) -> None: """Test template.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.LOCKED - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.UNLOCKED - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OPEN) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OPEN) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.OPEN hass.states.async_set(TEST_STATE_ENTITY_ID, "None") await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == STATE_UNKNOWN @@ -345,24 +191,23 @@ async def test_open_lock_optimistic( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test optimistic open.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_OPEN, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "open" - assert calls[0].data["caller"] == TEST_ENTITY_ID + assert calls[0].data["caller"] == TEST_LOCK.entity_id - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.OPEN @@ -375,10 +220,9 @@ async def test_open_lock_optimistic( async def test_template_state_boolean_on(hass: HomeAssistant) -> None: """Test the setting of the state with boolean on.""" # Ensure the trigger executes for trigger configurations - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.LOCKED @@ -391,10 +235,9 @@ async def test_template_state_boolean_on(hass: HomeAssistant) -> None: async def test_template_state_boolean_off(hass: HomeAssistant) -> None: """Test the setting of the state with off.""" # Ensure the trigger executes for trigger configurations - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.UNLOCKED @@ -448,9 +291,9 @@ async def test_template_code_template_syntax_error(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_state_lock") async def test_template_static(hass: HomeAssistant) -> None: """Test that we allow static templates.""" - hass.states.async_set(TEST_ENTITY_ID, LockState.LOCKED) + hass.states.async_set(TEST_LOCK.entity_id, LockState.LOCKED) await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.LOCKED @@ -472,10 +315,9 @@ async def test_template_static(hass: HomeAssistant) -> None: async def test_state_template(hass: HomeAssistant, expected: str) -> None: """Test state and value_template template.""" # Ensure the trigger executes for trigger configurations - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == expected @@ -496,13 +338,12 @@ async def test_state_template(hass: HomeAssistant, expected: str) -> None: @pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None: """Test entity_picture template.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.attributes.get("entity_picture") == initial_state - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.attributes["entity_picture"] == "/local/switch.png" @@ -522,14 +363,13 @@ async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None: - """Test entity_picture template.""" - state = hass.states.get(TEST_ENTITY_ID) + """Test icon template.""" + state = hass.states.get(TEST_LOCK.entity_id) assert state.attributes.get("icon") == initial_state - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.attributes["icon"] == "mdi:eye" @@ -543,22 +383,21 @@ async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None: @pytest.mark.usefixtures("setup_state_lock") async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test lock action.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "lock" - assert calls[0].data["caller"] == TEST_ENTITY_ID + assert calls[0].data["caller"] == TEST_LOCK.entity_id @pytest.mark.parametrize( @@ -571,22 +410,21 @@ async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non @pytest.mark.usefixtures("setup_state_lock") async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test unlock action.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "unlock" - assert calls[0].data["caller"] == TEST_ENTITY_ID + assert calls[0].data["caller"] == TEST_LOCK.entity_id @pytest.mark.parametrize( @@ -600,22 +438,21 @@ async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> N @pytest.mark.usefixtures("setup_state_lock_with_extra_config") async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test open action.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_OPEN, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "open" - assert calls[0].data["caller"] == TEST_ENTITY_ID + assert calls[0].data["caller"] == TEST_LOCK.entity_id @pytest.mark.parametrize( @@ -641,22 +478,21 @@ async def test_lock_action_with_code( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test lock action with defined code format and supplied lock code.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "LOCK_CODE"}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id, ATTR_CODE: "LOCK_CODE"}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "lock" - assert calls[0].data["caller"] == TEST_ENTITY_ID + assert calls[0].data["caller"] == TEST_LOCK.entity_id assert calls[0].data["code"] == "LOCK_CODE" @@ -684,22 +520,21 @@ async def test_unlock_action_with_code( ) -> None: """Test unlock action with code format and supplied unlock code.""" await setup.async_setup_component(hass, "switch", {}) - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "UNLOCK_CODE"}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id, ATTR_CODE: "UNLOCK_CODE"}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == "unlock" - assert calls[0].data["caller"] == TEST_ENTITY_ID + assert calls[0].data["caller"] == TEST_LOCK.entity_id assert calls[0].data["code"] == "UNLOCK_CODE" @@ -734,18 +569,17 @@ async def test_lock_actions_fail_with_invalid_code( ) -> None: """Test invalid lock codes.""" # Ensure trigger entities updated - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) await hass.services.async_call( lock.DOMAIN, test_action, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "non-number-value"}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id, ATTR_CODE: "non-number-value"}, ) await hass.services.async_call( lock.DOMAIN, test_action, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, ) await hass.async_block_till_done() @@ -777,18 +611,17 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error( """Test lock code format rendering fails block lock/unlock actions.""" # Ensure trigger entities updated - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, ) await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "any-value"}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id, ATTR_CODE: "any-value"}, ) await hass.async_block_till_done() @@ -819,22 +652,21 @@ async def test_actions_with_none_as_codeformat_ignores_code( hass: HomeAssistant, action, calls: list[ServiceCall] ) -> None: """Test lock actions with supplied lock code.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "any code"}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id, ATTR_CODE: "any code"}, ) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["action"] == action - assert calls[0].data["caller"] == TEST_ENTITY_ID + assert calls[0].data["caller"] == TEST_LOCK.entity_id assert calls[0].data["code"] == "any code" @@ -862,26 +694,25 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( hass: HomeAssistant, action, calls: list[ServiceCall] ) -> None: """Test lock actions don't execute with invalid regexp.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_OFF) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "1"}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id, ATTR_CODE: "1"}, ) await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_CODE: "x"}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id, ATTR_CODE: "x"}, ) await hass.services.async_call( lock.DOMAIN, action, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, ) await hass.async_block_till_done() @@ -910,10 +741,9 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( @pytest.mark.usefixtures("setup_state_lock") async def test_lock_state(hass: HomeAssistant, test_state) -> None: """Test value template.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, test_state) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, test_state) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == test_state @@ -943,14 +773,14 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Device State should not be unavailable - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + assert hass.states.get(TEST_LOCK.entity_id).state != STATE_UNAVAILABLE # When Availability template returns false hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() # device state should be unavailable - assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE + assert hass.states.get(TEST_LOCK.entity_id).state == STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -976,10 +806,9 @@ async def test_invalid_availability_template_keeps_component_available( hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" - hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, STATE_ON) - assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + assert hass.states.get(TEST_LOCK.entity_id).state != STATE_UNAVAILABLE err = "'x' is undefined" assert err in caplog_setup_text or err in caplog.text @@ -1021,83 +850,32 @@ async def test_legacy_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all("lock")) == 1 -async def test_modern_unique_id(hass: HomeAssistant) -> None: - """Test unique_id option only creates one cover per id.""" - config = { - "template": { - "lock": [ - { - "name": "test_template_lock_01", - "unique_id": "not-so-unique-anymore", - "state": "{{ false }}", - **OPTIMISTIC_LOCK, - }, - { - "name": "test_template_lock_02", - "unique_id": "not-so-unique-anymore", - "state": "{{ false }}", - **OPTIMISTIC_LOCK, - }, - ] - } - } - - with assert_setup_component(1, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - config, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 1 +@pytest.mark.parametrize("config", [OPTIMISTIC_LOCK]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +async def test_unique_id( + hass: HomeAssistant, style: ConfigurationStyle, config: ConfigType +) -> None: + """Test unique_id option only creates one entity per id.""" + await setup_and_test_unique_id(hass, TEST_LOCK, style, config, "{{ 'on' }}") +@pytest.mark.parametrize("config", [OPTIMISTIC_LOCK]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) async def test_nested_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + style: ConfigurationStyle, + config: ConfigType, + entity_registry: er.EntityRegistry, ) -> None: - """Test a template unique_id propagates to lock unique_ids.""" - with assert_setup_component(1, template.DOMAIN): - assert await async_setup_component( - hass, - template.DOMAIN, - { - "template": { - "unique_id": "x", - "lock": [ - { - **OPTIMISTIC_LOCK, - "name": "test_a", - "unique_id": "a", - "state": "{{ true }}", - }, - { - **OPTIMISTIC_LOCK, - "name": "test_b", - "unique_id": "b", - "state": "{{ true }}", - }, - ], - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert len(hass.states.async_all("lock")) == 2 - - entry = entity_registry.async_get("lock.test_a") - assert entry - assert entry.unique_id == "x-a" - - entry = entity_registry.async_get("lock.test_b") - assert entry - assert entry.unique_id == "x-b" + """Test a template unique_id propagates unique_ids.""" + await setup_and_test_nested_unique_id( + hass, TEST_LOCK, style, entity_registry, config, "{{ 'on' }}" + ) async def test_emtpy_action_config(hass: HomeAssistant) -> None: @@ -1148,12 +926,11 @@ async def test_emtpy_action_config(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("count", "lock_config"), + ("count", "config"), [ ( 1, { - "name": TEST_OBJECT_ID, "lock": [], "unlock": [], }, @@ -1168,41 +945,39 @@ async def test_emtpy_action_config(hass: HomeAssistant) -> None: async def test_optimistic(hass: HomeAssistant) -> None: """Test configuration with optimistic state.""" - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == STATE_UNKNOWN # Ensure Trigger template entities update. - hass.states.async_set(TEST_STATE_ENTITY_ID, "anything") - await hass.async_block_till_done() + await async_trigger(hass, TEST_STATE_ENTITY_ID, "anything") await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, lock.SERVICE_UNLOCK, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, blocking=True, ) - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.UNLOCKED @pytest.mark.parametrize( - ("count", "lock_config"), + ("count", "config"), [ ( 1, { - "name": TEST_OBJECT_ID, "state": "{{ is_state('sensor.test_state', 'on') }}", "lock": [], "unlock": [], @@ -1221,7 +996,7 @@ async def test_not_optimistic(hass: HomeAssistant) -> None: await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, - {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + {ATTR_ENTITY_ID: TEST_LOCK.entity_id}, blocking=True, ) @@ -1229,7 +1004,7 @@ async def test_not_optimistic(hass: HomeAssistant) -> None: hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "anything") await hass.async_block_till_done() - state = hass.states.get(TEST_ENTITY_ID) + state = hass.states.get(TEST_LOCK.entity_id) assert state.state == LockState.UNLOCKED From ec54a121c1a5afa09734e42e5198678f3bf32ce1 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:10:20 +0200 Subject: [PATCH 0681/1707] Add initial support for PlayerOptions: Text entities to Music Assistant (#167832) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/music_assistant/__init__.py | 1 + .../components/music_assistant/strings.json | 5 + .../components/music_assistant/text.py | 101 +++++++++++++ .../music_assistant/snapshots/test_text.ambr | 60 ++++++++ tests/components/music_assistant/test_text.py | 134 ++++++++++++++++++ 5 files changed, 301 insertions(+) create mode 100644 homeassistant/components/music_assistant/text.py create mode 100644 tests/components/music_assistant/snapshots/test_text.ambr create mode 100644 tests/components/music_assistant/test_text.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index c5b7706d219177..754d11a10dfec6 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -53,6 +53,7 @@ Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.TEXT, ] CONNECT_TIMEOUT = 10 diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index a1af34ab851339..65f8c730da8e87 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -82,6 +82,11 @@ "treble": { "name": "Treble" } + }, + "text": { + "network_name": { + "name": "Network name" + } } }, "issues": { diff --git a/homeassistant/components/music_assistant/text.py b/homeassistant/components/music_assistant/text.py new file mode 100644 index 00000000000000..23093a8e5d144b --- /dev/null +++ b/homeassistant/components/music_assistant/text.py @@ -0,0 +1,101 @@ +"""Music Assistant text platform.""" + +from __future__ import annotations + +from typing import Final + +from music_assistant_client.client import MusicAssistantClient +from music_assistant_models.player import PlayerOption, PlayerOptionType + +from homeassistant.components.text import TextEntity, TextEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX +from .entity import MusicAssistantPlayerOptionEntity +from .helpers import catch_musicassistant_error + +PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT: Final[list[str]] = [ + "network_name", +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant text Entities (Player Options) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + player = mass.players.get(player_id) + if player is None: + return + entities: list[MusicAssistantPlayerConfigText] = [] + for player_option in player.options: + if ( + not player_option.read_only + and player_option.type == PlayerOptionType.STRING + and not player_option.options # these we map to select + ): + # the MA translation key must have the format player_options. + # we ignore entities with unknown translation keys. + if ( + player_option.translation_key is None + or not player_option.translation_key.startswith( + PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX + ) + ): + continue + translation_key = player_option.translation_key[ + len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) : + ] + if translation_key not in PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT: + continue + + entities.append( + MusicAssistantPlayerConfigText( + mass, + player_id, + player_option=player_option, + entity_description=TextEntityDescription( + key=player_option.key, + translation_key=translation_key, + ), + ) + ) + async_add_entities(entities) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.TEXT, add_player) + + +class MusicAssistantPlayerConfigText(MusicAssistantPlayerOptionEntity, TextEntity): + """Representation of a text entity to control player provider dependent settings.""" + + def __init__( + self, + mass: MusicAssistantClient, + player_id: str, + player_option: PlayerOption, + entity_description: TextEntityDescription, + ) -> None: + """Initialize MusicAssistantPlayerConfigtext.""" + super().__init__(mass, player_id, player_option) + + self.entity_description = entity_description + + @catch_musicassistant_error + async def async_set_value(self, value: str) -> None: + """Set text value.""" + await self.mass.players.set_option(self.player_id, self.mass_option_key, value) + + def on_player_option_update(self, player_option: PlayerOption) -> None: + """Update on player option update.""" + self._attr_native_value = ( + player_option.value if isinstance(player_option.value, str) else None + ) diff --git a/tests/components/music_assistant/snapshots/test_text.ambr b/tests/components/music_assistant/snapshots/test_text.ambr new file mode 100644 index 00000000000000..6eb19ff5059552 --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_text.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_text_entities[text.test_player_1_network_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': , + 'entity_id': 'text.test_player_1_network_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Network name', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Network name', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'network_name', + 'unique_id': '00:00:00:00:00:01_network_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_text_entities[text.test_player_1_network_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Player 1 Network name', + 'max': 255, + 'min': 0, + 'mode': , + 'pattern': None, + }), + 'context': , + 'entity_id': 'text.test_player_1_network_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'receiver', + }) +# --- diff --git a/tests/components/music_assistant/test_text.py b/tests/components/music_assistant/test_text.py new file mode 100644 index 00000000000000..1bbe41dcc31551 --- /dev/null +++ b/tests/components/music_assistant/test_text.py @@ -0,0 +1,134 @@ +"""Test Music Assistant text entities.""" + +from unittest.mock import MagicMock, call + +from music_assistant_models.enums import EventType +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.components.music_assistant.text import ( + PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT, +) +from homeassistant.components.text import ( + ATTR_VALUE, + DOMAIN as TEXT_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.translation import LOCALE_EN, async_get_translations + +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) + + +async def test_text_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + music_assistant_client: MagicMock, +) -> None: + """Test text entities.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + snapshot_music_assistant_entities(hass, entity_registry, snapshot, Platform.TEXT) + + +async def test_text_set_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test text set action.""" + mass_player_id = "00:00:00:00:00:01" + mass_option_key = "network_name" + entity_id = "text.test_player_1_network_name" + + option_value = "new name" + + await setup_integration_from_fixtures(hass, music_assistant_client) + state = hass.states.get(entity_id) + assert state + + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: option_value, + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/set_option", + player_id=mass_player_id, + option_key=mass_option_key, + option_value=option_value, + ) + + +async def test_external_update( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test external value update.""" + mass_player_id = "00:00:00:00:00:01" + mass_option_key = "network_name" + entity_id = "text.test_player_1_network_name" + + await setup_integration_from_fixtures(hass, music_assistant_client) + + # get current option and remove it + text_option = next( + option + for option in music_assistant_client.players._players[mass_player_id].options + if option.key == mass_option_key + ) + music_assistant_client.players._players[mass_player_id].options.remove(text_option) + + # set new value different from previous one + previous_value = text_option.value + new_value = "other name" + text_option.value = new_value + assert previous_value != text_option.value + music_assistant_client.players._players[mass_player_id].options.append(text_option) + + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_OPTIONS_UPDATED, mass_player_id + ) + state = hass.states.get(entity_id) + assert state + assert state.state == new_value + + +async def test_ignored( + hass: HomeAssistant, + music_assistant_client: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test that non-compatible player options are ignored.""" + config_entry = await setup_integration_from_fixtures(hass, music_assistant_client) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + # we only have a single non read-only player option + assert sum(1 for entry in registry_entries if entry.domain == TEXT_DOMAIN) == 1 + + +async def test_name_translation_availability( + hass: HomeAssistant, +) -> None: + """Verify, that the list of available translation keys is reflected in strings.json.""" + # verify, that PLAYER_OPTIONS_TRANSLATION_KEYS_text matches strings.json + translations = await async_get_translations( + hass, language=LOCALE_EN, category="entity", integrations=[DOMAIN] + ) + prefix = f"component.{DOMAIN}.entity.{Platform.TEXT.value}." + for translation_key in PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT: + assert translations.get(f"{prefix}{translation_key}.name") is not None, ( + f"{translation_key} is missing in strings.json for platform text" + ) From f4a2f37fa65ed8d29db5a3d0ca230a6eea910a5a Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:45:47 +0300 Subject: [PATCH 0682/1707] Victron GX select platform (#167675) Co-authored-by: Norbert Rittel Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .../components/victron_gx/__init__.py | 1 + homeassistant/components/victron_gx/hub.py | 2 +- homeassistant/components/victron_gx/select.py | 82 ++++++++++++++ .../components/victron_gx/strings.json | 94 ++++++++++++++++ tests/components/victron_gx/test_select.py | 105 ++++++++++++++++++ 5 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/victron_gx/select.py create mode 100644 tests/components/victron_gx/test_select.py diff --git a/homeassistant/components/victron_gx/__init__.py b/homeassistant/components/victron_gx/__init__.py index 185848dbb19e9e..06a59e8245b7f9 100644 --- a/homeassistant/components/victron_gx/__init__.py +++ b/homeassistant/components/victron_gx/__init__.py @@ -13,6 +13,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/victron_gx/hub.py b/homeassistant/components/victron_gx/hub.py index 3fbabcb5094b8b..cb317f451f8a16 100644 --- a/homeassistant/components/victron_gx/hub.py +++ b/homeassistant/components/victron_gx/hub.py @@ -74,7 +74,7 @@ def __init__(self, hass: HomeAssistant, entry: VictronGxConfigEntry) -> None: installation_id=config.get(CONF_INSTALLATION_ID) or None, model_name=config.get(CONF_MODEL) or None, serial=config.get(CONF_SERIAL) or None, - operation_mode=OperationMode.READ_ONLY, + operation_mode=OperationMode.FULL, update_frequency_seconds=UPDATE_INTERVAL_SECONDS, ) self._hub.on_new_metric = self._on_new_metric diff --git a/homeassistant/components/victron_gx/select.py b/homeassistant/components/victron_gx/select.py new file mode 100644 index 00000000000000..2c0a426673c9b1 --- /dev/null +++ b/homeassistant/components/victron_gx/select.py @@ -0,0 +1,82 @@ +"""Support for Victron GX select entities.""" + +import logging +from typing import TYPE_CHECKING, Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + VictronEnum, + WritableMetric as VictronVenusWritableMetric, +) + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 # There is no I/O in the entity itself. + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX select entities from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new select metric discovery.""" + assert isinstance(metric, VictronVenusWritableMetric) + async_add_entities( + [VictronSelect(device, metric, device_info, installation_id)] + ) + + hub.register_new_metric_callback(MetricKind.SELECT, on_new_metric) + + +class VictronSelect(VictronBaseEntity, SelectEntity): + """Implementation of a Victron GX select entity.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusWritableMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the select entity.""" + super().__init__(device, metric, device_info, installation_id) + if TYPE_CHECKING: + assert metric.enum_values, "Select metric will always have enum values" + self._attr_options = metric.enum_values + self._attr_current_option = VictronSelect._normalize_value(metric.value) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_current_option = VictronSelect._normalize_value(value) + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if TYPE_CHECKING: + assert isinstance(self._metric, VictronVenusWritableMetric) + _LOGGER.debug("Setting select %s to %s", self._attr_unique_id, option) + self._metric.set(option) + + @staticmethod + def _normalize_value(value: Any) -> Any: + """Normalize Victron enum values to their enum code.""" + return value.id if isinstance(value, VictronEnum) else value diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 8b2bfc038ff000..c6a6cf463c6623 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -175,6 +175,100 @@ "name": "Grid lost alarm setting" } }, + "select": { + "acsystem_mode": { + "state": { + "charger_only": "Charger only", + "inverter_only": "Inverter only", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "passthrough": "Passthrough" + } + }, + "evcharger_mode": { + "name": "Mode", + "state": { + "auto": "[%key:common::state::auto%]", + "manual": "[%key:common::state::manual%]", + "scheduled_charge": "Scheduled charge" + } + }, + "inverter_mode": { + "state": { + "eco": "Eco", + "inverter": "Inverter", + "off": "[%key:common::state::off%]" + } + }, + "system_ess_batterylife_state": { + "name": "ESS BatteryLife state", + "state": { + "keep_batteries_charged": "'Keep batteries charged' mode enabled", + "recharge": "Recharge, SoC dropped 5% or more below minimum SoC", + "recharge_no_battery_life": "Recharge, SoC dropped 5% or more below minimum SoC (No BatteryLife)", + "self_consumption": "Self-consumption", + "self_consumption_soc_above_min": "Self-consumption, SoC at or above minimum SoC", + "self_consumption_soc_at_100": "Self-consumption, SoC at 100%", + "self_consumption_soc_below_min": "Self-consumption, SoC is below minimum SoC", + "self_consumption_soc_exceeds_85": "Self-consumption, SoC exceeds 85%", + "soc_below_battery_life_dynamic_soc_limit": "SoC below BatteryLife dynamic SoC limit", + "soc_below_soc_limit_24_hours": "SoC has been below SoC limit for more than 24 hours. Charging battery with 5 amps", + "sustain": "Multi/Quattro is in sustain", + "with_battery_life": "Optimized mode with BatteryLife" + } + }, + "system_ess_mode": { + "name": "ESS mode (Hub4)", + "state": { + "external_control": "External control", + "phase_compensation_disabled": "Optimized mode or 'keep batteries charged' and phase compensation disabled", + "phase_compensation_enabled": "Optimized mode or 'keep batteries charged' and phase compensation enabled" + } + }, + "system_ess_schedule_charge_slot_days": { + "name": "ESS BatteryLife schedule charge {slot} days", + "state": { + "disabled_every_day": "Disabled (Every day)", + "disabled_friday": "Disabled (Friday)", + "disabled_monday": "Disabled (Monday)", + "disabled_saturday": "Disabled (Saturday)", + "disabled_sunday": "Disabled (Sunday)", + "disabled_thursday": "Disabled (Thursday)", + "disabled_tuesday": "Disabled (Tuesday)", + "disabled_wednesday": "Disabled (Wednesday)", + "disabled_weekdays": "Disabled (Weekdays)", + "disabled_weekend": "Disabled (Weekends)", + "every_day": "Every day", + "friday": "[%key:common::time::friday%]", + "monday": "[%key:common::time::monday%]", + "saturday": "[%key:common::time::saturday%]", + "sunday": "[%key:common::time::sunday%]", + "thursday": "[%key:common::time::thursday%]", + "tuesday": "[%key:common::time::tuesday%]", + "wednesday": "[%key:common::time::wednesday%]", + "weekdays": "Weekdays", + "weekends": "Weekends" + } + }, + "system_settings_dess_mode": { + "name": "DESS mode", + "state": { + "auto_vrm": "Auto / VRM", + "buy": "Buy", + "node_red": "Node-RED", + "off": "[%key:common::state::off%]", + "sell": "Sell" + } + }, + "vebus_inverter_mode": { + "state": { + "charger_only": "Charger only", + "inverter_only": "Inverter only", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } + }, "sensor": { "acload_current": { "name": "Load current" diff --git a/tests/components/victron_gx/test_select.py b/tests/components/victron_gx/test_select.py new file mode 100644 index 00000000000000..74bc328d1469bf --- /dev/null +++ b/tests/components/victron_gx/test_select.py @@ -0,0 +1,105 @@ +"""Tests for Victron GX MQTT select entities.""" + +from __future__ import annotations + +from victron_mqtt import Hub as VictronVenusHub +from victron_mqtt.testing import finalize_injection, inject_message + +from homeassistant.components.victron_gx.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import MOCK_INSTALLATION_ID + +from tests.common import MockConfigEntry + + +async def test_victron_select( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test SELECT MetricKind - EV charger mode select is created and updated.""" + victron_hub, mock_config_entry = init_integration + + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/evcharger/0/Mode", + '{"value": 0}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert len(entities) == 1 + entity = entities[0] + assert entity.entity_id == "select.ev_charging_station_mode" + assert entity.unique_id == f"{MOCK_INSTALLATION_ID}_evcharger_0_evcharger_mode" + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "manual" + assert state.attributes["options"] == ["manual", "auto", "scheduled_charge"] + + # Verify device info was registered correctly + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{MOCK_INSTALLATION_ID}_evcharger_0")} + ) + assert device is not None + assert device.manufacturer == "Victron Energy" + + # Update the metric to exercise the entity update callback path. + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/evcharger/0/Mode", + '{"value": 1}', + ) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "auto" + + +async def test_victron_select_actions( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], + entity_registry: er.EntityRegistry, +) -> None: + """Test select_option service call.""" + victron_hub, mock_config_entry = init_integration + + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/evcharger/0/Mode", + '{"value": 0}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 1 + entity_id = entities[0].entity_id + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "manual" + + # Call select_option service and verify the entity updates. + await hass.services.async_call( + "select", + "select_option", + {"entity_id": entity_id, "option": "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "auto" From 431387b76d742c90e198d8a4f593ad484fd9629c Mon Sep 17 00:00:00 2001 From: Kurt Chrisford <92524101+kclif9@users.noreply.github.com> Date: Fri, 10 Apr 2026 06:47:59 +1000 Subject: [PATCH 0683/1707] Fix Actron Air quality scale rule statuses (#167149) --- .../components/actron_air/quality_scale.yaml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml index 982050be3bdb4c..80b515d55e0415 100644 --- a/homeassistant/components/actron_air/quality_scale.yaml +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -54,15 +54,9 @@ rules: docs-troubleshooting: done docs-use-cases: done dynamic-devices: todo - entity-category: - status: exempt - comment: This integration does not use entity categories. - entity-device-class: - status: exempt - comment: This integration does not use entity device classes. - entity-disabled-by-default: - status: exempt - comment: Not required for this integration at this stage. + entity-category: done + entity-device-class: todo + entity-disabled-by-default: todo entity-translations: todo exception-translations: done icon-translations: todo From 79dfa61e8b1c267789392152d603c29a298119c7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:51:32 +0200 Subject: [PATCH 0684/1707] Add favorite collection to immich media source (#167841) --- .../components/immich/media_source.py | 10 ++- tests/components/immich/conftest.py | 2 + tests/components/immich/const.py | 71 +++++++++++++++++++ tests/components/immich/test_media_source.py | 39 ++++++++-- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index e37172cb5e14f8..7fab6d5d093f6f 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -124,11 +124,11 @@ async def _async_build_immich( identifier=f"{identifier.unique_id}|{collection}", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, - title=collection, + title=collection.split("|", maxsplit=1)[0], can_play=False, can_expand=True, ) - for collection in ("albums", "people", "tags") + for collection in ("albums", "favorites|favorites", "people", "tags") ] # -------------------------------------------------------- @@ -239,6 +239,12 @@ async def _async_build_immich( ) except ImmichError: return [] + elif identifier.collection == "favorites": + LOGGER.debug("Render all assets for favorites collection") + try: + assets = await immich_api.search.async_get_all_favorites() + except ImmichError: + return [] ret: list[BrowseMediaSource] = [] for asset in assets: diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 56260db6cafe19..d67c21f7a6d897 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -42,6 +42,7 @@ from .const import ( MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS, + MOCK_FAVORITE_ASSETS, MOCK_PEOPLE_ASSETS, MOCK_TAGS_ASSETS, ) @@ -149,6 +150,7 @@ def mock_immich_people() -> AsyncMock: def mock_immich_search() -> AsyncMock: """Mock the Immich server.""" mock = AsyncMock(spec=ImmichSearch) + mock.async_get_all_favorites.return_value = MOCK_FAVORITE_ASSETS mock.async_get_all_by_person_ids.return_value = MOCK_PEOPLE_ASSETS mock.async_get_all_by_tag_ids.return_value = MOCK_TAGS_ASSETS return mock diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py index af718c4b7549b4..c88235651e5855 100644 --- a/tests/components/immich/const.py +++ b/tests/components/immich/const.py @@ -242,3 +242,74 @@ }, ), ] + +MOCK_FAVORITE_ASSETS = [ + ImmichAsset.from_dict( + { + "id": "70af6d9d-097b-4b22-8684-dc2fe0d5e167", + "createdAt": "2026-04-06T11:38:53.264Z", + "deviceAssetId": "55039", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "eca179936c70787e4f76e58338c617472c5f795d7961ae8a7207246919659b44", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/17/af/17afbef0-dccf-42ad-9c90-618f981914f5.jpg", + "originalFileName": "20260406_133809.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "YNcFRIZnd4qMdquQh4R4cHcGdw==", + "fileCreatedAt": "2026-04-06T11:38:09.894Z", + "fileModifiedAt": "2026-04-06T11:38:11.000Z", + "localDateTime": "2026-04-06T13:38:09.894Z", + "updatedAt": "2026-04-09T19:58:59.941Z", + "isFavorite": True, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "5WQREAfqvQ34aT01BJywGgsfJ7g=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + "width": 4000, + "height": 2252, + "isEdited": False, + }, + ), + ImmichAsset.from_dict( + { + "id": "eee5aa96-0943-48e9-ae11-992216485c6d", + "createdAt": "2026-03-19T18:31:11.540Z", + "deviceAssetId": "52952", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "eca179936c70787e4f76e58338c617472c5f795d7961ae8a7207246919659b44", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/f6/40/f640c504-bff3-43cc-b520-60a43269de4b.jpg", + "originalFileName": "20260319_192209.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "VEkGDIIke5yIl5h/UUUgXQKmBg==", + "fileCreatedAt": "2026-03-19T18:22:10.019Z", + "fileModifiedAt": "2026-03-19T18:22:11.000Z", + "localDateTime": "2026-03-19T19:22:10.019Z", + "updatedAt": "2026-04-09T19:59:18.967Z", + "isFavorite": True, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "xbrz2mvk/XIroFixqq+eidNo6wg=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + "width": 4000, + "height": 2252, + "isEdited": False, + }, + ), +] diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 2060b9c6a3acc8..37450d5a02273c 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -137,7 +137,7 @@ async def test_browse_media_get_root( result = await source.async_browse_media(item) assert result - assert len(result.children) == 3 + assert len(result.children) == 4 media_file = result.children[0] assert isinstance(media_file, BrowseMedia) @@ -148,12 +148,19 @@ async def test_browse_media_get_root( media_file = result.children[1] assert isinstance(media_file, BrowseMedia) + assert media_file.title == "favorites" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|favorites|favorites" + ) + + media_file = result.children[2] + assert isinstance(media_file, BrowseMedia) assert media_file.title == "people" assert media_file.media_content_id == ( "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|people" ) - media_file = result.children[2] + media_file = result.children[3] assert isinstance(media_file, BrowseMedia) assert media_file.title == "tags" assert media_file.media_content_id == ( @@ -227,6 +234,7 @@ async def test_browse_media_collections( ("collection", "mocked_get_fn"), [ ("albums", ("albums", "async_get_all_albums")), + ("favorites|favorites", ("search", "async_get_all_favorites")), ("people", ("people", "async_get_all_people")), ("tags", ("tags", "async_get_all_tags")), ], @@ -271,6 +279,7 @@ async def test_browse_media_collections_error( ("collection", "mocked_get_fn"), [ ("albums", ("albums", "async_get_album_info")), + ("favorites", ("search", "async_get_all_favorites")), ("people", ("search", "async_get_all_by_person_ids")), ("tags", ("search", "async_get_all_by_tag_ids")), ], @@ -338,6 +347,28 @@ async def test_browse_media_collection_items_error( }, ], ), + ( + "favorites", + "favorites", + [ + { + "original_file_name": "20260406_133809.jpg", + "asset_id": "70af6d9d-097b-4b22-8684-dc2fe0d5e167", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "20260319_192209.jpg", + "asset_id": "eee5aa96-0943-48e9-ae11-992216485c6d", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + ], + ), ( "people", "6176838a-ac5a-4d1f-9a35-91c591d962d8", @@ -435,11 +466,11 @@ async def test_media_view( mock_immich: Mock, mock_config_entry: MockConfigEntry, ) -> None: - """Test SynologyDsmMediaView returning albums.""" + """Test ImmichMediaView returning albums.""" view = ImmichMediaView(hass) request = MockRequest(b"", DOMAIN) - # immich noch configured + # immich not configured with pytest.raises(web.HTTPNotFound): await view.get(request, "", "") From 2a0a386e6d082d5645ebe8e564e2071179953915 Mon Sep 17 00:00:00 2001 From: johanzander Date: Thu, 9 Apr 2026 22:59:50 +0200 Subject: [PATCH 0685/1707] Update Growatt quality scale: mark docs rules done and exempt discovery (#166075) Co-authored-by: Claude Sonnet 4.6 --- .../growatt_server/quality_scale.yaml | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/growatt_server/quality_scale.yaml b/homeassistant/components/growatt_server/quality_scale.yaml index 5d29f1aa4942dc..15502bdc5b02e3 100644 --- a/homeassistant/components/growatt_server/quality_scale.yaml +++ b/homeassistant/components/growatt_server/quality_scale.yaml @@ -34,15 +34,23 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo - discovery: todo - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + discovery-update-info: + status: exempt + comment: >- + Growatt data loggers use a generic OUI and serial-number DHCP hostname, + making reliable local discovery not implementable. + discovery: + status: exempt + comment: >- + Growatt data loggers use a generic OUI and serial-number DHCP hostname, + making reliable local discovery not implementable. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: todo entity-category: done entity-device-class: done From ca96c751e177a656233c4aef866cd48d55bfeebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 9 Apr 2026 23:03:03 +0200 Subject: [PATCH 0686/1707] Add delayed start as an operation state that flags as program running at Home Connect (#167549) --- homeassistant/components/home_connect/const.py | 1 + .../components/home_connect/sensor.py | 2 ++ tests/components/home_connect/test_sensor.py | 18 +++++++++++------- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 0719d41c65e027..14e675b7e952cf 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -42,6 +42,7 @@ BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off" +BSH_OPERATION_STATE_DELAYED_START = "BSH.Common.EnumType.OperationState.DelayedStart" BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index a88ad6df746494..283fc7dfea4b75 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -21,6 +21,7 @@ from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, + BSH_OPERATION_STATE_DELAYED_START, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, @@ -624,6 +625,7 @@ def program_running(self) -> bool: """Return whether a program is running, paused or finished.""" status = self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) return status is not None and status.value in [ + BSH_OPERATION_STATE_DELAYED_START, BSH_OPERATION_STATE_RUN, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_FINISHED, diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 0fac889237bce3..1683dde433462b 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -42,6 +42,10 @@ EventType.STATUS: { EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.DelayedStart", }, + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 30, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 0, + }, } @@ -292,20 +296,20 @@ async def test_sensor_entity_availability( "ready", ), "sensor.dishwasher_program_finish_time": ( - "unavailable", + "2021-01-09T12:00:30+00:00", "2021-01-09T12:00:00+00:00", "2021-01-09T12:00:00+00:00", "2021-01-09T12:00:20+00:00", STATE_UNKNOWN, - "unavailable", + STATE_UNAVAILABLE, ), "sensor.dishwasher_program_progress": ( - "unavailable", + "0", "60", "80", "99", "99", - "unavailable", + STATE_UNAVAILABLE, ), } @@ -460,10 +464,10 @@ async def test_program_sensor_edge_case( # Expected state at each sequence. ENTITY_ID_EDGE_CASE_STATES = [ - "unavailable", + "2021-01-09T12:00:30+00:00", "2021-01-09T12:00:01+00:00", - "unavailable", - "unavailable", + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, ] From 53738c0168b99a534cd87ed4611976b195cd98b9 Mon Sep 17 00:00:00 2001 From: Fabian Neundorf Date: Thu, 9 Apr 2026 23:09:35 +0200 Subject: [PATCH 0687/1707] Add 2fa support in picnic integration (#167636) --- .../components/picnic/config_flow.py | 208 ++++++++---- homeassistant/components/picnic/const.py | 1 + homeassistant/components/picnic/strings.json | 23 ++ tests/components/picnic/test_config_flow.py | 315 ++++++++++++++++-- 4 files changed, 443 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index a60086173a8424..118d752dd9eca4 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -7,7 +7,11 @@ from typing import Any from python_picnic_api2 import PicnicAPI -from python_picnic_api2.session import PicnicAuthError +from python_picnic_api2.session import ( + Picnic2FAError, + Picnic2FARequired, + PicnicAuthError, +) import requests import voluptuous as vol @@ -18,13 +22,19 @@ CONF_PASSWORD, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import COUNTRY_CODES, DOMAIN +from .const import COUNTRY_CODES, DOMAIN, TWO_FA_CHANNELS _LOGGER = logging.getLogger(__name__) +CONF_2FA_CODE = "two_fa_code" +CONF_2FA_CHANNEL = "two_fa_channel" + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -35,45 +45,23 @@ } ) +STEP_2FA_CHANNEL_SCHEMA = vol.Schema( + { + vol.Required(CONF_2FA_CHANNEL, default=TWO_FA_CHANNELS[0]): SelectSelector( + SelectSelectorConfig( + options=TWO_FA_CHANNELS, + mode=SelectSelectorMode.LIST, + translation_key="two_fa_channel", + ) + ), + } +) -class PicnicHub: - """Hub class to test user authentication.""" - - @staticmethod - def authenticate(username, password, country_code) -> tuple[str, dict]: - """Test if we can authenticate with the Picnic API.""" - picnic = PicnicAPI(username, password, country_code) - return picnic.session.auth_token, picnic.get_user() - - -async def validate_input(hass: HomeAssistant, data): - """Validate the user input allows us to connect. - - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ - hub = PicnicHub() - - try: - auth_token, user_data = await hass.async_add_executor_job( - hub.authenticate, - data[CONF_USERNAME], - data[CONF_PASSWORD], - data[CONF_COUNTRY_CODE], - ) - except requests.exceptions.ConnectionError as error: - raise CannotConnect from error - except PicnicAuthError as error: - raise InvalidAuth from error - - # Return the validation result - address = ( - f"{user_data['address']['street']} {user_data['address']['house_number']}" - f"{user_data['address']['house_number_ext']}" - ) - return auth_token, { - "title": address, - "unique_id": user_data["user_id"], +STEP_2FA_SCHEMA = vol.Schema( + { + vol.Required(CONF_2FA_CODE): str, } +) class PicnicConfigFlow(ConfigFlow, domain=DOMAIN): @@ -81,6 +69,11 @@ class PicnicConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the config flow.""" + self._picnic: PicnicAPI | None = None + self._user_input: dict[str, Any] = {} + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -90,7 +83,7 @@ async def async_step_reauth( async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the authentication step, this is the generic step for both `step_user` and `step_reauth`.""" + """Handle the authentication step.""" if user_input is None: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA @@ -99,43 +92,122 @@ async def async_step_user( errors = {} try: - auth_token, info = await validate_input(self.hass, user_input) - except CannotConnect: + await self.hass.async_add_executor_job( + self._start_login, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_COUNTRY_CODE], + ) + except Picnic2FARequired: + self._user_input = user_input + return await self.async_step_2fa_channel() + except requests.exceptions.ConnectionError: errors["base"] = "cannot_connect" - except InvalidAuth: + except PicnicAuthError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - data = { - CONF_ACCESS_TOKEN: auth_token, - CONF_COUNTRY_CODE: user_input[CONF_COUNTRY_CODE], - } - existing_entry = await self.async_set_unique_id(info["unique_id"]) - - # Abort if we're adding a new config and the unique id is already in use, else create the entry - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured() - return self.async_create_entry(title="Picnic", data=data) - - # In case of re-auth, only continue if an exiting account exists with the same unique id - if existing_entry: - self.hass.config_entries.async_update_entry(existing_entry, data=data) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - - # Set the error because the account is different - errors["base"] = "different_account" + return await self._async_finish(user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + def _start_login(self, username: str, password: str, country_code: str) -> None: + self._picnic = PicnicAPI(country_code=country_code) + self._picnic.login(username, password) -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" + async def async_step_2fa_channel( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Let the user pick the 2FA delivery channel.""" + assert self._picnic is not None + if user_input is None: + return self.async_show_form( + step_id="2fa_channel", data_schema=STEP_2FA_CHANNEL_SCHEMA + ) -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" + errors = {} + channel = user_input[CONF_2FA_CHANNEL].upper() + try: + await self.hass.async_add_executor_job( + self._picnic.generate_2fa_code, channel + ) + except requests.exceptions.ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Failed to request 2FA code via %s", channel) + errors["base"] = "unknown" + else: + return await self.async_step_2fa() + + return self.async_show_form( + step_id="2fa_channel", + data_schema=STEP_2FA_CHANNEL_SCHEMA, + errors=errors, + ) + + async def async_step_2fa( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the 2FA verification step.""" + assert self._picnic is not None + + if user_input is None: + return self.async_show_form(step_id="2fa", data_schema=STEP_2FA_SCHEMA) + + errors = {} + + try: + await self.hass.async_add_executor_job( + self._picnic.verify_2fa_code, user_input[CONF_2FA_CODE] + ) + except Picnic2FAError: + errors["base"] = "invalid_2fa_code" + except requests.exceptions.ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during 2FA verification") + errors["base"] = "unknown" + else: + return await self._async_finish(self._user_input) + + return self.async_show_form( + step_id="2fa", data_schema=STEP_2FA_SCHEMA, errors=errors + ) + + async def _async_finish( + self, + user_input: dict[str, Any], + ) -> ConfigFlowResult: + """Finalize the config entry after successful authentication.""" + assert self._picnic is not None + + auth_token = self._picnic.session.auth_token + user_data = await self.hass.async_add_executor_job(self._picnic.get_user) + + data = { + CONF_ACCESS_TOKEN: auth_token, + CONF_COUNTRY_CODE: user_input[CONF_COUNTRY_CODE], + } + existing_entry = await self.async_set_unique_id(user_data["user_id"]) + + # Abort if we're adding a new config and the unique id is already in use, else create the entry + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Picnic", data=data) + + # In case of re-auth, only continue if an exiting account exists with the same unique id + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "different_account"}, + ) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 9cde3dea03dc34..8996e5b971b6fc 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -12,6 +12,7 @@ ATTR_PRODUCT_IDENTIFIERS = "product_identifiers" COUNTRY_CODES = ["NL", "DE", "BE", "FR"] +TWO_FA_CHANNELS = ["sms", "email"] ATTRIBUTION = "Data provided by Picnic" ADDRESS = "address" CART_DATA = "cart_data" diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index db56d032b1d279..e2cea9b4d4d2a1 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -7,10 +7,25 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "different_account": "Account should be the same as used for setting up the integration", + "invalid_2fa_code": "The verification code is incorrect or has expired.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "2fa": { + "data": { + "two_fa_code": "Verification code" + }, + "description": "A verification code has been sent to you via your selected channel.", + "title": "Two-factor authentication" + }, + "2fa_channel": { + "data": { + "two_fa_channel": "Channel" + }, + "description": "A second factor is required to complete the login. Select the channel through which you want to receive your second factor.", + "title": "Two-factor authentication" + }, "user": { "data": { "country_code": "Country code", @@ -77,6 +92,14 @@ } } }, + "selector": { + "two_fa_channel": { + "options": { + "email": "Email", + "sms": "Text message (SMS)" + } + } + }, "services": { "add_product": { "description": "Adds a product to the cart based on a search string or product ID. The search string and product ID are exclusive.", diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py index ba4c36682e1f94..63c03820f60cb1 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -3,7 +3,11 @@ from unittest.mock import patch import pytest -from python_picnic_api2.session import PicnicAuthError +from python_picnic_api2.session import ( + Picnic2FAError, + Picnic2FARequired, + PicnicAuthError, +) import requests from homeassistant import config_entries @@ -30,8 +34,12 @@ def picnic_api(): with patch( "homeassistant.components.picnic.config_flow.PicnicAPI", ) as picnic_mock: - picnic_mock().session.auth_token = auth_token - picnic_mock().get_user.return_value = auth_data + instance = picnic_mock.return_value + instance.session.auth_token = auth_token + instance.get_user.return_value = auth_data + instance.login.return_value = None # no 2FA by default + instance.generate_2fa_code.return_value = None + instance.verify_2fa_code.return_value = None yield picnic_mock @@ -69,17 +77,19 @@ async def test_form(hass: HomeAssistant, picnic_api) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid authentication.""" +async def test_form_2fa_required(hass: HomeAssistant, picnic_api) -> None: + """Test the full 2FA flow.""" + picnic_api.return_value.login.side_effect = Picnic2FARequired + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.picnic.config_flow.PicnicHub.authenticate", - side_effect=PicnicAuthError, + "homeassistant.components.picnic.async_setup_entry", + return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result_step_user = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", @@ -87,22 +97,50 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "country_code": "NL", }, ) + assert result_step_user["type"] is FlowResultType.FORM + assert result_step_user["step_id"] == "2fa_channel" - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + result_step_2fa_channel = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_channel": "sms"}, + ) + assert result_step_2fa_channel["type"] is FlowResultType.FORM + assert result_step_2fa_channel["step_id"] == "2fa" + + result_step_2fa_verify = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_code": "123456"}, + ) + await hass.async_block_till_done() + + assert result_step_2fa_verify["type"] is FlowResultType.CREATE_ENTRY + assert result_step_2fa_verify["title"] == "Picnic" + assert result_step_2fa_verify["data"] == { + CONF_ACCESS_TOKEN: picnic_api().session.auth_token, + CONF_COUNTRY_CODE: "NL", + } + assert picnic_api.return_value.generate_2fa_code.call_count == 1 + assert picnic_api.return_value.generate_2fa_code.call_args[0] == ("SMS",) + assert picnic_api.return_value.verify_2fa_code.call_count == 1 + assert picnic_api.return_value.verify_2fa_code.call_args[0] == ("123456",) -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle connection errors.""" +async def test_form_2fa_channel_cannot_connect(hass: HomeAssistant, picnic_api) -> None: + """Test we handle connection errors in the first 2fa step.""" + picnic_api.return_value.login.side_effect = Picnic2FARequired + picnic_api.return_value.generate_2fa_code.side_effect = ( + requests.exceptions.ConnectionError + ) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.picnic.config_flow.PicnicHub.authenticate", - side_effect=requests.exceptions.ConnectionError, + "homeassistant.components.picnic.async_setup_entry", + return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result_step_user = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", @@ -110,22 +148,67 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "country_code": "NL", }, ) + assert result_step_user["type"] is FlowResultType.FORM + assert result_step_user["step_id"] == "2fa_channel" - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + result_step_2fa_channel = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_channel": "sms"}, + ) + await hass.async_block_till_done() + assert result_step_2fa_channel["type"] is FlowResultType.FORM + assert result_step_2fa_channel["errors"] == {"base": "cannot_connect"} + + +async def test_form_2fa_channel_exception(hass: HomeAssistant, picnic_api) -> None: + """Test we handle random exceptions in the first 2fa step.""" + picnic_api.return_value.login.side_effect = Picnic2FARequired + picnic_api.return_value.generate_2fa_code.side_effect = Exception -async def test_form_exception(hass: HomeAssistant) -> None: - """Test we handle random exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.picnic.config_flow.PicnicHub.authenticate", - side_effect=Exception, + "homeassistant.components.picnic.async_setup_entry", + return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result_step_user = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + assert result_step_user["type"] is FlowResultType.FORM + assert result_step_user["step_id"] == "2fa_channel" + + result_step_2fa_channel = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_channel": "sms"}, + ) + await hass.async_block_till_done() + + assert result_step_2fa_channel["type"] is FlowResultType.FORM + assert result_step_2fa_channel["errors"] == {"base": "unknown"} + + +async def test_form_2fa_wrong_code(hass: HomeAssistant, picnic_api) -> None: + """Test the full 2FA flow with incorrect code.""" + picnic_api.return_value.login.side_effect = Picnic2FARequired + picnic_api.return_value.verify_2fa_code.side_effect = Picnic2FAError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.picnic.async_setup_entry", + return_value=True, + ): + result_step_user = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", @@ -133,6 +216,168 @@ async def test_form_exception(hass: HomeAssistant) -> None: "country_code": "NL", }, ) + assert result_step_user["type"] is FlowResultType.FORM + assert result_step_user["step_id"] == "2fa_channel" + + result_step_2fa_channel = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_channel": "sms"}, + ) + assert result_step_2fa_channel["type"] is FlowResultType.FORM + assert result_step_2fa_channel["step_id"] == "2fa" + + result_step_2fa_verify = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_code": "654321"}, + ) + await hass.async_block_till_done() + + assert result_step_2fa_verify["type"] is FlowResultType.FORM + assert result_step_2fa_verify["errors"] == {"base": "invalid_2fa_code"} + + +async def test_form_2fa_cannot_connect(hass: HomeAssistant, picnic_api) -> None: + """Test we handle connection errors in the last 2fa step.""" + picnic_api.return_value.login.side_effect = Picnic2FARequired + picnic_api.return_value.verify_2fa_code.side_effect = ( + requests.exceptions.ConnectionError + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.picnic.async_setup_entry", + return_value=True, + ): + result_step_user = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + assert result_step_user["type"] is FlowResultType.FORM + assert result_step_user["step_id"] == "2fa_channel" + + result_step_2fa_channel = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_channel": "sms"}, + ) + assert result_step_2fa_channel["type"] is FlowResultType.FORM + assert result_step_2fa_channel["step_id"] == "2fa" + + result_step_2fa_verify = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_code": "123456"}, + ) + await hass.async_block_till_done() + + assert result_step_2fa_verify["type"] is FlowResultType.FORM + assert result_step_2fa_verify["errors"] == {"base": "cannot_connect"} + + +async def test_form_2fa_exception(hass: HomeAssistant, picnic_api) -> None: + """Test we handle random exceptions in the last 2fa step.""" + picnic_api.return_value.login.side_effect = Picnic2FARequired + picnic_api.return_value.verify_2fa_code.side_effect = Exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.picnic.async_setup_entry", + return_value=True, + ): + result_step_user = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + assert result_step_user["type"] is FlowResultType.FORM + assert result_step_user["step_id"] == "2fa_channel" + + result_step_2fa_channel = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_channel": "sms"}, + ) + assert result_step_2fa_channel["type"] is FlowResultType.FORM + assert result_step_2fa_channel["step_id"] == "2fa" + + result_step_2fa_verify = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"two_fa_code": "123456"}, + ) + await hass.async_block_till_done() + + assert result_step_2fa_verify["type"] is FlowResultType.FORM + assert result_step_2fa_verify["errors"] == {"base": "unknown"} + + +async def test_form_invalid_auth(hass: HomeAssistant, picnic_api) -> None: + """Test we handle invalid authentication.""" + picnic_api.return_value.login.side_effect = PicnicAuthError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant, picnic_api) -> None: + """Test we handle connection errors.""" + picnic_api.return_value.login.side_effect = requests.exceptions.ConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_exception(hass: HomeAssistant, picnic_api) -> None: + """Test we handle random exceptions.""" + picnic_api.return_value.login.side_effect = Exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} @@ -203,8 +448,10 @@ async def test_step_reauth(hass: HomeAssistant, picnic_api) -> None: assert len(hass.config_entries.async_entries()) == 1 -async def test_step_reauth_failed(hass: HomeAssistant) -> None: +async def test_step_reauth_failed(hass: HomeAssistant, picnic_api) -> None: """Test the re-auth flow when authentication fails.""" + picnic_api.return_value.login.side_effect = PicnicAuthError + # Create a mocked config entry user_id = "f29-2a6-o32n" conf = {CONF_ACCESS_TOKEN: "a3p98fsen.a39p3fap", CONF_COUNTRY_CODE: "NL"} @@ -221,19 +468,15 @@ async def test_step_reauth_failed(hass: HomeAssistant) -> None: assert result_init["type"] is FlowResultType.FORM assert result_init["step_id"] == "user" - with patch( - "homeassistant.components.picnic.config_flow.PicnicHub.authenticate", - side_effect=PicnicAuthError, - ): - result_configure = await hass.config_entries.flow.async_configure( - result_init["flow_id"], - { - "username": "test-username", - "password": "test-password", - "country_code": "NL", - }, - ) - await hass.async_block_till_done() + result_configure = await hass.config_entries.flow.async_configure( + result_init["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country_code": "NL", + }, + ) + await hass.async_block_till_done() # Check that the returned flow has type form with error set assert result_configure["type"] is FlowResultType.FORM From 1e78666b9055dd5b842e5ebc2558a7f6146bf647 Mon Sep 17 00:00:00 2001 From: Jeef Date: Thu, 9 Apr 2026 15:22:33 -0600 Subject: [PATCH 0688/1707] Prevent the intellifire client from polling independently of its coordinator (#165341) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Robert Resch Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/intellifire/__init__.py | 3 +- .../components/intellifire/coordinator.py | 12 +++++++- tests/components/intellifire/conftest.py | 2 ++ tests/components/intellifire/test_init.py | 29 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 8a325152120346..77171044e9b9a7 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -143,7 +143,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry) try: fireplace: UnifiedFireplace = ( await UnifiedFireplace.build_fireplace_from_common( - _construct_common_data(entry) + _construct_common_data(entry), + polling_enabled=False, ) ) LOGGER.debug("Waiting for Fireplace to Initialize") diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index dc9aa45d58bcd0..c2eb374c3a14c7 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -4,6 +4,7 @@ from datetime import timedelta +import aiohttp from intellifire4py import UnifiedFireplace from intellifire4py.control import IntelliFireController from intellifire4py.model import IntelliFirePollData @@ -11,8 +12,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER @@ -52,6 +54,14 @@ def control_api(self) -> IntelliFireController: return self.fireplace.control_api async def _async_update_data(self) -> IntelliFirePollData: + try: + await self.fireplace.perform_poll() + except aiohttp.ClientResponseError as err: + if err.status == 403: + raise ConfigEntryAuthFailed("Authentication failed") from err + raise UpdateFailed(f"Error communicating with fireplace: {err}") from err + except (aiohttp.ClientError, TimeoutError) as err: + raise UpdateFailed(f"Error communicating with fireplace: {err}") from err return self.fireplace.data @property diff --git a/tests/components/intellifire/conftest.py b/tests/components/intellifire/conftest.py index a82deba64ee923..008e1db9fc3b48 100644 --- a/tests/components/intellifire/conftest.py +++ b/tests/components/intellifire/conftest.py @@ -257,6 +257,8 @@ def mock_fp(mock_common_data_local) -> Generator[AsyncMock]: mock_instance.set_read_mode = AsyncMock() mock_instance.set_control_mode = AsyncMock() + mock_instance.perform_poll = AsyncMock() + mock_instance.async_validate_connectivity = AsyncMock( return_value=(True, False) ) diff --git a/tests/components/intellifire/test_init.py b/tests/components/intellifire/test_init.py index 307a9df812c508..ac689a164b5bab 100644 --- a/tests/components/intellifire/test_init.py +++ b/tests/components/intellifire/test_init.py @@ -342,3 +342,32 @@ async def test_update_options_no_change( mock_fp.set_control_mode.assert_not_called() # But async_request_refresh should still be called coordinator.async_request_refresh.assert_called_once() + + +async def test_coordinator_performs_poll( + hass: HomeAssistant, + mock_config_entry_current: MockConfigEntry, + mock_apis_single_fp, +) -> None: + """Test that the library only polls when instructed by the coordinator. + + The library auto-polls by default; ensure the coordinator disables that + and drives polling explicitly via perform_poll(). + """ + _mock_local, _mock_cloud, mock_fp = mock_apis_single_fp + + with patch( + "homeassistant.components.intellifire.UnifiedFireplace.build_fireplace_from_common", + new_callable=AsyncMock, + return_value=mock_fp, + ) as mock_build: + mock_config_entry_current.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_current.entry_id) + await hass.async_block_till_done() + + # Verify the fireplace was constructed with library background polling disabled + mock_build.assert_awaited_once() + assert mock_build.call_args.kwargs.get("polling_enabled") is False + + # Verify the coordinator drove exactly one poll during initial refresh + mock_fp.perform_poll.assert_awaited_once() From caa1a8880feca95f72e1e0c842a84211ec39ebe9 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:23:37 -0400 Subject: [PATCH 0689/1707] Allow trigger based template entities to skip option validation (#167708) --- homeassistant/components/template/binary_sensor.py | 3 +++ homeassistant/components/template/trigger_entity.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 8bccb47687d2ae..90d03d4f5c8878 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -281,6 +281,9 @@ class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor): domain = BINARY_SENSOR_DOMAIN + # delay on and delay off are validated when the state is validated. + skip_rendered_result = (CONF_DELAY_ON, CONF_DELAY_OFF) + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 134c42bded15ca..03f5f03e000db0 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -30,6 +30,8 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module ): """Template entity based on trigger data.""" + skip_rendered_result: tuple[str, ...] | None = None + def __init__( self, hass: HomeAssistant, @@ -45,6 +47,10 @@ def __init__( self._rendered_entity_variables: dict | None = None self._state_render_error = False + self._skip_rendered_result: list[str] = [] + if self.skip_rendered_result is not None: + self._skip_rendered_result.extend(self.skip_rendered_result) + async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" await super().async_added_to_hass() @@ -204,6 +210,9 @@ def _handle_rendered_results(self) -> bool: return True for option, entity_template in self._templates.items(): + if option in self._skip_rendered_result: + continue + # Capture templates that did not render a result due to an exception and # ensure the state object updates. _SENTINEL is used to differentiate # templates that render None. From 2d45f9978eca30e156a6859164ca2f50c3ad1f90 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:24:36 -0400 Subject: [PATCH 0690/1707] List serial ports via USB integration helpers (Q-Z) (#167701) --- .../rainforest_raven/config_flow.py | 36 +- .../components/rfxtrx/config_flow.py | 25 +- homeassistant/components/rfxtrx/manifest.json | 1 + .../route_b_smart_meter/config_flow.py | 26 +- .../route_b_smart_meter/manifest.json | 2 +- .../components/velbus/config_flow.py | 7 +- .../components/zwave_js/config_flow.py | 26 +- .../components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 - requirements_test_all.txt | 2 - tests/components/rainforest_raven/const.py | 8 +- .../rainforest_raven/test_config_flow.py | 24 +- tests/components/rfxtrx/test_config_flow.py | 90 ++--- .../route_b_smart_meter/test_config_flow.py | 39 +- tests/components/velbus/test_config_flow.py | 41 +- tests/components/zwave_js/test_config_flow.py | 368 ++++++++++++------ 16 files changed, 396 insertions(+), 303 deletions(-) diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py index f8e3dde446ae6f..ac6584f6830fa7 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -8,8 +8,6 @@ from aioraven.data import MeterType from aioraven.device import RAVEnConnectionError from aioraven.serial import RAVEnSerialDevice -import serial.tools.list_ports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol from homeassistant.components import usb @@ -25,16 +23,19 @@ from .const import DEFAULT_NAME, DOMAIN -def _format_id(value: str | int) -> str: +def _format_id(value: str | int | None) -> str: if isinstance(value, str): return value return f"{value or 0:04X}" -def _generate_unique_id(info: ListPortInfo | UsbServiceInfo) -> str: +def _generate_unique_id(info: usb.USBDevice | usb.SerialDevice | UsbServiceInfo) -> str: """Generate unique id from usb attributes.""" + vid = info.vid if isinstance(info, (usb.USBDevice, UsbServiceInfo)) else None + pid = info.pid if isinstance(info, (usb.USBDevice, UsbServiceInfo)) else None + return ( - f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}" + f"{_format_id(vid)}:{_format_id(pid)}_{info.serial_number}" f"_{info.manufacturer}_{info.description}" ) @@ -101,8 +102,7 @@ async def async_step_meters( async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" - device = discovery_info.device - dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + dev_path = discovery_info.device unique_id = _generate_unique_id(discovery_info) await self.async_set_unique_id(unique_id) try: @@ -119,31 +119,29 @@ async def async_step_user( """Handle a flow initiated by the user.""" if self._async_in_progress(): return self.async_abort(reason="already_in_progress") - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await usb.async_scan_serial_ports(self.hass) existing_devices = [ entry.data[CONF_DEVICE] for entry in self._async_current_entries() ] - unused_ports = [ + port_map = { usb.human_readable_device_name( port.device, port.serial_number, port.manufacturer, port.description, - port.vid, - port.pid, - ) + port.vid if isinstance(port, usb.USBDevice) else None, + port.pid if isinstance(port, usb.USBDevice) else None, + ): port for port in ports if port.device not in existing_devices - ] - if not unused_ports: + } + if not port_map: return self.async_abort(reason="no_devices_found") errors = {} if user_input is not None and user_input.get(CONF_DEVICE, "").strip(): - port = ports[unused_ports.index(str(user_input[CONF_DEVICE]))] - dev_path = await self.hass.async_add_executor_job( - usb.get_serial_by_id, port.device - ) + port = port_map[user_input[CONF_DEVICE]] + dev_path = port.device unique_id = _generate_unique_id(port) await self.async_set_unique_id(unique_id) try: @@ -155,5 +153,5 @@ async def async_step_user( else: return await self.async_step_meters() - schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(list(port_map))}) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 53e14fdddf7413..cdc4d3cc55a5bc 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -6,14 +6,12 @@ from contextlib import suppress import copy import itertools -import os from typing import Any, TypedDict, cast import RFXtrx as rfxtrxmod -import serial -import serial.tools.list_ports import voluptuous as vol +from homeassistant.components import usb from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -556,9 +554,7 @@ async def async_step_setup_serial( if user_selection == CONF_MANUAL_PATH: return await self.async_step_setup_serial_manual_path() - dev_path = await self.hass.async_add_executor_job( - get_serial_by_id, user_selection - ) + dev_path = user_selection try: data = await self.async_validate_rfx(device=dev_path) @@ -568,11 +564,12 @@ async def async_step_setup_serial( if not errors: return self.async_create_entry(title="RFXTRX", data=data) - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await usb.async_scan_serial_ports(self.hass) list_of_ports = {} for port in ports: list_of_ports[port.device] = ( - f"{port}, s/n: {port.serial_number or 'n/a'}" + f"{port.device} - {port.description or 'n/a'}" + f", s/n: {port.serial_number or 'n/a'}" + (f" - {port.manufacturer}" if port.manufacturer else "") ) list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH @@ -653,17 +650,5 @@ def _test_transport(host: str | None, port: int | None, device: str | None) -> b return True -def get_serial_by_id(dev_path: str) -> str: - """Return a /dev/serial/by-id match for given device if available.""" - by_id = "/dev/serial/by-id" - if not os.path.isdir(by_id): - return dev_path - - for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): - if os.path.realpath(path) == dev_path: - return path - return dev_path - - class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json index 34df4c26c18611..a6958ae49d72ee 100644 --- a/homeassistant/components/rfxtrx/manifest.json +++ b/homeassistant/components/rfxtrx/manifest.json @@ -3,6 +3,7 @@ "name": "RFXCOM RFXtrx", "codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/rfxtrx", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/route_b_smart_meter/config_flow.py b/homeassistant/components/route_b_smart_meter/config_flow.py index 1cbeeab4c4e607..2d436f3c978fac 100644 --- a/homeassistant/components/route_b_smart_meter/config_flow.py +++ b/homeassistant/components/route_b_smart_meter/config_flow.py @@ -4,11 +4,13 @@ from typing import Any from momonga import Momonga, MomongaSkJoinFailure, MomongaSkScanFailure -from serial.tools.list_ports import comports -from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol -from homeassistant.components.usb import get_serial_by_id, human_readable_device_name +from homeassistant.components.usb import ( + USBDevice, + async_scan_serial_ports, + human_readable_device_name, +) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD from homeassistant.core import callback @@ -25,14 +27,14 @@ def _validate_input(device: str, id: str, password: str) -> None: pass -def _human_readable_device_name(port: UsbServiceInfo | ListPortInfo) -> str: +def _human_readable_device_name(port: UsbServiceInfo | USBDevice) -> str: return human_readable_device_name( port.device, port.serial_number, port.manufacturer, port.description, - str(port.vid) if port.vid else None, - str(port.pid) if port.pid else None, + port.vid, + port.pid, ) @@ -45,11 +47,9 @@ class BRouteConfigFlow(ConfigFlow, domain=DOMAIN): @callback def _get_discovered_device_id_and_name( - self, device_options: dict[str, ListPortInfo] + self, device_options: dict[str, USBDevice] ) -> tuple[str | None, str | None]: - discovered_device_id = ( - get_serial_by_id(self.device.device) if self.device else None - ) + discovered_device_id = self.device.device if self.device else None discovered_device = ( device_options.get(discovered_device_id) if discovered_device_id else None ) @@ -60,10 +60,10 @@ def _get_discovered_device_id_and_name( ) return discovered_device_id, discovered_device_name - async def _get_usb_devices(self) -> dict[str, ListPortInfo]: + async def _get_usb_devices(self) -> dict[str, USBDevice]: """Return a list of available USB devices.""" - devices = await self.hass.async_add_executor_job(comports) - return {get_serial_by_id(port.device): port for port in devices} + devices = await async_scan_serial_ports(self.hass) + return {port.device: port for port in devices if isinstance(port, USBDevice)} async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/route_b_smart_meter/manifest.json b/homeassistant/components/route_b_smart_meter/manifest.json index 6364dbb18d482d..36ff3ed6a209ee 100644 --- a/homeassistant/components/route_b_smart_meter/manifest.json +++ b/homeassistant/components/route_b_smart_meter/manifest.json @@ -13,5 +13,5 @@ "momonga.sk_wrapper_logger" ], "quality_scale": "bronze", - "requirements": ["pyserial==3.5", "momonga==0.3.0"] + "requirements": ["momonga==0.3.0"] } diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index e43ad364e841c0..561b5b26423457 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -6,12 +6,12 @@ import shutil from typing import Any, Final -import serial.tools.list_ports import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed from velbusaio.vlp_reader import VlpFile import voluptuous as vol +from homeassistant.components import usb from homeassistant.components.file_upload import process_uploaded_file from homeassistant.config_entries import ( SOURCE_RECONFIGURE, @@ -115,9 +115,10 @@ async def async_step_usbselect( ) -> ConfigFlowResult: """Handle usb select step.""" step_errors: dict[str, str] = {} - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await usb.async_scan_serial_ports(self.hass) list_of_ports = [ - f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" + f"{p.device} - {p.description or 'n/a'}" + f"{', s/n: ' + p.serial_number if p.serial_number else ''}" + (f" - {p.manufacturer}" if p.manufacturer else "") for p in ports ] diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index b22d1af3c56543..899e6b1faabe1d 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -11,7 +11,6 @@ from typing import Any from awesomeversion import AwesomeVersion -from serial.tools import list_ports import voluptuous as vol from zwave_js_server.client import Client from zwave_js_server.exceptions import FailedCommand @@ -160,30 +159,22 @@ async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo: raise InvalidInput("cannot_connect") from err -def get_usb_ports() -> dict[str, str]: +async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: """Return a dict of USB ports and their friendly names.""" - ports = list_ports.comports() port_descriptions = {} - for port in ports: + for port in await usb.async_scan_serial_ports(hass): if (port.manufacturer, port.description) in IGNORED_USB_DEVICES: continue - vid: str | None = None - pid: str | None = None - if port.vid is not None and port.pid is not None: - usb_device = usb.usb_device_from_port(port) - vid = usb_device.vid - pid = usb_device.pid - dev_path = usb.get_serial_by_id(port.device) human_name = usb.human_readable_device_name( - dev_path, + port.device, port.serial_number, port.manufacturer, port.description, - vid, - pid, + port.vid if isinstance(port, usb.USBDevice) else None, + port.pid if isinstance(port, usb.USBDevice) else None, ) - port_descriptions[dev_path] = human_name + port_descriptions[port.device] = human_name # Filter out "n/a" descriptions only if there are other ports available non_na_ports = { @@ -196,11 +187,6 @@ def get_usb_ports() -> dict[str, str]: return non_na_ports or port_descriptions -async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: - """Return a dict of USB ports and their friendly names.""" - return await hass.async_add_executor_job(get_usb_ports) - - class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Z-Wave JS.""" diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index cdef87d987a650..0abbd85e56be86 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.68.0"], + "requirements": ["zwave-js-server-python==0.68.0"], "usb": [ { "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"], diff --git a/requirements_all.txt b/requirements_all.txt index 3636df6394775a..0efdb67fe44ef7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2466,9 +2466,7 @@ pysenz==1.0.2 pyserial-asyncio-fast==0.16 # homeassistant.components.acer_projector -# homeassistant.components.route_b_smart_meter # homeassistant.components.usb -# homeassistant.components.zwave_js pyserial==3.5 # homeassistant.components.sesame diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80bbc94d3a14af..ae73cfb727da75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2107,9 +2107,7 @@ pysensibo==1.2.1 pysenz==1.0.2 # homeassistant.components.acer_projector -# homeassistant.components.route_b_smart_meter # homeassistant.components.usb -# homeassistant.components.zwave_js pyserial==3.5 # homeassistant.components.seventeentrack diff --git a/tests/components/rainforest_raven/const.py b/tests/components/rainforest_raven/const.py index 320299d2e60eea..22cb2f4813403c 100644 --- a/tests/components/rainforest_raven/const.py +++ b/tests/components/rainforest_raven/const.py @@ -17,8 +17,8 @@ DISCOVERY_INFO = UsbServiceInfo( device="/dev/ttyACM0", - pid="0x0003", - vid="0x04B4", + pid="0003", + vid="04B4", serial_number="1234", description="RFA-Z105-2 HW2.7.3 EMU-2", manufacturer="Rainforest Automation, Inc.", @@ -30,8 +30,8 @@ DISCOVERY_INFO.serial_number, DISCOVERY_INFO.manufacturer, DISCOVERY_INFO.description, - int(DISCOVERY_INFO.vid, 0), - int(DISCOVERY_INFO.pid, 0), + DISCOVERY_INFO.vid, + DISCOVERY_INFO.pid, ) diff --git a/tests/components/rainforest_raven/test_config_flow.py b/tests/components/rainforest_raven/test_config_flow.py index da7e65882a4eaa..dbe1215f022fc3 100644 --- a/tests/components/rainforest_raven/test_config_flow.py +++ b/tests/components/rainforest_raven/test_config_flow.py @@ -5,9 +5,9 @@ from aioraven.device import RAVEnConnectionError import pytest -from serial.tools.list_ports_common import ListPortInfo from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.components.usb import USBDevice from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_DEVICE, CONF_MAC, CONF_SOURCE from homeassistant.core import HomeAssistant @@ -55,17 +55,21 @@ def mock_device_timeout(mock_device: AsyncMock) -> AsyncMock: @pytest.fixture -def mock_comports() -> Generator[list[ListPortInfo]]: +def mock_comports() -> Generator[list[USBDevice]]: """Mock serial port list.""" - port = ListPortInfo(DISCOVERY_INFO.device) - port.serial_number = DISCOVERY_INFO.serial_number - port.manufacturer = DISCOVERY_INFO.manufacturer - port.device = DISCOVERY_INFO.device - port.description = DISCOVERY_INFO.description - port.pid = int(DISCOVERY_INFO.pid, 0) - port.vid = int(DISCOVERY_INFO.vid, 0) + port = USBDevice( + device=DISCOVERY_INFO.device, + vid=f"{int(DISCOVERY_INFO.vid, 16):04X}", + pid=f"{int(DISCOVERY_INFO.pid, 16):04X}", + serial_number=DISCOVERY_INFO.serial_number, + manufacturer=DISCOVERY_INFO.manufacturer, + description=DISCOVERY_INFO.description, + ) comports = [port] - with patch("serial.tools.list_ports.comports", return_value=comports): + with patch( + "homeassistant.components.rainforest_raven.config_flow.usb.async_scan_serial_ports", + return_value=comports, + ): yield comports diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 6fd4fc14bc56b6..cbec1365e4e8d4 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -1,13 +1,12 @@ """Test the Rfxtrx config flow.""" -import os -from unittest.mock import MagicMock, patch, sentinel +from unittest.mock import patch from RFXtrx import RFXtrxTransportError -import serial.tools.list_ports from homeassistant import config_entries -from homeassistant.components.rfxtrx import DOMAIN, config_flow +from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.components.usb import SerialDevice from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -18,15 +17,14 @@ SOME_PROTOCOLS = ["ac", "arc"] -def com_port(): +def com_port() -> SerialDevice: """Mock of a serial port.""" - port = serial.tools.list_ports_common.ListPortInfo("/dev/ttyUSB1234") - port.serial_number = "1234" - port.manufacturer = "Virtual serial port" - port.device = "/dev/ttyUSB1234" - port.description = "Some serial port" - - return port + return SerialDevice( + device="/dev/ttyUSB1234", + serial_number="1234", + manufacturer="Virtual serial port", + description="Some serial port", + ) async def start_options_flow( @@ -76,7 +74,10 @@ async def test_setup_network(transport_mock, hass: HomeAssistant) -> None: } -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.rfxtrx.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial(com_mock, transport_mock, hass: HomeAssistant) -> None: """Test we can setup serial.""" port = com_port() @@ -114,7 +115,10 @@ async def test_setup_serial(com_mock, transport_mock, hass: HomeAssistant) -> No } -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.rfxtrx.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial_manual( com_mock, transport_mock, hass: HomeAssistant ) -> None: @@ -189,7 +193,10 @@ async def test_setup_network_fail(transport_mock, hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.rfxtrx.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial_fail(com_mock, transport_mock, hass: HomeAssistant) -> None: """Test setup serial failed connection.""" transport_mock.return_value.connect.side_effect = RFXtrxTransportError @@ -221,7 +228,10 @@ async def test_setup_serial_fail(com_mock, transport_mock, hass: HomeAssistant) assert result["errors"] == {"base": "cannot_connect"} -@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +@patch( + "homeassistant.components.rfxtrx.config_flow.usb.async_scan_serial_ports", + return_value=[com_port()], +) async def test_setup_serial_manual_fail( com_mock, transport_mock, hass: HomeAssistant ) -> None: @@ -885,51 +895,3 @@ async def test_options_configure_rfy_cover_device( assert isinstance( entry.data["devices"]["0C1a0000010203010000000000"]["device_id"], list ) - - -def test_get_serial_by_id_no_dir() -> None: - """Test serial by id conversion if there's no /dev/serial/by-id.""" - p1 = patch("os.path.isdir", MagicMock(return_value=False)) - p2 = patch("os.scandir") - with p1 as is_dir_mock, p2 as scan_mock: - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.path - assert is_dir_mock.call_count == 1 - assert scan_mock.call_count == 0 - - -def test_get_serial_by_id() -> None: - """Test serial by id conversion.""" - - def _realpath(path): - if path is sentinel.matched_link: - return sentinel.path - return sentinel.serial_link_path - - with ( - patch("os.path.isdir", MagicMock(return_value=True)) as is_dir_mock, - patch("os.scandir") as scan_mock, - patch("os.path.realpath", side_effect=_realpath), - ): - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.path - assert is_dir_mock.call_count == 1 - assert scan_mock.call_count == 1 - - entry1 = MagicMock(spec_set=os.DirEntry) - entry1.is_symlink.return_value = True - entry1.path = sentinel.some_path - - entry2 = MagicMock(spec_set=os.DirEntry) - entry2.is_symlink.return_value = False - entry2.path = sentinel.other_path - - entry3 = MagicMock(spec_set=os.DirEntry) - entry3.is_symlink.return_value = True - entry3.path = sentinel.matched_link - - scan_mock.return_value = [entry1, entry2, entry3] - res = config_flow.get_serial_by_id(sentinel.path) - assert res is sentinel.matched_link - assert is_dir_mock.call_count == 2 - assert scan_mock.call_count == 2 diff --git a/tests/components/route_b_smart_meter/test_config_flow.py b/tests/components/route_b_smart_meter/test_config_flow.py index d7dc84a99992ea..fd12f4977d3360 100644 --- a/tests/components/route_b_smart_meter/test_config_flow.py +++ b/tests/components/route_b_smart_meter/test_config_flow.py @@ -5,9 +5,9 @@ from momonga import MomongaSkJoinFailure, MomongaSkScanFailure import pytest -from serial.tools.list_ports_linux import SysFS from homeassistant.components.route_b_smart_meter.const import DOMAIN, ENTRY_TITLE +from homeassistant.components.usb import SerialDevice, USBDevice from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD from homeassistant.core import HomeAssistant @@ -15,18 +15,27 @@ @pytest.fixture -def mock_comports() -> Generator[AsyncMock]: - """Override comports.""" - device = SysFS("/dev/ttyUSB42") - device.vid = 0x1234 - device.pid = 0x5678 - device.serial_number = "123456" - device.manufacturer = "Test" - device.description = "Test Device" +def mock_serial_ports() -> Generator[AsyncMock]: + """Override scan_serial_ports.""" + device = USBDevice( + device="/dev/ttyUSB42", + vid="1234", + pid="5678", + serial_number="123456", + manufacturer="Test", + description="Test Device", + ) + + unrelated_device = SerialDevice( + device="/dev/ttyUSB41", + serial_number=None, + manufacturer=None, + description=None, + ) with patch( - "homeassistant.components.route_b_smart_meter.config_flow.comports", - return_value=[SysFS("/dev/ttyUSB41"), device], + "homeassistant.components.route_b_smart_meter.config_flow.async_scan_serial_ports", + return_value=[unrelated_device, device], ) as mock: yield mock @@ -34,7 +43,7 @@ def mock_comports() -> Generator[AsyncMock]: async def test_step_user_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_comports: AsyncMock, + mock_serial_ports: AsyncMock, mock_momonga: Mock, user_input: dict[str, str], ) -> None: @@ -55,7 +64,7 @@ async def test_step_user_form( assert result["data"] == user_input assert result["result"].unique_id == user_input[CONF_ID] mock_setup_entry.assert_called_once() - mock_comports.assert_called() + mock_serial_ports.assert_called() mock_momonga.assert_called_once_with( dev=user_input[CONF_DEVICE], rbid=user_input[CONF_ID], @@ -76,7 +85,7 @@ async def test_step_user_form_errors( error: Exception, message: str, mock_setup_entry: AsyncMock, - mock_comports: AsyncMock, + mock_serial_ports: AsyncMock, mock_momonga: AsyncMock, user_input: dict[str, str], ) -> None: @@ -93,7 +102,7 @@ async def test_step_user_form_errors( assert result_configure["type"] is FlowResultType.FORM assert result_configure["errors"] == {"base": message} await hass.async_block_till_done() - mock_comports.assert_called() + mock_serial_ports.assert_called() mock_momonga.assert_called_once_with( dev=user_input[CONF_DEVICE], rbid=user_input[CONF_ID], diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 8c53e5a0329cb4..50ac696949f825 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -7,9 +7,9 @@ from uuid import uuid4 import pytest -import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed +from homeassistant.components.usb import SerialDevice from homeassistant.components.velbus.const import CONF_TLS, CONF_VLP_FILE, DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SOURCE @@ -34,14 +34,14 @@ USB_DEV = "/dev/ttyACME100 - Some serial port, s/n: 1234 - Virtual serial port" -def com_port(): +def com_port() -> SerialDevice: """Mock of a serial port.""" - port = serial.tools.list_ports_common.ListPortInfo(PORT_SERIAL) - port.serial_number = "1234" - port.manufacturer = "Virtual serial port" - port.device = PORT_SERIAL - port.description = "Some serial port" - return port + return SerialDevice( + device=PORT_SERIAL, + serial_number="1234", + manufacturer="Virtual serial port", + description="Some serial port", + ) @pytest.fixture @@ -192,7 +192,10 @@ async def test_user_network_connect_failure( @pytest.mark.usefixtures("controller_connection_failed") -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.velbus.config_flow.usb.async_scan_serial_ports", + new=AsyncMock(return_value=[com_port()]), +) async def test_user_usb_connect_failure(hass: HomeAssistant) -> None: """Test user usb step.""" result = await hass.config_entries.flow.async_init( @@ -215,7 +218,10 @@ async def test_user_usb_connect_failure(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("controller") -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.velbus.config_flow.usb.async_scan_serial_ports", + new=AsyncMock(return_value=[com_port()]), +) async def test_user_usb_success(hass: HomeAssistant) -> None: """Test user usb step.""" result = await hass.config_entries.flow.async_init( @@ -413,7 +419,10 @@ async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("controller") -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.velbus.config_flow.usb.async_scan_serial_ports", + new=AsyncMock(return_value=[com_port()]), +) async def test_flow_usb(hass: HomeAssistant) -> None: """Test usb discovery flow.""" result = await hass.config_entries.flow.async_init( @@ -435,7 +444,10 @@ async def test_flow_usb(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("controller") -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.velbus.config_flow.usb.async_scan_serial_ports", + new=AsyncMock(return_value=[com_port()]), +) async def test_flow_usb_if_already_setup(hass: HomeAssistant) -> None: """Test we abort if Velbus USB discovbery aborts in case it is already setup.""" entry = MockConfigEntry( @@ -465,7 +477,10 @@ async def test_flow_usb_if_already_setup(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("controller_connection_failed") -@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.velbus.config_flow.usb.async_scan_serial_ports", + new=AsyncMock(return_value=[com_port()]), +) async def test_flow_usb_failed(hass: HomeAssistant) -> None: """Test usb discovery flow with a failed velbus test.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 5a26b3509ab9ab..8c9954af9bfea3 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2,7 +2,6 @@ import asyncio from collections.abc import Generator -from copy import copy from ipaddress import ip_address from typing import Any from unittest.mock import AsyncMock, MagicMock, call, patch @@ -12,14 +11,14 @@ from aiohasupervisor.models import AddonsOptions, Discovery import aiohttp import pytest -from serial.tools.list_ports_common import ListPortInfo from voluptuous import InInvalid from zwave_js_server.exceptions import FailedCommand from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo from homeassistant import config_entries, data_entry_flow -from homeassistant.components.zwave_js.config_flow import TITLE, get_usb_ports +from homeassistant.components.usb import SerialDevice, USBDevice +from homeassistant.components.zwave_js.config_flow import TITLE, async_get_usb_ports from homeassistant.components.zwave_js.const import ( ADDON_SLUG, CONF_ADDON_DEVICE, @@ -164,37 +163,42 @@ def mock_addon_setup_time() -> Generator[None]: @pytest.fixture(name="serial_port") -def serial_port_fixture() -> ListPortInfo: +def serial_port_fixture() -> USBDevice: """Return a mock serial port.""" - port = ListPortInfo("/test", skip_link_detection=True) - port.serial_number = "1234" - port.manufacturer = "Virtual serial port" - port.device = "/test" - port.description = "Some serial port" - port.pid = 9876 - port.vid = 5678 - - return port + return USBDevice( + device="/test", + vid="162E", + pid="269C", + serial_number="1234", + manufacturer="Virtual serial port", + description="Some serial port", + ) -@pytest.fixture(name="mock_list_ports", autouse=True) -def mock_list_ports_fixture(serial_port) -> Generator[MagicMock]: - """Mock list ports.""" +@pytest.fixture(name="mock_scan_serial_ports", autouse=True) +def mock_scan_serial_ports_fixture(serial_port: USBDevice) -> Generator[MagicMock]: + """Mock scan serial ports.""" with patch( - "homeassistant.components.zwave_js.config_flow.list_ports.comports" - ) as mock_list_ports: - another_port = copy(serial_port) - another_port.device = "/new" - another_port.description = "New serial port" - another_port.serial_number = "5678" - another_port.pid = 8765 - no_vid_port = copy(serial_port) - no_vid_port.device = "/no_vid" - no_vid_port.description = "Port without vid" - no_vid_port.serial_number = "9123" - no_vid_port.vid = None - mock_list_ports.return_value = [serial_port, another_port, no_vid_port] - yield mock_list_ports + "homeassistant.components.zwave_js.config_flow.usb.async_scan_serial_ports" + ) as mock_scan: + another_port = USBDevice( + device="/new", + vid="162E", + pid="223D", + serial_number="5678", + manufacturer="Virtual serial port", + description="New serial port", + ) + + no_vid_port = SerialDevice( + device="/no_vid", + description="Port without vid", + serial_number="9123", + manufacturer=None, + ) + + mock_scan.return_value = [serial_port, another_port, no_vid_port] + yield mock_scan @pytest.fixture(name="mock_usb_serial_by_id", autouse=True) @@ -4888,44 +4892,92 @@ async def test_configure_addon_usb_ports_failure( assert result["reason"] == "usb_ports_failed" -async def test_get_usb_ports_filtering() -> None: +async def test_get_usb_ports_filtering(hass: HomeAssistant) -> None: """Test that get_usb_ports filters out 'n/a' descriptions when other ports are available.""" mock_ports = [ - ListPortInfo("/dev/ttyUSB0"), - ListPortInfo("/dev/ttyUSB1"), - ListPortInfo("/dev/ttyUSB2"), - ListPortInfo("/dev/ttyUSB3"), + USBDevice( + device="/dev/ttyUSB0", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="n/a", + ), + USBDevice( + device="/dev/ttyUSB1", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="Device A", + ), + USBDevice( + device="/dev/ttyUSB2", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="N/A", + ), + USBDevice( + device="/dev/ttyUSB3", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="Device B", + ), ] - mock_ports[0].description = "n/a" - mock_ports[1].description = "Device A" - mock_ports[2].description = "N/A" - mock_ports[3].description = "Device B" - with patch("serial.tools.list_ports.comports", return_value=mock_ports): - result = get_usb_ports() + with patch( + "homeassistant.components.zwave_js.config_flow.usb.async_scan_serial_ports", + return_value=mock_ports, + ): + result = await async_get_usb_ports(hass) descriptions = list(result.values()) # Verify that only non-"n/a" descriptions are returned assert descriptions == [ - "Device A - /dev/ttyUSB1, s/n: n/a", - "Device B - /dev/ttyUSB3, s/n: n/a", + "Device A - /dev/ttyUSB1, s/n: n/a - 1234:5678", + "Device B - /dev/ttyUSB3, s/n: n/a - 1234:5678", ] -async def test_get_usb_ports_all_na() -> None: +async def test_get_usb_ports_all_na(hass: HomeAssistant) -> None: """Test that get_usb_ports returns all ports as-is when only 'n/a' descriptions exist.""" mock_ports = [ - ListPortInfo("/dev/ttyUSB0"), - ListPortInfo("/dev/ttyUSB1"), - ListPortInfo("/dev/ttyUSB2"), + USBDevice( + device="/dev/ttyUSB0", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="n/a", + ), + USBDevice( + device="/dev/ttyUSB1", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="N/A", + ), + USBDevice( + device="/dev/ttyUSB2", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="n/a", + ), ] - mock_ports[0].description = "n/a" - mock_ports[1].description = "N/A" - mock_ports[2].description = "n/a" - with patch("serial.tools.list_ports.comports", return_value=mock_ports): - result = get_usb_ports() + with patch( + "homeassistant.components.zwave_js.config_flow.usb.async_scan_serial_ports", + return_value=mock_ports, + ): + result = await async_get_usb_ports(hass) descriptions = list(result.values()) @@ -4940,65 +4992,121 @@ async def test_get_usb_ports_all_na() -> None: assert "/dev/ttyUSB2" in device_paths -async def test_get_usb_ports_mixed_case_filtering() -> None: +async def test_get_usb_ports_mixed_case_filtering(hass: HomeAssistant) -> None: """Test that get_usb_ports filters out 'n/a' descriptions with different case variations.""" mock_ports = [ - ListPortInfo("/dev/ttyUSB0"), - ListPortInfo("/dev/ttyUSB1"), - ListPortInfo("/dev/ttyUSB2"), - ListPortInfo("/dev/ttyUSB3"), - ListPortInfo("/dev/ttyUSB4"), + USBDevice( + device="/dev/ttyUSB0", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="n/a", + ), + USBDevice( + device="/dev/ttyUSB1", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="Device A", + ), + USBDevice( + device="/dev/ttyUSB2", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="N/A", + ), + USBDevice( + device="/dev/ttyUSB3", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="n/A", + ), + USBDevice( + device="/dev/ttyUSB4", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="Device B", + ), ] - mock_ports[0].description = "n/a" - mock_ports[1].description = "Device A" - mock_ports[2].description = "N/A" - mock_ports[3].description = "n/A" - mock_ports[4].description = "Device B" - with patch("serial.tools.list_ports.comports", return_value=mock_ports): - result = get_usb_ports() + with patch( + "homeassistant.components.zwave_js.config_flow.usb.async_scan_serial_ports", + return_value=mock_ports, + ): + result = await async_get_usb_ports(hass) descriptions = list(result.values()) # Verify that only non-"n/a" descriptions are returned (case-insensitive filtering) assert descriptions == [ - "Device A - /dev/ttyUSB1, s/n: n/a", - "Device B - /dev/ttyUSB4, s/n: n/a", + "Device A - /dev/ttyUSB1, s/n: n/a - 1234:5678", + "Device B - /dev/ttyUSB4, s/n: n/a - 1234:5678", ] -async def test_get_usb_ports_empty_list() -> None: +async def test_get_usb_ports_empty_list(hass: HomeAssistant) -> None: """Test that get_usb_ports handles empty port list.""" - with patch("serial.tools.list_ports.comports", return_value=[]): - result = get_usb_ports() + with patch( + "homeassistant.components.zwave_js.config_flow.usb.async_scan_serial_ports", + return_value=[], + ): + result = await async_get_usb_ports(hass) # Verify that empty dict is returned assert result == {} -async def test_get_usb_ports_single_na_port() -> None: +async def test_get_usb_ports_single_na_port(hass: HomeAssistant) -> None: """Test that get_usb_ports returns single 'n/a' port when it's the only one available.""" - mock_ports = [ListPortInfo("/dev/ttyUSB0")] - mock_ports[0].description = "n/a" + mock_ports = [ + USBDevice( + device="/dev/ttyUSB0", + vid="1234", + pid="5678", + serial_number=None, + manufacturer=None, + description="n/a", + ), + ] - with patch("serial.tools.list_ports.comports", return_value=mock_ports): - result = get_usb_ports() + with patch( + "homeassistant.components.zwave_js.config_flow.usb.async_scan_serial_ports", + return_value=mock_ports, + ): + result = await async_get_usb_ports(hass) descriptions = list(result.values()) # Verify that the single "n/a" port is returned assert descriptions == [ - "n/a - /dev/ttyUSB0, s/n: n/a", + "n/a - /dev/ttyUSB0, s/n: n/a - 1234:5678", ] -async def test_get_usb_ports_single_valid_port() -> None: +async def test_get_usb_ports_single_valid_port(hass: HomeAssistant) -> None: """Test that get_usb_ports returns single valid port.""" - mock_ports = [ListPortInfo("/dev/ttyUSB0")] - mock_ports[0].description = "Device A" + mock_ports = [ + SerialDevice( + device="/dev/ttyUSB0", + serial_number=None, + manufacturer=None, + description="Device A", + ), + ] - with patch("serial.tools.list_ports.comports", return_value=mock_ports): - result = get_usb_ports() + with patch( + "homeassistant.components.zwave_js.config_flow.usb.async_scan_serial_ports", + return_value=mock_ports, + ): + result = await async_get_usb_ports(hass) descriptions = list(result.values()) @@ -5008,48 +5116,76 @@ async def test_get_usb_ports_single_valid_port() -> None: ] -async def test_get_usb_ports_ignored_devices() -> None: +async def test_get_usb_ports_ignored_devices(hass: HomeAssistant) -> None: """Test that get_usb_ports filters out ignored non-Z-Wave devices.""" mock_ports = [ - ListPortInfo("/dev/ttyUSB0"), - ListPortInfo("/dev/ttyUSB1"), - ListPortInfo("/dev/ttyUSB2"), - ListPortInfo("/dev/ttyUSB3"), - ListPortInfo("/dev/ttyUSB4"), - ListPortInfo("/dev/ttyUSB5"), + # ZBT-2, should be filtered + USBDevice( + device="/dev/ttyUSB0", + vid="10C4", + pid="EA60", + serial_number=None, + manufacturer="Nabu Casa", + description="ZBT-2", + ), + # SkyConnect, should be filtered + USBDevice( + device="/dev/ttyUSB1", + vid="10C4", + pid="EA60", + serial_number=None, + manufacturer="Nabu Casa", + description="SkyConnect v1.0", + ), + # ZBT-1, should be filtered + USBDevice( + device="/dev/ttyUSB2", + vid="10C4", + pid="EA60", + serial_number=None, + manufacturer="Nabu Casa", + description="Home Assistant Connect ZBT-1", + ), + # ZWA-2, should be shown + USBDevice( + device="/dev/ttyUSB3", + vid="10C4", + pid="EA60", + serial_number=None, + manufacturer="Nabu Casa", + description="ZWA-2", + ), + # unknown device with manufacturer/description, should be shown + USBDevice( + device="/dev/ttyUSB4", + vid="10C4", + pid="EA60", + serial_number=None, + manufacturer="Another Manufacturer", + description="Z-Wave USB Adapter", + ), + # unknown device with no manufacturer/description, should be shown + USBDevice( + device="/dev/ttyUSB5", + vid="10C4", + pid="EA60", + serial_number=None, + manufacturer=None, + description=None, + ), ] - # ZBT-2, should be filtered - mock_ports[0].manufacturer = "Nabu Casa" - mock_ports[0].description = "ZBT-2" - - # ZBT-1, should be filtered - mock_ports[2].manufacturer = "Nabu Casa" - mock_ports[2].description = "Home Assistant Connect ZBT-1" - # SkyConnect, should be filtered - mock_ports[1].manufacturer = "Nabu Casa" - mock_ports[1].description = "SkyConnect v1.0" - - # ZWA-2, should be shown - mock_ports[3].manufacturer = "Nabu Casa" - mock_ports[3].description = "ZWA-2" - - # unknown device with manufacturer/description, should be shown - mock_ports[4].manufacturer = "Another Manufacturer" - mock_ports[4].description = "Z-Wave USB Adapter" - - # unknown device with no manufacturer/description, should be shown - mock_ports[5].manufacturer = None - mock_ports[5].description = None - - with patch("serial.tools.list_ports.comports", return_value=mock_ports): - result = get_usb_ports() + with patch( + "homeassistant.components.zwave_js.config_flow.usb.async_scan_serial_ports", + return_value=mock_ports, + ): + result = await async_get_usb_ports(hass) descriptions = list(result.values()) assert descriptions == [ - "ZWA-2 - /dev/ttyUSB3, s/n: n/a - Nabu Casa", - "Z-Wave USB Adapter - /dev/ttyUSB4, s/n: n/a - Another Manufacturer", - "/dev/ttyUSB5, s/n: n/a", + "ZWA-2 - /dev/ttyUSB3, s/n: n/a - Nabu Casa - 10C4:EA60", + "Z-Wave USB Adapter - /dev/ttyUSB4, s/n: n/a - Another Manufacturer - 10C4:EA60", + "/dev/ttyUSB5, s/n: n/a - 10C4:EA60", ] From 496c9551b33db7e37da21511e715973cec7a4333 Mon Sep 17 00:00:00 2001 From: g4bri3lDev Date: Thu, 9 Apr 2026 23:37:25 +0200 Subject: [PATCH 0691/1707] Add event platform for OpenDisplay (#167393) --- .../components/opendisplay/__init__.py | 2 +- .../components/opendisplay/coordinator.py | 10 +- homeassistant/components/opendisplay/event.py | 93 ++++++++ .../components/opendisplay/strings.json | 13 ++ tests/components/opendisplay/__init__.py | 69 ++++++ tests/components/opendisplay/conftest.py | 76 ++++++- tests/components/opendisplay/test_event.py | 215 ++++++++++++++++++ 7 files changed, 473 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/opendisplay/event.py create mode 100644 tests/components/opendisplay/test_event.py diff --git a/homeassistant/components/opendisplay/__init__.py b/homeassistant/components/opendisplay/__init__.py index 30f88df8ed0fd6..01e9e7795ba778 100644 --- a/homeassistant/components/opendisplay/__init__.py +++ b/homeassistant/components/opendisplay/__init__.py @@ -36,7 +36,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _BASE_PLATFORMS: list[Platform] = [] -_FLEX_PLATFORMS = [Platform.SENSOR] +_FLEX_PLATFORMS = [Platform.EVENT, Platform.SENSOR] @dataclass diff --git a/homeassistant/components/opendisplay/coordinator.py b/homeassistant/components/opendisplay/coordinator.py index d7c9431c57f2ed..9b991f3207045e 100644 --- a/homeassistant/components/opendisplay/coordinator.py +++ b/homeassistant/components/opendisplay/coordinator.py @@ -2,11 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import logging -from opendisplay import MANUFACTURER_ID, parse_advertisement -from opendisplay.models.advertisement import AdvertisementData +from opendisplay import MANUFACTURER_ID, AdvertisementTracker, parse_advertisement +from opendisplay.models.advertisement import AdvertisementData, ButtonChangeEvent from homeassistant.components.bluetooth import ( BluetoothChange, @@ -27,6 +27,7 @@ class OpenDisplayUpdate: address: str advertisement: AdvertisementData + button_events: list[ButtonChangeEvent] = field(default_factory=list) class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator): @@ -42,6 +43,7 @@ def __init__(self, hass: HomeAssistant, address: str) -> None: connectable=True, ) self.data: OpenDisplayUpdate | None = None + self._tracker: AdvertisementTracker = AdvertisementTracker() @callback def _async_handle_unavailable( @@ -78,9 +80,11 @@ def _async_handle_bluetooth_event( exc_info=True, ) else: + button_events = self._tracker.update(service_info.address, advertisement) self.data = OpenDisplayUpdate( address=service_info.address, advertisement=advertisement, + button_events=button_events, ) super()._async_handle_bluetooth_event(service_info, change) diff --git a/homeassistant/components/opendisplay/event.py b/homeassistant/components/opendisplay/event.py new file mode 100644 index 00000000000000..9a4bd99626e02a --- /dev/null +++ b/homeassistant/components/opendisplay/event.py @@ -0,0 +1,93 @@ +"""Event platform for OpenDisplay devices — button press/release events.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OpenDisplayConfigEntry +from .entity import OpenDisplayEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OpenDisplayEventEntityDescription(EventEntityDescription): + """Describes an OpenDisplay button event entity.""" + + byte_index: int + button_id: int + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenDisplayConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenDisplay event entities from binary_inputs device config.""" + coordinator = entry.runtime_data.coordinator + + descriptions: list[OpenDisplayEventEntityDescription] = [] + button_number = 0 + for bi in entry.runtime_data.device_config.binary_inputs: + for button_id in range(8): # input_flags is a bitmask over 8 pin slots + if bi.input_flags & (1 << button_id): + button_number += 1 + descriptions.append( + OpenDisplayEventEntityDescription( + key=f"button_{bi.instance_number}_{button_id}", + translation_key="button", + translation_placeholders={"number": str(button_number)}, + device_class=EventDeviceClass.BUTTON, + event_types=["button_down", "button_up"], + byte_index=bi.button_data_byte_index, + button_id=button_id, + ) + ) + + active_unique_ids = {f"{coordinator.address}-{d.key}" for d in descriptions} + button_unique_id_prefix = f"{coordinator.address}-button_" + entity_registry = er.async_get(hass) + for entity_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ): + if ( + entity_entry.domain == "event" + and entity_entry.unique_id.startswith(button_unique_id_prefix) + and entity_entry.unique_id not in active_unique_ids + ): + entity_registry.async_remove(entity_entry.entity_id) + + async_add_entities( + OpenDisplayEventEntity(coordinator, description) for description in descriptions + ) + + +class OpenDisplayEventEntity(OpenDisplayEntity, EventEntity): + """A button event entity for an OpenDisplay device.""" + + entity_description: OpenDisplayEventEntityDescription + _last_processed_data: object | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """Fire events for button transitions reported by this coordinator update.""" + data = self.coordinator.data + if data is not None and data is not self._last_processed_data: + for event in data.button_events: + if ( + event.byte_index == self.entity_description.byte_index + and event.button_id == self.entity_description.button_id + and event.event_type in self.event_types + ): + self._trigger_event(event.event_type) + self._last_processed_data = data + self.async_write_ha_state() diff --git a/homeassistant/components/opendisplay/strings.json b/homeassistant/components/opendisplay/strings.json index bfc4ffe900143e..92478b3278e99f 100644 --- a/homeassistant/components/opendisplay/strings.json +++ b/homeassistant/components/opendisplay/strings.json @@ -51,6 +51,19 @@ } }, "entity": { + "event": { + "button": { + "name": "Button {number}", + "state_attributes": { + "event_type": { + "state": { + "button_down": "Button down", + "button_up": "Button up" + } + } + } + } + }, "sensor": { "battery_voltage": { "name": "Battery voltage" diff --git a/tests/components/opendisplay/__init__.py b/tests/components/opendisplay/__init__.py index 732df3736ba51c..4c0931b1bd60fb 100644 --- a/tests/components/opendisplay/__init__.py +++ b/tests/components/opendisplay/__init__.py @@ -4,6 +4,7 @@ from bleak.backends.scanner import AdvertisementData from opendisplay import ( + BinaryInputs, BoardManufacturer, ColorScheme, DisplayConfig, @@ -118,6 +119,74 @@ def make_service_info( ) +BINARY_INPUT = BinaryInputs( + instance_number=0, + input_type=0, + display_as=0, + reserved_pins=b"\x00" * 8, + input_flags=0x01, # bit 0 set → button_id 0 active + invert=0, + pullups=0, + pulldowns=0, + button_data_byte_index=0, +) + +BUTTON_DEVICE_CONFIG = GlobalConfig( + system=DEVICE_CONFIG.system, + manufacturer=DEVICE_CONFIG.manufacturer, + power=DEVICE_CONFIG.power, + displays=DEVICE_CONFIG.displays, + binary_inputs=[BINARY_INPUT], +) + + +def make_v1_service_info( + dynamic_data: bytes = b"\x00" * 11, + name: str | None = "OpenDisplay 1234", + address: str = TEST_ADDRESS, +) -> BluetoothServiceInfoBleak: + """Create a v1 advertisement service info with a custom 11-byte dynamic block.""" + # temperature=25.0°C, battery≈3700 mV, loop_counter=1 + return make_service_info( + name=name, + address=address, + manufacturer_data={OPENDISPLAY_MANUFACTURER_ID: dynamic_data + b"\x82\x72\x11"}, + ) + + +def make_binary_inputs( + instance_number: int = 0, + byte_index: int = 0, + input_flags: int = 0x01, +) -> BinaryInputs: + """Create a minimal BinaryInputs config entry. + + input_flags is a bitmask of active inputs: bit N set means button_id N is active. + """ + return BinaryInputs( + instance_number=instance_number, + input_type=0, + display_as=0, + reserved_pins=b"\x00" * 8, + input_flags=input_flags, + invert=0, + pullups=0, + pulldowns=0, + button_data_byte_index=byte_index, + ) + + +def make_button_device_config(binary_inputs: list[BinaryInputs]) -> GlobalConfig: + """Return a GlobalConfig with the given binary_inputs list.""" + return GlobalConfig( + system=DEVICE_CONFIG.system, + manufacturer=DEVICE_CONFIG.manufacturer, + power=DEVICE_CONFIG.power, + displays=DEVICE_CONFIG.displays, + binary_inputs=binary_inputs, + ) + + VALID_SERVICE_INFO = make_service_info() NOT_OPENDISPLAY_SERVICE_INFO = make_service_info( diff --git a/tests/components/opendisplay/conftest.py b/tests/components/opendisplay/conftest.py index cd6eab11a20511..940033f9cfb298 100644 --- a/tests/components/opendisplay/conftest.py +++ b/tests/components/opendisplay/conftest.py @@ -7,7 +7,16 @@ from homeassistant.components.opendisplay.const import CONF_ENCRYPTION_KEY, DOMAIN -from . import DEVICE_CONFIG, ENCRYPTION_KEY, FIRMWARE_VERSION, TEST_ADDRESS, TEST_TITLE +from . import ( + BUTTON_DEVICE_CONFIG, + DEVICE_CONFIG, + ENCRYPTION_KEY, + FIRMWARE_VERSION, + TEST_ADDRESS, + TEST_TITLE, + make_binary_inputs, + make_button_device_config, +) from tests.common import MockConfigEntry from tests.components.bluetooth import generate_ble_device @@ -81,6 +90,71 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_button_config_entry(mock_opendisplay_device: MagicMock) -> MockConfigEntry: + """Create a mock config entry for a device with one button configured.""" + mock_opendisplay_device.config = BUTTON_DEVICE_CONFIG + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + title=TEST_TITLE, + data={}, + ) + + +@pytest.fixture +def mock_two_button_config_entry(mock_opendisplay_device: MagicMock) -> MockConfigEntry: + """Create a mock config entry for a device with two buttons configured.""" + mock_opendisplay_device.config = make_button_device_config( + [make_binary_inputs(input_flags=0x03)] + ) + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + title=TEST_TITLE, + data={}, + ) + + +@pytest.fixture +def mock_three_button_config_entry( + mock_opendisplay_device: MagicMock, +) -> MockConfigEntry: + """Create a mock config entry for a device with three buttons configured.""" + mock_opendisplay_device.config = make_button_device_config( + [make_binary_inputs(input_flags=0x07)] + ) + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + title=TEST_TITLE, + data={}, + ) + + +@pytest.fixture +def mock_multi_instance_config_entry( + mock_opendisplay_device: MagicMock, +) -> MockConfigEntry: + """Create a mock config entry with two binary_inputs instances. + + Instance 0: byte_index=0, buttons 0 and 1 active → Button 1, Button 2 + Instance 1: byte_index=1, button 0 active → Button 3 + """ + mock_opendisplay_device.config = make_button_device_config( + [ + make_binary_inputs(instance_number=0, byte_index=0, input_flags=0x03), + make_binary_inputs(instance_number=1, byte_index=1, input_flags=0x01), + ] + ) + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + title=TEST_TITLE, + data={}, + ) + + @pytest.fixture def mock_encrypted_config_entry() -> MockConfigEntry: """Create a mock config entry with an encryption key.""" diff --git a/tests/components/opendisplay/test_event.py b/tests/components/opendisplay/test_event.py new file mode 100644 index 00000000000000..5cb651cf108e00 --- /dev/null +++ b/tests/components/opendisplay/test_event.py @@ -0,0 +1,215 @@ +"""Test the OpenDisplay button event platform.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + TEST_ADDRESS, + make_binary_inputs, + make_button_device_config, + make_v1_service_info, +) + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_no_entities_without_binary_inputs( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """No event entities are created when the device has no binary inputs configured.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert not any(e.domain == "event" for e in entries) + + +async def test_entities_created_per_active_button( + hass: HomeAssistant, + mock_three_button_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """One event entity is created per active button bit in binary_inputs.""" + mock_three_button_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup( + mock_three_button_config_entry.entry_id + ) + await hass.async_block_till_done() + + entries = er.async_entries_for_config_entry( + entity_registry, mock_three_button_config_entry.entry_id + ) + event_entries = [e for e in entries if e.domain == "event"] + assert len(event_entries) == 3 + assert {e.unique_id for e in event_entries} == { + f"{TEST_ADDRESS}-button_0_0", + f"{TEST_ADDRESS}-button_0_1", + f"{TEST_ADDRESS}-button_0_2", + } + + +async def test_multiple_binary_input_instances( + hass: HomeAssistant, + mock_multi_instance_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Entities across multiple BinaryInputs instances get globally sequential button numbers.""" + mock_multi_instance_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup( + mock_multi_instance_config_entry.entry_id + ) + await hass.async_block_till_done() + + entries = er.async_entries_for_config_entry( + entity_registry, mock_multi_instance_config_entry.entry_id + ) + event_entries = [e for e in entries if e.domain == "event"] + assert len(event_entries) == 3 + assert {e.unique_id for e in event_entries} == { + f"{TEST_ADDRESS}-button_0_0", + f"{TEST_ADDRESS}-button_0_1", + f"{TEST_ADDRESS}-button_1_0", + } + # Button numbers must be globally sequential, not reset per instance + names = { + e.unique_id: hass.states.get(e.entity_id).attributes.get("friendly_name", "") + for e in event_entries + } + assert names[f"{TEST_ADDRESS}-button_0_0"].endswith("Button 1") + assert names[f"{TEST_ADDRESS}-button_0_1"].endswith("Button 2") + assert names[f"{TEST_ADDRESS}-button_1_0"].endswith("Button 3") + + +async def test_stale_entities_removed_on_config_change( + hass: HomeAssistant, + mock_two_button_config_entry: MockConfigEntry, + mock_opendisplay_device: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Entities for buttons no longer in device config are removed on reload.""" + mock_two_button_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_two_button_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + len( + [ + e + for e in er.async_entries_for_config_entry( + entity_registry, mock_two_button_config_entry.entry_id + ) + if e.domain == "event" + ] + ) + == 2 + ) + + # Device reconfigured: now only 1 active button + mock_opendisplay_device.config = make_button_device_config( + [make_binary_inputs(input_flags=0x01)] + ) + assert await hass.config_entries.async_unload(mock_two_button_config_entry.entry_id) + assert await hass.config_entries.async_setup(mock_two_button_config_entry.entry_id) + await hass.async_block_till_done() + + event_entries = [ + e + for e in er.async_entries_for_config_entry( + entity_registry, mock_two_button_config_entry.entry_id + ) + if e.domain == "event" + ] + assert len(event_entries) == 1 + assert event_entries[0].unique_id == f"{TEST_ADDRESS}-button_0_0" + + +async def test_button_down_event_fired( + hass: HomeAssistant, + mock_button_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """A 'button_down' event fires when the button transitions to the down state.""" + mock_button_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_button_config_entry.entry_id) + await hass.async_block_till_done() + + # First advertisement — seeds tracker state (no events emitted) + inject_bluetooth_service_info(hass, make_v1_service_info()) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "event", "opendisplay", f"{TEST_ADDRESS}-button_0_0" + ) + assert entity_id is not None + state_before = hass.states.get(entity_id) + + # Second advertisement — byte 0: pressed=True, button_id=0 → raw=0x80 + inject_bluetooth_service_info(hass, make_v1_service_info(b"\x80" + b"\x00" * 10)) + await hass.async_block_till_done() + + state_after = hass.states.get(entity_id) + assert state_after is not None + assert state_after.attributes.get("event_type") == "button_down" + assert state_before != state_after + + +async def test_button_up_event_fired( + hass: HomeAssistant, + mock_button_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """A 'button_up' event fires when the button transitions from down to up.""" + mock_button_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_button_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "event", "opendisplay", f"{TEST_ADDRESS}-button_0_0" + ) + assert entity_id is not None + + # Seed with pressed state (pressed=True, button_id=0 → raw=0x80) + inject_bluetooth_service_info(hass, make_v1_service_info(b"\x80" + b"\x00" * 10)) + await hass.async_block_till_done() + + # Transition to released (pressed=False, button_id=0 → raw=0x00) + inject_bluetooth_service_info(hass, make_v1_service_info()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes.get("event_type") == "button_up" + + +async def test_no_event_for_wrong_button_id( + hass: HomeAssistant, + mock_two_button_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Entity watching button_id=1 does not fire when button_id=0 changes.""" + mock_two_button_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_two_button_config_entry.entry_id) + await hass.async_block_till_done() + + inject_bluetooth_service_info(hass, make_v1_service_info()) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "event", "opendisplay", f"{TEST_ADDRESS}-button_0_1" + ) + assert entity_id is not None + state_before = hass.states.get(entity_id) + + # Press button_id=0 only (raw=0x80: pressed=True, button_id=0) + inject_bluetooth_service_info(hass, make_v1_service_info(b"\x80" + b"\x00" * 10)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) == state_before From 5f8483ba078fd2125f59bd385c856359954fd0af Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:40:15 -0500 Subject: [PATCH 0692/1707] Add party mode to Russound RIO (#167342) --- .../components/russound_rio/__init__.py | 2 +- .../components/russound_rio/icons.json | 5 + .../components/russound_rio/select.py | 85 ++++++ .../components/russound_rio/strings.json | 10 + tests/components/russound_rio/conftest.py | 1 + .../russound_rio/snapshots/test_select.ambr | 245 ++++++++++++++++++ tests/components/russound_rio/test_select.py | 69 +++++ 7 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/russound_rio/select.py create mode 100644 tests/components/russound_rio/snapshots/test_select.ambr create mode 100644 tests/components/russound_rio/test_select.py diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index ddaa83632df773..c4dd54644ac702 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -14,7 +14,7 @@ from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS -PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SWITCH] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/russound_rio/icons.json b/homeassistant/components/russound_rio/icons.json index 7d4ddc4cf98d2a..e7cf42dc5848b0 100644 --- a/homeassistant/components/russound_rio/icons.json +++ b/homeassistant/components/russound_rio/icons.json @@ -1,5 +1,10 @@ { "entity": { + "select": { + "party_mode": { + "default": "mdi:party-popper" + } + }, "switch": { "loudness": { "default": "mdi:volume-high", diff --git a/homeassistant/components/russound_rio/select.py b/homeassistant/components/russound_rio/select.py new file mode 100644 index 00000000000000..6735851015e8c2 --- /dev/null +++ b/homeassistant/components/russound_rio/select.py @@ -0,0 +1,85 @@ +"""Support for Russound RIO select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from aiorussound.models import PartyMode +from aiorussound.rio import Controller, ZoneControlSurface + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import RussoundConfigEntry +from .entity import RussoundBaseEntity, command + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RussoundZoneSelectEntityDescription(SelectEntityDescription): + """Describes Russound RIO select entity.""" + + value_fn: Callable[[ZoneControlSurface], str | None] + set_value_fn: Callable[[ZoneControlSurface, str], Awaitable[None]] + + +CONTROL_ENTITIES: tuple[RussoundZoneSelectEntityDescription, ...] = ( + RussoundZoneSelectEntityDescription( + key="party_mode", + translation_key="party_mode", + options=[ + PartyMode.OFF.value.lower(), + PartyMode.ON.value.lower(), + PartyMode.MASTER.value.lower(), + ], + entity_category=EntityCategory.CONFIG, + value_fn=lambda zone: zone.party_mode.lower() if zone.party_mode else None, + set_value_fn=lambda zone, value: zone.set_party_mode(PartyMode(value.upper())), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RussoundConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Russound RIO select entities based on a config entry.""" + client = entry.runtime_data + async_add_entities( + RussoundSelectEntity(controller, zone_id, description) + for controller in client.controllers.values() + for zone_id in controller.zones + for description in CONTROL_ENTITIES + ) + + +class RussoundSelectEntity(RussoundBaseEntity, SelectEntity): + """Defines a Russound RIO select entity.""" + + entity_description: RussoundZoneSelectEntityDescription + + def __init__( + self, + controller: Controller, + zone_id: int, + description: RussoundZoneSelectEntityDescription, + ) -> None: + """Initialize Russound RIO select.""" + super().__init__(controller, zone_id) + self.entity_description = description + self._attr_unique_id = ( + f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}" + ) + + @property + def current_option(self) -> str | None: + """Return the state of the select.""" + return self.entity_description.value_fn(self._zone) + + @command + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.set_value_fn(self._zone, option) diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index 7fdc3cdc7afdad..1722494f328958 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -55,6 +55,16 @@ "name": "Turn-on volume" } }, + "select": { + "party_mode": { + "name": "Party mode", + "state": { + "master": "Leader", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } + }, "switch": { "loudness": { "name": "Loudness" diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 8aa144333e537e..8c1f96b5d00a8b 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -85,6 +85,7 @@ def mock_russound_client() -> Generator[AsyncMock]: zone.set_turn_on_volume = AsyncMock() zone.set_loudness = AsyncMock() zone.restore_preset = AsyncMock() + zone.set_party_mode = AsyncMock() client.controllers = { 1: Controller( diff --git a/tests/components/russound_rio/snapshots/test_select.ambr b/tests/components/russound_rio/snapshots/test_select.ambr new file mode 100644 index 00000000000000..9ec68a75e4ad7f --- /dev/null +++ b/tests/components/russound_rio/snapshots/test_select.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_all_entities[select.backyard_party_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + 'master', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.backyard_party_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Party mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Party mode', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'party_mode', + 'unique_id': '00:11:22:33:44:55-C[1].Z[1]-party_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.backyard_party_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Backyard Party mode', + 'options': list([ + 'off', + 'on', + 'master', + ]), + }), + 'context': , + 'entity_id': 'select.backyard_party_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[select.bedroom_party_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + 'master', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.bedroom_party_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Party mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Party mode', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'party_mode', + 'unique_id': '00:11:22:33:44:55-C[1].Z[3]-party_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.bedroom_party_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Party mode', + 'options': list([ + 'off', + 'on', + 'master', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_party_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[select.kitchen_party_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + 'master', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.kitchen_party_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Party mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Party mode', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'party_mode', + 'unique_id': '00:11:22:33:44:55-C[1].Z[2]-party_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.kitchen_party_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Party mode', + 'options': list([ + 'off', + 'on', + 'master', + ]), + }), + 'context': , + 'entity_id': 'select.kitchen_party_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[select.living_room_party_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + 'master', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.living_room_party_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Party mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Party mode', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'party_mode', + 'unique_id': '00:11:22:33:44:55-C[2].Z[9]-party_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.living_room_party_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Party mode', + 'options': list([ + 'off', + 'on', + 'master', + ]), + }), + 'context': , + 'entity_id': 'select.living_room_party_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/russound_rio/test_select.py b/tests/components/russound_rio/test_select.py new file mode 100644 index 00000000000000..c8b957c74b30f0 --- /dev/null +++ b/tests/components/russound_rio/test_select.py @@ -0,0 +1,69 @@ +"""Tests for the Russound RIO select platform.""" + +from unittest.mock import AsyncMock, patch + +from aiorussound.models import PartyMode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.russound_rio.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_setting_value( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.backyard_party_mode", + ATTR_OPTION: "master", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].set_party_mode.assert_called_once_with( + PartyMode.MASTER + ) + mock_russound_client.controllers[1].zones[1].set_party_mode.reset_mock() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.backyard_party_mode", + ATTR_OPTION: "off", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].set_party_mode.assert_called_once_with( + PartyMode.OFF + ) From 86b5efaf2c71d0ffd2461bfb2b0978ffcf52ca2f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 9 Apr 2026 23:44:42 +0200 Subject: [PATCH 0693/1707] =?UTF-8?q?Don't=20use=20async=5Fupdate=5Freload?= =?UTF-8?q?=5Fand=5Fabort=20with=20update=20listeners=20in=20tele=E2=80=A6?= =?UTF-8?q?=20(#167696)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/telegram_bot/__init__.py | 3 +++ homeassistant/components/telegram_bot/bot.py | 1 + homeassistant/components/telegram_bot/config_flow.py | 6 +++--- tests/components/telegram_bot/test_config_flow.py | 5 ++++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index e757830811f66a..2ef42e7d9586e0 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -913,6 +913,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) async def update_listener(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> None: """Handle config changes.""" entry.runtime_data.parse_mode = entry.options[ATTR_PARSER] + if entry.runtime_data.old_config_data != entry.data: + # Reload if config data has changed + hass.config_entries.async_schedule_reload(entry.entry_id) # reload entities await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index fd45e4c219d543..1763ba617ffd92 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -302,6 +302,7 @@ def __init__( """Initialize the service.""" self.app = app self.config = config + self.old_config_data = config.data.copy() self._parsers: dict[str, str | None] = { PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN, diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index c2d6ed368edc6f..596b2a65861cd6 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -369,7 +369,7 @@ async def async_step_webhooks( if self.source == SOURCE_RECONFIGURE: user_input.update(self._step_user_data) - return self.async_update_reload_and_abort( + return self.async_update_and_abort( self._get_reconfigure_entry(), title=self._bot_name, data_updates=user_input, @@ -534,7 +534,7 @@ async def async_step_reconfigure( if user_input[CONF_PLATFORM] != PLATFORM_WEBHOOKS: await self._shutdown_bot() - return self.async_update_reload_and_abort( + return self.async_update_and_abort( self._get_reconfigure_entry(), title=bot_name, data_updates=user_input ) @@ -579,7 +579,7 @@ async def async_step_reauth_confirm( description_placeholders=description_placeholders, ) - return self.async_update_reload_and_abort( + return self.async_update_and_abort( self._get_reauth_entry(), title=bot_name, data_updates=updated_data ) diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 5db2e92edbfa57..df398efca00475 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -75,11 +75,14 @@ async def test_options_flow( async def test_reconfigure_flow_broadcast( hass: HomeAssistant, - mock_webhooks_config_entry: MockConfigEntry, + mock_register_webhook: None, mock_external_calls: None, + mock_webhooks_config_entry: MockConfigEntry, ) -> None: """Test reconfigure flow for broadcast bot.""" mock_webhooks_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_webhooks_config_entry.entry_id) + await hass.async_block_till_done() result = await mock_webhooks_config_entry.start_reconfigure_flow(hass) assert result["step_id"] == "reconfigure" From 910dcb4d689544c757ce336b64b207c46cd737b9 Mon Sep 17 00:00:00 2001 From: David Bishop Date: Thu, 9 Apr 2026 14:45:37 -0700 Subject: [PATCH 0694/1707] Govee light local availability test cleanup (#167702) Co-authored-by: Claude Opus 4.6 (1M context) --- .../govee_light_local/test_light.py | 97 ++++++++----------- 1 file changed, 42 insertions(+), 55 deletions(-) diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index a3e7d39ee97ded..b524cffe6aae2c 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -1,10 +1,12 @@ """Test Govee light local.""" from errno import EADDRINUSE, ENETDOWN -from unittest.mock import AsyncMock, MagicMock, call, patch +from typing import Any +from unittest.mock import AsyncMock, call, patch from freezegun.api import FrozenDateTimeFactory from govee_local_api import GoveeDevice +from govee_local_api.light_capabilities import ON_OFF_CAPABILITIES from govee_local_api.message import DevStatusResponse import pytest @@ -16,6 +18,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, @@ -84,7 +87,7 @@ async def test_light_unknown_device( ip="192.168.1.101", fingerprint="unkown_device", sku="XYZK", - capabilities=None, + capabilities=ON_OFF_CAPABILITIES, ) ] @@ -194,7 +197,7 @@ async def test_light_setup_error( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: +async def test_light_on_off(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test light on and then off.""" mock_govee_api.devices = [ @@ -269,12 +272,12 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N ) async def test_turn_on_call_order( hass: HomeAssistant, - mock_govee_api: MagicMock, + mock_govee_api: AsyncMock, attribute: str, value: str | int | list[int], mock_call: str, mock_call_args: list[str], - mock_call_kwargs: dict[str, any], + mock_call_kwargs: dict[str, Any], ) -> None: """Test that turn_on is called after set_brightness/set_color/set_preset.""" mock_govee_api.devices = [ @@ -318,7 +321,7 @@ async def test_turn_on_call_order( ) -async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: +async def test_light_brightness(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test changing brightness.""" mock_govee_api.devices = [ GoveeDevice( @@ -345,7 +348,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {"entity_id": light.entity_id, "brightness_pct": 50}, + {"entity_id": light.entity_id, ATTR_BRIGHTNESS_PCT: 50}, blocking=True, ) await hass.async_block_till_done() @@ -385,8 +388,8 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) -async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: - """Test changing brightness.""" +async def test_light_color(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: + """Test changing color.""" mock_govee_api.devices = [ GoveeDevice( controller=mock_govee_api, @@ -421,7 +424,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No assert light is not None assert light.state == "on" assert light.attributes[ATTR_RGB_COLOR] == (100, 255, 50) - assert light.attributes["color_mode"] == ColorMode.RGB + assert light.attributes[ATTR_COLOR_MODE] == ColorMode.RGB mock_govee_api.set_color.assert_awaited_with( mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None @@ -430,7 +433,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {"entity_id": light.entity_id, "color_temp_kelvin": 4400}, + {"entity_id": light.entity_id, ATTR_COLOR_TEMP_KELVIN: 4400}, blocking=True, ) await hass.async_block_till_done() @@ -438,15 +441,15 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["color_temp_kelvin"] == 4400 - assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + assert light.attributes[ATTR_COLOR_TEMP_KELVIN] == 4400 + assert light.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP mock_govee_api.set_color.assert_awaited_with( mock_govee_api.devices[0], rgb=None, temperature=4400 ) -async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: +async def test_scene_on(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test turning on scene.""" mock_govee_api.devices = [ @@ -487,7 +490,7 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: async def test_scene_restore_rgb( - hass: HomeAssistant, mock_govee_api: MagicMock + hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test restore rgb color.""" @@ -570,7 +573,7 @@ async def test_scene_restore_rgb( async def test_scene_restore_temperature( - hass: HomeAssistant, mock_govee_api: MagicMock + hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test restore color temperature.""" @@ -601,7 +604,7 @@ async def test_scene_restore_temperature( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, + {"entity_id": light.entity_id, ATTR_COLOR_TEMP_KELVIN: initial_color}, blocking=True, ) await hass.async_block_till_done() @@ -609,7 +612,7 @@ async def test_scene_restore_temperature( light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - assert light.attributes["color_temp_kelvin"] == initial_color + assert light.attributes[ATTR_COLOR_TEMP_KELVIN] == initial_color mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) # Activate scene @@ -640,11 +643,11 @@ async def test_scene_restore_temperature( assert light is not None assert light.state == "on" assert light.attributes[ATTR_EFFECT] is None - assert light.attributes["color_temp_kelvin"] == initial_color + assert light.attributes[ATTR_COLOR_TEMP_KELVIN] == initial_color async def test_update_callback_registered_and_triggers_state_update( - hass: HomeAssistant, mock_govee_api: MagicMock + hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test that update callback is registered and triggers state update.""" device = GoveeDevice( @@ -679,7 +682,7 @@ async def test_update_callback_registered_and_triggers_state_update( async def test_update_callback_cleared_on_remove( - hass: HomeAssistant, mock_govee_api: MagicMock + hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test that update callback is cleared when entity is removed.""" device = GoveeDevice( @@ -705,7 +708,7 @@ async def test_update_callback_cleared_on_remove( assert device.update_callback is None -async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: +async def test_scene_none(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test turn on 'none' scene.""" mock_govee_api.devices = [ @@ -794,12 +797,17 @@ def _status_response( ) -async def test_device_becomes_unavailable_after_timeout( +async def test_device_availability( hass: HomeAssistant, - mock_govee_api: MagicMock, + mock_govee_api: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: - """Test that a device goes unavailable when no status response arrives.""" + """Test device availability tracks lastseen against DEVICE_TIMEOUT. + + Walks the full timeline in a single fixture: stays available below the + timeout, goes unavailable past it, and recovers when a status response + refreshes ``lastseen``. + """ device = GoveeDevice( controller=mock_govee_api, ip="192.168.1.100", @@ -819,40 +827,18 @@ async def test_device_becomes_unavailable_after_timeout( assert state is not None assert state.state == STATE_OFF - # Advance past DEVICE_TIMEOUT without firing any status responses, and - # tick the coordinator forward so a state write occurs. - freezer.tick(DEVICE_TIMEOUT + SCAN_INTERVAL) + # Advance but stay below DEVICE_TIMEOUT: the device must remain available + # even though no status responses have arrived. + freezer.tick(DEVICE_TIMEOUT - SCAN_INTERVAL) async_fire_time_changed(hass, dt_util.utcnow()) await hass.async_block_till_done() state = hass.states.get("light.H615A") assert state is not None - assert state.state == STATE_UNAVAILABLE - - -async def test_device_recovers_after_status_response( - hass: HomeAssistant, - mock_govee_api: MagicMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Test that an unavailable device recovers when it responds again.""" - device = GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=DEFAULT_CAPABILITIES, - ) - mock_govee_api.devices = [device] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert state.state == STATE_OFF - # Drive it unavailable first. - freezer.tick(DEVICE_TIMEOUT + SCAN_INTERVAL) + # Advance past DEVICE_TIMEOUT: the device should go unavailable. + freezer.tick(SCAN_INTERVAL * 2) async_fire_time_changed(hass, dt_util.utcnow()) await hass.async_block_till_done() @@ -860,7 +846,8 @@ async def test_device_recovers_after_status_response( assert state is not None assert state.state == STATE_UNAVAILABLE - # A status response refreshes lastseen and fires the entity callback. + # A status response refreshes lastseen and fires the entity callback, so + # the device recovers without waiting for another coordinator poll. device.update(_status_response()) await hass.async_block_till_done() @@ -871,7 +858,7 @@ async def test_device_recovers_after_status_response( async def test_one_silent_device_does_not_affect_others( hass: HomeAssistant, - mock_govee_api: MagicMock, + mock_govee_api: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that one silent device does not pull the others unavailable.""" From 8f6ae15a6ac699dd95f69832b059bed5e9a382a8 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 9 Apr 2026 23:46:50 +0200 Subject: [PATCH 0695/1707] KNX: Configure entity expose from config panel UI (#167692) --- homeassistant/components/knx/__init__.py | 4 + homeassistant/components/knx/knx_module.py | 2 + .../components/knx/storage/config_store.py | 59 ++++++- .../knx/storage/expose_controller.py | 154 ++++++++++++++++++ .../components/knx/storage/migration.py | 5 + homeassistant/components/knx/strings.json | 42 +++++ homeassistant/components/knx/websocket.py | 142 ++++++++++++++++ .../fixtures/config_store_binarysensor.json | 3 +- .../knx/fixtures/config_store_light.json | 3 +- .../knx/snapshots/test_diagnostic.ambr | 8 + tests/components/knx/test_config_store.py | 129 ++++++++++++++- tests/components/knx/test_expose.py | 114 +++++++++++++ 12 files changed, 658 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/knx/storage/expose_controller.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 40c5ea8a65b8af..d9ec7dd0937bc6 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -123,6 +123,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: knx_module.ui_time_server_controller.start( knx_module.xknx, knx_module.config_store.get_time_server_config() ) + knx_module.ui_expose_controller.start( + hass, knx_module.xknx, knx_module.config_store.get_exposes() + ) if CONF_KNX_EXPOSE in config: knx_module.yaml_exposures.extend( create_combined_knx_exposure(hass, knx_module.xknx, config[CONF_KNX_EXPOSE]) @@ -157,6 +160,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for exposure in knx_module.service_exposures.values(): exposure.async_remove() knx_module.ui_time_server_controller.stop() + knx_module.ui_expose_controller.stop() configured_platforms_yaml = { platform diff --git a/homeassistant/components/knx/knx_module.py b/homeassistant/components/knx/knx_module.py index 105817a04d5922..698fd3041b469a 100644 --- a/homeassistant/components/knx/knx_module.py +++ b/homeassistant/components/knx/knx_module.py @@ -58,6 +58,7 @@ from .project import KNXProject from .repairs import data_secure_group_key_issue_dispatcher from .storage.config_store import KNXConfigStore +from .storage.expose_controller import ExposeController from .storage.time_server import TimeServerController from .telegrams import Telegrams @@ -76,6 +77,7 @@ def __init__( self.connected = False self.yaml_exposures: list[KnxExposeEntity | KnxExposeTime] = [] self.service_exposures: dict[str, KnxExposeEntity | KnxExposeTime] = {} + self.ui_expose_controller = ExposeController() self.ui_time_server_controller = TimeServerController() self.entry = entry diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 05a74fcc15d8d7..3df90ed45fd417 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -11,15 +11,16 @@ from homeassistant.helpers.storage import Store from homeassistant.util.ulid import ulid_now -from ..const import DOMAIN +from ..const import DOMAIN, KNX_MODULE_KEY from . import migration from .const import CONF_DATA +from .expose_controller import KNXExposeStoreModel, KNXExposeStoreOptionModel from .time_server import KNXTimeServerStoreModel _LOGGER = logging.getLogger(__name__) STORAGE_VERSION: Final = 2 -STORAGE_VERSION_MINOR: Final = 3 +STORAGE_VERSION_MINOR: Final = 4 STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration @@ -32,6 +33,7 @@ class KNXConfigStoreModel(TypedDict): """Represent KNX configuration store data.""" entities: KNXEntityStoreModel + expose: KNXExposeStoreModel time_server: KNXTimeServerStoreModel @@ -68,6 +70,10 @@ async def _async_migrate_func( # version 2.3 introduced in 2026.3 migration.migrate_2_2_to_2_3(old_data) + if old_major_version <= 2 and old_minor_version < 4: + # version 2.4 introduced in 2026.5 + migration.migrate_2_3_to_2_4(old_data) + return old_data @@ -87,6 +93,7 @@ def __init__( ) self.data = KNXConfigStoreModel( # initialize with default structure entities={}, + expose={}, time_server={}, ) self._platform_controllers: dict[Platform, PlatformControllerBase] = {} @@ -99,6 +106,10 @@ async def load_data(self) -> None: "Loaded KNX config data from storage. %s entity platforms", len(self.data["entities"]), ) + _LOGGER.debug( + "Loaded KNX config data from storage. %s exposes", + len(self.data["expose"]), + ) def add_platform( self, platform: Platform, controller: PlatformControllerBase @@ -183,6 +194,48 @@ def get_entity_entries(self) -> list[er.RegistryEntry]: if registry_entry.unique_id in unique_ids ] + def get_exposes(self) -> KNXExposeStoreModel: + """Return KNX entity state expose configuration.""" + return self.data["expose"] + + def get_expose_groups(self) -> dict[str, list[str]]: + """Return KNX entity state exposes and their group addresses.""" + return { + entity_id: [option["ga"]["write"] for option in config] + for entity_id, config in self.data["expose"].items() + } + + def get_expose_config(self, entity_id: str) -> list[KNXExposeStoreOptionModel]: + """Return KNX entity state expose configuration for an entity.""" + return self.data["expose"].get(entity_id, []) + + async def update_expose( + self, entity_id: str, expose_config: list[KNXExposeStoreOptionModel] + ) -> None: + """Update KNX expose configuration for an entity.""" + knx_module = self.hass.data[KNX_MODULE_KEY] + expose_controller = knx_module.ui_expose_controller + expose_controller.update_entity_expose( + self.hass, knx_module.xknx, entity_id, expose_config + ) + + self.data["expose"][entity_id] = expose_config + await self._store.async_save(self.data) + + async def delete_expose(self, entity_id: str) -> None: + """Delete KNX expose configuration for an entity.""" + knx_module = self.hass.data[KNX_MODULE_KEY] + expose_controller = knx_module.ui_expose_controller + expose_controller.remove_entity_expose(entity_id) + + try: + del self.data["expose"][entity_id] + except KeyError as err: + raise ConfigStoreException( + f"Entity not found in expose configuration: {entity_id}" + ) from err + await self._store.async_save(self.data) + @callback def get_time_server_config(self) -> KNXTimeServerStoreModel: """Return KNX time server configuration.""" @@ -191,7 +244,7 @@ def get_time_server_config(self) -> KNXTimeServerStoreModel: async def update_time_server_config(self, config: KNXTimeServerStoreModel) -> None: """Update time server configuration.""" self.data["time_server"] = config - knx_module = self.hass.data.get(DOMAIN) + knx_module = self.hass.data[KNX_MODULE_KEY] if knx_module: knx_module.ui_time_server_controller.start(knx_module.xknx, config) await self._store.async_save(self.data) diff --git a/homeassistant/components/knx/storage/expose_controller.py b/homeassistant/components/knx/storage/expose_controller.py new file mode 100644 index 00000000000000..cd67cdd44f60dc --- /dev/null +++ b/homeassistant/components/knx/storage/expose_controller.py @@ -0,0 +1,154 @@ +"""KNX configuration storage for entity state exposes.""" + +from typing import Any, NotRequired, TypedDict + +import voluptuous as vol +from xknx import XKNX +from xknx.dpt import DPTBase +from xknx.telegram.address import parse_device_group_address + +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + config_validation as cv, + selector, + template as template_helper, +) + +from ..expose import KnxExposeEntity, KnxExposeOptions +from .entity_store_validation import validate_config_store_data +from .knx_selector import GASelector + +type KNXExposeStoreModel = dict[ + str, list[KNXExposeStoreOptionModel] # entity_id: configuration +] + + +class KNXExposeStoreOptionModel(TypedDict): + """Represent KNX entity state expose configuration for an entity.""" + + ga: dict[str, Any] # group address configuration with write and dpt + attribute: NotRequired[str] + cooldown: NotRequired[float] + default: NotRequired[Any] + periodic_send: NotRequired[float] + respond_to_read: NotRequired[bool] + value_template: NotRequired[str] + + +class KNXExposeDataModel(TypedDict): + """Represent a loaded KNX expose config for validation.""" + + entity_id: str + options: list[KNXExposeStoreOptionModel] + + +def validate_expose_template_no_coerce(value: str) -> str: + """Validate a value is a valid expose template without coercing it to a Template object.""" + temp = cv.template(value) # validate template + if temp.is_static: + raise vol.Invalid( + "Static templates are not supported. Template should start with '{{' and end with '}}'" + ) + return value # return original string for storage and later template creation + + +EXPOSE_OPTION_SCHEMA = vol.Schema( + { + vol.Required("ga"): GASelector( + state=False, + passive=False, + write_required=True, + dpt=["numeric", "enum", "complex", "string"], + ), + vol.Optional("attribute"): str, + vol.Optional("default"): object, + vol.Optional("cooldown"): cv.positive_float, # frontend renders to duration + vol.Optional("periodic_send"): cv.positive_float, + vol.Optional("respond_to_read"): bool, + vol.Optional("value_template"): validate_expose_template_no_coerce, + } +) + +EXPOSE_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY_ID): selector.EntitySelector(), + vol.Required("options"): [EXPOSE_OPTION_SCHEMA], + }, + extra=vol.REMOVE_EXTRA, +) + + +def validate_expose_data(data: dict) -> KNXExposeDataModel: + """Validate and convert expose configuration data.""" + return validate_config_store_data(EXPOSE_CONFIG_SCHEMA, data) # type: ignore[return-value] + + +def _store_to_expose_option( + hass: HomeAssistant, config: KNXExposeStoreOptionModel +) -> KnxExposeOptions: + """Convert config store option model to expose options.""" + ga = parse_device_group_address(config["ga"]["write"]) + dpt: type[DPTBase] = DPTBase.parse_transcoder(config["ga"]["dpt"]) # type: ignore[assignment] + value_template = None + if (_value_template_config := config.get("value_template")) is not None: + value_template = template_helper.Template(_value_template_config, hass) + return KnxExposeOptions( + group_address=ga, + dpt=dpt, + attribute=config.get("attribute"), + cooldown=config.get("cooldown", 0), + default=config.get("default"), + periodic_send=config.get("periodic_send", 0), + respond_to_read=config.get("respond_to_read", True), + value_template=value_template, + ) + + +class ExposeController: + """Controller class for UI entity exposures.""" + + def __init__(self) -> None: + """Initialize entity expose controller.""" + self._entity_exposes: dict[str, KnxExposeEntity] = {} + + @callback + def stop(self) -> None: + """Shutdown entity expose controller.""" + for expose in self._entity_exposes.values(): + expose.async_remove() + self._entity_exposes.clear() + + @callback + def start( + self, hass: HomeAssistant, xknx: XKNX, config: KNXExposeStoreModel + ) -> None: + """Update entity expose configuration.""" + if self._entity_exposes: + self.stop() + for entity_id, options in config.items(): + self.update_entity_expose(hass, xknx, entity_id, options) + + @callback + def update_entity_expose( + self, + hass: HomeAssistant, + xknx: XKNX, + entity_id: str, + expose_config: list[KNXExposeStoreOptionModel], + ) -> None: + """Update entity expose configuration for an entity.""" + self.remove_entity_expose(entity_id) + + expose_options = [ + _store_to_expose_option(hass, config) for config in expose_config + ] + expose = KnxExposeEntity(hass, xknx, entity_id, expose_options) + self._entity_exposes[entity_id] = expose + expose.async_register() + + @callback + def remove_entity_expose(self, entity_id: str) -> None: + """Remove entity expose configuration for an entity.""" + if entity_id in self._entity_exposes: + self._entity_exposes.pop(entity_id).async_remove() diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py index de158f4c5f9c3c..e4c33e319d1630 100644 --- a/homeassistant/components/knx/storage/migration.py +++ b/homeassistant/components/knx/storage/migration.py @@ -55,3 +55,8 @@ def migrate_2_1_to_2_2(data: dict[str, Any]) -> None: def migrate_2_2_to_2_3(data: dict[str, Any]) -> None: """Migrate from schema 2.2 to schema 2.3.""" data.setdefault("time_server", {}) + + +def migrate_2_3_to_2_4(data: dict[str, Any]) -> None: + """Migrate from schema 2.3 to schema 2.4.""" + data.setdefault("expose", {}) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 04372c78fdad20..71e83f1e05370b 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -950,6 +950,48 @@ "description": "Add and manage KNX entities", "title": "Entities" }, + "expose": { + "create": { + "add_expose": "Add expose", + "attribute": { + "description": "Expose changes of a specific attribute of the entity instead of the state. Optional. If the attribute is not set, the entity state is exposed." + }, + "cooldown": { + "description": "Minimum time between consecutive sends. This can be used to prevent high traffic on the KNX bus when values change very frequently. Only the most recent value during the cooldown period is sent.", + "label": "Cooldown" + }, + "default": { + "description": "The value to send if the entity state is `unavailable` or `unknown`, or if the attribute is not set. If `default` is omitted, nothing is sent in these cases, but the last known value remains available for read requests.", + "label": "Default value" + }, + "entity": { + "description": "Home Assistant entity to expose state changes to the KNX bus.", + "label": "Entity" + }, + "ga": { + "label": "Group address" + }, + "periodic_send": { + "description": "Time interval to automatically resend the current value to the KNX bus, even if it hasn’t changed.", + "label": "Periodic send interval" + }, + "respond_to_read": { + "description": "[%key:component::knx::config_panel::entities::create::_::knx::respond_to_read::description%]", + "label": "[%key:component::knx::config_panel::entities::create::_::knx::respond_to_read::label%]" + }, + "section_advanced_options": { + "title": "Advanced options" + }, + "show_raw_values": "Show raw values", + "title": "Add exposure", + "value_template": { + "description": "Optionally transform the entity state or attribute value before sending it to KNX using a template. The template receives the entity state or attribute value as `value` variable.", + "label": "Value template" + } + }, + "description": "Expose Home Assistant entity states to the KNX bus", + "title": "Expose" + }, "group_monitor": { "description": "Monitor KNX group communication", "title": "Group monitor" diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index e70f89d59340b6..51c7daa0613fee 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -35,6 +35,7 @@ EntityStoreValidationSuccess, validate_entity_data, ) +from .storage.expose_controller import validate_expose_data from .storage.serialize import get_serialized_schema from .storage.time_server import validate_time_server_data from .telegrams import ( @@ -68,6 +69,11 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_schema) websocket_api.async_register_command(hass, ws_get_time_server_config) websocket_api.async_register_command(hass, ws_update_time_server_config) + websocket_api.async_register_command(hass, ws_get_expose_groups) + websocket_api.async_register_command(hass, ws_get_expose_config) + websocket_api.async_register_command(hass, ws_update_expose) + websocket_api.async_register_command(hass, ws_delete_expose) + websocket_api.async_register_command(hass, ws_validate_expose) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -588,6 +594,142 @@ def ws_create_device( connection.send_result(msg["id"], _device.dict_repr) +######## +# Expose +######## + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_expose_groups", + } +) +@provide_knx +@callback +def ws_get_expose_groups( + hass: HomeAssistant, + knx: KNXModule, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get exposes from config store.""" + connection.send_result(msg["id"], knx.config_store.get_expose_groups()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_expose_config", + vol.Required("entity_id"): str, + } +) +@provide_knx +@callback +def ws_get_expose_config( + hass: HomeAssistant, + knx: KNXModule, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get expose configuration from config store.""" + connection.send_result( + msg["id"], knx.config_store.get_expose_config(msg["entity_id"]) + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/update_expose", + vol.Required("entity_id"): str, + vol.Required("options"): list, # validation done in handler + } +) +@websocket_api.async_response +@provide_knx +async def ws_update_expose( + hass: HomeAssistant, + knx: KNXModule, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Update expose configuration in config store.""" + try: + validated_data = validate_expose_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) + return + try: + await knx.config_store.update_expose( + validated_data["entity_id"], validated_data["options"] + ) + except ConfigStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/delete_expose", + vol.Required("entity_id"): str, + } +) +@websocket_api.async_response +@provide_knx +async def ws_delete_expose( + hass: HomeAssistant, + knx: KNXModule, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Delete expose configuration from config store.""" + try: + await knx.config_store.delete_expose(msg["entity_id"]) + except ConfigStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/validate_expose", + vol.Required("entity_id"): str, + vol.Required("options"): list, # validation done in handler + } +) +@callback +def ws_validate_expose( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Validate expose data.""" + try: + validate_expose_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) + return + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) + ) + + +############# +# Time server +############# + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json index dde4ca4c372075..03d5446ace115d 100644 --- a/tests/components/knx/fixtures/config_store_binarysensor.json +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 3, + "minor_version": 4, "key": "knx/config_store.json", "data": { "entities": { @@ -22,6 +22,7 @@ } } }, + "expose": {}, "time_server": {} } } diff --git a/tests/components/knx/fixtures/config_store_light.json b/tests/components/knx/fixtures/config_store_light.json index 159db0b1ff5bf1..634ac9290afe35 100644 --- a/tests/components/knx/fixtures/config_store_light.json +++ b/tests/components/knx/fixtures/config_store_light.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 3, + "minor_version": 4, "key": "knx/config_store.json", "data": { "entities": { @@ -138,6 +138,7 @@ } } }, + "expose": {}, "time_server": {} } } diff --git a/tests/components/knx/snapshots/test_diagnostic.ambr b/tests/components/knx/snapshots/test_diagnostic.ambr index 355d799737400f..d37f8ba27423c3 100644 --- a/tests/components/knx/snapshots/test_diagnostic.ambr +++ b/tests/components/knx/snapshots/test_diagnostic.ambr @@ -12,6 +12,8 @@ 'config_store': dict({ 'entities': dict({ }), + 'expose': dict({ + }), 'time_server': dict({ }), }), @@ -44,6 +46,8 @@ 'config_store': dict({ 'entities': dict({ }), + 'expose': dict({ + }), 'time_server': dict({ }), }), @@ -69,6 +73,8 @@ 'config_store': dict({ 'entities': dict({ }), + 'expose': dict({ + }), 'time_server': dict({ }), }), @@ -134,6 +140,8 @@ }), }), }), + 'expose': dict({ + }), 'time_server': dict({ }), }), diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 6bcfcf88e7c803..768b81158db680 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -445,6 +445,131 @@ async def test_validate_entity( assert res["result"]["error_base"].startswith("required key not provided") +######## +# EXPOSE +######## + + +async def test_update_expose_error( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose update validation errors.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/update_expose", + "entity_id": "switch.test", + "options": [{"ga": {"dpt": "1.001"}}], + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + assert res["result"]["errors"][0]["path"] == ["options", "0", "ga", "write"] + assert res["result"]["errors"][0]["error_message"] == "required key not provided" + + +async def test_validate_expose( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose validation endpoint.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/validate_expose", + "entity_id": "switch.test", + "options": [{"ga": {"write": "1/2/3", "dpt": "1.001"}}], + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is True + + await client.send_json_auto_id( + { + "type": "knx/validate_expose", + "entity_id": "switch.test", + "options": [{"ga": {"write": "1/2/3", "dpt": "invalid"}}], + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + assert res["result"]["errors"][0]["path"] == ["options", "0", "ga", "dpt"] + + +async def test_delete_expose( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test expose deletion.""" + ENTITY_ID = "switch.test" + + await knx.setup_integration() + client = await hass_ws_client(hass) + + expose_options = [{"ga": {"write": "2/2/2", "dpt": "1.001"}}] + + await client.send_json_auto_id( + { + "type": "knx/update_expose", + "entity_id": ENTITY_ID, + "options": expose_options, + } + ) + res = await client.receive_json() + assert res["success"], res + assert ENTITY_ID in hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["expose"] + + await client.send_json_auto_id( + { + "type": "knx/delete_expose", + "entity_id": ENTITY_ID, + } + ) + res = await client.receive_json() + assert res["success"], res + assert ENTITY_ID not in hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["expose"] + + +async def test_delete_expose_error( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test expose deletion errors.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/delete_expose", + "entity_id": "switch.non_existing_entity", + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith( + "Entity not found in expose configuration" + ) + + +########### +# MIGRATION +########### + + async def test_migration_1_to_2( hass: HomeAssistant, knx: KNXTestKit, @@ -460,12 +585,12 @@ async def test_migration_1_to_2( assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data -async def test_migration_2_1_to_2_3( +async def test_migration_2_1_to_2_4( hass: HomeAssistant, knx: KNXTestKit, hass_storage: dict[str, Any], ) -> None: - """Test migration from schema 2.1 to schema 2.3.""" + """Test migration from schema 2.1 to schema 2.4.""" await knx.setup_integration( config_store_fixture="config_store_binarysensor_v2_1.json", state_updater=False, diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index d221b472b612da..f0b2459349c149 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -1,12 +1,16 @@ """Test KNX expose.""" from datetime import timedelta +from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.knx.const import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema +from homeassistant.components.knx.storage.config_store import ( + STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, +) from homeassistant.const import ( CONF_ATTRIBUTE, CONF_ENTITY_ID, @@ -18,6 +22,7 @@ from .conftest import KNXTestKit from tests.common import async_fire_time_changed +from tests.typing import WebSocketGenerator async def test_binary_expose(hass: HomeAssistant, knx: KNXTestKit) -> None: @@ -378,6 +383,115 @@ async def test_expose_conversion_exception( ) +async def test_ui_expose_create_and_update( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test expose create and update.""" + ENTITY_ID = "light.test" + GROUP_ADDRESS_1 = "1/1/1" + GROUP_ADDRESS_2 = "2/2/2" + + await knx.setup_integration() + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "knx/update_expose", + "entity_id": ENTITY_ID, + "options": [{"ga": {"write": GROUP_ADDRESS_1, "dpt": "1.001"}}], + } + ) + res = await ws_client.receive_json() + assert res["success"], res + assert res["result"]["success"] is True, res["result"] + + assert ENTITY_ID in hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["expose"] + + hass.states.async_set(ENTITY_ID, "on", {"brightness": 30}) + await hass.async_block_till_done() + await knx.assert_write(GROUP_ADDRESS_1, True) + + await ws_client.send_json_auto_id( + { + "type": "knx/update_expose", + "entity_id": ENTITY_ID, + "options": [ + {"ga": {"write": GROUP_ADDRESS_1, "dpt": "1.001"}}, + { + "ga": {"write": GROUP_ADDRESS_2, "dpt": "5.001"}, + "attribute": "brightness", + }, + ], + } + ) + res = await ws_client.receive_json() + assert res["success"], res + assert res["result"]["success"] is True, res["result"] + + hass.states.async_set(ENTITY_ID, "on", {"brightness": 50}) + await hass.async_block_till_done() + await knx.assert_write(GROUP_ADDRESS_1, True) + await knx.assert_write(GROUP_ADDRESS_2, (128,)) + + +async def test_ui_expose_with_options( + hass: HomeAssistant, + knx: KNXTestKit, + freezer: FrozenDateTimeFactory, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test expose create with options from UI.""" + ENTITY_ID = "light.test" + GROUP_ADDRESS_1 = "1/1/1" + + await knx.setup_integration() + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "knx/update_expose", + "entity_id": ENTITY_ID, + "options": [ + { + "ga": {"write": GROUP_ADDRESS_1, "dpt": "5.010"}, + "attribute": "brightness", + "cooldown": 2.5, + "default": 0, + "periodic_send": 60.0, + "respond_to_read": False, + "value_template": "{{ 50 if value >= 50 else 1 }}", + } + ], + } + ) + res = await ws_client.receive_json() + assert res["success"], res + assert res["result"]["success"] is True, res["result"] + + # Change attribute to None - 1 because of value template + hass.states.async_set(ENTITY_ID, "on", {"brightness": 10}) + await hass.async_block_till_done() + await knx.assert_write(GROUP_ADDRESS_1, (1,)) + # Change attribute to 2 - skip because of cooldown + hass.states.async_set(ENTITY_ID, "on", {"brightness": 100}) + await hass.async_block_till_done() + await knx.assert_no_telegram() + # Wait for cooldown to pass + freezer.tick(timedelta(seconds=2.5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await knx.assert_write(GROUP_ADDRESS_1, (50,)) + # Wait for periodictime to pass + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await knx.assert_write(GROUP_ADDRESS_1, (50,)) + + @pytest.mark.freeze_time("2022-1-7 9:13:14") # UTC -> +1h = Vienna in winter (9 -> 0xA) @pytest.mark.parametrize( ("time_type", "raw"), From e7e4c495fd2112b1b2d69a1af2284751711d2fe9 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Thu, 9 Apr 2026 23:54:31 +0200 Subject: [PATCH 0696/1707] Add Duco integration (#167220) --- CODEOWNERS | 2 + homeassistant/components/duco/__init__.py | 34 +++++ homeassistant/components/duco/config_flow.py | 74 ++++++++++ homeassistant/components/duco/const.py | 9 ++ homeassistant/components/duco/coordinator.py | 75 ++++++++++ homeassistant/components/duco/entity.py | 52 +++++++ homeassistant/components/duco/fan.py | 127 +++++++++++++++++ homeassistant/components/duco/manifest.json | 12 ++ .../components/duco/quality_scale.yaml | 89 ++++++++++++ homeassistant/components/duco/strings.json | 45 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/duco/__init__.py | 1 + tests/components/duco/conftest.py | 132 ++++++++++++++++++ tests/components/duco/snapshots/test_fan.ambr | 62 ++++++++ tests/components/duco/test_config_flow.py | 96 +++++++++++++ tests/components/duco/test_fan.py | 113 +++++++++++++++ tests/components/duco/test_init.py | 72 ++++++++++ 20 files changed, 1008 insertions(+) create mode 100644 homeassistant/components/duco/__init__.py create mode 100644 homeassistant/components/duco/config_flow.py create mode 100644 homeassistant/components/duco/const.py create mode 100644 homeassistant/components/duco/coordinator.py create mode 100644 homeassistant/components/duco/entity.py create mode 100644 homeassistant/components/duco/fan.py create mode 100644 homeassistant/components/duco/manifest.json create mode 100644 homeassistant/components/duco/quality_scale.yaml create mode 100644 homeassistant/components/duco/strings.json create mode 100644 tests/components/duco/__init__.py create mode 100644 tests/components/duco/conftest.py create mode 100644 tests/components/duco/snapshots/test_fan.ambr create mode 100644 tests/components/duco/test_config_flow.py create mode 100644 tests/components/duco/test_fan.py create mode 100644 tests/components/duco/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 109248b6c72729..48c5d6a029fce3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -418,6 +418,8 @@ CLAUDE.md @home-assistant/core /tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /homeassistant/components/duckdns/ @tr4nt0r /tests/components/duckdns/ @tr4nt0r +/homeassistant/components/duco/ @ronaldvdmeer +/tests/components/duco/ @ronaldvdmeer /homeassistant/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd /homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 diff --git a/homeassistant/components/duco/__init__.py b/homeassistant/components/duco/__init__.py new file mode 100644 index 00000000000000..39975c0163ece2 --- /dev/null +++ b/homeassistant/components/duco/__init__.py @@ -0,0 +1,34 @@ +"""The Duco integration.""" + +from __future__ import annotations + +from duco import DucoClient + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import DucoConfigEntry, DucoCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool: + """Set up Duco from a config entry.""" + client = DucoClient( + session=async_get_clientsession(hass), + host=entry.data[CONF_HOST], + ) + + coordinator = DucoCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool: + """Unload a Duco config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/duco/config_flow.py b/homeassistant/components/duco/config_flow.py new file mode 100644 index 00000000000000..e255cac31b23a5 --- /dev/null +++ b/homeassistant/components/duco/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for the Duco integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from duco import DucoClient +from duco.exceptions import DucoConnectionError, DucoError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class DucoConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Duco.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + box_name, mac = await self._validate_input(user_input[CONF_HOST]) + except DucoConnectionError: + errors["base"] = "cannot_connect" + except DucoError: + _LOGGER.exception("Unexpected error connecting to Duco box") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=box_name, + data={CONF_HOST: user_input[CONF_HOST]}, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors=errors, + ) + + async def _validate_input(self, host: str) -> tuple[str, str]: + """Validate the user input by connecting to the Duco box. + + Returns a tuple of (box_name, mac_address). + """ + client = DucoClient( + session=async_get_clientsession(self.hass), + host=host, + ) + board_info = await client.async_get_board_info() + lan_info = await client.async_get_lan_info() + return board_info.box_name, lan_info.mac diff --git a/homeassistant/components/duco/const.py b/homeassistant/components/duco/const.py new file mode 100644 index 00000000000000..53f1a477d356ec --- /dev/null +++ b/homeassistant/components/duco/const.py @@ -0,0 +1,9 @@ +"""Constants for the Duco integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "duco" +PLATFORMS = [Platform.FAN] +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py new file mode 100644 index 00000000000000..c18af9fdffd7df --- /dev/null +++ b/homeassistant/components/duco/coordinator.py @@ -0,0 +1,75 @@ +"""Data update coordinator for the Duco integration.""" + +from __future__ import annotations + +import logging + +from duco import DucoClient +from duco.exceptions import DucoConnectionError, DucoError +from duco.models import BoardInfo, Node + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type DucoConfigEntry = ConfigEntry[DucoCoordinator] +type DucoData = dict[int, Node] + + +class DucoCoordinator(DataUpdateCoordinator[DucoData]): + """Coordinator for the Duco integration.""" + + config_entry: DucoConfigEntry + board_info: BoardInfo + + def __init__( + self, + hass: HomeAssistant, + config_entry: DucoConfigEntry, + client: DucoClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + + async def _async_setup(self) -> None: + """Fetch board info once during initial setup.""" + try: + self.board_info = await self.client.async_get_board_info() + except DucoConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except DucoError as err: + raise ConfigEntryError(f"Duco API error: {err}") from err + + async def _async_update_data(self) -> DucoData: + """Fetch node data from the Duco box.""" + try: + nodes = await self.client.async_get_nodes() + except DucoConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except DucoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": repr(err)}, + ) from err + return {node.node_id: node for node in nodes} diff --git a/homeassistant/components/duco/entity.py b/homeassistant/components/duco/entity.py new file mode 100644 index 00000000000000..bed8b6bb570a44 --- /dev/null +++ b/homeassistant/components/duco/entity.py @@ -0,0 +1,52 @@ +"""Base entity for the Duco integration.""" + +from __future__ import annotations + +from duco.models import Node + +from homeassistant.const import ATTR_VIA_DEVICE +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import DucoCoordinator + + +class DucoEntity(CoordinatorEntity[DucoCoordinator]): + """Base class for Duco entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: DucoCoordinator, node: Node) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._node_id = node.node_id + mac = coordinator.config_entry.unique_id + assert mac is not None + device_info = DeviceInfo( + identifiers={(DOMAIN, f"{mac}_{node.node_id}")}, + manufacturer="Duco", + model=coordinator.board_info.box_name + if node.general.node_type == "BOX" + else node.general.node_type, + name=node.general.name or f"Node {node.node_id}", + ) + device_info.update( + { + "connections": {(CONNECTION_NETWORK_MAC, mac)}, + "serial_number": coordinator.board_info.serial_board_box, + } + if node.general.node_type == "BOX" + else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")} + ) + self._attr_device_info = device_info + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._node_id in self.coordinator.data + + @property + def _node(self) -> Node: + """Return the current node data from the coordinator.""" + return self.coordinator.data[self._node_id] diff --git a/homeassistant/components/duco/fan.py b/homeassistant/components/duco/fan.py new file mode 100644 index 00000000000000..0f967277b17d71 --- /dev/null +++ b/homeassistant/components/duco/fan.py @@ -0,0 +1,127 @@ +"""Fan platform for the Duco integration.""" + +from __future__ import annotations + +from duco.exceptions import DucoError +from duco.models import Node, VentilationState + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import percentage_to_ordered_list_item + +from .const import DOMAIN +from .coordinator import DucoConfigEntry, DucoCoordinator +from .entity import DucoEntity + +PARALLEL_UPDATES = 1 + +# Permanent speed states ordered low → high. +ORDERED_NAMED_FAN_SPEEDS: list[VentilationState] = [ + VentilationState.CNT1, + VentilationState.CNT2, + VentilationState.CNT3, +] + +PRESET_AUTO = "auto" + +# Upper-bound percentages for 3 speed levels: 33 / 66 / 100. +# Using upper bounds guarantees that reading a percentage back and writing it +# again always round-trips to the same Duco state. +_SPEED_LEVEL_PERCENTAGES: list[int] = [ + (i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS) + for i in range(len(ORDERED_NAMED_FAN_SPEEDS)) +] + +# Maps every active Duco state (including timed MAN variants) to its +# display percentage so externally-set timed modes show the correct level. +_STATE_TO_PERCENTAGE: dict[VentilationState, int] = { + VentilationState.CNT1: _SPEED_LEVEL_PERCENTAGES[0], + VentilationState.MAN1: _SPEED_LEVEL_PERCENTAGES[0], + VentilationState.MAN1x2: _SPEED_LEVEL_PERCENTAGES[0], + VentilationState.MAN1x3: _SPEED_LEVEL_PERCENTAGES[0], + VentilationState.CNT2: _SPEED_LEVEL_PERCENTAGES[1], + VentilationState.MAN2: _SPEED_LEVEL_PERCENTAGES[1], + VentilationState.MAN2x2: _SPEED_LEVEL_PERCENTAGES[1], + VentilationState.MAN2x3: _SPEED_LEVEL_PERCENTAGES[1], + VentilationState.CNT3: _SPEED_LEVEL_PERCENTAGES[2], + VentilationState.MAN3: _SPEED_LEVEL_PERCENTAGES[2], + VentilationState.MAN3x2: _SPEED_LEVEL_PERCENTAGES[2], + VentilationState.MAN3x3: _SPEED_LEVEL_PERCENTAGES[2], +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DucoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Duco fan entities.""" + coordinator = entry.runtime_data + + async_add_entities( + DucoVentilationFanEntity(coordinator, node) + for node in coordinator.data.values() + if node.general.node_type == "BOX" + ) + + +class DucoVentilationFanEntity(DucoEntity, FanEntity): + """Fan entity for the ventilation control of a Duco node.""" + + _attr_translation_key = "ventilation" + _attr_name = None + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + _attr_preset_modes = [PRESET_AUTO] + _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) + + def __init__(self, coordinator: DucoCoordinator, node: Node) -> None: + """Initialize the fan entity.""" + super().__init__(coordinator, node) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{node.node_id}" + + @property + def percentage(self) -> int | None: + """Return the current speed as a percentage, or None when in AUTO mode.""" + node = self._node + if node.ventilation is None: + return None + return _STATE_TO_PERCENTAGE.get(node.ventilation.state) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode (auto when Duco controls, else None).""" + node = self._node + if node.ventilation is None: + return None + if node.ventilation.state not in _STATE_TO_PERCENTAGE: + return PRESET_AUTO + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode: 'auto' hands control back to Duco.""" + self._valid_preset_mode_or_raise(preset_mode) + await self._async_set_state(VentilationState.AUTO) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the fan speed as a percentage (maps to low/medium/high).""" + if percentage == 0: + await self._async_set_state(VentilationState.AUTO) + return + state = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) + await self._async_set_state(state) + + async def _async_set_state(self, state: VentilationState) -> None: + """Send the ventilation state to the device and refresh coordinator.""" + try: + await self.coordinator.client.async_set_ventilation_state( + self._node_id, state + ) + except DucoError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_to_set_state", + translation_placeholders={"error": repr(err)}, + ) from err + await self.coordinator.async_refresh() diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json new file mode 100644 index 00000000000000..4ccdb930d01214 --- /dev/null +++ b/homeassistant/components/duco/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "duco", + "name": "Duco", + "codeowners": ["@ronaldvdmeer"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/duco", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["duco"], + "quality_scale": "bronze", + "requirements": ["python-duco-client==0.2.0"] +} diff --git a/homeassistant/components/duco/quality_scale.yaml b/homeassistant/components/duco/quality_scale.yaml new file mode 100644 index 00000000000000..7d75c3b8e676b7 --- /dev/null +++ b/homeassistant/components/duco/quality_scale.yaml @@ -0,0 +1,89 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not provide service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration uses a coordinator; entities do not subscribe to events directly. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not provide an option flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by the DataUpdateCoordinator. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Integration uses a local API that requires no credentials. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: todo + comment: >- + DHCP host updating to be implemented in a follow-up PR. + The device hostname follows the pattern duco_ + (e.g. duco_061293), which can be used for DHCP hostname matching. + discovery: + status: todo + comment: >- + Device can be discovered via DHCP. The hostname follows the pattern + duco_ (e.g. duco_061293). To be implemented + in a follow-up PR together with discovery-update-info. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: todo + comment: >- + Users can pair new modules (CO2 sensors, humidity sensors, zone valves) + to their Duco box. Dynamic device support to be added in a follow-up PR. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: todo + comment: >- + To be implemented together with dynamic device support in a follow-up PR. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json new file mode 100644 index 00000000000000..1410c92d40626e --- /dev/null +++ b/homeassistant/components/duco/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "IP address or hostname of your Duco ventilation box." + } + } + } + }, + "entity": { + "fan": { + "ventilation": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "[%key:common::state::auto%]" + } + } + } + } + } + }, + "exceptions": { + "api_error": { + "message": "Unexpected error from the Duco API: {error}" + }, + "cannot_connect": { + "message": "An error occurred while trying to connect to the Duco instance: {error}" + }, + "failed_to_set_state": { + "message": "Failed to set ventilation state: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eb103b00ced2e1..532c4fe74707b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -165,6 +165,7 @@ "dsmr", "dsmr_reader", "duckdns", + "duco", "dunehd", "duotecno", "dwd_weather_warnings", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1f9c0286ca3708..11e5784078baa7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1521,6 +1521,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "duco": { + "name": "Duco", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "dunehd": { "name": "Dune HD", "integration_type": "device", diff --git a/requirements_all.txt b/requirements_all.txt index 0efdb67fe44ef7..0c45ebfb26d18f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2565,6 +2565,9 @@ python-digitalocean==1.13.2 # homeassistant.components.dropbox python-dropbox-api==0.1.3 +# homeassistant.components.duco +python-duco-client==0.2.0 + # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae73cfb727da75..a7e73867e37afa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2185,6 +2185,9 @@ python-bsblan==5.1.3 # homeassistant.components.dropbox python-dropbox-api==0.1.3 +# homeassistant.components.duco +python-duco-client==0.2.0 + # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/tests/components/duco/__init__.py b/tests/components/duco/__init__.py new file mode 100644 index 00000000000000..8daa2f960fd494 --- /dev/null +++ b/tests/components/duco/__init__.py @@ -0,0 +1 @@ +"""Tests for the Duco integration.""" diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py new file mode 100644 index 00000000000000..77dd6c96c4f7c6 --- /dev/null +++ b/tests/components/duco/conftest.py @@ -0,0 +1,132 @@ +"""Fixtures for Duco tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from duco.models import BoardInfo, LanInfo, Node, NodeGeneralInfo, NodeVentilationInfo +import pytest + +from homeassistant.components.duco.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_HOST = "192.168.1.100" +TEST_MAC = "aa:bb:cc:dd:ee:ff" + +USER_INPUT = {CONF_HOST: TEST_HOST} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="SILENT_CONNECT", + domain=DOMAIN, + data=USER_INPUT, + unique_id=TEST_MAC, + ) + + +@pytest.fixture +def mock_board_info() -> BoardInfo: + """Return mock board info.""" + return BoardInfo( + box_name="SILENT_CONNECT", + box_sub_type_name="Eu", + serial_board_box="ABC123", + serial_board_comm="DEF456", + serial_duco_box="GHI789", + serial_duco_comm="JKL012", + time=1700000000, + ) + + +@pytest.fixture +def mock_lan_info() -> LanInfo: + """Return mock LAN info.""" + return LanInfo( + mode="WIFI_CLIENT", + ip=TEST_HOST, + net_mask="255.255.255.0", + default_gateway="192.168.1.1", + dns="8.8.8.8", + mac=TEST_MAC, + host_name="duco-box", + rssi_wifi=-60, + ) + + +@pytest.fixture +def mock_nodes() -> list[Node]: + """Return a list with a single BOX node.""" + return [ + Node( + node_id=1, + general=NodeGeneralInfo( + node_type="BOX", + sub_type=1, + network_type="VIRT", + parent=0, + asso=0, + name="Living", + identify=0, + ), + ventilation=NodeVentilationInfo( + state="AUTO", + time_state_remain=0, + time_state_end=0, + mode="AUTO", + flow_lvl_tgt=0, + ), + ) + ] + + +@pytest.fixture +def mock_duco_client( + mock_board_info: BoardInfo, + mock_lan_info: LanInfo, + mock_nodes: list[Node], +) -> Generator[AsyncMock]: + """Return a mocked DucoClient used by both the integration and config flow.""" + with ( + patch( + "homeassistant.components.duco.DucoClient", + autospec=True, + ) as mock_class, + patch( + "homeassistant.components.duco.config_flow.DucoClient", + new=mock_class, + ), + ): + client = mock_class.return_value + client.async_get_board_info.return_value = mock_board_info + client.async_get_lan_info.return_value = mock_lan_info + client.async_get_nodes.return_value = mock_nodes + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.duco.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> MockConfigEntry: + """Set up the Duco integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/duco/snapshots/test_fan.ambr b/tests/components/duco/snapshots/test_fan.ambr new file mode 100644 index 00000000000000..acbaa0a4d7a2ac --- /dev/null +++ b/tests/components/duco/snapshots/test_fan.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_fan_entity_state[fan.living-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'auto', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'aa:bb:cc:dd:ee:ff_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_entity_state[fan.living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living', + 'percentage': None, + 'percentage_step': 33.333333333333336, + 'preset_mode': 'auto', + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/duco/test_config_flow.py b/tests/components/duco/test_config_flow.py new file mode 100644 index 00000000000000..8d19a412380abc --- /dev/null +++ b/tests/components/duco/test_config_flow.py @@ -0,0 +1,96 @@ +"""Tests for the Duco config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from duco.exceptions import DucoConnectionError, DucoError +import pytest + +from homeassistant.components.duco.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_MAC, USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_user_flow_success( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test a successful user 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 result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "SILENT_CONNECT" + assert result["data"] == USER_INPUT + assert result["result"].unique_id == TEST_MAC + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (DucoConnectionError("Connection refused"), "cannot_connect"), + (DucoError("Unexpected error"), "unknown"), + ], +) +async def test_user_flow_error( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test handling of connection and unknown errors in the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_duco_client.async_get_board_info.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + mock_duco_client.async_get_board_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_duplicate( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a duplicate config entry is aborted.""" + mock_config_entry.add_to_hass(hass) + + # Second attempt for the same device + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/duco/test_fan.py b/tests/components/duco/test_fan.py new file mode 100644 index 00000000000000..a59e893913cb37 --- /dev/null +++ b/tests/components/duco/test_fan.py @@ -0,0 +1,113 @@ +"""Tests for the Duco fan platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from duco.exceptions import DucoConnectionError, DucoError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.duco.const import SCAN_INTERVAL +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +_FAN_ENTITY = "fan.living" + + +@pytest.mark.usefixtures("init_integration") +async def test_fan_entity_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the fan entity is created with the correct state.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("service", "service_data", "expected_duco_state"), + [ + (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 0}, "AUTO"), + (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 33}, "CNT1"), + (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 66}, "CNT2"), + (SERVICE_SET_PERCENTAGE, {ATTR_PERCENTAGE: 100}, "CNT3"), + (SERVICE_SET_PRESET_MODE, {ATTR_PRESET_MODE: "auto"}, "AUTO"), + ], +) +async def test_fan_set_state( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + service: str, + service_data: dict, + expected_duco_state: str, +) -> None: + """Test that fan service calls map to the correct Duco ventilation state.""" + mock_duco_client.async_set_ventilation_state = AsyncMock() + + await hass.services.async_call( + FAN_DOMAIN, + service, + {ATTR_ENTITY_ID: _FAN_ENTITY, **service_data}, + blocking=True, + ) + + mock_duco_client.async_set_ventilation_state.assert_called_once_with( + 1, expected_duco_state + ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "exception", + [DucoConnectionError("Connection refused"), DucoError("Unexpected error")], +) +async def test_fan_set_state_error( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + exception: Exception, +) -> None: + """Test that a HomeAssistantError is raised on API failure.""" + mock_duco_client.async_set_ventilation_state = AsyncMock(side_effect=exception) + + with pytest.raises(HomeAssistantError, match="Failed to set ventilation state"): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: _FAN_ENTITY, ATTR_PERCENTAGE: 100}, + blocking=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_coordinator_update_marks_unavailable( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that entities become unavailable when the coordinator fails.""" + mock_duco_client.async_get_nodes = AsyncMock( + side_effect=DucoConnectionError("offline") + ) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(_FAN_ENTITY) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py new file mode 100644 index 00000000000000..cef7f759ef53b5 --- /dev/null +++ b/tests/components/duco/test_init.py @@ -0,0 +1,72 @@ +"""Tests for the Duco integration setup.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from duco.exceptions import DucoConnectionError, DucoError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("method", "exception", "expected_state"), + [ + ( + "async_get_board_info", + DucoConnectionError("Connection refused"), + ConfigEntryState.SETUP_RETRY, + ), + ( + "async_get_board_info", + DucoError("Unexpected API error"), + ConfigEntryState.SETUP_ERROR, + ), + ( + "async_get_nodes", + DucoConnectionError("Connection refused"), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + method: str, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test that fetch errors during setup result in the correct state.""" + getattr(mock_duco_client, method).side_effect = exception + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + + +@pytest.mark.usefixtures("mock_duco_client") +async def test_setup_entry_success( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test successful setup of the Duco integration.""" + assert init_integration.state is ConfigEntryState.LOADED + + +async def test_unload_entry( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test unloading the Duco integration.""" + assert init_integration.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + assert init_integration.state is ConfigEntryState.NOT_LOADED From cf4d8f0974d90885e66610e1fbc1936dc10dd836 Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Fri, 10 Apr 2026 00:03:49 +0200 Subject: [PATCH 0697/1707] Add VoIP sensors to sfr_box (#166609) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/sfr_box/__init__.py | 9 + .../components/sfr_box/binary_sensor.py | 27 +- .../components/sfr_box/coordinator.py | 3 +- homeassistant/components/sfr_box/icons.json | 12 + homeassistant/components/sfr_box/sensor.py | 22 +- homeassistant/components/sfr_box/strings.json | 21 ++ tests/components/sfr_box/conftest.py | 15 +- .../sfr_box/fixtures/voip_getInfo.json | 6 + .../sfr_box/snapshots/test_binary_sensor.ambr | 308 ++++++++++++++++++ .../sfr_box/snapshots/test_sensor.ambr | 81 +++++ .../components/sfr_box/test_binary_sensor.py | 31 +- tests/components/sfr_box/test_button.py | 4 +- tests/components/sfr_box/test_sensor.py | 31 +- 13 files changed, 558 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/sfr_box/icons.json create mode 100644 tests/components/sfr_box/fixtures/voip_getInfo.json diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index 1a717e82d824b8..9e57a0f25ff7d2 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -22,6 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: """Set up SFR box as config entry.""" box = SFRBox(ip=entry.data[CONF_HOST], client=async_get_clientsession(hass)) platforms = PLATFORMS + has_auth = False if (username := entry.data.get(CONF_USERNAME)) and ( password := entry.data.get(CONF_PASSWORD) ): @@ -39,6 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: translation_placeholders={"error": str(err)}, ) from err platforms = PLATFORMS_WITH_AUTH + has_auth = True data = SFRRuntimeData( box=box, @@ -51,10 +53,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: system=SFRDataUpdateCoordinator( hass, entry, box, "system", lambda b: b.system_get_info() ), + voip=None, wan=SFRDataUpdateCoordinator( hass, entry, box, "wan", lambda b: b.wan_get_info() ), ) + if has_auth: + data.voip = SFRDataUpdateCoordinator( + hass, entry, box, "voip", lambda b: b.voip_get_info() + ) # Preload system information await data.system.async_config_entry_first_refresh() system_info = data.system.data @@ -63,6 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool: # Preload other coordinators (based on net infrastructure) tasks = [data.wan.async_config_entry_first_refresh()] + if data.voip is not None: + tasks.append(data.voip.async_config_entry_first_refresh()) if (net_infra := system_info.net_infra) == "adsl": tasks.append(data.dsl.async_config_entry_first_refresh()) elif net_infra == "ftth": diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index bcd0fd71d8f256..1cf71db89dad10 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from sfrbox_api.models import DslInfo, FtthInfo, WanInfo +from sfrbox_api.models import DslInfo, FtthInfo, VoipInfo, WanInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -49,6 +49,26 @@ class SFRBoxBinarySensorEntityDescription[_T](BinarySensorEntityDescription): translation_key="ftth_status", ), ) +VOIP_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[VoipInfo], ...] = ( + SFRBoxBinarySensorEntityDescription[VoipInfo]( + key="status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda x: x.status == "up", + translation_key="voip_status", + ), + SFRBoxBinarySensorEntityDescription[VoipInfo]( + key="callhistory_active", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda x: x.callhistory_active == "on", + translation_key="voip_callhistory_active", + ), + SFRBoxBinarySensorEntityDescription[VoipInfo]( + key="hook_status", + value_fn=lambda x: x.hook_status == "offhook", + translation_key="voip_hook_status", + ), +) WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( SFRBoxBinarySensorEntityDescription[WanInfo]( key="status", @@ -75,6 +95,11 @@ async def async_setup_entry( SFRBoxBinarySensor(data.wan, description, system_info) for description in WAN_SENSOR_TYPES ] + if data.voip is not None: + entities.extend( + SFRBoxBinarySensor(data.voip, description, system_info) + for description in VOIP_SENSOR_TYPES + ) if (net_infra := system_info.net_infra) == "adsl": entities.extend( SFRBoxBinarySensor(data.dsl, description, system_info) diff --git a/homeassistant/components/sfr_box/coordinator.py b/homeassistant/components/sfr_box/coordinator.py index 9b131177e37668..b57267cee7116e 100644 --- a/homeassistant/components/sfr_box/coordinator.py +++ b/homeassistant/components/sfr_box/coordinator.py @@ -10,7 +10,7 @@ from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxError -from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, VoipInfo, WanInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -32,6 +32,7 @@ class SFRRuntimeData: dsl: SFRDataUpdateCoordinator[DslInfo] ftth: SFRDataUpdateCoordinator[FtthInfo] system: SFRDataUpdateCoordinator[SystemInfo] + voip: SFRDataUpdateCoordinator[VoipInfo] | None wan: SFRDataUpdateCoordinator[WanInfo] diff --git a/homeassistant/components/sfr_box/icons.json b/homeassistant/components/sfr_box/icons.json new file mode 100644 index 00000000000000..a24499e0ae3240 --- /dev/null +++ b/homeassistant/components/sfr_box/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "voip_hook_status": { + "default": "mdi:phone-hangup", + "state": { + "on": "mdi:phone-in-talk" + } + } + } + } +} diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 88477903687e14..d37c5fc93cfa7e 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from sfrbox_api.models import DslInfo, SystemInfo, WanInfo +from sfrbox_api.models import DslInfo, SystemInfo, VoipInfo, WanInfo from homeassistant.components.sensor import ( SensorDeviceClass, @@ -183,6 +183,21 @@ class SFRBoxSensorEntityDescription[_T](SensorEntityDescription): value_fn=lambda x: _get_temperature(x.temperature), ), ) +VOIP_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[VoipInfo], ...] = ( + SFRBoxSensorEntityDescription[VoipInfo]( + key="infra", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=[ + "adsl", + "ftth", + "gprs", + ], + translation_key="voip_infra", + value_fn=lambda x: _value_to_option(x.infra), + ), +) WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( SFRBoxSensorEntityDescription[WanInfo]( key="mode", @@ -232,6 +247,11 @@ async def async_setup_entry( SFRBoxSensor(data.wan, description, system_info) for description in WAN_SENSOR_TYPES ) + if data.voip is not None: + entities.extend( + SFRBoxSensor(data.voip, description, system_info) + for description in VOIP_SENSOR_TYPES + ) if system_info.net_infra == "adsl": entities.extend( SFRBoxSensor(data.dsl, description, system_info) diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 52ba0b295cde62..dbf02e369cced4 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -47,6 +47,19 @@ "ftth_status": { "name": "FTTH status" }, + "voip_callhistory_active": { + "name": "VoIP call history active" + }, + "voip_hook_status": { + "name": "VoIP phone hook status", + "state": { + "off": "On-hook", + "on": "Off-hook" + } + }, + "voip_status": { + "name": "VoIP status" + }, "wan_status": { "name": "WAN status" } @@ -113,6 +126,14 @@ "gprs": "GPRS" } }, + "voip_infra": { + "name": "VoIP infrastructure", + "state": { + "adsl": "[%key:component::sfr_box::entity::sensor::net_infra::state::adsl%]", + "ftth": "[%key:component::sfr_box::entity::sensor::net_infra::state::ftth%]", + "gprs": "[%key:component::sfr_box::entity::sensor::net_infra::state::gprs%]" + } + }, "wan_mode": { "name": "WAN mode", "state": { diff --git a/tests/components/sfr_box/conftest.py b/tests/components/sfr_box/conftest.py index a6ce1da165e9b1..6ad0493817eb8b 100644 --- a/tests/components/sfr_box/conftest.py +++ b/tests/components/sfr_box/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest -from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, VoipInfo, WanInfo from homeassistant.components.sfr_box.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntry @@ -96,6 +96,19 @@ async def system_get_info(hass: HomeAssistant) -> AsyncGenerator[SystemInfo]: yield info +@pytest.fixture +async def voip_get_info(hass: HomeAssistant) -> AsyncGenerator[VoipInfo]: + """Fixture for SFRBox.voip_get_info.""" + info = VoipInfo( + **(await async_load_json_object_fixture(hass, "voip_getInfo.json", DOMAIN)) + ) + with patch( + "homeassistant.components.sfr_box.coordinator.SFRBox.voip_get_info", + return_value=info, + ): + yield info + + @pytest.fixture async def wan_get_info(hass: HomeAssistant) -> AsyncGenerator[WanInfo]: """Fixture for SFRBox.wan_get_info.""" diff --git a/tests/components/sfr_box/fixtures/voip_getInfo.json b/tests/components/sfr_box/fixtures/voip_getInfo.json new file mode 100644 index 00000000000000..c1a112e6050b89 --- /dev/null +++ b/tests/components/sfr_box/fixtures/voip_getInfo.json @@ -0,0 +1,6 @@ +{ + "status": "up", + "infra": "ftth", + "hook_status": "onhook", + "callhistory_active": "on" +} diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index 8fa6d9e72a0534..890b5d2fcc76eb 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -50,6 +50,157 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_voip_call_history_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sfr_box_voip_call_history_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'VoIP call history active', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VoIP call history active', + 'platform': 'sfr_box', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voip_callhistory_active', + 'unique_id': 'e4:5d:51:00:11:22_voip_callhistory_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_voip_call_history_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box VoIP call history active', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_voip_call_history_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_voip_phone_hook_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.sfr_box_voip_phone_hook_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'VoIP phone hook status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VoIP phone hook status', + 'platform': 'sfr_box', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voip_hook_status', + 'unique_id': 'e4:5d:51:00:11:22_voip_hook_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_voip_phone_hook_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box VoIP phone hook status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_voip_phone_hook_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_voip_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sfr_box_voip_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'VoIP status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VoIP status', + 'platform': 'sfr_box', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voip_status', + 'unique_id': 'e4:5d:51:00:11:22_voip_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_voip_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box VoIP status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_voip_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[adsl][binary_sensor.sfr_box_wan_status-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -152,6 +303,157 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_voip_call_history_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sfr_box_voip_call_history_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'VoIP call history active', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VoIP call history active', + 'platform': 'sfr_box', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voip_callhistory_active', + 'unique_id': 'e4:5d:51:00:11:22_voip_callhistory_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_voip_call_history_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box VoIP call history active', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_voip_call_history_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_voip_phone_hook_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.sfr_box_voip_phone_hook_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'VoIP phone hook status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VoIP phone hook status', + 'platform': 'sfr_box', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voip_hook_status', + 'unique_id': 'e4:5d:51:00:11:22_voip_hook_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_voip_phone_hook_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box VoIP phone hook status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_voip_phone_hook_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_voip_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sfr_box_voip_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'VoIP status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VoIP status', + 'platform': 'sfr_box', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voip_status', + 'unique_id': 'e4:5d:51:00:11:22_voip_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_voip_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box VoIP status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_voip_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[ftth][binary_sensor.sfr_box_wan_status-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -203,3 +505,9 @@ 'state': 'on', }) # --- +# name: test_binary_sensors_no_auth + list([ + 'binary_sensor.sfr_box_dsl_status', + 'binary_sensor.sfr_box_wan_status', + ]) +# --- diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 6b5877d449f170..f266b550b49850 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -747,6 +747,68 @@ 'state': '27.56', }) # --- +# name: test_sensors[sensor.sfr_box_voip_infrastructure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'adsl', + 'ftth', + 'gprs', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_voip_infrastructure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'VoIP infrastructure', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VoIP infrastructure', + 'platform': 'sfr_box', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voip_infra', + 'unique_id': 'e4:5d:51:00:11:22_voip_infra', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.sfr_box_voip_infrastructure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box VoIP infrastructure', + 'options': list([ + 'adsl', + 'ftth', + 'gprs', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_voip_infrastructure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ftth', + }) +# --- # name: test_sensors[sensor.sfr_box_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -869,3 +931,22 @@ 'state': 'adsl_routed', }) # --- +# name: test_sensors_no_auth + list([ + 'sensor.sfr_box_dsl_attenuation_down', + 'sensor.sfr_box_dsl_attenuation_up', + 'sensor.sfr_box_dsl_connect_count', + 'sensor.sfr_box_dsl_crc_error_count', + 'sensor.sfr_box_dsl_line_mode', + 'sensor.sfr_box_dsl_line_status', + 'sensor.sfr_box_dsl_noise_down', + 'sensor.sfr_box_dsl_noise_up', + 'sensor.sfr_box_dsl_rate_down', + 'sensor.sfr_box_dsl_rate_up', + 'sensor.sfr_box_dsl_training', + 'sensor.sfr_box_network_infrastructure', + 'sensor.sfr_box_temperature', + 'sensor.sfr_box_voltage', + 'sensor.sfr_box_wan_mode', + ]) +# --- diff --git a/tests/components/sfr_box/test_binary_sensor.py b/tests/components/sfr_box/test_binary_sensor.py index aa226d2e3500cc..04818fc293680f 100644 --- a/tests/components/sfr_box/test_binary_sensor.py +++ b/tests/components/sfr_box/test_binary_sensor.py @@ -15,21 +15,28 @@ from tests.common import snapshot_platform pytestmark = pytest.mark.usefixtures( - "system_get_info", "dsl_get_info", "ftth_get_info", "wan_get_info" + "system_get_info", "dsl_get_info", "ftth_get_info", "voip_get_info", "wan_get_info" ) @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.sfr_box.PLATFORMS", [Platform.BINARY_SENSOR]): + with ( + patch("homeassistant.components.sfr_box.PLATFORMS", [Platform.BINARY_SENSOR]), + patch( + "homeassistant.components.sfr_box.PLATFORMS_WITH_AUTH", + [Platform.BINARY_SENSOR], + ), + patch("homeassistant.components.sfr_box.coordinator.SFRBox.authenticate"), + ): yield @pytest.mark.parametrize("net_infra", ["adsl", "ftth"]) async def test_binary_sensors( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry_with_auth: ConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, system_get_info: SystemInfo, @@ -37,7 +44,23 @@ async def test_binary_sensors( ) -> None: """Test for SFR Box binary sensors.""" system_get_info.net_infra = net_infra + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + await hass.async_block_till_done() + + await snapshot_platform( + hass, entity_registry, snapshot, config_entry_with_auth.entry_id + ) + + +async def test_binary_sensors_no_auth( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for SFR Box binary sensors.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + # Ensure auth-only entities are not registered + assert sorted(entity_registry.entities) == snapshot diff --git a/tests/components/sfr_box/test_button.py b/tests/components/sfr_box/test_button.py index 4f5739237b4978..dce4bf5a4bd3c9 100644 --- a/tests/components/sfr_box/test_button.py +++ b/tests/components/sfr_box/test_button.py @@ -16,7 +16,9 @@ from tests.common import snapshot_platform -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") +pytestmark = pytest.mark.usefixtures( + "system_get_info", "dsl_get_info", "voip_get_info", "wan_get_info" +) @pytest.fixture(autouse=True) diff --git a/tests/components/sfr_box/test_sensor.py b/tests/components/sfr_box/test_sensor.py index aec692282b7daa..7ce7c38af53ecb 100644 --- a/tests/components/sfr_box/test_sensor.py +++ b/tests/components/sfr_box/test_sensor.py @@ -13,18 +13,42 @@ from tests.common import snapshot_platform -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") +pytestmark = pytest.mark.usefixtures( + "system_get_info", "dsl_get_info", "voip_get_info", "wan_get_info" +) @pytest.fixture(autouse=True) def override_platforms() -> Generator[None]: """Override PLATFORMS.""" - with patch("homeassistant.components.sfr_box.PLATFORMS", [Platform.SENSOR]): + with ( + patch("homeassistant.components.sfr_box.PLATFORMS", [Platform.SENSOR]), + patch( + "homeassistant.components.sfr_box.PLATFORMS_WITH_AUTH", [Platform.SENSOR] + ), + patch("homeassistant.components.sfr_box.coordinator.SFRBox.authenticate"), + ): yield @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( + hass: HomeAssistant, + config_entry_with_auth: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for SFR Box sensors.""" + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + await hass.async_block_till_done() + + await snapshot_platform( + hass, entity_registry, snapshot, config_entry_with_auth.entry_id + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_no_auth( hass: HomeAssistant, config_entry: ConfigEntry, entity_registry: er.EntityRegistry, @@ -34,4 +58,5 @@ async def test_sensors( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + # Ensure auth-only entities are not registered + assert sorted(entity_registry.entities) == snapshot From 0764e3e2399b3e7c1099cb7494e5603754b3039a Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:11:06 +0200 Subject: [PATCH 0698/1707] Add support for sound modes to Music Assistant. (#167838) --- .../components/music_assistant/const.py | 1 + .../music_assistant/media_player.py | 38 +++++++++++ .../components/music_assistant/strings.json | 63 +++++++++++++++++++ .../music_assistant/fixtures/players.json | 17 ++++- .../snapshots/test_media_player.ambr | 18 ++++-- .../music_assistant/test_media_player.py | 28 +++++++++ 6 files changed, 159 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 5a89510471fa19..2a823c48cf5745 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -82,3 +82,4 @@ LOGGER = logging.getLogger(__package__) PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX = "player_options." +SOUND_MODES_TRANSLATION_KEY_PREFIX = "player_sound_mode." diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8eb13002fd9c3a..2c1d8f5fec36f6 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -60,6 +60,7 @@ ATTR_REPEAT_MODE, ATTR_SHUFFLE_ENABLED, DOMAIN, + SOUND_MODES_TRANSLATION_KEY_PREFIX, ) from .entity import MusicAssistantEntity from .helpers import catch_musicassistant_error @@ -131,6 +132,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): _attr_name = None _attr_media_image_remotely_accessible = True _attr_media_content_type = HAMediaType.MUSIC + _attr_translation_key = "ma_media_player" def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: """Initialize MediaPlayer entity.""" @@ -140,6 +142,7 @@ def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 self._source_list_mapping: dict[str, str] = {} + self._sound_mode_list_mapping: dict[str, str] = {} async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -218,6 +221,29 @@ async def async_on_update(self) -> None: self._source_list_mapping = source_mappings self._attr_source = active_source_name + # same for sound modes + sound_mode_mappings: dict[str, str] = {} + for sound_mode in player.sound_mode_list: + if sound_mode.passive: + # ignore passive sound_mode because HA does not differentiate between + # active and passive sound mode + continue + if ( + sound_mode.translation_key is None + or SOUND_MODES_TRANSLATION_KEY_PREFIX not in sound_mode.translation_key + ): + # MA's data class initializes the translation_key to + # player_sound_mode. automatically if it is not given, so we should + # always have a non None value + continue + translation_key = sound_mode.translation_key[ + len(SOUND_MODES_TRANSLATION_KEY_PREFIX) : + ] + sound_mode_mappings[translation_key] = sound_mode.id + self._attr_sound_mode_list = list(sound_mode_mappings.keys()) + self._sound_mode_list_mapping = sound_mode_mappings + self._attr_sound_mode = player.active_sound_mode + group_members: list[str] = [] if player.group_members: group_members = player.group_members @@ -397,6 +423,16 @@ async def async_select_source(self, source: str) -> None: ) await self.mass.players.player_command_select_source(self.player_id, source_id) + @catch_musicassistant_error + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + sound_mode_id = self._sound_mode_list_mapping.get(sound_mode) + if sound_mode_id is None: + raise ServiceValidationError( + f"Sound mode '{sound_mode}' not found for player {self.name}" + ) + await self.mass.players.select_sound_mode(self.player_id, sound_mode_id) + @catch_musicassistant_error async def _async_handle_play_media( self, @@ -682,4 +718,6 @@ def _set_supported_features(self) -> None: supported_features |= MediaPlayerEntityFeature.TURN_OFF if PlayerFeature.SELECT_SOURCE in self.player.supported_features: supported_features |= MediaPlayerEntityFeature.SELECT_SOURCE + if PlayerFeature.SELECT_SOUND_MODE in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE self._attr_supported_features = supported_features diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 65f8c730da8e87..7ee7669087ba1c 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -54,6 +54,69 @@ "name": "Favorite current song" } }, + "media_player": { + "ma_media_player": { + "state_attributes": { + "sound_mode": { + "state": { + "2ch_stereo": "2ch stereo", + "5ch_stereo": "5ch stereo", + "7ch_stereo": "7ch stereo", + "9ch_stereo": "9ch stereo", + "11ch_stereo": "11ch stereo", + "action_game": "Action game", + "adventure": "Adventure", + "all_ch_stereo": "All ch stereo", + "amsterdam": "Hall in Amsterdam", + "arena": "Arena", + "bottom_line": "The Bottom Line", + "cellar_club": "Cellar club", + "chamber": "Chamber", + "concert": "Live concert", + "disco": "Disco", + "drama": "Drama", + "enhanced": "Enhanced", + "frankfurt": "Hall in Frankfurt", + "freiburg": "Church in Freiburg", + "game": "Game", + "jazz_club": "Jazz club", + "mono_movie": "Mono movie", + "movie": "Movie", + "munich": "Hall in Munich", + "munich_a": "Hall in Munich A", + "munich_b": "Hall in Munich B", + "music": "Music", + "music_video": "Music video", + "my_surround": "My surround", + "off": "[%key:common::state::off%]", + "pavilion": "Pavilion", + "recital_opera": "Recital/opera", + "roleplaying_game": "Roleplaying game", + "roxy_theatre": "The Roxy Theatre", + "royaumont": "Church in Royaumont", + "sci-fi": "Sci-fi", + "spectacle": "Spectacle", + "sports": "Sports", + "standard": "Standard", + "stereo": "Stereo", + "straight": "Straight", + "stuttgart": "Hall in Stuttgart", + "surr_decoder": "Surround decoder", + "talk_show": "Talk show", + "target": "Target", + "tokyo": "Church in Tokyo", + "tv_program": "TV program", + "usa_a": "Hall in USA A", + "usa_b": "Hall in USA B", + "vienna": "Hall in Vienna", + "village_gate": "Village Gate", + "village_vanguard": "Village Vanguard", + "warehouse_loft": "Warehouse loft" + } + } + } + } + }, "number": { "bass": { "name": "Bass" diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 5306b17d1bc925..cb078259dfbc7e 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -18,7 +18,8 @@ "set_members", "power", "enqueue", - "select_source" + "select_source", + "select_sound_mode" ], "elapsed_time": null, "elapsed_time_last_updated": 0, @@ -193,6 +194,20 @@ "can_seek": false, "can_next_previous": false } + ], + "sound_mode_list": [ + { + "id": "munich", + "name": "Munich", + "passive": false, + "translation_key": "player_sound_mode.munich" + }, + { + "id": "vienna", + "name": "Vienna", + "passive": false, + "translation_key": "player_sound_mode.vienna" + } ] }, { diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index dc9e7603570bf6..f4cff652b0b9a9 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -32,7 +32,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'ma_media_player', 'unique_id': '00:00:00:00:00:02', 'unit_of_measurement': None, }) @@ -103,7 +103,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'ma_media_player', 'unique_id': 'test_group_player_1', 'unit_of_measurement': None, }) @@ -152,6 +152,10 @@ ]), 'area_id': None, 'capabilities': dict({ + 'sound_mode_list': list([ + 'munich', + 'vienna', + ]), 'source_list': list([ 'Music Assistant Queue', 'Line-In', @@ -181,8 +185,8 @@ 'platform': 'music_assistant', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'ma_media_player', 'unique_id': '00:00:00:00:00:01', 'unit_of_measurement': None, }) @@ -198,11 +202,15 @@ 'icon': 'mdi:speaker', 'last_non_buffering_state': , 'mass_player_type': 'player', + 'sound_mode_list': list([ + 'munich', + 'vienna', + ]), 'source_list': list([ 'Music Assistant Queue', 'Line-In', ]), - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'media_player.test_player_1', diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index f6fcce103b315f..57107c933ffa2f 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -24,9 +24,11 @@ ATTR_MEDIA_SHUFFLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, + SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, MediaPlayerEntityFeature, @@ -653,6 +655,31 @@ async def test_media_player_select_source_action( ) +async def test_media_player_select_sound_mode_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player entity select sound mode action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SOUND_MODE: "munich", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/select_sound_mode", player_id=mass_player_id, sound_mode="munich" + ) + + async def test_media_player_supported_features( hass: HomeAssistant, music_assistant_client: MagicMock, @@ -686,6 +713,7 @@ async def test_media_player_supported_features( | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SEARCH_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) assert state.attributes["supported_features"] == expected_features # remove power control capability from player, trigger subscription callback From b0888b051c12b3a5cd3b2b8471de19615076368e Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 9 Apr 2026 23:12:30 +0100 Subject: [PATCH 0699/1707] Improve services.yaml in Evohome to improve UI/UX (#167788) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/evohome/services.py | 2 +- homeassistant/components/evohome/services.yaml | 16 ++++++++++++---- homeassistant/components/evohome/strings.json | 16 ++++++++-------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index b117ca6e4d7a0e..efd4071ac7a163 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -27,7 +27,7 @@ # System service schemas (registered as domain services) SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { # unsupported modes are rejected at runtime with ServiceValidationError - vol.Required(ATTR_MODE): cv.string, # avoid vol.In(SystemMode) + vol.Required(ATTR_MODE): cv.string, # ... so, don't use SystemMode enum here vol.Exclusive(ATTR_DURATION, "temporary"): vol.All( cv.time_period, vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index cbf39f9c215707..77f5066fe88b3f 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -4,6 +4,8 @@ set_system_mode: fields: mode: + required: true + default: Auto example: Away selector: select: @@ -19,9 +21,10 @@ set_system_mode: selector: object: duration: - example: '{"hours": 18}' + example: "18:00" selector: - object: + duration: + enable_second: false reset_system: @@ -32,6 +35,8 @@ set_zone_override: entity: integration: evohome domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE fields: setpoint: required: true @@ -41,12 +46,15 @@ set_zone_override: max: 35.0 step: 0.1 duration: - example: '{"minutes": 135}' + example: "02:15" selector: - object: + duration: + enable_second: false clear_zone_override: target: entity: integration: evohome domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 5f19ff49339760..1d96f4651b6a3b 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -4,13 +4,13 @@ "message": "The requested system mode is not supported: {error}" }, "mode_cant_be_temporary": { - "message": "The mode `{mode}` does not support `duration` or `period`" + "message": "The mode `{mode}` does not support 'Duration' or 'Period'" }, "mode_cant_have_duration": { - "message": "The mode `{mode}` does not support `duration`; use `period` instead" + "message": "The mode `{mode}` does not support 'Duration'; use 'Period' instead" }, "mode_cant_have_period": { - "message": "The mode `{mode}` does not support `period`; use `duration` instead" + "message": "The mode `{mode}` does not support 'Period'; use 'Duration' instead" }, "mode_not_supported": { "message": "The mode `{mode}` is not supported by this controller" @@ -29,14 +29,14 @@ "name": "Refresh system" }, "reset_system": { - "description": "Sets the system to Auto mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode).", + "description": "Sets the system to `Auto` mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. `AutoWithReset` mode).", "name": "Reset system" }, "set_system_mode": { - "description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to Auto. Not all systems support all modes.", + "description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to `Auto`. Not all systems support all modes.", "fields": { "duration": { - "description": "The duration in hours; used only with AutoWithEco mode (up to 24 hours).", + "description": "The duration in hours; used only with `AutoWithEco` mode (up to 24 hours).", "name": "Duration" }, "mode": { @@ -44,14 +44,14 @@ "name": "[%key:common::config_flow::data::mode%]" }, "period": { - "description": "A period of time in days; used only with Away, DayOff, or Custom mode. The system will revert to Auto mode at midnight (up to 99 days, today is day 1).", + "description": "A period of time in days; used only with `Away`, `DayOff`, or `Custom` mode. The system will revert to `Auto` mode at midnight (up to 99 days, today is day 1).", "name": "Period" } }, "name": "Set system mode" }, "set_zone_override": { - "description": "Overrides a zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.", + "description": "Overrides the zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.", "fields": { "duration": { "description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.", From 8c50cb2ab107d24883ff68e332b7713f318fa410 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:14:37 +0200 Subject: [PATCH 0700/1707] Add initial support for PlayerOptions: Switch entities to Music Assistant (#167829) Co-authored-by: Joost Lekkerkerker Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/music_assistant/__init__.py | 1 + .../components/music_assistant/strings.json | 32 +++++ .../components/music_assistant/switch.py | 118 ++++++++++++++++ .../snapshots/test_switch.ambr | 51 +++++++ .../components/music_assistant/test_switch.py | 131 ++++++++++++++++++ 5 files changed, 333 insertions(+) create mode 100644 homeassistant/components/music_assistant/switch.py create mode 100644 tests/components/music_assistant/snapshots/test_switch.ambr create mode 100644 tests/components/music_assistant/test_switch.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 754d11a10dfec6..3dcc5d15c52563 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -53,6 +53,7 @@ Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.SWITCH, Platform.TEXT, ] diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 7ee7669087ba1c..88a14b49ecf328 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -146,6 +146,38 @@ "name": "Treble" } }, + "switch": { + "adaptive_drc": { + "name": "Adaptive DRC" + }, + "bass_extension": { + "name": "Bass extension" + }, + "clear_voice": { + "name": "Clear voice" + }, + "enhancer": { + "name": "Enhancer" + }, + "extra_bass": { + "name": "Extra bass" + }, + "party_mode": { + "name": "Party mode" + }, + "pure_direct": { + "name": "Pure direct" + }, + "speaker_a": { + "name": "Speaker A" + }, + "speaker_b": { + "name": "Speaker B" + }, + "surround_3d": { + "name": "Surround 3D" + } + }, "text": { "network_name": { "name": "Network name" diff --git a/homeassistant/components/music_assistant/switch.py b/homeassistant/components/music_assistant/switch.py new file mode 100644 index 00000000000000..9d9822257aeac6 --- /dev/null +++ b/homeassistant/components/music_assistant/switch.py @@ -0,0 +1,118 @@ +"""Music Assistant Switch platform.""" + +from __future__ import annotations + +from typing import Any, Final + +from music_assistant_client.client import MusicAssistantClient +from music_assistant_models.player import PlayerOption, PlayerOptionType + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX +from .entity import MusicAssistantPlayerOptionEntity +from .helpers import catch_musicassistant_error + +PLAYER_OPTIONS_SWITCH: Final[dict[str, bool]] = { + # translation_key: enabled_by_default + "adaptive_drc": False, + "bass_extension": False, + "clear_voice": False, + "enhancer": True, + "extra_bass": False, + "party_mode": False, + "pure_direct": True, + "speaker_a": True, + "speaker_b": True, + "surround_3d": False, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant Switch Entities (Player Options) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + player = mass.players.get(player_id) + if player is None: + return + entities: list[MusicAssistantPlayerConfigSwitch] = [] + for player_option in player.options: + if ( + not player_option.read_only + and player_option.type == PlayerOptionType.BOOLEAN + ): + # the MA translation key must have the format player_options. + # we ignore entities with unknown translation keys. + if ( + player_option.translation_key is None + or not player_option.translation_key.startswith( + PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX + ) + ): + continue + translation_key = player_option.translation_key[ + len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) : + ] + if translation_key not in PLAYER_OPTIONS_SWITCH: + continue + + entities.append( + MusicAssistantPlayerConfigSwitch( + mass, + player_id, + player_option=player_option, + entity_description=SwitchEntityDescription( + key=player_option.key, + translation_key=translation_key, + entity_registry_enabled_default=PLAYER_OPTIONS_SWITCH[ + translation_key + ], + ), + ) + ) + async_add_entities(entities) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.SWITCH, add_player) + + +class MusicAssistantPlayerConfigSwitch(MusicAssistantPlayerOptionEntity, SwitchEntity): + """Representation of a Switch entity to control player provider dependent settings.""" + + def __init__( + self, + mass: MusicAssistantClient, + player_id: str, + player_option: PlayerOption, + entity_description: SwitchEntityDescription, + ) -> None: + """Initialize MusicAssistantPlayerConfigSwitch.""" + super().__init__(mass, player_id, player_option) + + self.entity_description = entity_description + + @catch_musicassistant_error + async def async_turn_on(self, **kwargs: Any) -> None: + """Handle turn on command.""" + await self.mass.players.set_option(self.player_id, self.mass_option_key, True) + + @catch_musicassistant_error + async def async_turn_off(self, **kwargs: Any) -> None: + """Handle turn off command.""" + await self.mass.players.set_option(self.player_id, self.mass_option_key, False) + + def on_player_option_update(self, player_option: PlayerOption) -> None: + """Update on player option update.""" + self._attr_is_on = ( + player_option.value if isinstance(player_option.value, bool) else None + ) diff --git a/tests/components/music_assistant/snapshots/test_switch.ambr b/tests/components/music_assistant/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..7b9332fa4d1d09 --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_switch.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_switch_entities[switch.test_player_1_enhancer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_player_1_enhancer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Enhancer', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Enhancer', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'enhancer', + 'unique_id': '00:00:00:00:00:01_enhancer', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.test_player_1_enhancer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Player 1 Enhancer', + }), + 'context': , + 'entity_id': 'switch.test_player_1_enhancer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/music_assistant/test_switch.py b/tests/components/music_assistant/test_switch.py new file mode 100644 index 00000000000000..13ae78f91dcfd7 --- /dev/null +++ b/tests/components/music_assistant/test_switch.py @@ -0,0 +1,131 @@ +"""Test Music Assistant switch entities.""" + +from unittest.mock import MagicMock, call + +from music_assistant_models.enums import EventType +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.components.music_assistant.switch import PLAYER_OPTIONS_SWITCH +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TOGGLE +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.translation import LOCALE_EN, async_get_translations + +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) + + +async def test_switch_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + music_assistant_client: MagicMock, +) -> None: + """Test switch entities.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + snapshot_music_assistant_entities(hass, entity_registry, snapshot, Platform.SWITCH) + + +async def test_switch_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test switch set action.""" + mass_player_id = "00:00:00:00:00:01" + mass_option_key = "enhancer" + entity_id = "switch.test_player_1_enhancer" + + await setup_integration_from_fixtures(hass, music_assistant_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + # toggle off -> on and verify, that client got called once with correct parameters + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/set_option", + player_id=mass_player_id, + option_key=mass_option_key, + option_value=True, + ) + + +async def test_external_update( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test external value update.""" + mass_player_id = "00:00:00:00:00:01" + mass_option_key = "enhancer" + entity_id = "switch.test_player_1_enhancer" + + await setup_integration_from_fixtures(hass, music_assistant_client) + + # get current option and remove it + switch_option = next( + option + for option in music_assistant_client.players._players[mass_player_id].options + if option.key == mass_option_key + ) + music_assistant_client.players._players[mass_player_id].options.remove( + switch_option + ) + + # set new value different from previous one + previous_value = switch_option.value + assert isinstance(previous_value, bool) + switch_option.value = not previous_value + music_assistant_client.players._players[mass_player_id].options.append( + switch_option + ) + + # verify old HA state before trigger + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_OPTIONS_UPDATED, mass_player_id + ) + + # verify new HA state after trigger + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + +async def test_ignored( + hass: HomeAssistant, + music_assistant_client: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test that non-compatible player options are ignored.""" + config_entry = await setup_integration_from_fixtures(hass, music_assistant_client) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + # only a single player option available + assert sum(1 for entry in registry_entries if entry.domain == SWITCH_DOMAIN) == 1 + + +async def test_name_translation_availability( + hass: HomeAssistant, +) -> None: + """Verify, that the list of available translation keys is reflected in strings.json.""" + # verify, that PLAYER_OPTIONS_TRANSLATION_KEYS_SWITCH matches strings.json + translations = await async_get_translations( + hass, language=LOCALE_EN, category="entity", integrations=[DOMAIN] + ) + prefix = f"component.{DOMAIN}.entity.{Platform.SWITCH.value}." + for translation_key in PLAYER_OPTIONS_SWITCH: + assert translations.get(f"{prefix}{translation_key}.name") is not None, ( + f"{translation_key} is missing in strings.json for platform switch" + ) From 8f383bccd9fe6c0816df0485d8f5737b491b774f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:20:47 +0200 Subject: [PATCH 0701/1707] Set assumed state on Renault number entity (#167644) --- homeassistant/components/renault/number.py | 1 + tests/components/renault/test_number.py | 21 ++++++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/renault/number.py b/homeassistant/components/renault/number.py index 4b71f77718b896..6891b8313e063e 100644 --- a/homeassistant/components/renault/number.py +++ b/homeassistant/components/renault/number.py @@ -86,6 +86,7 @@ async def _set_charge_limits( entity.coordinator.data.socMin = min_soc entity.coordinator.data.socTarget = target_soc + entity.coordinator.assumed_state = True entity.coordinator.async_set_updated_data(entity.coordinator.data) diff --git a/tests/components/renault/test_number.py b/tests/components/renault/test_number.py index 3ebeec4013873e..0a61d62a26310c 100644 --- a/tests/components/renault/test_number.py +++ b/tests/components/renault/test_number.py @@ -15,7 +15,7 @@ ) from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -139,6 +139,13 @@ async def test_number_action( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + min_charge_level = hass.states.get("number.reg_zoe_40_minimum_charge_level") + target_charge_level = hass.states.get("number.reg_zoe_40_target_charge_level") + assert min_charge_level.state == "15" + assert target_charge_level.state == "80" + assert not min_charge_level.attributes.get(ATTR_ASSUMED_STATE) + assert not target_charge_level.attributes.get(ATTR_ASSUMED_STATE) + with patch( "renault_api.renault_vehicle.RenaultVehicle.set_battery_soc", return_value=( @@ -157,12 +164,12 @@ async def test_number_action( mock_action.assert_awaited_once_with(min=expected_min, target=expected_target) # Verify optimistic update of coordinator data - assert hass.states.get("number.reg_zoe_40_minimum_charge_level").state == str( - expected_min - ) - assert hass.states.get("number.reg_zoe_40_target_charge_level").state == str( - expected_target - ) + min_charge_level = hass.states.get("number.reg_zoe_40_minimum_charge_level") + target_charge_level = hass.states.get("number.reg_zoe_40_target_charge_level") + assert min_charge_level.state == str(expected_min) + assert target_charge_level.state == str(expected_target) + assert min_charge_level.attributes.get(ATTR_ASSUMED_STATE) + assert target_charge_level.attributes.get(ATTR_ASSUMED_STATE) @pytest.mark.usefixtures("fixtures_with_no_data") From 7f0d94da9f8f1eaa746e31e802c4898984f26179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 10 Apr 2026 00:34:50 +0200 Subject: [PATCH 0702/1707] Fix service.yaml values for Home Connect (#167847) --- .../components/home_connect/services.py | 8 +++ .../components/home_connect/services.yaml | 33 +++++----- .../components/home_connect/strings.json | 62 +++++++++---------- .../components/home_connect/test_services.py | 48 +++++++++++++- 4 files changed, 103 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index bb9783be62b03c..56dbfb608d00ed 100644 --- a/homeassistant/components/home_connect/services.py +++ b/homeassistant/components/home_connect/services.py @@ -68,8 +68,16 @@ ), OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)), OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE: bool, OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS: bool, + OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING: bool, + OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD: bool, + OptionKey.LAUNDRY_CARE_WASHER_PREWASH: bool, + OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD: bool, + OptionKey.LAUNDRY_CARE_WASHER_SOAK: bool, + OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS: bool, }.items() } diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 2bec0dc6cf58d1..af9a2400459e3d 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -119,7 +119,7 @@ set_program_and_options: - cooking_common_program_hood_automatic - cooking_common_program_hood_venting - cooking_common_program_hood_delayed_shut_off - - cooking_oven_program_heating_mode_3_d_heating + - cooking_oven_program_heating_mode_3_d_hot_air - cooking_oven_program_heating_mode_air_fry - cooking_oven_program_heating_mode_grill_large_area - cooking_oven_program_heating_mode_grill_small_area @@ -210,6 +210,7 @@ set_program_and_options: mode: box unit_of_measurement: "%" heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode: + example: heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic required: false selector: select: @@ -222,7 +223,7 @@ set_program_and_options: collapsed: true fields: consumer_products_cleaning_robot_option_reference_map_id: - example: consumer_products_cleaning_robot_enum_type_available_maps_map1 + example: consumer_products_cleaning_robot_enum_type_available_maps_map_1 required: false selector: select: @@ -230,9 +231,9 @@ set_program_and_options: translation_key: available_maps options: - consumer_products_cleaning_robot_enum_type_available_maps_temp_map - - consumer_products_cleaning_robot_enum_type_available_maps_map1 - - consumer_products_cleaning_robot_enum_type_available_maps_map2 - - consumer_products_cleaning_robot_enum_type_available_maps_map3 + - consumer_products_cleaning_robot_enum_type_available_maps_map_1 + - consumer_products_cleaning_robot_enum_type_available_maps_map_2 + - consumer_products_cleaning_robot_enum_type_available_maps_map_3 consumer_products_cleaning_robot_option_cleaning_mode: example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard required: false @@ -310,7 +311,7 @@ set_program_and_options: - consumer_products_coffee_maker_enum_type_coffee_temperature_94_c - consumer_products_coffee_maker_enum_type_coffee_temperature_95_c - consumer_products_coffee_maker_enum_type_coffee_temperature_96_c - consumer_products_coffee_maker_option_bean_container: + consumer_products_coffee_maker_option_bean_container_selection: example: consumer_products_coffee_maker_enum_type_bean_container_selection_right required: false selector: @@ -468,8 +469,8 @@ set_program_and_options: hood_options: collapsed: true fields: - cooking_hood_option_venting_level: - example: cooking_hood_enum_type_stage_fan_stage01 + cooking_common_option_hood_venting_level: + example: cooking_hood_enum_type_stage_fan_stage_01 required: false selector: select: @@ -482,8 +483,8 @@ set_program_and_options: - cooking_hood_enum_type_stage_fan_stage_03 - cooking_hood_enum_type_stage_fan_stage_04 - cooking_hood_enum_type_stage_fan_stage_05 - cooking_hood_option_intensive_level: - example: cooking_hood_enum_type_intensive_stage_intensive_stage1 + cooking_common_option_hood_intensive_level: + example: cooking_hood_enum_type_intensive_stage_intensive_stage_1 required: false selector: select: @@ -491,8 +492,8 @@ set_program_and_options: translation_key: intensive_level options: - cooking_hood_enum_type_intensive_stage_intensive_stage_off - - cooking_hood_enum_type_intensive_stage_intensive_stage1 - - cooking_hood_enum_type_intensive_stage_intensive_stage2 + - cooking_hood_enum_type_intensive_stage_intensive_stage_1 + - cooking_hood_enum_type_intensive_stage_intensive_stage_2 oven_options: collapsed: true fields: @@ -567,7 +568,7 @@ set_program_and_options: - laundry_care_washer_enum_type_temperature_ul_hot - laundry_care_washer_enum_type_temperature_ul_extra_hot laundry_care_washer_option_spin_speed: - example: laundry_care_washer_enum_type_spin_speed_r_p_m800 + example: laundry_care_washer_enum_type_spin_speed_r_p_m_800 required: false selector: select: @@ -611,12 +612,12 @@ set_program_and_options: required: false selector: boolean: - laundry_care_washer_option_i_dos1_active: + laundry_care_washer_option_i_dos_1_active: example: false required: false selector: boolean: - laundry_care_washer_option_i_dos2_active: + laundry_care_washer_option_i_dos_2_active: example: false required: false selector: @@ -656,7 +657,7 @@ set_program_and_options: required: false selector: boolean: - laundry_care_washer_option_vario_perfect: + laundry_care_common_option_vario_perfect: example: laundry_care_common_enum_type_vario_perfect_eco_perfect required: false selector: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index b49476407dff10..8a50dfe860c5cc 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -260,7 +260,7 @@ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", - "cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]", + "cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]", "cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", @@ -431,7 +431,7 @@ } }, "bean_container": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container_selection::name%]", "state": { "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]", "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]" @@ -484,9 +484,9 @@ "current_map": { "name": "Current map", "state": { - "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]", "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]" } }, @@ -557,19 +557,19 @@ } }, "intensive_level": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_intensive_level::name%]", "state": { - "cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]", - "cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage_1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_1%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage_2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_2%]", "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]" } }, "reference_map_id": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", "state": { - "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]", "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]" } }, @@ -620,7 +620,7 @@ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", - "cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]", + "cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]", "cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", @@ -786,7 +786,7 @@ } }, "vario_perfect": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_common_option_vario_perfect::name%]", "state": { "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", @@ -794,7 +794,7 @@ } }, "venting_level": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_venting_level::name%]", "state": { "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", "cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]", @@ -1272,10 +1272,10 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]" }, "i_dos1_active": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_1_active::name%]" }, "i_dos2_active": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_2_active::name%]" }, "intensiv_zone": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]" @@ -1458,9 +1458,9 @@ }, "available_maps": { "options": { - "consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1", - "consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2", - "consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3", + "consumer_products_cleaning_robot_enum_type_available_maps_map_1": "Map 1", + "consumer_products_cleaning_robot_enum_type_available_maps_map_2": "Map 2", + "consumer_products_cleaning_robot_enum_type_available_maps_map_3": "Map 3", "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map" } }, @@ -1584,8 +1584,8 @@ }, "intensive_level": { "options": { - "cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1", - "cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2", + "cooking_hood_enum_type_intensive_stage_intensive_stage_1": "Intensive stage 1", + "cooking_hood_enum_type_intensive_stage_intensive_stage_2": "Intensive stage 2", "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off" } }, @@ -1629,7 +1629,7 @@ "cooking_common_program_hood_automatic": "Automatic", "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", "cooking_common_program_hood_venting": "Venting", - "cooking_oven_program_heating_mode_3_d_heating": "3D heating", + "cooking_oven_program_heating_mode_3_d_hot_air": "3D hot air", "cooking_oven_program_heating_mode_air_fry": "Air fry", "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", "cooking_oven_program_heating_mode_bread_baking": "Bread baking", @@ -1892,7 +1892,7 @@ "description": "Describes the amount of coffee beans used in a coffee machine program.", "name": "Bean amount" }, - "consumer_products_coffee_maker_option_bean_container": { + "consumer_products_coffee_maker_option_bean_container_selection": { "description": "Defines the preferred bean container.", "name": "Bean container" }, @@ -1920,11 +1920,11 @@ "description": "Defines if double dispensing is enabled.", "name": "Multiple beverages" }, - "cooking_hood_option_intensive_level": { + "cooking_common_option_hood_intensive_level": { "description": "Defines the intensive setting.", "name": "Intensive level" }, - "cooking_hood_option_venting_level": { + "cooking_common_option_hood_venting_level": { "description": "Defines the required fan setting.", "name": "Venting level" }, @@ -1992,15 +1992,19 @@ "description": "Defines if the silent mode is activated.", "name": "Silent mode" }, + "laundry_care_common_option_vario_perfect": { + "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).", + "name": "Vario perfect" + }, "laundry_care_dryer_option_drying_target": { "description": "Describes the drying target for a dryer program.", "name": "Drying target" }, - "laundry_care_washer_option_i_dos1_active": { + "laundry_care_washer_option_i_dos_1_active": { "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)", "name": "i-Dos 1 Active" }, - "laundry_care_washer_option_i_dos2_active": { + "laundry_care_washer_option_i_dos_2_active": { "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)", "name": "i-Dos 2 Active" }, @@ -2044,10 +2048,6 @@ "description": "Defines the temperature of the washing program.", "name": "Temperature" }, - "laundry_care_washer_option_vario_perfect": { - "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).", - "name": "Vario perfect" - }, "laundry_care_washer_option_water_plus": { "description": "Defines if the water plus option is activated.", "name": "Water +" diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py index d56a5478079e95..a5ab71f17c65c5 100644 --- a/tests/components/home_connect/test_services.py +++ b/tests/components/home_connect/test_services.py @@ -17,12 +17,19 @@ from syrupy.assertion import SnapshotAssertion from voluptuous.error import MultipleInvalid -from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components import home_connect +from homeassistant.components.home_connect.const import ( + DOMAIN, + PROGRAM_ENUM_OPTIONS, + TRANSLATION_KEYS_PROGRAMS_MAP, +) +from homeassistant.components.home_connect.services import PROGRAM_OPTIONS from homeassistant.components.home_connect.utils import bsh_key_to_translation_key from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from homeassistant.util.yaml import load_yaml_dict from tests.common import MockConfigEntry @@ -95,6 +102,45 @@ ] +def test_services_yaml_set_program_and_options_program_keys() -> None: + """Test that all program keys in services.yaml exist in the translation map.""" + services = load_yaml_dict(f"{home_connect.__path__[0]}/services.yaml") + yaml_programs = set( + services["set_program_and_options"]["fields"]["program"]["selector"]["select"][ + "options" + ] + ) + + assert yaml_programs <= set(TRANSLATION_KEYS_PROGRAMS_MAP.keys()) + + +def test_services_yaml_set_program_and_options_option_keys() -> None: + """Test that all program keys in services.yaml exist in the translation map.""" + services = load_yaml_dict(f"{home_connect.__path__[0]}/services.yaml") + groups = services["set_program_and_options"]["fields"] + groups.pop("device_id") + groups.pop("affects_to") + groups.pop("program") + for group in groups.values(): + for option, option_data in group["fields"].items(): + assert option in PROGRAM_ENUM_OPTIONS or option in PROGRAM_OPTIONS, ( + f"{option} is missing from both PROGRAM_ENUM_OPTIONS and PROGRAM_OPTIONS" + ) + if option in PROGRAM_ENUM_OPTIONS: + enum_values = set(PROGRAM_ENUM_OPTIONS[option][1]) + assert enum_values == set( + option_data["selector"]["select"]["options"] + ), ( + f"Options for {option} do not match between services.yaml and constants.py" + ) + assert "example" in option_data, ( + f"Example value for {option} is missing" + ) + assert option_data["example"] in enum_values, ( + f"Example value for {option} is not a valid option" + ) + + @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize("service_call", SERVICE_KV_CALL_PARAMS) async def test_key_value_services( From 6ac7952f26008a781c63882a9ddf7a9e6a799092 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 10 Apr 2026 08:39:28 +1000 Subject: [PATCH 0703/1707] Tessie: use Vehicle methods for button commands (#167193) Co-authored-by: Claude Sonnet 4.6 --- homeassistant/components/tessie/__init__.py | 1 + homeassistant/components/tessie/button.py | 29 +++++++-------- homeassistant/components/tessie/entity.py | 1 + homeassistant/components/tessie/helpers.py | 5 +++ homeassistant/components/tessie/models.py | 2 +- tests/components/tessie/test_button.py | 39 +++++++++++++++++---- tests/components/tessie/test_init.py | 17 --------- 7 files changed, 53 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index a9a7406e5d61a7..684a5eb9336599 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -91,6 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo vehicle_api = tessie.vehicles.create(vin) vehicles.append( TessieVehicleData( + api=vehicle_api, vin=vin, data_coordinator=TessieStateUpdateCoordinator( hass, diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index a370f5043231cb..b413823df31e73 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -2,17 +2,11 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Any -from tessie_api import ( - boombox, - enable_keyless_driving, - flash_lights, - honk, - trigger_homelink, - wake, -) +from tesla_fleet_api.tessie import Vehicle from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant @@ -29,21 +23,22 @@ class TessieButtonEntityDescription(ButtonEntityDescription): """Describes a Tessie Button entity.""" - func: Callable + func: Callable[[Vehicle], Awaitable[dict[str, Any]]] DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( - TessieButtonEntityDescription(key="wake", func=lambda: wake), - TessieButtonEntityDescription(key="flash_lights", func=lambda: flash_lights), - TessieButtonEntityDescription(key="honk", func=lambda: honk), + TessieButtonEntityDescription(key="wake", func=lambda api: api.wake()), + TessieButtonEntityDescription(key="flash_lights", func=lambda api: api.flash()), + TessieButtonEntityDescription(key="honk", func=lambda api: api.honk()), TessieButtonEntityDescription( - key="trigger_homelink", func=lambda: trigger_homelink + key="trigger_homelink", + func=lambda api: api.tessie_trigger_homelink(), ), TessieButtonEntityDescription( key="enable_keyless_driving", - func=lambda: enable_keyless_driving, + func=lambda api: api.remote_start(), ), - TessieButtonEntityDescription(key="boombox", func=lambda: boombox), + TessieButtonEntityDescription(key="boombox", func=lambda api: api.remote_boombox()), ) @@ -78,4 +73,4 @@ def __init__( async def async_press(self) -> None: """Press the button.""" - await self.run(self.entity_description.func()) + await self.run(self.entity_description.func(self.api)) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index e42ed57316c958..20abf3f9750d03 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -77,6 +77,7 @@ def __init__( data_key: str | None = None, ) -> None: """Initialize common aspects of a Tessie vehicle entity.""" + self.api = vehicle.api self.vin = vehicle.vin self._session = vehicle.data_coordinator.session self._api_key = vehicle.data_coordinator.api_key diff --git a/homeassistant/components/tessie/helpers.py b/homeassistant/components/tessie/helpers.py index 82202890ca6b9a..c37a9f4d0f6fea 100644 --- a/homeassistant/components/tessie/helpers.py +++ b/homeassistant/components/tessie/helpers.py @@ -30,6 +30,11 @@ async def handle_command(command: Awaitable[dict[str, Any]]) -> dict[str, Any]: """Handle an awaitable Vehicle/EnergySite command.""" try: result = await command + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e except TeslaFleetError as e: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index c9b1105281ef2f..8bbdb3441070b0 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -40,7 +40,7 @@ class TessieEnergyData: class TessieVehicleData: """Data for a Tessie vehicle.""" + api: Vehicle data_coordinator: TessieStateUpdateCoordinator device: DeviceInfo vin: str - api: Vehicle | None = None diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index da5942c0fdd45d..1a1411fdc52d10 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -2,14 +2,16 @@ from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import assert_entities, setup_platform +from .common import ERROR_UNKNOWN, assert_entities, setup_platform async def test_buttons( @@ -23,14 +25,14 @@ async def test_buttons( for entity_id, func in ( ("button.test_wake", "wake"), - ("button.test_flash_lights", "flash_lights"), + ("button.test_flash_lights", "flash"), ("button.test_honk_horn", "honk"), - ("button.test_homelink", "trigger_homelink"), - ("button.test_keyless_driving", "enable_keyless_driving"), - ("button.test_play_fart", "boombox"), + ("button.test_homelink", "tessie_trigger_homelink"), + ("button.test_keyless_driving", "remote_start"), + ("button.test_play_fart", "remote_boombox"), ): with patch( - f"homeassistant.components.tessie.button.{func}", + f"tesla_fleet_api.tessie.Vehicle.{func}", ) as mock_press: await hass.services.async_call( BUTTON_DOMAIN, @@ -39,3 +41,28 @@ async def test_buttons( blocking=True, ) mock_press.assert_called_once() + + +async def test_button_error(hass: HomeAssistant) -> None: + """Test button transport errors are translated.""" + + await setup_platform(hass, [Platform.BUTTON]) + + with ( + patch( + "tesla_fleet_api.tessie.Vehicle.wake", + side_effect=ERROR_UNKNOWN, + ) as mock_press, + pytest.raises(HomeAssistantError) as error, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ["button.test_wake"]}, + blocking=True, + ) + + mock_press.assert_called_once() + assert error.value.__cause__ == ERROR_UNKNOWN + assert error.value.translation_domain == "tessie" + assert error.value.translation_key == "cannot_connect" diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index 0e5b67375a3e56..7eb0c46a99ba3a 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -25,13 +25,6 @@ async def test_load_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED -async def test_runtime_vehicle_api_handle_is_optional(hass: HomeAssistant) -> None: - """Test the runtime vehicle API handle remains optional during migration.""" - - entry = await setup_platform(hass) - assert all(vehicle.api is None for vehicle in entry.runtime_data.vehicles) - - async def test_auth_failure( hass: HomeAssistant, mock_get_state_of_all_vehicles: AsyncMock ) -> None: @@ -81,13 +74,3 @@ async def test_scopes_error(hass: HomeAssistant) -> None: ): entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_vehicle_api_handle_is_optional(hass: HomeAssistant) -> None: - """Test runtime vehicle API handle defaults to None during scaffold stage.""" - - entry = await setup_platform(hass) - assert entry.state is ConfigEntryState.LOADED - vehicles = entry.runtime_data.vehicles - assert vehicles - assert all(vehicle.api is None for vehicle in vehicles) From 1c5e0203448245ebbd6b1ba108f1e96147799217 Mon Sep 17 00:00:00 2001 From: norkudev <74642859+norkudev@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:56:37 +0300 Subject: [PATCH 0704/1707] Include indirect automation references in device view (#167719) --- homeassistant/components/search/__init__.py | 23 +++++++++++++++++++++ tests/components/search/test_init.py | 17 +++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index adec8ff12575cf..546f1ca4927b9e 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -307,6 +307,29 @@ def _async_search_device(self, device_id: str, *, entry_point: bool = True) -> N automation.automations_with_device(self.hass, device_id), ) + # Automations referencing labels assigned to this device + for label_id in device_entry.labels: + self._add( + ItemType.AUTOMATION, + automation.automations_with_label(self.hass, label_id), + ) + + if device_entry.area_id: + # Automations referencing this device via its area + self._add( + ItemType.AUTOMATION, + automation.automations_with_area(self.hass, device_entry.area_id), + ) + # Automations referencing this device via its areas floor + if area_entry := self._area_registry.async_get_area(device_entry.area_id): + if area_entry.floor_id: + self._add( + ItemType.AUTOMATION, + automation.automations_with_floor( + self.hass, area_entry.floor_id + ), + ) + # Scripts referencing this device self._add(ItemType.SCRIPT, script.scripts_with_device(self.hass, device_id)) diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 268f829ba0a02e..93b0c16d1c9704 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -603,6 +603,7 @@ def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: assert not search(ItemType.CONFIG_ENTRY, "unknown") assert search(ItemType.CONFIG_ENTRY, hue_config_entry.entry_id) == { ItemType.AREA: {kitchen_area.id}, + ItemType.AUTOMATION: {"automation.area", "automation.floor"}, ItemType.DEVICE: {hue_device.id}, ItemType.ENTITY: { hue_segment_1_entity.entity_id, @@ -616,7 +617,12 @@ def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: } assert search(ItemType.CONFIG_ENTRY, wled_config_entry.entry_id) == { ItemType.AREA: {bedroom_area.id, living_room_area.id}, - ItemType.AUTOMATION: {"automation.wled_entity", "automation.wled_device"}, + ItemType.AUTOMATION: { + "automation.floor", + "automation.label", + "automation.wled_entity", + "automation.wled_device", + }, ItemType.DEVICE: {wled_device.id}, ItemType.ENTITY: { wled_segment_1_entity.entity_id, @@ -632,7 +638,12 @@ def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: assert not search(ItemType.DEVICE, "unknown") assert search(ItemType.DEVICE, wled_device.id) == { ItemType.AREA: {bedroom_area.id, living_room_area.id}, - ItemType.AUTOMATION: {"automation.wled_entity", "automation.wled_device"}, + ItemType.AUTOMATION: { + "automation.floor", + "automation.label", + "automation.wled_entity", + "automation.wled_device", + }, ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, ItemType.ENTITY: { wled_segment_1_entity.entity_id, @@ -647,6 +658,7 @@ def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: } assert search(ItemType.DEVICE, hue_device.id) == { ItemType.AREA: {kitchen_area.id}, + ItemType.AUTOMATION: {"automation.floor", "automation.area"}, ItemType.CONFIG_ENTRY: {hue_config_entry.entry_id}, ItemType.ENTITY: { hue_segment_1_entity.entity_id, @@ -987,6 +999,7 @@ def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: assert response["success"] assert response["result"] == { ItemType.AREA: [kitchen_area.id], + ItemType.AUTOMATION: unordered(["automation.area", "automation.floor"]), ItemType.ENTITY: unordered( [ hue_segment_1_entity.entity_id, From 6a3937b96bf7c93a5eaf194344258599ea486755 Mon Sep 17 00:00:00 2001 From: Brendan McShane Date: Fri, 10 Apr 2026 03:33:22 -0400 Subject: [PATCH 0705/1707] Add HomeKit AirPlay Enable (Ecobee) (#159564) Co-authored-by: Joost Lekkerkerker --- .../components/homekit_controller/const.py | 1 + .../components/homekit_controller/icons.json | 3 + .../components/homekit_controller/switch.py | 6 ++ .../homekit_controller/test_switch.py | 76 +++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 77deb07b3ddd47..fdd34455486de9 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -103,6 +103,7 @@ CharacteristicsTypes.THREAD_NODE_CAPABILITIES: "sensor", CharacteristicsTypes.THREAD_CONTROL_POINT: "button", CharacteristicsTypes.MUTE: "switch", + CharacteristicsTypes.AIRPLAY_ENABLE: "switch", CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor", CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch", CharacteristicsTypes.TEMPERATURE_UNITS: "select", diff --git a/homeassistant/components/homekit_controller/icons.json b/homeassistant/components/homekit_controller/icons.json index 49ea157a56066c..f1086ec166fee7 100644 --- a/homeassistant/components/homekit_controller/icons.json +++ b/homeassistant/components/homekit_controller/icons.json @@ -36,6 +36,9 @@ } }, "switch": { + "airplay_enable": { + "default": "mdi:cast-variant" + }, "lock_physical_controls": { "default": "mdi:lock-open" }, diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index c24a4edf545575..3007ba01aa306b 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -70,6 +70,12 @@ class DeclarativeSwitchEntityDescription(SwitchEntityDescription): translation_key="sleep_mode", entity_category=EntityCategory.CONFIG, ), + CharacteristicsTypes.AIRPLAY_ENABLE: DeclarativeSwitchEntityDescription( + key=CharacteristicsTypes.AIRPLAY_ENABLE, + name="AirPlay Enable", + translation_key="airplay_enable", + entity_category=EntityCategory.CONFIG, + ), } diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index d841323bd59a0c..74f09fa16e48cf 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -61,6 +61,18 @@ def create_char_switch_service(accessory: Accessory) -> None: on_char.value = False +def create_airplay_enable_switch_service(accessory: Accessory) -> None: + """Define AirPlay enable switch characteristics.""" + service = accessory.add_service(ServicesTypes.OUTLET) + + outlet_on_char = service.add_char(CharacteristicsTypes.ON) + outlet_on_char.value = False + + on_char = service.add_char(CharacteristicsTypes.AIRPLAY_ENABLE) + on_char.perms.append("ev") + on_char.value = 0 + + async def test_switch_change_outlet_state( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -295,6 +307,70 @@ async def test_char_switch_read_state( assert switch_1.state == "off" +async def test_airplay_enable_switch_change_state( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can turn AirPlay Enable on and off again.""" + helper = await setup_test_component( + hass, + get_next_aid(), + create_airplay_enable_switch_service, + suffix="airplay_enable", + ) + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.testdevice_airplay_enable"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.OUTLET, + { + CharacteristicsTypes.AIRPLAY_ENABLE: 1, + }, + ) + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.testdevice_airplay_enable"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.OUTLET, + { + CharacteristicsTypes.AIRPLAY_ENABLE: 0, + }, + ) + + +async def test_airplay_enable_switch_read_state( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can read the state of a HomeKit AirPlay switch.""" + helper = await setup_test_component( + hass, + get_next_aid(), + create_airplay_enable_switch_service, + suffix="airplay_enable", + ) + + # Simulate that someone switched on the device in the real world not via HA + switch_1 = await helper.async_update( + ServicesTypes.OUTLET, + {CharacteristicsTypes.AIRPLAY_ENABLE: 1}, + ) + assert switch_1.state == "on" + + # Simulate that device switched off in the real world not via HA + switch_1 = await helper.async_update( + ServicesTypes.OUTLET, + {CharacteristicsTypes.AIRPLAY_ENABLE: 0}, + ) + assert switch_1.state == "off" + + async def test_migrate_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 44eea221b79594e8c323e112eb86a9974e201165 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:43:06 +0200 Subject: [PATCH 0706/1707] Use runtime_data in Snooz (#167867) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/snooz/__init__.py | 27 +++++++--------------- homeassistant/components/snooz/fan.py | 9 +++----- homeassistant/components/snooz/models.py | 4 ++++ tests/components/snooz/test_config_flow.py | 2 +- 4 files changed, 16 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/snooz/__init__.py b/homeassistant/components/snooz/__init__.py index c97c89c2f4add1..c60697cb2f881b 100644 --- a/homeassistant/components/snooz/__init__.py +++ b/homeassistant/components/snooz/__init__.py @@ -7,16 +7,15 @@ from pysnooz.device import SnoozDevice from homeassistant.components.bluetooth import async_ble_device_from_address -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, PLATFORMS -from .models import SnoozConfigurationData +from .const import PLATFORMS +from .models import SnoozConfigEntry, SnoozConfigurationData -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> bool: """Set up Snooz device from a config entry.""" address: str = entry.data[CONF_ADDRESS] token: str = entry.data[CONF_TOKEN] @@ -31,33 +30,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device = SnoozDevice(ble_device, token) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SnoozConfigurationData( - ble_device, device, entry.title - ) + entry.runtime_data = SnoozConfigurationData(ble_device, device, entry.title) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: SnoozConfigEntry) -> None: """Handle options update.""" - data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: + if entry.title != entry.runtime_data.title: await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] - # also called by fan entities, but do it here too for good measure - await data.device.async_disconnect() - - hass.data[DOMAIN].pop(entry.entry_id) - - if not hass.config_entries.async_entries(DOMAIN): - hass.data.pop(DOMAIN) + await entry.runtime_data.device.async_disconnect() return unload_ok diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index ce804450cab337..52a7dc2cad7976 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -17,7 +17,6 @@ import voluptuous as vol from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -34,12 +33,12 @@ SERVICE_TRANSITION_OFF, SERVICE_TRANSITION_ON, ) -from .models import SnoozConfigurationData +from .models import SnoozConfigEntry, SnoozConfigurationData async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SnoozConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Snooz device from a config entry.""" @@ -67,9 +66,7 @@ async def async_setup_entry( "async_transition_off", ) - data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([SnoozFan(data)]) + async_add_entities([SnoozFan(entry.runtime_data)]) class SnoozFan(FanEntity, RestoreEntity): diff --git a/homeassistant/components/snooz/models.py b/homeassistant/components/snooz/models.py index d1c49fe9dc61a0..0ac7cfd2d99f05 100644 --- a/homeassistant/components/snooz/models.py +++ b/homeassistant/components/snooz/models.py @@ -5,6 +5,10 @@ from bleak.backends.device import BLEDevice from pysnooz.device import SnoozDevice +from homeassistant.config_entries import ConfigEntry + +type SnoozConfigEntry = ConfigEntry[SnoozConfigurationData] + @dataclass class SnoozConfigurationData: diff --git a/tests/components/snooz/test_config_flow.py b/tests/components/snooz/test_config_flow.py index d326f81d9b6f36..3c6ac19219f244 100644 --- a/tests/components/snooz/test_config_flow.py +++ b/tests/components/snooz/test_config_flow.py @@ -6,7 +6,7 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.snooz import DOMAIN +from homeassistant.components.snooz.const import DOMAIN from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant From 3f388e88e053901b99caa6a2a7e2824466841914 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:47:20 +0100 Subject: [PATCH 0707/1707] Add support for deletion of stale devices for Squeezebox (#159848) Co-authored-by: Erik Montnemery --- .../components/squeezebox/__init__.py | 41 +++++++++++ .../components/squeezebox/coordinator.py | 7 ++ .../components/squeezebox/media_player.py | 9 ++- tests/components/squeezebox/test_init.py | 73 +++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 3ba320091a6818..4383be1eb6dec5 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -22,11 +22,13 @@ ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, + HomeAssistantError, ) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, + DeviceEntry, DeviceEntryType, format_mac, ) @@ -77,6 +79,9 @@ class SqueezeboxData: coordinator: LMSStatusDataUpdateCoordinator server: Server + player_coordinators: dict[str, SqueezeBoxPlayerUpdateCoordinator] = field( + default_factory=dict + ) known_player_ids: set[str] = field(default_factory=set) @@ -216,6 +221,9 @@ async def _discovered_player(player: Player) -> None: hass, entry, player, lms.uuid ) await player_coordinator.async_refresh() + entry.runtime_data.player_coordinators[player.player_id] = ( + player_coordinator + ) entry.runtime_data.known_player_ids.add(player.player_id) async_dispatcher_send( hass, SIGNAL_PLAYER_DISCOVERED + entry.entry_id, player_coordinator @@ -259,3 +267,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) hass.data.pop(SQUEEZEBOX_HASS_DATA) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: SqueezeboxConfigEntry, + device_entry: DeviceEntry, +) -> bool: + """Allow removal of a Squeezebox player only if its coordinator is unavailable.""" + if device_entry.entry_type is DeviceEntryType.SERVICE: + raise HomeAssistantError( + f"Cannot remove Lyrion Music Server '{device_entry.name}' directly. " + "Please delete the associated config entry instead." + ) + + player_id = next( + (id_ for domain, id_ in device_entry.identifiers if domain == DOMAIN), None + ) + + if not player_id: + return False # Not a Squeezebox device + + coordinator = config_entry.runtime_data.player_coordinators.get(player_id) + + if coordinator is None: + return True + + if coordinator.available: + raise HomeAssistantError( + f"Cannot remove Squeezebox player '{coordinator.player_uuid}' " + "because it is currently online." + ) + + return True diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index c078fc377b5050..2aac7ed15b858e 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -138,3 +138,10 @@ def rediscovered(self, unique_id: str, connected: bool) -> None: _LOGGER.info("Player %s is available again", self.name) if self._remove_dispatcher: self._remove_dispatcher() + + @callback + def async_shutdown_dispatcher(self) -> None: + """Close down the dispatcher.""" + if self._remove_dispatcher: + self._remove_dispatcher() + self._remove_dispatcher = None diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 094f50397a6002..5757003e3d479e 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -292,10 +292,17 @@ async def async_added_to_hass(self) -> None: async def async_will_remove_from_hass(self) -> None: """Remove from list of known players when removed from hass.""" - self.coordinator.config_entry.runtime_data.known_player_ids.remove( + self.coordinator.async_shutdown_dispatcher() + + self.coordinator.config_entry.runtime_data.known_player_ids.discard( self.coordinator.player.player_id ) + self.coordinator.config_entry.runtime_data.player_coordinators.pop( + self.coordinator.player.player_id, None + ) + await super().async_will_remove_from_hass() + @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" diff --git a/tests/components/squeezebox/test_init.py b/tests/components/squeezebox/test_init.py index bb7b3a4d7452e9..6570e572215cbf 100644 --- a/tests/components/squeezebox/test_init.py +++ b/tests/components/squeezebox/test_init.py @@ -6,10 +6,13 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.squeezebox import async_remove_config_entry_device from homeassistant.components.squeezebox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceRegistry from .conftest import TEST_MAC @@ -130,3 +133,73 @@ async def test_device_registry_server_merged( reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) assert reg_device is not None assert reg_device == snapshot + + +@pytest.mark.parametrize( + ("is_service", "is_online", "expected_error"), + [ + (True, False, "Please delete the associated config entry"), + (False, True, "is currently online"), + ], +) +async def test_remove_device_blocked( + hass: HomeAssistant, + setup_squeezebox: MockConfigEntry, + device_registry: dr.DeviceRegistry, + is_service: bool, + is_online: bool, + expected_error: str, +) -> None: + """Test that removal is blocked for server or online players.""" + entry = setup_squeezebox + device: dr.DeviceEntry | None + + if is_service: + device = next( + d + for d in dr.async_entries_for_config_entry(device_registry, entry.entry_id) + if d.entry_type is dr.DeviceEntryType.SERVICE + ) + else: + player_id = TEST_MAC[0] + entry.runtime_data.player_coordinators[player_id].available = is_online + device = device_registry.async_get_device(identifiers={(DOMAIN, player_id)}) + + assert device is not None + with pytest.raises(HomeAssistantError, match=expected_error): + await async_remove_config_entry_device(hass, entry, device) + + +async def test_remove_device_allowed_offline_player( + hass: HomeAssistant, + setup_squeezebox: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that removal is allowed for an offline player.""" + entry = setup_squeezebox + player_id = TEST_MAC[0] + # Ensure the player coordinator exists and is marked offline/unavailable. + coordinator = entry.runtime_data.player_coordinators[player_id] + coordinator.available = False + device = device_registry.async_get_device(identifiers={(DOMAIN, player_id)}) + assert device is not None + result = await async_remove_config_entry_device(hass, entry, device) + assert result is True + + +async def test_remove_device_allowed_stale_player( + hass: HomeAssistant, + setup_squeezebox: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that removal is allowed for a stale player without coordinator.""" + entry = setup_squeezebox + stale_player_id = "stale_player" + # Create a device in the registry that has no matching coordinator. + assert stale_player_id not in entry.runtime_data.player_coordinators + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, stale_player_id)}, + ) + result = await async_remove_config_entry_device(hass, entry, device) + assert result is True From eaa1fc591a119c189983616c1ad0f3b42f69b296 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 10 Apr 2026 09:52:19 +0200 Subject: [PATCH 0708/1707] Bump ZHA to 1.1.2 (#167849) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 34afaecac7f96a..f87bf12699d3eb 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==1.1.1", "serialx==1.1.1"], + "requirements": ["zha==1.1.2", "serialx==1.1.1"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index 0c45ebfb26d18f..30c4c658b72fb9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3386,7 +3386,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.1.1 +zha==1.1.2 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7e73867e37afa..de9efe49d3d393 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2868,7 +2868,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.1.1 +zha==1.1.2 # homeassistant.components.zinvolt zinvolt==0.4.1 From 853b6a80d2133a4ff3d5da014fd08abbf8f0e3f3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 10 Apr 2026 09:53:51 +0200 Subject: [PATCH 0709/1707] Fix stale devices removal for Alexa devices (#167837) --- .../components/alexa_devices/coordinator.py | 11 ++++++- .../alexa_devices/test_coordinator.py | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 87299e647fe346..8988d3e13cf785 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -54,7 +54,16 @@ def __init__( entry.data[CONF_PASSWORD], entry.data[CONF_LOGIN_DATA], ) - self.previous_devices: set[str] = set() + device_registry = dr.async_get(hass) + self.previous_devices: set[str] = { + identifier + for device in device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ) + if device.entry_type != dr.DeviceEntryType.SERVICE + for identifier_domain, identifier in device.identifiers + if identifier_domain == DOMAIN + } async def _async_update_data(self) -> dict[str, AmazonDevice]: """Update device data.""" diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py index 3e0880fcd0782f..b53734f8ceee2e 100644 --- a/tests/components/alexa_devices/test_coordinator.py +++ b/tests/components/alexa_devices/test_coordinator.py @@ -4,9 +4,11 @@ from freezegun.api import FrozenDateTimeFactory +from homeassistant.components.alexa_devices.const import DOMAIN from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN @@ -50,3 +52,33 @@ async def test_coordinator_stale_device( # Entity is removed assert not hass.states.get(entity_id_1) + + +async def test_coordinator_load_previous_devices_from_registry( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test coordinator preloads previous devices from registry excluding services.""" + mock_config_entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, + name="Echo Test", + manufacturer="Amazon", + model="Echo Dot", + ) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Amazon", + model="Echo Dot", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + await setup_integration(hass, mock_config_entry) + coordinator = mock_config_entry.runtime_data + assert coordinator.previous_devices == {TEST_DEVICE_1_SN} From c42e37dd7d0f36a9d69836b25156b3394e0b8de1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:55:57 +0200 Subject: [PATCH 0710/1707] Bump docker/login-action from 4.0.0 to 4.1.0 (#167860) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index d906d3c209ebbd..dc928b70be1a22 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -344,13 +344,13 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -523,7 +523,7 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From 09e6b6533a457234c0f0cfc4656c42f7a32a41fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:56:29 +0200 Subject: [PATCH 0711/1707] Bump dawidd6/action-download-artifact from 19 to 20 (#167861) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index dc928b70be1a22..d4dec8825a6c66 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -108,7 +108,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19 + uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -119,7 +119,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19 + uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: OHF-Voice/intents-package From 4494f9ff6b7d7d536b6abcc1ca2e6bc52b6cacb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 21:57:28 -1000 Subject: [PATCH 0712/1707] Bump aioesphomeapi to 44.13.1 (#167855) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index f642dfb56943bd..8d9daaedd1c41f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==44.6.2", + "aioesphomeapi==44.13.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.7.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 30c4c658b72fb9..3493ab60421bc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -251,7 +251,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==44.6.2 +aioesphomeapi==44.13.1 # homeassistant.components.matrix # homeassistant.components.slack diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de9efe49d3d393..e707cce0ee33ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -242,7 +242,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==44.6.2 +aioesphomeapi==44.13.1 # homeassistant.components.matrix # homeassistant.components.slack From 35ffffb1592a7085931f63802e8a7f34f32d4b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 10 Apr 2026 09:57:32 +0200 Subject: [PATCH 0713/1707] Improve Tibber price coordinator (#166175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tibber/__init__.py | 17 +++++- .../components/tibber/coordinator.py | 60 +++++++++++++------ homeassistant/components/tibber/sensor.py | 11 ++-- homeassistant/components/tibber/services.py | 49 ++++++++++++++- homeassistant/components/tibber/strings.json | 9 +++ tests/components/tibber/conftest.py | 2 + tests/components/tibber/test_services.py | 40 ++++++++++++- 7 files changed, 159 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 0596a5a2dc07ca..2fa987782a9a0e 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -23,7 +23,11 @@ from homeassistant.util import dt as dt_util, ssl as ssl_util from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEntry -from .coordinator import TibberDataAPICoordinator +from .coordinator import ( + TibberDataAPICoordinator, + TibberDataCoordinator, + TibberPriceCoordinator, +) from .services import async_setup_services PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR] @@ -39,6 +43,8 @@ class TibberRuntimeData: session: OAuth2Session data_api_coordinator: TibberDataAPICoordinator | None = field(default=None) + data_coordinator: TibberDataCoordinator | None = field(default=None) + price_coordinator: TibberPriceCoordinator | None = field(default=None) _client: tibber.Tibber | None = None async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber: @@ -124,6 +130,15 @@ async def _close(event: Event) -> None: except tibber.FatalHttpExceptionError as err: raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err + if tibber_connection.get_homes(only_active=True): + price_coordinator = TibberPriceCoordinator(hass, entry) + await price_coordinator.async_config_entry_first_refresh() + entry.runtime_data.price_coordinator = price_coordinator + + data_coordinator = TibberDataCoordinator(hass, entry, tibber_connection) + await data_coordinator.async_config_entry_first_refresh() + entry.runtime_data.data_coordinator = data_coordinator + coordinator = TibberDataAPICoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data.data_api_coordinator = coordinator diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 75a76326146149..1edb5e932692f5 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -5,6 +5,7 @@ import asyncio from datetime import datetime, timedelta import logging +import random from typing import TYPE_CHECKING, TypedDict, cast from aiohttp.client_exceptions import ClientError @@ -271,9 +272,10 @@ def __init__( name=f"{DOMAIN} price", update_interval=timedelta(minutes=1), ) + self._tomorrow_price_poll_threshold_seconds = random.uniform(0, 3600 * 10) - def _seconds_until_next_15_minute(self) -> float: - """Return seconds until the next 15-minute boundary (0, 15, 30, 45) in UTC.""" + def _time_until_next_15_minute(self) -> timedelta: + """Return time until the next 15-minute boundary (0, 15, 30, 45) in UTC.""" now = dt_util.utcnow() next_minute = ((now.minute // 15) + 1) * 15 if next_minute >= 60: @@ -284,7 +286,7 @@ def _seconds_until_next_15_minute(self) -> float: next_run = now.replace( minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC ) - return (next_run - now).total_seconds() + return next_run - now async def _async_update_data(self) -> dict[str, TibberHomeData]: """Update data via API and return per-home data for sensors.""" @@ -292,22 +294,44 @@ async def _async_update_data(self) -> dict[str, TibberHomeData]: self.hass ) active_homes = tibber_connection.get_homes(only_active=True) - try: - await asyncio.gather( - tibber_connection.fetch_consumption_data_active_homes(), - tibber_connection.fetch_production_data_active_homes(), - ) - now = dt_util.now() - homes_to_update = [ - home - for home in active_homes - if ( - (last_data_timestamp := home.last_data_timestamp) is None - or (last_data_timestamp - now).total_seconds() < 11 * 3600 - ) - ] + now = dt_util.now() + today_start = dt_util.start_of_local_day(now) + today_end = today_start + timedelta(days=1) + tomorrow_start = today_end + tomorrow_end = tomorrow_start + timedelta(days=1) + + def _has_prices_today(home: tibber.TibberHome) -> bool: + """Return True if the home has any prices today.""" + for start in home.price_total: + start_dt = dt_util.as_local(datetime.fromisoformat(str(start))) + if today_start <= start_dt < today_end: + return True + return False + + def _has_prices_tomorrow(home: tibber.TibberHome) -> bool: + """Return True if the home has any prices tomorrow.""" + for start in home.price_total: + start_dt = dt_util.as_local(datetime.fromisoformat(str(start))) + if tomorrow_start <= start_dt < tomorrow_end: + return True + return False + + def _needs_update(home: tibber.TibberHome) -> bool: + """Return True if the home needs to be updated.""" + if not _has_prices_today(home): + return True + if _has_prices_tomorrow(home): + return False + if (today_end - now).total_seconds() < ( + self._tomorrow_price_poll_threshold_seconds + ): + return True + return False + + homes_to_update = [home for home in active_homes if _needs_update(home)] + try: if homes_to_update: await asyncio.gather( *(home.update_info_and_price_info() for home in homes_to_update) @@ -319,7 +343,7 @@ async def _async_update_data(self) -> dict[str, TibberHomeData]: result = {home.home_id: _build_home_data(home) for home in active_homes} - self.update_interval = timedelta(seconds=self._seconds_until_next_15_minute()) + self.update_interval = self._time_until_next_15_minute() return result diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 008e3abef28c11..39accbaf9bb9aa 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -609,8 +609,8 @@ async def _async_setup_graphql_sensors( entity_registry = er.async_get(hass) - coordinator: TibberDataCoordinator | None = None - price_coordinator: TibberPriceCoordinator | None = None + coordinator = entry.runtime_data.data_coordinator + price_coordinator = entry.runtime_data.price_coordinator entities: list[TibberSensor] = [] for home in tibber_connection.get_homes(only_active=False): try: @@ -626,12 +626,9 @@ async def _async_setup_graphql_sensors( _LOGGER.error("Error connecting to Tibber home: %s ", err) raise PlatformNotReady from err - if home.has_active_subscription: - if price_coordinator is None: - price_coordinator = TibberPriceCoordinator(hass, entry) + if price_coordinator is not None and home.has_active_subscription: entities.append(TibberSensorElPrice(price_coordinator, home)) - if coordinator is None: - coordinator = TibberDataCoordinator(hass, entry, tibber_connection) + if coordinator is not None and home.has_active_subscription: entities.extend( TibberDataSensor(home, coordinator, entity_description) for entity_description in SENSORS diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 099739e4478d85..68009824c6d0a7 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -6,6 +6,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Final +import aiohttp +import tibber import voluptuous as vol from homeassistant.core import ( @@ -15,7 +17,7 @@ SupportsResponse, callback, ) -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -52,7 +54,52 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse: tibber_prices: dict[str, Any] = {} + now = dt_util.now() + today_start = dt_util.start_of_local_day(now) + today_end = today_start + dt.timedelta(days=1) + tomorrow_end = today_start + dt.timedelta(days=2) + + def _has_valid_prices(home: tibber.TibberHome) -> bool: + """Return True if the home has valid prices.""" + for price_start in home.price_total: + start_dt = dt_util.as_local(datetime.fromisoformat(str(price_start))) + + if now.hour >= 13: + if today_end <= start_dt < tomorrow_end: + return True + elif today_start <= start_dt < today_end: + return True + return False + for tibber_home in tibber_connection.get_homes(only_active=True): + if not _has_valid_prices(tibber_home): + try: + await tibber_home.update_info_and_price_info() + except TimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_timeout", + ) from err + except tibber.InvalidLoginError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_invalid_login", + ) from err + except ( + tibber.RetryableHttpExceptionError, + tibber.FatalHttpExceptionError, + ) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_communication_failed", + translation_placeholders={"detail": str(err.status)}, + ) from err + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_communication_failed", + translation_placeholders={"detail": str(err)}, + ) from err home_nickname = tibber_home.name price_data = [ diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index d07f295785ed44..c175f2fe96265f 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -235,6 +235,15 @@ "data_api_reauth_required": { "message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features." }, + "get_prices_communication_failed": { + "message": "Could not fetch energy prices from Tibber ({detail})" + }, + "get_prices_invalid_login": { + "message": "Could not authenticate with Tibber while fetching prices" + }, + "get_prices_timeout": { + "message": "Timeout fetching energy prices from Tibber" + }, "invalid_date": { "message": "Invalid datetime provided {date}" }, diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index befc3b68c87943..b6943067d0cbc0 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -183,6 +183,8 @@ def tibber_mock() -> AsyncGenerator[MagicMock]: tibber_mock.send_notification = AsyncMock() tibber_mock.rt_disconnect = AsyncMock() tibber_mock.get_homes = MagicMock(return_value=[]) + tibber_mock.fetch_consumption_data_active_homes = AsyncMock(return_value=None) + tibber_mock.fetch_production_data_active_homes = AsyncMock(return_value=None) tibber_mock.set_access_token = AsyncMock() data_api_mock = MagicMock() diff --git a/tests/components/tibber/test_services.py b/tests/components/tibber/test_services.py index 9c9fb86f91717f..ca8254cf728d75 100644 --- a/tests/components/tibber/test_services.py +++ b/tests/components/tibber/test_services.py @@ -1,15 +1,17 @@ """Test service for Tibber integration.""" import datetime as dt -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock +import aiohttp from freezegun.api import FrozenDateTimeFactory import pytest +import tibber from homeassistant.components.tibber.const import DOMAIN from homeassistant.components.tibber.services import PRICE_SERVICE_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError START_TIME = dt.datetime.fromtimestamp(1615766400).replace(tzinfo=dt.UTC) @@ -262,3 +264,37 @@ async def test_get_prices_invalid_input( blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + "exception", + [ + pytest.param(TimeoutError(), id="timeout"), + pytest.param(tibber.InvalidLoginError(401), id="invalid_login"), + pytest.param(tibber.RetryableHttpExceptionError(503), id="retryable_http"), + pytest.param(tibber.FatalHttpExceptionError(500), id="fatal_http"), + pytest.param(aiohttp.ClientError("connection failed"), id="client_error"), + ], +) +async def test_get_prices_refresh_raises_handled_exception( + mock_tibber_setup: MagicMock, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """When price refresh fails with handled exceptions, raise HomeAssistantError.""" + freezer.move_to(START_TIME) + mock_home = MagicMock() + mock_home.name = "home" + mock_home.price_total = {} + mock_home.update_info_and_price_info = AsyncMock(side_effect=exception) + mock_tibber_setup.get_homes.return_value = [mock_home] + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + PRICE_SERVICE_NAME, + {}, + blocking=True, + return_response=True, + ) From 191dd42a92ae041f73dea66a11e0b427f77375f9 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 10 Apr 2026 09:59:53 +0200 Subject: [PATCH 0714/1707] Bump velbusaio to 2026.4.0 (#167868) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 237323dd481e50..eb4c90aaf83eaa 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "silver", - "requirements": ["velbus-aio==2026.2.0"], + "requirements": ["velbus-aio==2026.4.0"], "usb": [ { "pid": "0B1B", diff --git a/requirements_all.txt b/requirements_all.txt index 3493ab60421bc1..1d99ccf32e8a34 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3222,7 +3222,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2026.2.0 +velbus-aio==2026.4.0 # homeassistant.components.venstar venstarcolortouch==0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e707cce0ee33ea..91a8962986478d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2728,7 +2728,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2026.2.0 +velbus-aio==2026.4.0 # homeassistant.components.venstar venstarcolortouch==0.21 From 3a9f805f10c8db2b1e2a293d959508f14a774ee9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:31:42 +0200 Subject: [PATCH 0715/1707] Use runtime_data in surepetcare integration (#167877) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/surepetcare/__init__.py | 23 +++++++------------ .../components/surepetcare/binary_sensor.py | 8 +++---- .../components/surepetcare/coordinator.py | 6 +++-- homeassistant/components/surepetcare/lock.py | 8 +++---- .../components/surepetcare/sensor.py | 9 ++++---- 5 files changed, 22 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 130242b7742fba..dc9310f1d8e5cb 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -9,7 +9,6 @@ from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -24,7 +23,7 @@ SERVICE_SET_LOCK_STATE, SERVICE_SET_PET_LOCATION, ) -from .coordinator import SurePetcareDataCoordinator +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,15 +31,10 @@ SCAN_INTERVAL = timedelta(minutes=3) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SurePetcareConfigEntry) -> bool: """Set up Sure Petcare from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - try: - hass.data[DOMAIN][entry.entry_id] = coordinator = SurePetcareDataCoordinator( - hass, - entry, - ) + coordinator = SurePetcareDataCoordinator(hass, entry) except SurePetcareAuthenticationError as error: _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") raise ConfigEntryAuthFailed from error @@ -49,6 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) lock_state_service_schema = vol.Schema( @@ -91,10 +86,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SurePetcareConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 9600f87437e29b..6a4707fe7edfd2 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -12,26 +12,24 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SurePetcareDataCoordinator +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator from .entity import SurePetcareEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SurePetcareConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare Flaps binary sensors based on a config entry.""" entities: list[SurePetcareBinarySensor] = [] - coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data for surepy_entity in coordinator.data.values(): # connectivity diff --git a/homeassistant/components/surepetcare/coordinator.py b/homeassistant/components/surepetcare/coordinator.py index d8112cebc90129..32a54484685bef 100644 --- a/homeassistant/components/surepetcare/coordinator.py +++ b/homeassistant/components/surepetcare/coordinator.py @@ -29,13 +29,15 @@ SCAN_INTERVAL = timedelta(minutes=3) +type SurePetcareConfigEntry = ConfigEntry[SurePetcareDataCoordinator] + class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): """Handle Surepetcare data.""" - config_entry: ConfigEntry + config_entry: SurePetcareConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: SurePetcareConfigEntry) -> None: """Initialize the data handler.""" self.surepy = Surepy( entry.data[CONF_USERNAME], diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py index 09fadf8be60e04..dc9fa8dd96d84e 100644 --- a/homeassistant/components/surepetcare/lock.py +++ b/homeassistant/components/surepetcare/lock.py @@ -8,23 +8,21 @@ from surepy.enums import EntityType, LockState as SurepyLockState from homeassistant.components.lock import LockEntity, LockState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SurePetcareDataCoordinator +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator from .entity import SurePetcareEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SurePetcareConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare locks on a config entry.""" - coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SurePetcareLock(surepy_entity.id, coordinator, lock_state) diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index a34675eee74279..0a0a4b505cee29 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -10,26 +10,25 @@ from surepy.enums import EntityType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VOLTAGE, PERCENTAGE, EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW -from .coordinator import SurePetcareDataCoordinator +from .const import SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator from .entity import SurePetcareEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SurePetcareConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare Flaps sensors.""" entities: list[SurePetcareEntity] = [] - coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data for surepy_entity in coordinator.data.values(): if surepy_entity.type in [ From 14f24226ae3c766ef13607fd0b6e6e37542dc9f3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:31:55 +0200 Subject: [PATCH 0716/1707] Use runtime_data in streamlabswater (#167874) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/streamlabswater/__init__.py | 14 +++++--------- .../components/streamlabswater/binary_sensor.py | 8 +++----- .../components/streamlabswater/coordinator.py | 7 +++++-- homeassistant/components/streamlabswater/sensor.py | 9 +++------ 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 1c1357a9b2b5a6..ccbbcf53a50fc2 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -3,13 +3,12 @@ from streamlabswater.streamlabswater import StreamlabsClient import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from .const import DOMAIN -from .coordinator import StreamlabsCoordinator +from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator ATTR_AWAY_MODE = "away_mode" SERVICE_SET_AWAY_MODE = "set_away_mode" @@ -30,7 +29,7 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) -> bool: """Set up StreamLabs from a config entry.""" api_key = entry.data[CONF_API_KEY] @@ -39,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) def set_away_mode(service: ServiceCall) -> None: @@ -55,9 +54,6 @@ def set_away_mode(service: ServiceCall) -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index e3e966edde0d5f..9e02ecf8ec43d7 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -3,22 +3,20 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import StreamlabsCoordinator -from .const import DOMAIN +from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator from .entity import StreamlabsWaterEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StreamlabsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Streamlabs water binary sensor from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( StreamlabsAwayMode(coordinator, location_id) for location_id in coordinator.data diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py index df4a6056b36ce7..d038a3657b825d 100644 --- a/homeassistant/components/streamlabswater/coordinator.py +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -23,15 +23,18 @@ class StreamlabsData: yearly_usage: float +type StreamlabsConfigEntry = ConfigEntry[StreamlabsCoordinator] + + class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): """Coordinator for Streamlabs.""" - config_entry: ConfigEntry + config_entry: StreamlabsConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: StreamlabsConfigEntry, client: StreamlabsClient, ) -> None: """Coordinator for Streamlabs.""" diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index dea3f081326626..5fc8ac73769823 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -10,15 +10,12 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import StreamlabsCoordinator -from .const import DOMAIN -from .coordinator import StreamlabsData +from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator, StreamlabsData from .entity import StreamlabsWaterEntity @@ -59,11 +56,11 @@ class StreamlabsWaterSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: StreamlabsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Streamlabs water sensor from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( StreamLabsSensor(coordinator, location_id, entity_description) From 4c8ea3669ca68bf80bd1f03a291bb410f49becfa Mon Sep 17 00:00:00 2001 From: Renaud Allard Date: Fri, 10 Apr 2026 10:38:17 +0200 Subject: [PATCH 0717/1707] Load lovelace resource collection eagerly during setup (#165773) --- .../components/lovelace/resources.py | 26 ++++++++++---- tests/components/lovelace/test_resources.py | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index 96f84ccbc6056c..b2f1c80dda20ef 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -62,14 +62,32 @@ def __init__(self, hass: HomeAssistant, ll_config: LovelaceConfig) -> None: ) self.ll_config = ll_config - async def async_get_info(self) -> dict[str, int]: - """Return the resources info for YAML mode.""" + async def _async_ensure_loaded(self) -> None: + """Ensure the collection has been loaded from storage.""" if not self.loaded: await self.async_load() self.loaded = True + async def async_get_info(self) -> dict[str, int]: + """Return the resources info for YAML mode.""" + await self._async_ensure_loaded() return {"resources": len(self.async_items() or [])} + async def async_create_item(self, data: dict) -> dict: + """Create a new item.""" + await self._async_ensure_loaded() + return await super().async_create_item(data) + + async def async_update_item(self, item_id: str, updates: dict) -> dict: + """Update item.""" + await self._async_ensure_loaded() + return await super().async_update_item(item_id, updates) + + async def async_delete_item(self, item_id: str) -> None: + """Delete item.""" + await self._async_ensure_loaded() + await super().async_delete_item(item_id) + async def _async_load_data(self) -> collection.SerializedStorageCollection | None: """Load the data.""" if (store_data := await self.store.async_load()) is not None: @@ -118,10 +136,6 @@ def _get_suggested_id(self, info: dict) -> str: async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" - if not self.loaded: - await self.async_load() - self.loaded = True - update_data = self.UPDATE_SCHEMA(update_data) if CONF_RESOURCE_TYPE_WS in update_data: update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS) diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index 281fb001fc2bde..2b248161b3e82c 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.lovelace import dashboard, resources +from homeassistant.components.lovelace.const import LOVELACE_DATA from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -278,6 +279,41 @@ async def test_storage_resources_import_invalid( ) +async def test_storage_resources_create_preserves_existing( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test async_create_item lazy-loads before writing. + + Custom integrations may call async_create_item() during startup before the + frontend triggers a resource listing. Without a lazy-load guard, the + collection is empty and async_create_item() overwrites all existing + resources on disk. + """ + resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES] + hass_storage[resources.RESOURCE_STORAGE_KEY] = { + "key": resources.RESOURCE_STORAGE_KEY, + "version": 1, + "data": {"items": resource_config}, + } + assert await async_setup_component(hass, "lovelace", {}) + + resource_collection = hass.data[LOVELACE_DATA].resources + + # Directly call async_create_item before any websocket listing + await resource_collection.async_create_item( + {"res_type": "module", "url": "/local/new.js"} + ) + + # Existing resources must still be present + items = resource_collection.async_items() + assert len(items) == len(resource_config) + 1 + urls = [item["url"] for item in items] + for original in resource_config: + assert original["url"] in urls + assert "/local/new.js" in urls + + @pytest.mark.parametrize("list_cmd", ["lovelace/resources", "lovelace/resources/list"]) async def test_storage_resources_safe_mode( hass: HomeAssistant, From ea642980f22e655a3e0bfc58efdbaa4c3fe7bf45 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:45:04 +0200 Subject: [PATCH 0718/1707] Use runtime_data in switchbee (#167878) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/switchbee/__init__.py | 23 +++++++++---------- homeassistant/components/switchbee/button.py | 8 +++---- homeassistant/components/switchbee/climate.py | 8 +++---- .../components/switchbee/coordinator.py | 6 +++-- homeassistant/components/switchbee/cover.py | 10 ++++---- homeassistant/components/switchbee/light.py | 12 ++++------ homeassistant/components/switchbee/switch.py | 8 +++---- 7 files changed, 33 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 6e4bf004a3ddc2..0459d872605e5a 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -9,7 +9,6 @@ from switchbee.api import CentralUnitPolling, CentralUnitWsRPC, is_wsrpc_api from switchbee.api.central_unit import SwitchBeeError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -17,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry, SwitchBeeCoordinator _LOGGER = logging.getLogger(__name__) @@ -53,10 +52,9 @@ async def get_api_object( return api -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SwitchBeeConfigEntry) -> bool: """Set up SwitchBee Smart Home from a config entry.""" - hass.data.setdefault(DOMAIN, {}) central_unit = entry.data[CONF_HOST] user = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] @@ -67,27 +65,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SwitchBeeConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok - -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: SwitchBeeConfigEntry +) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: SwitchBeeConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) diff --git a/homeassistant/components/switchbee/button.py b/homeassistant/components/switchbee/button.py index 1ac81ec4e0dbb4..1e831306e87670 100644 --- a/homeassistant/components/switchbee/button.py +++ b/homeassistant/components/switchbee/button.py @@ -4,23 +4,21 @@ from switchbee.device import ApiStateCommand, DeviceType from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry from .entity import SwitchBeeEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbee button.""" - coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SwitchBeeButton(switchbee_device, coordinator) for switchbee_device in coordinator.data.values() diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index 7837798b0cbee1..e9e794f9910ec2 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -23,14 +23,12 @@ HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry, SwitchBeeCoordinator from .entity import SwitchBeeDeviceEntity FAN_SB_TO_HASS = { @@ -75,11 +73,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee climate.""" - coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SwitchBeeClimateEntity(switchbee_device, coordinator) for switchbee_device in coordinator.data.values() diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index b0ea1707be8bd9..6f4577f43473bf 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -19,16 +19,18 @@ _LOGGER = logging.getLogger(__name__) +type SwitchBeeConfigEntry = ConfigEntry[SwitchBeeCoordinator] + class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevice]]): """Class to manage fetching SwitchBee data API.""" - config_entry: ConfigEntry + config_entry: SwitchBeeConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SwitchBeeConfigEntry, swb_api: CentralUnitPolling | CentralUnitWsRPC, ) -> None: """Initialize.""" diff --git a/homeassistant/components/switchbee/cover.py b/homeassistant/components/switchbee/cover.py index 247063ab18a7de..0b05dff0cbfcc0 100644 --- a/homeassistant/components/switchbee/cover.py +++ b/homeassistant/components/switchbee/cover.py @@ -14,23 +14,21 @@ CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry from .entity import SwitchBeeDeviceEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up SwitchBee switch.""" - coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + """Set up SwitchBee covers.""" + coordinator = entry.runtime_data entities: list[CoverEntity] = [] for device in coordinator.data.values(): diff --git a/homeassistant/components/switchbee/light.py b/homeassistant/components/switchbee/light.py index 228667540df3b4..eff93b36b3980b 100644 --- a/homeassistant/components/switchbee/light.py +++ b/homeassistant/components/switchbee/light.py @@ -2,19 +2,17 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from switchbee.api.central_unit import SwitchBeeDeviceOfflineError, SwitchBeeError from switchbee.device import ApiStateCommand, DeviceType, SwitchBeeDimmer from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry, SwitchBeeCoordinator from .entity import SwitchBeeDeviceEntity MAX_BRIGHTNESS = 255 @@ -36,13 +34,13 @@ def _switchbee_brightness_to_hass(value: int) -> int: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee light.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( - SwitchBeeLightEntity(switchbee_device, coordinator) + SwitchBeeLightEntity(cast(SwitchBeeDimmer, switchbee_device), coordinator) for switchbee_device in coordinator.data.values() if switchbee_device.type == DeviceType.Dimmer ) diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py index 41538f6fd71d93..3332aad1ad401e 100644 --- a/homeassistant/components/switchbee/switch.py +++ b/homeassistant/components/switchbee/switch.py @@ -14,23 +14,21 @@ ) from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SwitchBeeCoordinator +from .coordinator import SwitchBeeConfigEntry, SwitchBeeCoordinator from .entity import SwitchBeeDeviceEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchBeeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbee switch.""" - coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SwitchBeeSwitchEntity(device, coordinator) From 39a2c08d4eb1e8ec2acf2e4a34fae52c501872af Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:59:20 +0200 Subject: [PATCH 0719/1707] Use runtime_data in switchbot_cloud integration (#167879) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/switchbot_cloud/__init__.py | 29 ++++++++++--------- .../switchbot_cloud/binary_sensor.py | 8 ++--- .../components/switchbot_cloud/button.py | 8 ++--- .../components/switchbot_cloud/climate.py | 8 ++--- .../components/switchbot_cloud/cover.py | 9 +++--- .../components/switchbot_cloud/fan.py | 9 +++--- .../components/switchbot_cloud/humidifier.py | 9 +++--- .../components/switchbot_cloud/image.py | 8 ++--- .../components/switchbot_cloud/light.py | 9 +++--- .../components/switchbot_cloud/lock.py | 8 ++--- .../components/switchbot_cloud/sensor.py | 7 ++--- .../components/switchbot_cloud/switch.py | 7 ++--- .../components/switchbot_cloud/vacuum.py | 8 ++--- .../components/switchbot_cloud/test_image.py | 3 +- 14 files changed, 56 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 597157b07d2913..8ab53db23bdadc 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -75,9 +75,12 @@ class SwitchbotCloudData: devices: SwitchbotDevices +type SwitchbotCloudConfigEntry = ConfigEntry[SwitchbotCloudData] + + async def coordinator_for_device( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, api: SwitchBotAPI, device: Device | Remote, coordinators_by_id: dict[str, SwitchBotCoordinator], @@ -97,7 +100,7 @@ async def coordinator_for_device( async def make_switchbot_devices( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, api: SwitchBotAPI, devices: list[Device | Remote], coordinators_by_id: dict[str, SwitchBotCoordinator], @@ -115,7 +118,7 @@ async def make_switchbot_devices( async def make_device_data( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, api: SwitchBotAPI, device: Device | Remote, devices_data: SwitchbotDevices, @@ -330,7 +333,9 @@ async def make_device_data( devices_data.sensors.append((device, coordinator)) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: SwitchbotCloudConfigEntry +) -> bool: """Set up SwitchBot via API from a config entry.""" token = entry.data[CONF_API_TOKEN] secret = entry.data[CONF_API_KEY] @@ -353,10 +358,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: switchbot_devices = await make_switchbot_devices( hass, entry, api, devices, coordinators_by_id ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = SwitchbotCloudData( - api=api, devices=switchbot_devices - ) + entry.runtime_data = SwitchbotCloudData(api=api, devices=switchbot_devices) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -365,17 +367,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SwitchbotCloudConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _initialize_webhook( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, api: SwitchBotAPI, coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> None: diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index dac916c6caecb2..494d64a93209c8 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -11,13 +11,11 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData -from .const import DOMAIN +from . import SwitchbotCloudConfigEntry from .coordinator import SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -137,11 +135,11 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription) async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( SwitchBotCloudBinarySensor(data.api, device, coordinator, description) diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py index d64139a052c1e6..3e493ab9036610 100644 --- a/homeassistant/components/switchbot_cloud/button.py +++ b/homeassistant/components/switchbot_cloud/button.py @@ -12,12 +12,10 @@ from switchbot_api.commands import ArtFrameCommands, BotCommands, CommonCommands from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -58,11 +56,11 @@ class SwitchbotCloudButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data entities: list[SwitchBotCloudBot] = [] for device, coordinator in data.devices.buttons: description_set = BUTTON_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 629e34197f4a47..5276409b719de6 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -26,7 +26,6 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PRECISION_TENTHS, STATE_UNAVAILABLE, @@ -37,10 +36,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import SwitchbotCloudData, SwitchBotCoordinator +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator from .const import ( CLIMATE_PRESET_SCHEDULE, - DOMAIN, SMART_RADIATOR_THERMOSTAT_AFTER_COMMAND_REFRESH, ) from .entity import SwitchBotCloudEntity @@ -69,11 +67,11 @@ async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.climates diff --git a/homeassistant/components/switchbot_cloud/cover.py b/homeassistant/components/switchbot_cloud/cover.py index e5e7b745cbb5a0..0543d2bb5d0a5c 100644 --- a/homeassistant/components/switchbot_cloud/cover.py +++ b/homeassistant/components/switchbot_cloud/cover.py @@ -18,22 +18,21 @@ CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import COVER_ENTITY_AFTER_COMMAND_REFRESH, DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator +from .const import COVER_ENTITY_AFTER_COMMAND_REFRESH from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.covers diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py index 45704d49922ad7..32675cf83f2960 100644 --- a/homeassistant/components/switchbot_cloud/fan.py +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -13,13 +13,12 @@ ) from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData -from .const import AFTER_COMMAND_REFRESH, DOMAIN, AirPurifierMode +from . import SwitchbotCloudConfigEntry +from .const import AFTER_COMMAND_REFRESH, AirPurifierMode from .entity import SwitchBotCloudEntity _LOGGER = logging.getLogger(__name__) @@ -28,11 +27,11 @@ async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data for device, coordinator in data.devices.fans: if device.device_type.startswith("Air Purifier"): async_add_entities( diff --git a/homeassistant/components/switchbot_cloud/humidifier.py b/homeassistant/components/switchbot_cloud/humidifier.py index dc4824bd890102..808c4c02619073 100644 --- a/homeassistant/components/switchbot_cloud/humidifier.py +++ b/homeassistant/components/switchbot_cloud/humidifier.py @@ -12,13 +12,12 @@ HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData -from .const import AFTER_COMMAND_REFRESH, DOMAIN, HUMIDITY_LEVELS, Humidifier2Mode +from . import SwitchbotCloudConfigEntry +from .const import AFTER_COMMAND_REFRESH, HUMIDITY_LEVELS, Humidifier2Mode from .entity import SwitchBotCloudEntity PARALLEL_UPDATES = 0 @@ -26,11 +25,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot based on a config entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( SwitchBotHumidifier(data.api, device, coordinator) if device.device_type == "Humidifier" diff --git a/homeassistant/components/switchbot_cloud/image.py b/homeassistant/components/switchbot_cloud/image.py index e6966845ae009b..9e513d8f4a25c7 100644 --- a/homeassistant/components/switchbot_cloud/image.py +++ b/homeassistant/components/switchbot_cloud/image.py @@ -6,22 +6,20 @@ from switchbot_api.utils import get_file_stream_from_cloud from homeassistant.components.image import ImageEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.images diff --git a/homeassistant/components/switchbot_cloud/light.py b/homeassistant/components/switchbot_cloud/light.py index d3bf22beebbce6..eedba4377bee1a 100644 --- a/homeassistant/components/switchbot_cloud/light.py +++ b/homeassistant/components/switchbot_cloud/light.py @@ -14,12 +14,11 @@ ) from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import AFTER_COMMAND_REFRESH, DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator +from .const import AFTER_COMMAND_REFRESH from .entity import SwitchBotCloudEntity @@ -35,11 +34,11 @@ def brightness_map_value(value: int) -> int: async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.lights diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index 191b17c397e17d..916d3239ce2be9 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -5,22 +5,20 @@ from switchbot_api import Device, LockCommands, LockV2Commands, Remote, SwitchBotAPI from homeassistant.components.lock import LockEntity, LockEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData, SwitchBotCoordinator -from .const import DOMAIN +from . import SwitchbotCloudConfigEntry, SwitchBotCoordinator from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( SwitchBotCloudLock(data.api, device, coordinator) for device, coordinator in data.devices.locks diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 11cb9f7bb577ae..b6497572266aad 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -12,7 +12,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -26,7 +25,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData +from . import SwitchbotCloudConfigEntry from .const import DOMAIN from .coordinator import SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -267,11 +266,11 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data entities: list[SwitchBotCloudSensor] = [] for device, coordinator in data.devices.sensors: for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type]: diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index 2ca98f928b46de..d6e123f9183d2d 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -6,12 +6,11 @@ from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotAPI from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData +from . import SwitchbotCloudConfigEntry from .const import AFTER_COMMAND_REFRESH, DOMAIN from .coordinator import SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -19,11 +18,11 @@ async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data entities: list[SwitchBotCloudSwitch] = [] for device, coordinator in data.devices.switches: if device.device_type == "Relay Switch 2PM": diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 595bcee8e2e15a..40e694225e0fdc 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -17,13 +17,11 @@ VacuumActivity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData +from . import SwitchbotCloudConfigEntry from .const import ( - DOMAIN, VACUUM_FAN_SPEED_MAX, VACUUM_FAN_SPEED_QUIET, VACUUM_FAN_SPEED_STANDARD, @@ -35,11 +33,11 @@ async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + config: SwitchbotCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" - data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + data = config.runtime_data async_add_entities( _async_make_entity(data.api, device, coordinator) for device, coordinator in data.devices.vacuums diff --git a/tests/components/switchbot_cloud/test_image.py b/tests/components/switchbot_cloud/test_image.py index 72047f94d7bec9..6b2c2ad6d26c00 100644 --- a/tests/components/switchbot_cloud/test_image.py +++ b/tests/components/switchbot_cloud/test_image.py @@ -4,7 +4,6 @@ from switchbot_api import Device -from homeassistant.components.switchbot_cloud import DOMAIN from homeassistant.components.switchbot_cloud.image import SwitchBotCloudImage from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNKNOWN @@ -63,7 +62,7 @@ async def test_async_image( entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED - cloud_data = hass.data[DOMAIN][entry.entry_id] + cloud_data = entry.runtime_data device, coordinator = cloud_data.devices.images[0] image_entity = SwitchBotCloudImage(cloud_data.api, device, coordinator) From fb541d8835f1abf02d870cadd5590d717e21563b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 10 Apr 2026 10:04:52 +0100 Subject: [PATCH 0720/1707] Replace `ding` with new `ring` event in Ring integration doorbell (#167728) --- homeassistant/components/ring/event.py | 8 +++++-- homeassistant/components/ring/strings.json | 9 +++++++- .../components/ring/snapshots/test_event.ambr | 8 +++---- tests/components/ring/test_event.py | 21 ++++++++++++++++--- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py index db99a10de74fda..4734bf68f81dee 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -7,6 +7,7 @@ from ring_doorbell.const import KIND_DING, KIND_INTERCOM_UNLOCK, KIND_MOTION from homeassistant.components.event import ( + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -34,7 +35,7 @@ class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): key=KIND_DING, translation_key=KIND_DING, device_class=EventDeviceClass.DOORBELL, - event_types=[KIND_DING], + event_types=[DoorbellEventType.RING], capability=RingCapability.DING, ), RingEventEntityDescription( @@ -100,7 +101,10 @@ def _get_coordinator_alert(self) -> RingAlert | None: @callback def _handle_coordinator_update(self) -> None: if (alert := self._get_coordinator_alert()) and not alert.is_update: - self._async_handle_event(alert.kind) + if alert.kind == KIND_DING: + self._async_handle_event(DoorbellEventType.RING) + else: + self._async_handle_event(alert.kind) super()._handle_coordinator_update() @property diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 1159a8b906e690..e7321b207fbe88 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -73,7 +73,14 @@ }, "event": { "ding": { - "name": "Ding" + "name": "Ding", + "state_attributes": { + "event_type": { + "state": { + "ring": "[%key:component::event::entity_component::doorbell::state_attributes::event_type::state::ring%]" + } + } + } }, "intercom_unlock": { "name": "Intercom unlock" diff --git a/tests/components/ring/snapshots/test_event.ambr b/tests/components/ring/snapshots/test_event.ambr index cea8cb11f2fccc..48fd29a908598d 100644 --- a/tests/components/ring/snapshots/test_event.ambr +++ b/tests/components/ring/snapshots/test_event.ambr @@ -7,7 +7,7 @@ 'area_id': None, 'capabilities': dict({ 'event_types': list([ - 'ding', + , ]), }), 'config_entry_id': , @@ -47,7 +47,7 @@ 'device_class': 'doorbell', 'event_type': None, 'event_types': list([ - 'ding', + , ]), 'friendly_name': 'Front Door Ding', }), @@ -187,7 +187,7 @@ 'area_id': None, 'capabilities': dict({ 'event_types': list([ - 'ding', + , ]), }), 'config_entry_id': , @@ -227,7 +227,7 @@ 'device_class': 'doorbell', 'event_type': None, 'event_types': list([ - 'ding', + , ]), 'friendly_name': 'Ingress Ding', }), diff --git a/tests/components/ring/test_event.py b/tests/components/ring/test_event.py index 5cd60382a97e2b..deb2ddceed2171 100644 --- a/tests/components/ring/test_event.py +++ b/tests/components/ring/test_event.py @@ -9,6 +9,7 @@ from ring_doorbell import Ring from syrupy.assertion import SnapshotAssertion +from homeassistant.components.event import ATTR_EVENT_TYPE, DoorbellEventType from homeassistant.components.ring.binary_sensor import RingEvent from homeassistant.components.ring.coordinator import RingEventListener from homeassistant.const import Platform @@ -35,26 +36,38 @@ async def test_states( @pytest.mark.parametrize( - ("device_id", "device_name", "alert_kind", "device_class"), + ("device_id", "device_name", "alert_kind", "device_class", "event_type"), [ pytest.param( FRONT_DOOR_DEVICE_ID, "front_door", "motion", "motion", + "motion", id="front_door_motion", ), pytest.param( - FRONT_DOOR_DEVICE_ID, "front_door", "ding", "doorbell", id="front_door_ding" + FRONT_DOOR_DEVICE_ID, + "front_door", + "ding", + "doorbell", + DoorbellEventType.RING, + id="front_door_ding", ), pytest.param( - INGRESS_DEVICE_ID, "ingress", "ding", "doorbell", id="ingress_ding" + INGRESS_DEVICE_ID, + "ingress", + "ding", + "doorbell", + DoorbellEventType.RING, + id="ingress_ding", ), pytest.param( INGRESS_DEVICE_ID, "ingress", "intercom_unlock", "button", + "intercom_unlock", id="ingress_unlock", ), ], @@ -68,6 +81,7 @@ async def test_event( device_name: str, alert_kind: str, device_class: str, + event_type: str, ) -> None: """Test the Ring event platforms.""" @@ -96,3 +110,4 @@ async def test_event( state = hass.states.get(entity_id) assert state is not None assert state.state == start_time_str + assert state.attributes[ATTR_EVENT_TYPE] == event_type From 6ccede7f30731a25e4b4bbb4e8783e55e85e7726 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 10 Apr 2026 11:18:10 +0200 Subject: [PATCH 0721/1707] Add fabric index fields to Matter lock user and credential responses (#167875) --- .../components/matter/lock_helpers.py | 11 +- tests/components/matter/test_lock.py | 115 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/lock_helpers.py b/homeassistant/components/matter/lock_helpers.py index 1f95aba19877a9..4cfef792a7de11 100644 --- a/homeassistant/components/matter/lock_helpers.py +++ b/homeassistant/components/matter/lock_helpers.py @@ -71,6 +71,8 @@ class LockUserData(TypedDict): user_type: str credential_rule: str credentials: list[LockUserCredentialData] + creator_fabric_index: int | None + last_modified_fabric_index: int | None next_user_index: int | None @@ -115,6 +117,8 @@ class GetLockCredentialStatusResult(TypedDict): credential_exists: bool user_index: int | None + creator_fabric_index: int | None + last_modified_fabric_index: int | None next_credential_index: int | None @@ -214,6 +218,8 @@ def _format_user_response(user_data: Any) -> LockUserData | None: _get_attr(user_data, "credentialRule"), "unknown" ), credentials=credentials, + creator_fabric_index=_get_attr(user_data, "creatorFabricIndex"), + last_modified_fabric_index=_get_attr(user_data, "lastModifiedFabricIndex"), next_user_index=_get_attr(user_data, "nextUserIndex"), ) @@ -817,7 +823,8 @@ async def get_lock_credential_status( ) -> GetLockCredentialStatusResult: """Get the status of a credential slot on the lock. - Returns typed dict with credential_exists, user_index, next_credential_index. + Returns typed dict with credential_exists, user_index, creator_fabric_index, + last_modified_fabric_index, and next_credential_index. Raises HomeAssistantError on failure. """ lock_endpoint = _get_lock_endpoint_or_raise(node) @@ -839,5 +846,7 @@ async def get_lock_credential_status( return GetLockCredentialStatusResult( credential_exists=bool(_get_attr(response, "credentialExists")), user_index=_get_attr(response, "userIndex"), + creator_fabric_index=_get_attr(response, "creatorFabricIndex"), + last_modified_fabric_index=_get_attr(response, "lastModifiedFabricIndex"), next_credential_index=_get_attr(response, "nextCredentialIndex"), ) diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index 39d5ccd69f8f77..5f74c633a342a9 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -587,6 +587,8 @@ async def test_get_lock_users_service( "user_type": "unrestricted_user", "credential_rule": "single", "credentials": [], + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_user_index": None, } ], @@ -745,6 +747,8 @@ async def test_get_lock_users_iterates_with_next_index( "user_type": "unrestricted_user", "credential_rule": "single", "credentials": [], + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_user_index": 5, }, { @@ -755,6 +759,8 @@ async def test_get_lock_users_iterates_with_next_index( "user_type": "unrestricted_user", "credential_rule": "single", "credentials": [], + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_user_index": None, }, ], @@ -889,6 +895,8 @@ async def test_get_lock_users_with_credentials( {"type": "pin", "index": 1}, {"type": "pin", "index": 2}, ], + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_user_index": None, } ], @@ -942,6 +950,59 @@ async def test_get_lock_users_with_nullvalue_credentials( assert user["credentials"] == [] +@pytest.mark.parametrize("node_fixture", ["mock_door_lock"]) +@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}]) +async def test_get_lock_users_with_fabric_indices( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test get_lock_users returns fabric indices and normalizes NullValue.""" + matter_client.send_device_command = AsyncMock( + side_effect=[ + { + "userIndex": 1, + "userName": "HA User", + "userUniqueID": None, + "userStatus": 1, + "userType": 0, + "credentialRule": 0, + "credentials": None, + "creatorFabricIndex": 3, + "lastModifiedFabricIndex": NullValue, + "nextUserIndex": 2, + }, + { + "userIndex": 2, + "userName": "External User", + "userUniqueID": None, + "userStatus": 1, + "userType": 0, + "credentialRule": 0, + "credentials": None, + "creatorFabricIndex": NullValue, + "lastModifiedFabricIndex": 5, + "nextUserIndex": None, + }, + ] + ) + + result = await hass.services.async_call( + DOMAIN, + "get_lock_users", + {ATTR_ENTITY_ID: "lock.mock_door_lock"}, + blocking=True, + return_response=True, + ) + + users = result["lock.mock_door_lock"]["users"] + assert len(users) == 2 + assert users[0]["creator_fabric_index"] == 3 + assert users[0]["last_modified_fabric_index"] is None + assert users[1]["creator_fabric_index"] is None + assert users[1]["last_modified_fabric_index"] == 5 + + @pytest.mark.parametrize("node_fixture", ["mock_door_lock"]) @pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}]) @pytest.mark.parametrize( @@ -1524,6 +1585,8 @@ async def test_get_lock_credential_status( assert result["lock.mock_door_lock"] == { "credential_exists": True, "user_index": 2, + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_credential_index": 3, } @@ -1571,10 +1634,62 @@ async def test_get_lock_credential_status_empty_slot( assert result["lock.mock_door_lock"] == { "credential_exists": False, "user_index": None, + "creator_fabric_index": None, + "last_modified_fabric_index": None, "next_credential_index": None, } +@pytest.mark.parametrize("node_fixture", ["mock_door_lock"]) +@pytest.mark.parametrize("attributes", [{"1/257/65532": _FEATURE_USR_PIN}]) +@pytest.mark.parametrize( + ("creator", "last_modified", "expected_creator", "expected_last_modified"), + [ + (3, NullValue, 3, None), + (NullValue, 2, None, 2), + ], +) +async def test_get_lock_credential_status_with_fabric_indices( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, + creator: int, + last_modified: int, + expected_creator: int | None, + expected_last_modified: int | None, +) -> None: + """Test get_lock_credential_status returns fabric indices and normalizes NullValue.""" + matter_client.send_device_command = AsyncMock( + return_value={ + "credentialExists": True, + "userIndex": 2, + "creatorFabricIndex": creator, + "lastModifiedFabricIndex": last_modified, + "nextCredentialIndex": 5, + } + ) + + result = await hass.services.async_call( + DOMAIN, + "get_lock_credential_status", + { + ATTR_ENTITY_ID: "lock.mock_door_lock", + ATTR_CREDENTIAL_TYPE: "pin", + ATTR_CREDENTIAL_INDEX: 1, + }, + blocking=True, + return_response=True, + ) + + assert result["lock.mock_door_lock"] == { + "credential_exists": True, + "user_index": 2, + "creator_fabric_index": expected_creator, + "last_modified_fabric_index": expected_last_modified, + "next_credential_index": 5, + } + + @pytest.mark.parametrize("node_fixture", ["mock_door_lock"]) async def test_credential_services_without_usr_feature( hass: HomeAssistant, From 038bb6c15db36a03227ae91e1a81e9b293434694 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:29:02 +0800 Subject: [PATCH 0722/1707] Add child lock and wireless charging switches for air purifier (#167140) Co-authored-by: Claude Sonnet 4.6 --- .../components/switchbot/__init__.py | 4 + homeassistant/components/switchbot/icons.json | 14 +++ .../components/switchbot/strings.json | 6 ++ homeassistant/components/switchbot/switch.py | 97 ++++++++++++++++- tests/components/switchbot/test_switch.py | 102 ++++++++++++++++++ 5 files changed, 221 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 628ad86bcf19fc..590ddd2f123901 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -114,21 +114,25 @@ Platform.FAN, Platform.SENSOR, Platform.BUTTON, + Platform.SWITCH, ], SupportedModels.AIR_PURIFIER_US.value: [ Platform.FAN, Platform.SENSOR, Platform.BUTTON, + Platform.SWITCH, ], SupportedModels.AIR_PURIFIER_TABLE_JP.value: [ Platform.FAN, Platform.SENSOR, Platform.BUTTON, + Platform.SWITCH, ], SupportedModels.AIR_PURIFIER_TABLE_US.value: [ Platform.FAN, Platform.SENSOR, Platform.BUTTON, + Platform.SWITCH, ], SupportedModels.EVAPORATIVE_HUMIDIFIER.value: [ Platform.HUMIDIFIER, diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index 32740998ce0609..de090a74d0ab77 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -145,6 +145,20 @@ "medium": "mdi:water" } } + }, + "switch": { + "child_lock": { + "state": { + "off": "mdi:lock-open", + "on": "mdi:lock" + } + }, + "wireless_charging": { + "state": { + "off": "mdi:battery-charging-wireless-outline", + "on": "mdi:battery-charging-wireless" + } + } } }, "services": { diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index f6ece12c92af2c..b3e7adfd7b7eff 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -326,6 +326,12 @@ } } } + }, + "child_lock": { + "name": "Child lock" + }, + "wireless_charging": { + "name": "Wireless charging" } }, "vacuum": { diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index d67aaed3412c82..c336602f3ba0c6 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -2,22 +2,61 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import logging from typing import Any import switchbot -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN +from .const import AIRPURIFIER_BASIC_MODELS, AIRPURIFIER_TABLE_MODELS, DOMAIN from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotSwitchedEntity, exception_handler + +@dataclass(frozen=True, kw_only=True) +class SwitchbotSwitchEntityDescription(SwitchEntityDescription): + """Describes a Switchbot switch entity.""" + + is_on_fn: Callable[[switchbot.SwitchbotDevice], bool | None] + turn_on_fn: Callable[[switchbot.SwitchbotDevice], Awaitable[Any]] + turn_off_fn: Callable[[switchbot.SwitchbotDevice], Awaitable[Any]] + + +AIRPURIFIER_BASIC_SWITCHES: tuple[SwitchbotSwitchEntityDescription, ...] = ( + SwitchbotSwitchEntityDescription( + key="child_lock", + translation_key="child_lock", + device_class=SwitchDeviceClass.SWITCH, + is_on_fn=lambda device: device.is_child_lock_on(), + turn_on_fn=lambda device: device.open_child_lock(), + turn_off_fn=lambda device: device.close_child_lock(), + ), +) + +AIRPURIFIER_TABLE_SWITCHES: tuple[SwitchbotSwitchEntityDescription, ...] = ( + *AIRPURIFIER_BASIC_SWITCHES, + SwitchbotSwitchEntityDescription( + key="wireless_charging", + translation_key="wireless_charging", + device_class=SwitchDeviceClass.SWITCH, + is_on_fn=lambda device: device.is_wireless_charging_on(), + turn_on_fn=lambda device: device.open_wireless_charging(), + turn_off_fn=lambda device: device.close_wireless_charging(), + ), +) + PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) @@ -36,10 +75,64 @@ async def async_setup_entry( for channel in range(1, coordinator.device.channel + 1) ] async_add_entities(entries) + elif coordinator.model in AIRPURIFIER_BASIC_MODELS: + async_add_entities( + [ + SwitchbotGenericSwitch(coordinator, desc) + for desc in AIRPURIFIER_BASIC_SWITCHES + ] + ) + elif coordinator.model in AIRPURIFIER_TABLE_MODELS: + async_add_entities( + [ + SwitchbotGenericSwitch(coordinator, desc) + for desc in AIRPURIFIER_TABLE_SWITCHES + ] + ) else: async_add_entities([SwitchBotSwitch(coordinator)]) +class SwitchbotGenericSwitch(SwitchbotSwitchedEntity, SwitchEntity): + """Representation of a Switchbot switch controlled via entity description.""" + + entity_description: SwitchbotSwitchEntityDescription + _device: switchbot.SwitchbotDevice + + def __init__( + self, + coordinator: SwitchbotDataUpdateCoordinator, + description: SwitchbotSwitchEntityDescription, + ) -> None: + """Initialize the Switchbot generic switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.base_unique_id}-{description.key}" + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + return self.entity_description.is_on_fn(self._device) + + @exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + _LOGGER.debug( + "Turning on %s for %s", self.entity_description.key, self._address + ) + await self.entity_description.turn_on_fn(self._device) + self.async_write_ha_state() + + @exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + _LOGGER.debug( + "Turning off %s for %s", self.entity_description.key, self._address + ) + await self.entity_description.turn_off_fn(self._device) + self.async_write_ha_state() + + class SwitchBotSwitch(SwitchbotSwitchedEntity, SwitchEntity, RestoreEntity): """Representation of a Switchbot switch.""" diff --git a/tests/components/switchbot/test_switch.py b/tests/components/switchbot/test_switch.py index 28adac8cb00903..1ee0052ac7c67b 100644 --- a/tests/components/switchbot/test_switch.py +++ b/tests/components/switchbot/test_switch.py @@ -18,6 +18,10 @@ from homeassistant.exceptions import HomeAssistantError from . import ( + AIR_PURIFIER_JP_SERVICE_INFO, + AIR_PURIFIER_TABLE_JP_SERVICE_INFO, + AIR_PURIFIER_TABLE_US_SERVICE_INFO, + AIR_PURIFIER_US_SERVICE_INFO, PLUG_MINI_EU_SERVICE_INFO, RELAY_SWITCH_1_SERVICE_INFO, RELAY_SWITCH_2PM_SERVICE_INFO, @@ -294,3 +298,101 @@ async def test_relay_switch_control_with_exception( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ( + "service_info", + "sensor_type", + "entity_id", + "turn_on_method", + "turn_off_method", + ), + [ + ( + AIR_PURIFIER_JP_SERVICE_INFO, + "air_purifier_jp", + "switch.test_name_child_lock", + "open_child_lock", + "close_child_lock", + ), + ( + AIR_PURIFIER_TABLE_JP_SERVICE_INFO, + "air_purifier_table_jp", + "switch.test_name_child_lock", + "open_child_lock", + "close_child_lock", + ), + ( + AIR_PURIFIER_US_SERVICE_INFO, + "air_purifier_us", + "switch.test_name_child_lock", + "open_child_lock", + "close_child_lock", + ), + ( + AIR_PURIFIER_TABLE_US_SERVICE_INFO, + "air_purifier_table_us", + "switch.test_name_child_lock", + "open_child_lock", + "close_child_lock", + ), + ( + AIR_PURIFIER_TABLE_JP_SERVICE_INFO, + "air_purifier_table_jp", + "switch.test_name_wireless_charging", + "open_wireless_charging", + "close_wireless_charging", + ), + ( + AIR_PURIFIER_TABLE_US_SERVICE_INFO, + "air_purifier_table_us", + "switch.test_name_wireless_charging", + "open_wireless_charging", + "close_wireless_charging", + ), + ], +) +async def test_air_purifier_switch_control( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service_info: BluetoothServiceInfoBleak, + sensor_type: str, + entity_id: str, + turn_on_method: str, + turn_off_method: str, +) -> None: + """Test air purifier switch control.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + + mocked_turn_on = AsyncMock(return_value=True) + mocked_turn_off = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.switch.switchbot.SwitchbotAirPurifier", + update=AsyncMock(return_value=None), + **{turn_on_method: mocked_turn_on, turn_off_method: mocked_turn_off}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_turn_on.assert_awaited_once() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_turn_off.assert_awaited_once() From 837cd7d89d116931b17666b0106cb06ad4f81a8a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:46:12 +0200 Subject: [PATCH 0723/1707] Use runtime_data in sanix integration (#167856) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/sanix/__init__.py | 16 ++++++---------- homeassistant/components/sanix/coordinator.py | 6 ++++-- homeassistant/components/sanix/sensor.py | 7 +++---- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sanix/__init__.py b/homeassistant/components/sanix/__init__.py index 60cc5b56f2e1b4..59984601768fff 100644 --- a/homeassistant/components/sanix/__init__.py +++ b/homeassistant/components/sanix/__init__.py @@ -2,17 +2,16 @@ from sanix import Sanix -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from .const import CONF_SERIAL_NUMBER, DOMAIN -from .coordinator import SanixCoordinator +from .const import CONF_SERIAL_NUMBER +from .coordinator import SanixConfigEntry, SanixCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SanixConfigEntry) -> bool: """Set up Sanix from a config entry.""" serial_no = entry.data[CONF_SERIAL_NUMBER] @@ -22,16 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = SanixCoordinator(hass, entry, sanix_api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SanixConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sanix/coordinator.py b/homeassistant/components/sanix/coordinator.py index 64d28fa91911c7..804421dfe594af 100644 --- a/homeassistant/components/sanix/coordinator.py +++ b/homeassistant/components/sanix/coordinator.py @@ -15,14 +15,16 @@ _LOGGER = logging.getLogger(__name__) +type SanixConfigEntry = ConfigEntry[SanixCoordinator] + class SanixCoordinator(DataUpdateCoordinator[Measurement]): """Sanix coordinator.""" - config_entry: ConfigEntry + config_entry: SanixConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, sanix_api: Sanix + self, hass: HomeAssistant, config_entry: SanixConfigEntry, sanix_api: Sanix ) -> None: """Initialize coordinator.""" super().__init__( diff --git a/homeassistant/components/sanix/sensor.py b/homeassistant/components/sanix/sensor.py index d2a1aecb099020..81531f111a98b7 100644 --- a/homeassistant/components/sanix/sensor.py +++ b/homeassistant/components/sanix/sensor.py @@ -20,7 +20,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -28,7 +27,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER -from .coordinator import SanixCoordinator +from .coordinator import SanixConfigEntry, SanixCoordinator @dataclass(frozen=True, kw_only=True) @@ -83,11 +82,11 @@ class SanixSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SanixConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sanix Sensor entities based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SanixSensorEntity(coordinator, description) for description in SENSOR_TYPES From a79988aca781fe1fd04bcf4f7b63ab8830cd6b02 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:46:43 +0200 Subject: [PATCH 0724/1707] Use runtime_data in sia integration (#167857) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/sia/__init__.py | 18 ++++++++---------- homeassistant/components/sia/config_flow.py | 17 +++++++---------- homeassistant/components/sia/hub.py | 16 +++++++++------- 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/sia/__init__.py b/homeassistant/components/sia/__init__.py index d1bc3fa99683ac..215228623941d0 100644 --- a/homeassistant/components/sia/__init__.py +++ b/homeassistant/components/sia/__init__.py @@ -1,21 +1,18 @@ """The sia integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, PLATFORMS -from .hub import SIAHub +from .const import PLATFORMS +from .hub import SIAConfigEntry, SIAHub -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SIAConfigEntry) -> bool: """Set up sia from a config entry.""" - hub: SIAHub = SIAHub(hass, entry) + hub = SIAHub(hass, entry) hub.async_setup_hub() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = hub try: if hub.sia_client: await hub.sia_client.async_start(reuse_port=True) @@ -23,14 +20,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"SIA Server at port {entry.data[CONF_PORT]} could not start." ) from exc + + entry.runtime_data = hub await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SIAConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hub: SIAHub = hass.data[DOMAIN].pop(entry.entry_id) - await hub.async_shutdown() + await entry.runtime_data.async_shutdown() return unload_ok diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index a23978145e72d1..16ba3103a753bf 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -16,12 +16,7 @@ ) import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback @@ -36,7 +31,7 @@ DOMAIN, TITLE, ) -from .hub import SIAHub +from .hub import SIAConfigEntry, SIAHub _LOGGER = logging.getLogger(__name__) @@ -100,7 +95,7 @@ class SIAConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SIAConfigEntry, ) -> SIAOptionsFlowHandler: """Get the options flow for this handler.""" return SIAOptionsFlowHandler(config_entry) @@ -179,7 +174,9 @@ def _update_data(self, user_input: dict[str, Any]) -> None: class SIAOptionsFlowHandler(OptionsFlow): """Handle SIA options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + config_entry: SIAConfigEntry + + def __init__(self, config_entry: SIAConfigEntry) -> None: """Initialize SIA options flow.""" self.options = deepcopy(dict(config_entry.options)) self.hub: SIAHub | None = None @@ -189,7 +186,7 @@ async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the SIA options.""" - self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id] + self.hub = self.config_entry.runtime_data assert self.hub is not None assert self.hub.sia_accounts is not None self.accounts_todo = [a.account_id for a in self.hub.sia_accounts] diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 591e4aadad76ee..6c0bf80c64c6f6 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -8,7 +8,7 @@ from pysiaalarm.aio import CommunicationsProtocol, SIAAccount, SIAClient, SIAEvent -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -28,6 +28,8 @@ _LOGGER = logging.getLogger(__name__) +type SIAConfigEntry = ConfigEntry[SIAHub] + DEFAULT_TIMEBAND = (80, 40) @@ -37,11 +39,11 @@ class SIAHub: def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SIAConfigEntry, ) -> None: """Create the SIAHub.""" - self._hass: HomeAssistant = hass - self._entry: ConfigEntry = entry + self._hass = hass + self._entry = entry self._port: int = entry.data[CONF_PORT] self._title: str = entry.title self._accounts: list[dict[str, Any]] = deepcopy(entry.data[CONF_ACCOUNTS]) @@ -131,7 +133,7 @@ def _load_options(self) -> None: @staticmethod async def async_config_entry_updated( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: SIAConfigEntry ) -> None: """Handle signals of config entry being updated. @@ -139,8 +141,8 @@ async def async_config_entry_updated( Second, unload underlying platforms, and then setup platforms, this reflects any changes in number of zones. """ - if not (hub := hass.data[DOMAIN].get(config_entry.entry_id)): + if config_entry.state != ConfigEntryState.LOADED: return - hub.update_accounts() + config_entry.runtime_data.update_accounts() await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) From 3758d606c9d73112a700852d149d2851ee258b46 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:47:58 +0200 Subject: [PATCH 0725/1707] Use runtime_data in simplisafe integration (#167858) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/simplisafe/__init__.py | 24 +++++++++++-------- .../simplisafe/alarm_control_panel.py | 8 +++---- .../components/simplisafe/binary_sensor.py | 19 +++++++++------ homeassistant/components/simplisafe/button.py | 8 +++---- .../components/simplisafe/config_flow.py | 10 +++----- .../components/simplisafe/diagnostics.py | 8 +++---- homeassistant/components/simplisafe/lock.py | 13 +++++----- homeassistant/components/simplisafe/sensor.py | 15 +++++++----- 8 files changed, 54 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index d9ab3e3b4f137f..4c00c7441193d7 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import Callable, Coroutine -from typing import Any, cast +from typing import Any from simplipy import API from simplipy.errors import ( @@ -39,7 +39,7 @@ ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_ID, @@ -88,6 +88,8 @@ from .coordinator import SimpliSafeDataUpdateCoordinator from .typing import SystemType +type SimpliSafeConfigEntry = ConfigEntry[SimpliSafe] + ATTR_CATEGORY = "category" ATTR_LAST_EVENT_CHANGED_BY = "last_event_changed_by" ATTR_LAST_EVENT_SENSOR_SERIAL = "last_event_sensor_serial" @@ -223,10 +225,15 @@ def _async_get_system_for_service_call( ] system_id = int(system_id_str) + entry: SimpliSafeConfigEntry | None for entry_id in base_station_device_entry.config_entries: - if (simplisafe := hass.data[DOMAIN].get(entry_id)) is None: + if ( + (entry := hass.config_entries.async_get_entry(entry_id)) is None + or entry.domain != DOMAIN + or entry.state != ConfigEntryState.LOADED + ): continue - return cast(SystemType, simplisafe.systems[system_id]) + return entry.runtime_data.systems[system_id] raise ValueError(f"No system for device ID: {device_id}") @@ -286,7 +293,7 @@ def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) -> hass.config_entries.async_update_entry(entry, **entry_updates) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -> bool: """Set up SimpliSafe as config entry.""" _async_standardize_config_entry(hass, entry) @@ -310,8 +317,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SimplipyError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = simplisafe + entry.runtime_data = simplisafe await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -396,11 +402,9 @@ async def async_reload_entry(_: HomeAssistant, updated_entry: ConfigEntry) -> No return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -> bool: """Unload a SimpliSafe config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of SimpliSafe, deregister any services diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index c5a1b2bc7084b3..31240fdb071a15 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -28,12 +28,11 @@ AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe +from . import SimpliSafe, SimpliSafeConfigEntry from .const import ( ATTR_ALARM_DURATION, ATTR_ALARM_VOLUME, @@ -44,7 +43,6 @@ ATTR_EXIT_DELAY_HOME, ATTR_LIGHT, ATTR_VOICE_PROMPT_VOLUME, - DOMAIN, LOGGER, ) from .entity import SimpliSafeEntity @@ -104,11 +102,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a SimpliSafe alarm control panel based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data async_add_entities( [SimpliSafeAlarm(simplisafe, system) for system in simplisafe.systems.values()], True, diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 4cd02431148c8b..7de2e8482c71c0 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING, cast + from simplipy.device import DeviceTypes, DeviceV3 from simplipy.device.sensor.v3 import SensorV3 from simplipy.system.v3 import SystemV3 @@ -11,13 +13,12 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe -from .const import DOMAIN, LOGGER +from . import SimpliSafe, SimpliSafeConfigEntry +from .const import LOGGER from .entity import SimpliSafeEntity SUPPORTED_BATTERY_SENSOR_TYPES = [ @@ -59,11 +60,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe binary sensors based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data sensors: list[BatteryBinarySensor | TriggeredBinarySensor] = [] @@ -72,18 +73,22 @@ async def async_setup_entry( LOGGER.warning("Skipping sensor setup for V2 system: %s", system.system_id) continue + if TYPE_CHECKING: + assert isinstance(system, SystemV3) for sensor in system.sensors.values(): if sensor.type in TRIGGERED_SENSOR_TYPES: sensors.append( TriggeredBinarySensor( simplisafe, system, - sensor, + cast(SensorV3, sensor), TRIGGERED_SENSOR_TYPES[sensor.type], ) ) if sensor.type in SUPPORTED_BATTERY_SENSOR_TYPES: - sensors.append(BatteryBinarySensor(simplisafe, system, sensor)) + sensors.append( + BatteryBinarySensor(simplisafe, system, cast(DeviceV3, sensor)) + ) sensors.extend( BatteryBinarySensor(simplisafe, system, lock) diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index 129209354c3898..a4888ed8b6d059 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -9,14 +9,12 @@ from simplipy.system import System from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe -from .const import DOMAIN +from . import SimpliSafe, SimpliSafeConfigEntry from .entity import SimpliSafeEntity from .typing import SystemType @@ -47,11 +45,11 @@ async def _async_clear_notifications(system: System) -> None: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe buttons based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 6494b84981bd4d..c6c760d099adab 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -14,16 +14,12 @@ ) import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_CODE, CONF_TOKEN, CONF_URL, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv +from . import SimpliSafeConfigEntry from .const import DOMAIN, LOGGER CONF_AUTH_CODE = "auth_code" @@ -68,7 +64,7 @@ def __init__(self) -> None: @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SimpliSafeConfigEntry, ) -> SimpliSafeOptionsFlowHandler: """Define the config flow to handle options.""" return SimpliSafeOptionsFlowHandler() diff --git a/homeassistant/components/simplisafe/diagnostics.py b/homeassistant/components/simplisafe/diagnostics.py index e63e1551740343..7260efeb5afcf7 100644 --- a/homeassistant/components/simplisafe/diagnostics.py +++ b/homeassistant/components/simplisafe/diagnostics.py @@ -5,7 +5,6 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ADDRESS, CONF_CODE, @@ -16,8 +15,7 @@ ) from homeassistant.core import HomeAssistant -from . import SimpliSafe -from .const import DOMAIN +from . import SimpliSafeConfigEntry CONF_CREDIT_CARD = "creditCard" CONF_EXPIRES = "expires" @@ -53,10 +51,10 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SimpliSafeConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - simplisafe: SimpliSafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data return async_redact_data( { diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index a0626898a211cd..90e52a87969a4c 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from simplipy.device.lock import Lock, LockStates from simplipy.errors import SimplipyError @@ -10,13 +10,12 @@ from simplipy.websocket import EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED, WebsocketEvent from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe -from .const import DOMAIN, LOGGER +from . import SimpliSafe, SimpliSafeConfigEntry +from .const import LOGGER from .entity import SimpliSafeEntity ATTR_LOCK_LOW_BATTERY = "lock_low_battery" @@ -32,11 +31,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe locks based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data locks: list[SimpliSafeLock] = [] for system in simplisafe.systems.values(): @@ -44,6 +43,8 @@ async def async_setup_entry( LOGGER.warning("Skipping lock setup for V2 system: %s", system.system_id) continue + if TYPE_CHECKING: + assert isinstance(system, SystemV3) locks.extend( SimpliSafeLock(simplisafe, system, lock) for lock in system.locks.values() ) diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index b82162f0fe7b39..79bd5b599baa8b 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING, cast + from simplipy.device import DeviceTypes from simplipy.device.sensor.v3 import SensorV3 from simplipy.system.v3 import SystemV3 @@ -11,23 +13,22 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SimpliSafe -from .const import DOMAIN, LOGGER +from . import SimpliSafe, SimpliSafeConfigEntry +from .const import LOGGER from .entity import SimpliSafeEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SimpliSafeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe freeze sensors based on a config entry.""" - simplisafe = hass.data[DOMAIN][entry.entry_id] + simplisafe = entry.runtime_data sensors: list[SimplisafeFreezeSensor] = [] for system in simplisafe.systems.values(): @@ -35,8 +36,10 @@ async def async_setup_entry( LOGGER.warning("Skipping sensor setup for V2 system: %s", system.system_id) continue + if TYPE_CHECKING: + assert isinstance(system, SystemV3) sensors.extend( - SimplisafeFreezeSensor(simplisafe, system, sensor) + SimplisafeFreezeSensor(simplisafe, system, cast(SensorV3, sensor)) for sensor in system.sensors.values() if sensor.type == DeviceTypes.TEMPERATURE ) From 1597b740daadc162c5a334cab3548305acd83d6f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:48:48 +0200 Subject: [PATCH 0726/1707] Use runtime_data in skybell integration (#167862) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/skybell/__init__.py | 14 +++++--------- homeassistant/components/skybell/binary_sensor.py | 8 +++----- homeassistant/components/skybell/camera.py | 8 +++----- homeassistant/components/skybell/coordinator.py | 9 +++++++-- homeassistant/components/skybell/light.py | 7 +++---- homeassistant/components/skybell/sensor.py | 8 ++++---- homeassistant/components/skybell/switch.py | 7 +++---- 7 files changed, 28 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 5baa4ad83ade83..71ff9dc9788083 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -7,14 +7,12 @@ from aioskybell import Skybell from aioskybell.exceptions import SkybellAuthenticationException, SkybellException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import SkybellDataUpdateCoordinator +from .coordinator import SkybellConfigEntry, SkybellDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -25,7 +23,7 @@ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SkybellConfigEntry) -> bool: """Set up Skybell from a config entry.""" email = entry.data[CONF_EMAIL] password = entry.data[CONF_PASSWORD] @@ -53,14 +51,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in device_coordinators ] ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device_coordinators + entry.runtime_data = device_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SkybellConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index cc42da48b26d43..cd9c8dd5eebe33 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -9,12 +9,10 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN -from .coordinator import SkybellDataUpdateCoordinator +from .coordinator import SkybellConfigEntry, SkybellDataUpdateCoordinator from .entity import SkybellEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -32,14 +30,14 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell binary sensor.""" async_add_entities( SkybellBinarySensor(coordinator, sensor) for sensor in BINARY_SENSOR_TYPES - for coordinator in hass.data[DOMAIN][entry.entry_id] + for coordinator in entry.runtime_data ) diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 4ee873f83501ae..6bc285a8cf6e5b 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -7,14 +7,12 @@ from homeassistant.components.camera import Camera, CameraEntityDescription from homeassistant.components.ffmpeg import get_ffmpeg_manager -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SkybellDataUpdateCoordinator +from .coordinator import SkybellConfigEntry, SkybellDataUpdateCoordinator from .entity import SkybellEntity CAMERA_TYPES: tuple[CameraEntityDescription, ...] = ( @@ -31,13 +29,13 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell camera.""" entities = [] for description in CAMERA_TYPES: - for coordinator in hass.data[DOMAIN][entry.entry_id]: + for coordinator in entry.runtime_data: if description.key == "avatar": entities.append(SkybellCamera(coordinator, description)) else: diff --git a/homeassistant/components/skybell/coordinator.py b/homeassistant/components/skybell/coordinator.py index 48e67c63ac96c5..499363191f8904 100644 --- a/homeassistant/components/skybell/coordinator.py +++ b/homeassistant/components/skybell/coordinator.py @@ -10,14 +10,19 @@ from .const import LOGGER +type SkybellConfigEntry = ConfigEntry[list[SkybellDataUpdateCoordinator]] + class SkybellDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for the Skybell integration.""" - config_entry: ConfigEntry + config_entry: SkybellConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: SkybellDevice + self, + hass: HomeAssistant, + config_entry: SkybellConfigEntry, + device: SkybellDevice, ) -> None: """Initialize the coordinator.""" super().__init__( diff --git a/homeassistant/components/skybell/light.py b/homeassistant/components/skybell/light.py index 3f924f68da89be..dce8040b8ae29f 100644 --- a/homeassistant/components/skybell/light.py +++ b/homeassistant/components/skybell/light.py @@ -13,23 +13,22 @@ LightEntity, LightEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .coordinator import SkybellConfigEntry from .entity import SkybellEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell switch.""" async_add_entities( SkybellLight(coordinator, LightEntityDescription(key="light")) - for coordinator in hass.data[DOMAIN][entry.entry_id] + for coordinator in entry.runtime_data ) diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index a67fdae3b351a4..9674a6af6f56a2 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -14,13 +14,13 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .entity import DOMAIN, SkybellEntity +from .coordinator import SkybellConfigEntry +from .entity import SkybellEntity @dataclass(frozen=True, kw_only=True) @@ -89,13 +89,13 @@ class SkybellSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell sensor.""" async_add_entities( SkybellSensor(coordinator, description) - for coordinator in hass.data[DOMAIN][entry.entry_id] + for coordinator in entry.runtime_data for description in SENSOR_TYPES if coordinator.device.owner or description.key not in CONST.ATTR_OWNER_STATS ) diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index 858363043ca222..7827aec70fb99c 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -5,11 +5,10 @@ from typing import Any, cast from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .coordinator import SkybellConfigEntry from .entity import SkybellEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( @@ -30,13 +29,13 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SkybellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SkyBell switch.""" async_add_entities( SkybellSwitch(coordinator, description) - for coordinator in hass.data[DOMAIN][entry.entry_id] + for coordinator in entry.runtime_data for description in SWITCH_TYPES ) From a54ea071f8aebfe17693c6cc5c080c4820eb701c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:50:19 +0200 Subject: [PATCH 0727/1707] Use runtime_data in Slack (#167864) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/slack/__init__.py | 34 +++++++++++++++++----- homeassistant/components/slack/entity.py | 18 +++++------- homeassistant/components/slack/sensor.py | 10 +++---- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index 899b46ee7e8393..f2c6926dfcf9d1 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging from aiohttp.client_exceptions import ClientError @@ -30,6 +31,17 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type SlackConfigEntry = ConfigEntry[SlackData] + + +@dataclass +class SlackData: + """Runtime data for the Slack integration.""" + + client: AsyncWebClient + url: str + user_id: str + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Slack component.""" @@ -37,7 +49,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SlackConfigEntry) -> bool: """Set up Slack from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) slack = AsyncWebClient( @@ -52,19 +64,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False raise ConfigEntryNotReady("Error while setting up integration") from ex - data = { - DATA_CLIENT: slack, - ATTR_URL: res[ATTR_URL], - ATTR_USER_ID: res[ATTR_USER_ID], - } - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data | {SLACK_DATA: data} + entry.runtime_data = SlackData( + client=slack, + url=res[ATTR_URL], + user_id=res[ATTR_USER_ID], + ) hass.async_create_task( discovery.async_load_platform( hass, Platform.NOTIFY, DOMAIN, - hass.data[DOMAIN][entry.entry_id], + entry.data + | { + SLACK_DATA: { + DATA_CLIENT: slack, + ATTR_URL: res[ATTR_URL], + ATTR_USER_ID: res[ATTR_USER_ID], + } + }, hass.data[DATA_HASS_CONFIG], ) ) diff --git a/homeassistant/components/slack/entity.py b/homeassistant/components/slack/entity.py index 30218360054fda..040cb58aa0cb86 100644 --- a/homeassistant/components/slack/entity.py +++ b/homeassistant/components/slack/entity.py @@ -1,14 +1,10 @@ """The slack integration.""" -from __future__ import annotations - -from slack_sdk.web.async_client import AsyncWebClient - -from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from .const import ATTR_URL, ATTR_USER_ID, DATA_CLIENT, DEFAULT_NAME, DOMAIN +from . import SlackConfigEntry, SlackData +from .const import DEFAULT_NAME, DOMAIN class SlackEntity(Entity): @@ -16,16 +12,16 @@ class SlackEntity(Entity): def __init__( self, - data: dict[str, AsyncWebClient], + data: SlackData, description: EntityDescription, - entry: ConfigEntry, + entry: SlackConfigEntry, ) -> None: """Initialize a Slack entity.""" - self._client: AsyncWebClient = data[DATA_CLIENT] + self._client = data.client self.entity_description = description - self._attr_unique_id = f"{data[ATTR_USER_ID]}_{description.key}" + self._attr_unique_id = f"{data.user_id}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url=str(data[ATTR_URL]), + configuration_url=data.url, entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry.entry_id)}, manufacturer=DEFAULT_NAME, diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index 042ab00916ecc4..df8517f660ab03 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -9,25 +9,25 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA +from . import SlackConfigEntry +from .const import ATTR_SNOOZE from .entity import SlackEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SlackConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Slack select.""" + """Set up the Slack sensor.""" async_add_entities( [ SlackSensorEntity( - hass.data[DOMAIN][entry.entry_id][SLACK_DATA], + entry.runtime_data, SensorEntityDescription( key="do_not_disturb_until", translation_key="do_not_disturb_until", From d7f28a09bb974bc16c7b0e61b86b47bdbf3f0f9a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:50:53 +0200 Subject: [PATCH 0728/1707] Use runtime_data in sleepiq integration (#167865) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/sleepiq/__init__.py | 11 +++++------ homeassistant/components/sleepiq/binary_sensor.py | 9 ++++----- homeassistant/components/sleepiq/button.py | 8 +++----- homeassistant/components/sleepiq/coordinator.py | 14 ++++++++------ homeassistant/components/sleepiq/light.py | 8 +++----- homeassistant/components/sleepiq/number.py | 8 +++----- homeassistant/components/sleepiq/select.py | 9 ++++----- homeassistant/components/sleepiq/sensor.py | 8 +++----- homeassistant/components/sleepiq/switch.py | 8 +++----- 9 files changed, 36 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 8eb703b7f5f3ee..235df79b976ef5 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -23,6 +23,7 @@ from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER from .coordinator import ( + SleepIQConfigEntry, SleepIQData, SleepIQDataUpdateCoordinator, SleepIQPauseUpdateCoordinator, @@ -64,7 +65,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> bool: """Set up the SleepIQ config entry.""" conf = entry.data email = conf[CONF_USERNAME] @@ -104,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await pause_coordinator.async_config_entry_first_refresh() await sleep_data_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SleepIQData( + entry.runtime_data = SleepIQData( data_coordinator=coordinator, pause_coordinator=pause_coordinator, sleep_data_coordinator=sleep_data_coordinator, @@ -116,11 +117,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SleepIQConfigEntry) -> bool: """Unload the config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def _async_migrate_unique_ids( diff --git a/homeassistant/components/sleepiq/binary_sensor.py b/homeassistant/components/sleepiq/binary_sensor.py index 99fff9c49b0c70..501e2a824dc8f3 100644 --- a/homeassistant/components/sleepiq/binary_sensor.py +++ b/homeassistant/components/sleepiq/binary_sensor.py @@ -6,22 +6,21 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, ICON_EMPTY, ICON_OCCUPIED, IS_IN_BED -from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator +from .const import ICON_EMPTY, ICON_OCCUPIED, IS_IN_BED +from .coordinator import SleepIQConfigEntry, SleepIQDataUpdateCoordinator from .entity import SleepIQSleeperEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed binary sensors.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( IsInBedBinarySensor(data.data_coordinator, bed, sleeper) for bed in data.client.beds.values() diff --git a/homeassistant/components/sleepiq/button.py b/homeassistant/components/sleepiq/button.py index 74b1bc0789f83b..e0dec0d2897a52 100644 --- a/homeassistant/components/sleepiq/button.py +++ b/homeassistant/components/sleepiq/button.py @@ -9,12 +9,10 @@ from asyncsleepiq import SleepIQBed from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SleepIQData +from .coordinator import SleepIQConfigEntry from .entity import SleepIQEntity @@ -43,11 +41,11 @@ class SleepIQButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sleep number buttons.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( SleepNumberButton(bed, ed) diff --git a/homeassistant/components/sleepiq/coordinator.py b/homeassistant/components/sleepiq/coordinator.py index 0baeca03fe560d..d15094049cf58e 100644 --- a/homeassistant/components/sleepiq/coordinator.py +++ b/homeassistant/components/sleepiq/coordinator.py @@ -18,16 +18,18 @@ LONGER_UPDATE_INTERVAL = timedelta(minutes=5) SLEEP_DATA_UPDATE_INTERVAL = timedelta(hours=1) # Sleep data doesn't change frequently +type SleepIQConfigEntry = ConfigEntry[SleepIQData] + class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): """SleepIQ data update coordinator.""" - config_entry: ConfigEntry + config_entry: SleepIQConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SleepIQConfigEntry, client: AsyncSleepIQ, ) -> None: """Initialize coordinator.""" @@ -51,12 +53,12 @@ async def _async_update_data(self) -> None: class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]): """SleepIQ data update coordinator.""" - config_entry: ConfigEntry + config_entry: SleepIQConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SleepIQConfigEntry, client: AsyncSleepIQ, ) -> None: """Initialize coordinator.""" @@ -78,12 +80,12 @@ async def _async_update_data(self) -> None: class SleepIQSleepDataCoordinator(DataUpdateCoordinator[None]): """SleepIQ sleep health data coordinator.""" - config_entry: ConfigEntry + config_entry: SleepIQConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SleepIQConfigEntry, client: AsyncSleepIQ, ) -> None: """Initialize coordinator.""" diff --git a/homeassistant/components/sleepiq/light.py b/homeassistant/components/sleepiq/light.py index 542c212df27107..9b273df1ea430d 100644 --- a/homeassistant/components/sleepiq/light.py +++ b/homeassistant/components/sleepiq/light.py @@ -6,12 +6,10 @@ from asyncsleepiq import SleepIQBed, SleepIQLight from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator +from .coordinator import SleepIQConfigEntry, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity _LOGGER = logging.getLogger(__name__) @@ -19,11 +17,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed lights.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( SleepIQLightEntity(data.data_coordinator, bed, light) for bed in data.client.beds.values() diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 1a99f47c38c6d8..57ccd5457f5d87 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -21,7 +21,6 @@ NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -29,13 +28,12 @@ from .const import ( ACTUATOR, CORE_CLIMATE_TIMER, - DOMAIN, ENTITY_TYPES, FIRMNESS, FOOT_WARMING_TIMER, ICON_OCCUPIED, ) -from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator +from .coordinator import SleepIQConfigEntry, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity, sleeper_for_side @@ -180,11 +178,11 @@ def _get_core_climate_unique_id( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed sensors.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[SleepIQNumberEntity] = [] for bed in data.client.beds.values(): diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index d4bc9fda3a4358..332e41879070cb 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -13,22 +13,21 @@ ) from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CORE_CLIMATE, DOMAIN, FOOT_WARMER -from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator +from .const import CORE_CLIMATE, FOOT_WARMER +from .coordinator import SleepIQConfigEntry, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ foundation preset select entities.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[SleepIQBedEntity] = [] for bed in data.client.beds.values(): entities.extend( diff --git a/homeassistant/components/sleepiq/sensor.py b/homeassistant/components/sleepiq/sensor.py index 5d22897d97b314..c1f1d94c98a232 100644 --- a/homeassistant/components/sleepiq/sensor.py +++ b/homeassistant/components/sleepiq/sensor.py @@ -13,13 +13,11 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, HEART_RATE, HRV, PRESSURE, @@ -29,7 +27,7 @@ SLEEP_SCORE, ) from .coordinator import ( - SleepIQData, + SleepIQConfigEntry, SleepIQDataUpdateCoordinator, SleepIQSleepDataCoordinator, ) @@ -112,11 +110,11 @@ class SleepIQSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed sensors.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[SensorEntity] = [] diff --git a/homeassistant/components/sleepiq/switch.py b/homeassistant/components/sleepiq/switch.py index 8363782c064d2a..283a479d3b0fc6 100644 --- a/homeassistant/components/sleepiq/switch.py +++ b/homeassistant/components/sleepiq/switch.py @@ -7,22 +7,20 @@ from asyncsleepiq import SleepIQBed from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SleepIQData, SleepIQPauseUpdateCoordinator +from .coordinator import SleepIQConfigEntry, SleepIQPauseUpdateCoordinator from .entity import SleepIQBedEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SleepIQConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sleep number switches.""" - data: SleepIQData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( SleepNumberPrivateSwitch(data.pause_coordinator, bed) for bed in data.client.beds.values() From f7096e37445dbc31d7b8cedda59913c93c77cfc0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:51:29 +0200 Subject: [PATCH 0729/1707] Use runtime_data in srp_energy integration (#167870) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/srp_energy/__init__.py | 17 ++++++----------- .../components/srp_energy/coordinator.py | 9 +++++++-- homeassistant/components/srp_energy/sensor.py | 11 ++++------- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index 13c21709445f94..0a540638e69e13 100644 --- a/homeassistant/components/srp_energy/__init__.py +++ b/homeassistant/components/srp_energy/__init__.py @@ -2,17 +2,16 @@ from srpenergy.client import SrpEnergyClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN, LOGGER -from .coordinator import SRPEnergyDataUpdateCoordinator +from .const import LOGGER +from .coordinator import SRPEnergyConfigEntry, SRPEnergyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SRPEnergyConfigEntry) -> bool: """Set up the SRP Energy component from a config entry.""" api_account_id: str = entry.data[CONF_ID] api_username: str = entry.data[CONF_USERNAME] @@ -30,17 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SRPEnergyConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/srp_energy/coordinator.py b/homeassistant/components/srp_energy/coordinator.py index f3821891afa869..c581c5d8686305 100644 --- a/homeassistant/components/srp_energy/coordinator.py +++ b/homeassistant/components/srp_energy/coordinator.py @@ -23,14 +23,19 @@ TIMEOUT = 10 PHOENIX_ZONE_INFO = dt_util.get_time_zone(PHOENIX_TIME_ZONE) +type SRPEnergyConfigEntry = ConfigEntry[SRPEnergyDataUpdateCoordinator] + class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): """A srp_energy Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: SRPEnergyConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: SrpEnergyClient + self, + hass: HomeAssistant, + config_entry: SRPEnergyConfigEntry, + client: SrpEnergyClient, ) -> None: """Initialize the srp_energy data coordinator.""" self._client = client diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 89274390411e09..a6148d27fd0a37 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -7,7 +7,6 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -15,19 +14,17 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SRPEnergyDataUpdateCoordinator from .const import DEVICE_CONFIG_URL, DEVICE_MANUFACTURER, DEVICE_MODEL, DOMAIN +from .coordinator import SRPEnergyConfigEntry, SRPEnergyDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SRPEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SRP Energy Usage sensor.""" - coordinator: SRPEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([SrpEntity(coordinator, entry)]) + async_add_entities([SrpEntity(entry.runtime_data, entry)]) class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity): @@ -43,7 +40,7 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) def __init__( self, coordinator: SRPEnergyDataUpdateCoordinator, - config_entry: ConfigEntry, + config_entry: SRPEnergyConfigEntry, ) -> None: """Initialize the SrpEntity class.""" super().__init__(coordinator) From 8d2564f00f7f645739c54438ad77a8e208ab5f29 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:52:47 +0200 Subject: [PATCH 0730/1707] Use runtime_data in soundtouch integration (#167869) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/soundtouch/__init__.py | 45 ++++++++++++------- .../components/soundtouch/media_player.py | 14 +++--- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py index bb11ebfaa195cc..c7a618b4c4a8af 100644 --- a/homeassistant/components/soundtouch/__init__.py +++ b/homeassistant/components/soundtouch/__init__.py @@ -1,6 +1,9 @@ """The soundtouch component.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING from libsoundtouch import soundtouch_device from libsoundtouch.device import SoundTouchDevice @@ -22,6 +25,11 @@ SERVICE_REMOVE_ZONE_SLAVE, ) +if TYPE_CHECKING: + from .media_player import SoundTouchMediaPlayer + +type SoundTouchConfigEntry = ConfigEntry[SoundTouchData] + _LOGGER = logging.getLogger(__name__) SERVICE_PLAY_EVERYWHERE_SCHEMA = vol.Schema({vol.Required("master"): cv.entity_id}) @@ -50,12 +58,12 @@ class SoundTouchData: - """SoundTouch data stored in the Home Assistant data object.""" + """SoundTouch data stored in the config entry runtime data.""" def __init__(self, device: SoundTouchDevice) -> None: """Initialize the SoundTouch data object for a device.""" self.device = device - self.media_player = None + self.media_player: SoundTouchMediaPlayer | None = None async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -65,20 +73,25 @@ async def service_handle(service: ServiceCall) -> None: """Handle the applying of a service.""" master_id = service.data.get("master") slaves_ids = service.data.get("slaves") + all_media_players = [ + entry.runtime_data.media_player + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.runtime_data.media_player is not None + ] slaves = [] if slaves_ids: slaves = [ - data.media_player - for data in hass.data[DOMAIN].values() - if data.media_player.entity_id in slaves_ids + media_player + for media_player in all_media_players + if media_player.entity_id in slaves_ids ] master = next( iter( [ - data.media_player - for data in hass.data[DOMAIN].values() - if data.media_player.entity_id == master_id + media_player + for media_player in all_media_players + if media_player.entity_id == master_id ] ), None, @@ -90,9 +103,9 @@ async def service_handle(service: ServiceCall) -> None: if service.service == SERVICE_PLAY_EVERYWHERE: slaves = [ - data.media_player - for data in hass.data[DOMAIN].values() - if data.media_player.entity_id != master_id + media_player + for media_player in all_media_players + if media_player.entity_id != master_id ] await hass.async_add_executor_job(master.create_zone, slaves) elif service.service == SERVICE_CREATE_ZONE: @@ -130,7 +143,7 @@ async def service_handle(service: ServiceCall) -> None: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SoundTouchConfigEntry) -> bool: """Set up Bose SoundTouch from a config entry.""" try: device = await hass.async_add_executor_job( @@ -141,14 +154,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unable to connect to SoundTouch device at {entry.data[CONF_HOST]}" ) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SoundTouchData(device) + entry.runtime_data = SoundTouchData(device) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SoundTouchConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 02c0d8a1bbf9ff..7deefe97363cfc 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -19,7 +19,6 @@ MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import ( @@ -29,6 +28,7 @@ ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import SoundTouchConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -46,16 +46,16 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SoundTouchConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Bose SoundTouch media player based on a config entry.""" - device = hass.data[DOMAIN][entry.entry_id].device + device = entry.runtime_data.device media_player = SoundTouchMediaPlayer(device) async_add_entities([media_player], True) - hass.data[DOMAIN][entry.entry_id].media_player = media_player + entry.runtime_data.media_player = media_player class SoundTouchMediaPlayer(MediaPlayerEntity): @@ -388,14 +388,16 @@ def get_zone_info(self): def _get_instance_by_ip(self, ip_address): """Search and return a SoundTouchDevice instance by it's IP address.""" - for data in self.hass.data[DOMAIN].values(): + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN): + data = entry.runtime_data if data.device.config.device_ip == ip_address: return data.media_player return None def _get_instance_by_id(self, instance_id): """Search and return a SoundTouchDevice instance by it's ID (aka MAC address).""" - for data in self.hass.data[DOMAIN].values(): + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN): + data = entry.runtime_data if data.device.config.device_id == instance_id: return data.media_player return None From 6f7fa85d18bd172a06854a2c994a7f0d29937a1b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:53:53 +0200 Subject: [PATCH 0731/1707] Use runtime_data in system_bridge integration (#167880) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/system_bridge/__init__.py | 76 +++++++++---------- .../components/system_bridge/binary_sensor.py | 8 +- .../components/system_bridge/coordinator.py | 6 +- .../components/system_bridge/media_player.py | 8 +- .../components/system_bridge/media_source.py | 39 +++++----- .../components/system_bridge/notify.py | 11 +-- .../components/system_bridge/sensor.py | 8 +- .../components/system_bridge/update.py | 8 +- 8 files changed, 78 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index c057ae0c214727..b1274b2c2e80d6 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -21,7 +21,7 @@ from systembridgeconnector.version import Version import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, CONF_COMMAND, @@ -57,7 +57,24 @@ from .config_flow import SystemBridgeConfigFlow from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator + + +def _get_coordinator( + hass: HomeAssistant, entry_id: str +) -> SystemBridgeDataUpdateCoordinator: + """Return the coordinator for a config entry id.""" + entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if entry is None or entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": entry_id}, + ) + return entry.runtime_data + _LOGGER = logging.getLogger(__name__) @@ -93,7 +110,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, ) -> bool: """Set up System Bridge from a config entry.""" @@ -198,8 +215,7 @@ async def async_setup_entry( # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator # Set up all platforms except notify await hass.config_entries.async_forward_entry_setups( @@ -216,7 +232,7 @@ async def async_setup_entry( CONF_NAME: f"{DOMAIN}_{coordinator.data.system.hostname}", CONF_ENTITY_ID: entry.entry_id, }, - hass.data[DOMAIN][entry.entry_id], + {}, ) ) @@ -249,9 +265,7 @@ def valid_device(device: str) -> str: async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse: """Handle the get process by id service call.""" _LOGGER.debug("Get process by id: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) processes: list[Process] = coordinator.data.processes # Find process.id from list, raise ServiceValidationError if not found @@ -275,9 +289,7 @@ async def handle_get_processes_by_name( ) -> ServiceResponse: """Handle the get process by name service call.""" _LOGGER.debug("Get process by name: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) # Find processes from list items: list[dict[str, Any]] = [ @@ -295,9 +307,7 @@ async def handle_get_processes_by_name( async def handle_open_path(service_call: ServiceCall) -> ServiceResponse: """Handle the open path service call.""" _LOGGER.debug("Open path: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await coordinator.websocket_client.open_path( OpenPath(path=service_call.data[CONF_PATH]) ) @@ -306,9 +316,7 @@ async def handle_open_path(service_call: ServiceCall) -> ServiceResponse: async def handle_power_command(service_call: ServiceCall) -> ServiceResponse: """Handle the power command service call.""" _LOGGER.debug("Power command: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await getattr( coordinator.websocket_client, POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]], @@ -318,9 +326,7 @@ async def handle_power_command(service_call: ServiceCall) -> ServiceResponse: async def handle_open_url(service_call: ServiceCall) -> ServiceResponse: """Handle the open url service call.""" _LOGGER.debug("Open URL: %s", service_call.data) - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await coordinator.websocket_client.open_url( OpenUrl(url=service_call.data[CONF_URL]) ) @@ -328,9 +334,7 @@ async def handle_open_url(service_call: ServiceCall) -> ServiceResponse: async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse: """Handle the send_keypress service call.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await coordinator.websocket_client.keyboard_keypress( KeyboardKey(key=service_call.data[CONF_KEY]) ) @@ -338,9 +342,7 @@ async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse: async def handle_send_text(service_call: ServiceCall) -> ServiceResponse: """Handle the send_keypress service call.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - service_call.data[CONF_BRIDGE] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await coordinator.websocket_client.keyboard_text( KeyboardText(text=service_call.data[CONF_TEXT]) ) @@ -446,33 +448,27 @@ async def handle_send_text(service_call: ServiceCall) -> ServiceResponse: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SystemBridgeConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] ) if unload_ok: - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data # Ensure disconnected and cleanup stop sub await coordinator.websocket_client.close() if coordinator.unsub: coordinator.unsub() - del hass.data[DOMAIN][entry.entry_id] - - if not hass.data[DOMAIN]: - hass.services.async_remove(DOMAIN, SERVICE_OPEN_PATH) - hass.services.async_remove(DOMAIN, SERVICE_OPEN_URL) - hass.services.async_remove(DOMAIN, SERVICE_SEND_KEYPRESS) - hass.services.async_remove(DOMAIN, SERVICE_SEND_TEXT) - return unload_ok -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry( + hass: HomeAssistant, entry: SystemBridgeConfigEntry +) -> None: """Reload the config entry when it changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 883c74f2589d98..9a09bb5eac1c12 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -10,13 +10,11 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator from .data import SystemBridgeData from .entity import SystemBridgeEntity @@ -64,11 +62,11 @@ def camera_in_use(data: SystemBridgeData) -> bool | None: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge binary sensor based on a config entry.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ SystemBridgeBinarySensor(coordinator, description, entry.data[CONF_PORT]) diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 6fca2e5902fbf5..9736a6a5b9e610 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -36,18 +36,20 @@ from .const import DOMAIN, GET_DATA_WAIT_TIMEOUT, MODULES from .data import SystemBridgeData +type SystemBridgeConfigEntry = ConfigEntry[SystemBridgeDataUpdateCoordinator] + class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]): """Class to manage fetching System Bridge data from single endpoint.""" - config_entry: ConfigEntry + config_entry: SystemBridgeConfigEntry def __init__( self, hass: HomeAssistant, LOGGER: logging.Logger, *, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, ) -> None: """Initialize global System Bridge data updater.""" self.title = entry.title diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index c7b1fab679a709..1c3b707d4eec1b 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -15,13 +15,11 @@ MediaPlayerState, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator from .data import SystemBridgeData from .entity import SystemBridgeEntity @@ -64,11 +62,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge media players based on a config entry.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data data = coordinator.data if data.media is not None: diff --git a/homeassistant/components/system_bridge/media_source.py b/homeassistant/components/system_bridge/media_source.py index 930557568b83a7..5cfccaf3a88e66 100644 --- a/homeassistant/components/system_bridge/media_source.py +++ b/homeassistant/components/system_bridge/media_source.py @@ -15,12 +15,22 @@ MediaSourceItem, PlayMedia, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry + + +def _get_loaded_entry(hass: HomeAssistant, entry_id: str) -> SystemBridgeConfigEntry: + """Return a loaded System Bridge config entry by id.""" + entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if entry is None or entry.state is not ConfigEntryState.LOADED: + raise ValueError("Invalid entry") + return entry async def async_get_media_source(hass: HomeAssistant) -> MediaSource: @@ -46,9 +56,7 @@ async def async_resolve_media( ) -> PlayMedia: """Resolve media to a url.""" entry_id, path, mime_type = item.identifier.split("~~", 2) - entry = self.hass.config_entries.async_get_entry(entry_id) - if entry is None: - raise ValueError("Invalid entry") + entry = _get_loaded_entry(self.hass, entry_id) path_split = path.split("/", 1) return PlayMedia( f"{_build_base_url(entry)}&base={path_split[0]}&path={path_split[1]}", @@ -64,21 +72,14 @@ async def async_browse_media( return self._build_bridges() if "~~" not in item.identifier: - entry = self.hass.config_entries.async_get_entry(item.identifier) - if entry is None: - raise ValueError("Invalid entry") - coordinator: SystemBridgeDataUpdateCoordinator = self.hass.data[DOMAIN].get( - entry.entry_id - ) + entry = _get_loaded_entry(self.hass, item.identifier) + coordinator = entry.runtime_data directories = await coordinator.websocket_client.get_directories() return _build_root_paths(entry, directories) entry_id, path = item.identifier.split("~~", 1) - entry = self.hass.config_entries.async_get_entry(entry_id) - if entry is None: - raise ValueError("Invalid entry") - - coordinator = self.hass.data[DOMAIN].get(entry.entry_id) + entry = _get_loaded_entry(self.hass, entry_id) + coordinator = entry.runtime_data path_split = path.split("/", 1) @@ -123,7 +124,7 @@ def _build_bridges(self) -> BrowseMediaSource: def _build_base_url( - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, ) -> str: """Build base url for System Bridge media.""" return ( @@ -133,7 +134,7 @@ def _build_base_url( def _build_root_paths( - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, media_directories: list[MediaDirectory], ) -> BrowseMediaSource: """Build base categories for System Bridge media.""" @@ -164,7 +165,7 @@ def _build_root_paths( def _build_media_items( - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, media_files: MediaFiles, path: str, identifier: str, diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py index 2b13fef071e119..1f43d75a367d62 100644 --- a/homeassistant/components/system_bridge/notify.py +++ b/homeassistant/components/system_bridge/notify.py @@ -17,8 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -37,11 +36,13 @@ async def async_get_service( if discovery_info is None: return None - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry( discovery_info[CONF_ENTITY_ID] - ] + ) + if entry is None: + return None - return SystemBridgeNotificationService(coordinator) + return SystemBridgeNotificationService(entry.runtime_data) class SystemBridgeNotificationService(BaseNotificationService): diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 220d2c8823bca4..341641e50a0744 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -17,7 +17,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PORT, PERCENTAGE, @@ -33,8 +32,7 @@ from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator from .data import SystemBridgeData from .entity import SystemBridgeEntity @@ -364,11 +362,11 @@ def partition_usage( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge sensor based on a config entry.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ SystemBridgeSensor(coordinator, description, entry.data[CONF_PORT]) diff --git a/homeassistant/components/system_bridge/update.py b/homeassistant/components/system_bridge/update.py index 12060c28669725..cf2b4a0442630d 100644 --- a/homeassistant/components/system_bridge/update.py +++ b/homeassistant/components/system_bridge/update.py @@ -3,23 +3,21 @@ from __future__ import annotations from homeassistant.components.update import UpdateEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator from .entity import SystemBridgeEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge update based on a config entry.""" - coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ From 109ec0705c03bf59c50e807dc77cb1ff02911cd8 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 10 Apr 2026 11:54:32 +0200 Subject: [PATCH 0732/1707] Use runtime_data in vilfo integration (#167886) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/vilfo/__init__.py | 17 +++++++---------- homeassistant/components/vilfo/sensor.py | 6 +++--- tests/components/vilfo/conftest.py | 2 +- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/vilfo/__init__.py b/homeassistant/components/vilfo/__init__.py index ca74e74f37abbd..95e6c8de89c31c 100644 --- a/homeassistant/components/vilfo/__init__.py +++ b/homeassistant/components/vilfo/__init__.py @@ -12,7 +12,9 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import Throttle -from .const import ATTR_BOOT_TIME, ATTR_LOAD, DOMAIN, ROUTER_DEFAULT_HOST +from .const import ATTR_BOOT_TIME, ATTR_LOAD, ROUTER_DEFAULT_HOST + +type VilfoConfigEntry = ConfigEntry[VilfoRouterData] PLATFORMS = [Platform.SENSOR] @@ -21,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: VilfoConfigEntry) -> bool: """Set up Vilfo Router from a config entry.""" host = entry.data[CONF_HOST] access_token = entry.data[CONF_ACCESS_TOKEN] @@ -33,21 +35,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not vilfo_router.available: raise ConfigEntryNotReady - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = vilfo_router + entry.runtime_data = vilfo_router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VilfoConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class VilfoRouterData: diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index fa2d5cae196425..7755f55a7ea83b 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -7,12 +7,12 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import VilfoConfigEntry from .const import ( ATTR_API_DATA_FIELD_BOOT_TIME, ATTR_API_DATA_FIELD_LOAD, @@ -50,11 +50,11 @@ class VilfoSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: VilfoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Vilfo Router entities from a config_entry.""" - vilfo = hass.data[DOMAIN][config_entry.entry_id] + vilfo = config_entry.runtime_data entities = [VilfoRouterSensor(vilfo, description) for description in SENSOR_TYPES] diff --git a/tests/components/vilfo/conftest.py b/tests/components/vilfo/conftest.py index fbc48da28b3ea2..082baad46352a2 100644 --- a/tests/components/vilfo/conftest.py +++ b/tests/components/vilfo/conftest.py @@ -5,7 +5,7 @@ import pytest -from homeassistant.components.vilfo import DOMAIN +from homeassistant.components.vilfo.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from tests.common import MockConfigEntry From 244ed140190b1a36844d54825298c0a7c53d95eb Mon Sep 17 00:00:00 2001 From: Andre v d Walt Date: Fri, 10 Apr 2026 12:12:36 +0200 Subject: [PATCH 0733/1707] smartthings: add Samsung OCF AC purify switch (#167705) --- .../components/smartthings/icons.json | 3 ++ .../components/smartthings/strings.json | 3 ++ .../components/smartthings/switch.py | 7 +++ .../smartthings/snapshots/test_switch.ambr | 50 +++++++++++++++++++ tests/components/smartthings/test_switch.py | 33 ++++++++++++ 5 files changed, 96 insertions(+) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 29754f1cbed44d..536a7fba80b599 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -246,6 +246,9 @@ "power_freeze": { "default": "mdi:snowflake" }, + "purify": { + "default": "mdi:air-purifier" + }, "rinse_plus": { "default": "mdi:water-plus" }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 6deaefceae49d0..78a34e0339e12d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -1014,6 +1014,9 @@ "power_freeze": { "name": "Power freeze" }, + "purify": { + "name": "Purify" + }, "rinse_plus": { "name": "Rinse plus" }, diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index fbf6ebd630f9da..01c1eaaedd14fa 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -80,6 +80,13 @@ class SmartThingsDishwasherWashingOptionSwitchEntityDescription( CAPABILITY_TO_COMMAND_SWITCHES: dict[ Capability | str, SmartThingsCommandSwitchEntityDescription ] = { + Capability.CUSTOM_SPI_MODE: SmartThingsCommandSwitchEntityDescription( + key=Capability.CUSTOM_SPI_MODE, + translation_key="purify", + status_attribute=Attribute.SPI_MODE, + command=Command.SET_SPI_MODE, + entity_category=EntityCategory.CONFIG, + ), Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING: SmartThingsCommandSwitchEntityDescription( key=Capability.SAMSUNG_CE_AIR_CONDITIONER_LIGHTING, translation_key="display_lighting", diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index e49cfa851663db..c2021bbda82778 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -149,6 +149,56 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_ac_rac_000001][switch.ac_office_granit_purify-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ac_office_granit_purify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Purify', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Purify', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'purify', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_custom.spiMode_spiMode_spiMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_000001][switch.ac_office_granit_purify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Purify', + }), + 'context': , + 'entity_id': 'switch.ac_office_granit_purify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ac_cac_01001][switch.ar_varanda_sound_effect-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index f6598957f94a50..ae7b9b78bcb3ff 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -183,6 +183,39 @@ async def test_custom_commands( ) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ("action", "argument"), + [ + (SERVICE_TURN_ON, "on"), + (SERVICE_TURN_OFF, "off"), + ], +) +async def test_ac_purify_switch( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + argument: str, +) -> None: + """Test Samsung OCF AC purify switch.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.ac_office_granit_purify"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.CUSTOM_SPI_MODE, + Command.SET_SPI_MODE, + MAIN, + argument, + ) + + @pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) async def test_state_update( hass: HomeAssistant, From 5edcfdf621de01ebf60ccb182b67652671e4d6db Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:13:06 +0300 Subject: [PATCH 0734/1707] Mark docs-examples and docs-known-limitations as done for victron_gx (#167866) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- homeassistant/components/victron_gx/quality_scale.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/victron_gx/quality_scale.yaml b/homeassistant/components/victron_gx/quality_scale.yaml index 48bc8e1f3f62da..f68019b055f785 100644 --- a/homeassistant/components/victron_gx/quality_scale.yaml +++ b/homeassistant/components/victron_gx/quality_scale.yaml @@ -46,8 +46,8 @@ rules: discovery-update-info: done discovery: done docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-examples: done + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done From aa293ba2f4ed419dd4b3147ecbf15eef63c8df85 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:31:36 +0200 Subject: [PATCH 0735/1707] Add ability to load custom Tuya quirks (#166952) --- homeassistant/components/tuya/__init__.py | 6 ++++ homeassistant/components/tuya/camera.py | 27 +++++++++++++-- homeassistant/components/tuya/entity.py | 10 +++--- tests/components/tuya/test_camera.py | 42 +++++++++++++++++------ 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 0555f8a145a8fb..d80558942014f8 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations import logging +from pathlib import Path from typing import Any, NamedTuple +from tuya_device_handlers.devices import register_tuya_quirks from tuya_sharing import ( CustomerDevice, Manager, @@ -58,6 +60,10 @@ def _create_manager(entry: TuyaConfigEntry, token_listener: TokenListener) -> Ma async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Async setup hass config entry.""" + await hass.async_add_executor_job( + register_tuya_quirks, str(Path(hass.config.config_dir, "tuya_quirks")) + ) + token_listener = TokenListener(hass, entry) # Move to executor as it makes blocking call to import_module diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 36b69885b2e564..ee721705e56915 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -2,7 +2,9 @@ from __future__ import annotations +from tuya_device_handlers import TUYA_QUIRKS_REGISTRY from tuya_device_handlers.definition.camera import ( + CameraQuirk, TuyaCameraDefinition, get_default_definition, ) @@ -28,6 +30,20 @@ } +def _get_quirk_entities( + manager: Manager, device: CustomerDevice +) -> list[TuyaCameraEntity] | None: + if (quirk := TUYA_QUIRKS_REGISTRY.get_quirk_for_device(device)) is None or ( + entity_quirks := quirk.camera_quirks + ) is None: + return None + return [ + TuyaCameraEntity(device, manager, definition, quirk=entity_quirk) + for entity_quirk in entity_quirks + if (definition := entity_quirk.definition_fn(device)) + ] + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -42,10 +58,13 @@ def async_discover_device(device_ids: list[str]) -> None: entities: list[TuyaCameraEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] + if (quirk_entities := _get_quirk_entities(manager, device)) is not None: + entities.extend(quirk_entities) + continue if description := CAMERAS.get(device.category): entities.append( TuyaCameraEntity( - device, manager, description, get_default_definition(device) + device, manager, get_default_definition(device), description ) ) @@ -69,8 +88,10 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: CameraEntityDescription, definition: TuyaCameraDefinition, + description: CameraEntityDescription | None = None, + *, + quirk: CameraQuirk | None = None, ) -> None: """Init Tuya Camera.""" super().__init__(device, device_manager, description) @@ -78,6 +99,8 @@ def __init__( self._attr_model = device.product_name self._motion_detection_switch = definition.motion_detection_switch self._recording_status = definition.recording_status + if quirk and quirk.key: + self._attr_unique_id = f"tuya.{device.id}_{quirk.key}" @property def is_recording(self) -> bool: diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 7ebe9aaf416fd4..23abba61927dff 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -24,11 +24,13 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, - description: EntityDescription, + description: EntityDescription | None, ) -> None: - """Init TuyaHaEntity.""" - self._attr_unique_id = f"tuya.{device.id}{description.key}" - self.entity_description = description + """Init TuyaEntity.""" + self._attr_unique_id = f"tuya.{device.id}" + if description: + self.entity_description = description + self._attr_unique_id = f"tuya.{device.id}{description.key}" # TuyaEntity initialize mq can subscribe device.set_up = True self.device = device diff --git a/tests/components/tuya/test_camera.py b/tests/components/tuya/test_camera.py index e2fddd4bac4a39..e288cbf26363f3 100644 --- a/tests/components/tuya/test_camera.py +++ b/tests/components/tuya/test_camera.py @@ -3,10 +3,12 @@ from __future__ import annotations from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from syrupy.assertion import SnapshotAssertion +from tuya_device_handlers import TUYA_QUIRKS_REGISTRY +from tuya_device_handlers.definition.camera import CameraQuirk, get_default_definition from tuya_sharing import CustomerDevice, Manager from homeassistant.components.camera import ( @@ -37,16 +39,6 @@ def platform_autouse(): yield -@pytest.fixture(autouse=True) -def mock_getrandbits(): - """Mock camera access token which normally is randomized.""" - with patch( - "homeassistant.components.camera.SystemRandom.getrandbits", - return_value=1, - ): - yield - - async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: Manager, @@ -67,6 +59,34 @@ async def test_platform_setup_and_discovery( ) +@pytest.mark.parametrize("mock_device_code", ["sp_rudejjigkywujjvs"]) +@pytest.mark.parametrize( + ("get_quirks", "available"), + [ + (None, True), + ([], False), + ([CameraQuirk(key="", definition_fn=get_default_definition)], True), + ([CameraQuirk(key="", definition_fn=lambda d: None)], False), + ], +) +async def test_empty_quirk( + hass: HomeAssistant, + mock_manager: Manager, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + get_quirks: list | None, + available: bool, +) -> None: + """Test None quirks use defaults and empty quirk list skips default entities.""" + with patch.object(TUYA_QUIRKS_REGISTRY, "get_quirk_for_device") as mock_get_quirk: + mock_get_quirk.return_value = Mock() + mock_get_quirk.return_value.camera_quirks = get_quirks + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get("camera.burocam") + assert (state is not None) is available + + @pytest.mark.parametrize( "mock_device_code", ["sp_rudejjigkywujjvs"], From 10c922b21fb5ae5772c79ab2b2d6274848796c71 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 10 Apr 2026 12:34:05 +0200 Subject: [PATCH 0736/1707] Support Chess.com accounts with no name (#167824) --- .../components/chess_com/config_flow.py | 4 +++- .../components/chess_com/test_config_flow.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/chess_com/config_flow.py b/homeassistant/components/chess_com/config_flow.py index fea9ffd94df9ba..73d2260726f0e2 100644 --- a/homeassistant/components/chess_com/config_flow.py +++ b/homeassistant/components/chess_com/config_flow.py @@ -39,7 +39,9 @@ async def async_step_user( else: await self.async_set_unique_id(str(user.player_id)) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user.name, data=user_input) + return self.async_create_entry( + title=user.name or user.username, data=user_input + ) return self.async_show_form( step_id="user", diff --git a/tests/components/chess_com/test_config_flow.py b/tests/components/chess_com/test_config_flow.py index b602b50097be85..e1f87a53118b3f 100644 --- a/tests/components/chess_com/test_config_flow.py +++ b/tests/components/chess_com/test_config_flow.py @@ -34,6 +34,27 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") +async def test_flow_no_name(hass: HomeAssistant, mock_chess_client: AsyncMock) -> None: + """Test the flow with no name.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_chess_client.get_player.return_value.name = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "joostlek"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "joostlek" + assert result["data"] == {CONF_USERNAME: "joostlek"} + assert result["result"].unique_id == "532748851" + + @pytest.mark.parametrize( ("exception", "error"), [ From 777f78f74d4d066ab0fc0d0eb5374936d8db8753 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 10 Apr 2026 12:35:31 +0200 Subject: [PATCH 0737/1707] Use runtime_data in litejet integration (#167888) --- homeassistant/components/litejet/__init__.py | 13 +++++++------ homeassistant/components/litejet/config_flow.py | 10 +++------- homeassistant/components/litejet/diagnostics.py | 9 +++------ homeassistant/components/litejet/light.py | 8 ++++---- homeassistant/components/litejet/scene.py | 6 +++--- homeassistant/components/litejet/switch.py | 6 +++--- homeassistant/components/litejet/trigger.py | 3 +-- tests/components/litejet/__init__.py | 2 +- 8 files changed, 25 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 84667d6c94dc9f..0c30aa4b73e219 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -9,12 +9,14 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, PLATFORMS +from .const import PLATFORMS + +type LiteJetConfigEntry = ConfigEntry[pylitejet.LiteJet] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LiteJetConfigEntry) -> bool: """Set up LiteJet via a config entry.""" port = entry.data[CONF_PORT] @@ -38,19 +40,18 @@ async def handle_stop(event: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop) ) - hass.data[DOMAIN] = system + entry.runtime_data = system await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LiteJetConfigEntry) -> bool: """Unload a LiteJet config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - await hass.data[DOMAIN].close() - hass.data.pop(DOMAIN) + await entry.runtime_data.close() return unload_ok diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index aeae8f52144684..03182b79ef097f 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -8,16 +8,12 @@ from serial import SerialException import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PORT from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from . import LiteJetConfigEntry from .const import CONF_DEFAULT_TRANSITION, DOMAIN @@ -77,7 +73,7 @@ async def async_step_user( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: LiteJetConfigEntry, ) -> LiteJetOptionsFlow: """Get the options flow for this handler.""" return LiteJetOptionsFlow() diff --git a/homeassistant/components/litejet/diagnostics.py b/homeassistant/components/litejet/diagnostics.py index 7a10f4d6754f0f..e010d1ea13ff86 100644 --- a/homeassistant/components/litejet/diagnostics.py +++ b/homeassistant/components/litejet/diagnostics.py @@ -2,19 +2,16 @@ from typing import Any -from pylitejet import LiteJet - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from . import LiteJetConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LiteJetConfigEntry ) -> dict[str, Any]: """Return diagnostics for LiteJet config entry.""" - system: LiteJet = hass.data[DOMAIN] + system = entry.runtime_data return { "model": system.model_name, "loads": list(system.loads()), diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index 95870927072700..54ded894f4d4ea 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -13,12 +13,12 @@ LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import LiteJetConfigEntry from .const import CONF_DEFAULT_TRANSITION, DOMAIN ATTR_NUMBER = "number" @@ -26,12 +26,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LiteJetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - system: LiteJet = hass.data[DOMAIN] + system = config_entry.runtime_data entities = [] for index in system.loads(): @@ -52,7 +52,7 @@ class LiteJetLight(LightEntity): _attr_name = None def __init__( - self, config_entry: ConfigEntry, system: LiteJet, index: int, name: str + self, config_entry: LiteJetConfigEntry, system: LiteJet, index: int, name: str ) -> None: """Initialize a LiteJet light.""" self._config_entry = config_entry diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index dd96b5accb6356..657c882e74dc81 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -6,12 +6,12 @@ from pylitejet import LiteJet, LiteJetError from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import LiteJetConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -21,12 +21,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LiteJetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - system: LiteJet = hass.data[DOMAIN] + system = config_entry.runtime_data entities = [] for i in system.scenes(): diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index 1b46ba360c3c9a..e1468347e477c9 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -5,12 +5,12 @@ from pylitejet import LiteJet, LiteJetError from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import LiteJetConfigEntry from .const import DOMAIN ATTR_NUMBER = "number" @@ -18,12 +18,12 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LiteJetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - system: LiteJet = hass.data[DOMAIN] + system = config_entry.runtime_data entities = [] for i in system.button_switches(): diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index a35bf6fb65ed3b..8f10ab619ad2aa 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -6,7 +6,6 @@ from datetime import datetime from typing import cast -from pylitejet import LiteJet import voluptuous as vol from homeassistant.const import CONF_PLATFORM @@ -109,7 +108,7 @@ def released() -> None: ): hass.add_job(call_action) - system: LiteJet = hass.data[DOMAIN] + system = hass.config_entries.async_loaded_entries(DOMAIN)[0].runtime_data system.on_switch_pressed(number, pressed) system.on_switch_released(number, released) diff --git a/tests/components/litejet/__init__.py b/tests/components/litejet/__init__.py index bf992836043750..9c2af0b1e3f721 100644 --- a/tests/components/litejet/__init__.py +++ b/tests/components/litejet/__init__.py @@ -1,7 +1,7 @@ """Tests for the litejet component.""" from homeassistant.components import scene, switch -from homeassistant.components.litejet import DOMAIN +from homeassistant.components.litejet.const import DOMAIN from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er From 9a97f1e8d2163196c1c3ffee7e6942db03c0e0af Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 10 Apr 2026 12:49:39 +0200 Subject: [PATCH 0738/1707] Use runtime_data in soma integration (#167890) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/soma/__init__.py | 23 ++++++++++++++++++----- homeassistant/components/soma/const.py | 3 --- homeassistant/components/soma/cover.py | 11 +++++------ homeassistant/components/soma/sensor.py | 11 ++++------- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index 127b51338ee065..486c2c05bf9e02 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -2,6 +2,9 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Any + from api.soma_api import SomaApi import voluptuous as vol @@ -12,7 +15,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import API, DEVICES, DOMAIN, HOST, PORT +from .const import DOMAIN, HOST, PORT CONFIG_SCHEMA = vol.Schema( vol.All( @@ -26,6 +29,17 @@ extra=vol.ALLOW_EXTRA, ) + +@dataclass +class SomaData: + """Runtime data for the Soma integration.""" + + api: SomaApi + devices: list[dict[str, Any]] + + +type SomaConfigEntry = ConfigEntry[SomaData] + PLATFORMS = [Platform.COVER, Platform.SENSOR] @@ -45,18 +59,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SomaConfigEntry) -> bool: """Set up Soma from a config entry.""" - hass.data[DOMAIN] = {} api = await hass.async_add_executor_job(SomaApi, entry.data[HOST], entry.data[PORT]) devices = await hass.async_add_executor_job(api.list_devices) - hass.data[DOMAIN] = {API: api, DEVICES: devices["shades"]} + entry.runtime_data = SomaData(api, devices["shades"]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SomaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/soma/const.py b/homeassistant/components/soma/const.py index b34596abe93f57..20f5d60b2c442e 100644 --- a/homeassistant/components/soma/const.py +++ b/homeassistant/components/soma/const.py @@ -3,6 +3,3 @@ DOMAIN = "soma" HOST = "host" PORT = "port" -API = "api" - -DEVICES = "devices" diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 15aa21b1f48c94..2e183f3c27b229 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -11,28 +11,27 @@ CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import API, DEVICES, DOMAIN +from . import SomaConfigEntry from .entity import SomaEntity from .utils import is_api_response_success async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SomaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Soma cover platform.""" - api = hass.data[DOMAIN][API] - devices = hass.data[DOMAIN][DEVICES] + data = config_entry.runtime_data + api = data.api entities: list[SomaTilt | SomaShade] = [] - for device in devices: + for device in data.devices: # Assume a shade device if the type is not present in the api response (Connect <2.2.6) if "type" in device and device["type"].lower() == "tilt": entities.append(SomaTilt(device, api)) diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 839f28e9a65e63..b992d1f8b1d596 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -3,13 +3,12 @@ from datetime import timedelta from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle -from .const import API, DEVICES, DOMAIN +from . import SomaConfigEntry from .entity import SomaEntity MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) @@ -17,16 +16,14 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SomaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Soma sensor platform.""" - devices = hass.data[DOMAIN][DEVICES] + data = config_entry.runtime_data - async_add_entities( - [SomaSensor(sensor, hass.data[DOMAIN][API]) for sensor in devices], True - ) + async_add_entities([SomaSensor(sensor, data.api) for sensor in data.devices], True) class SomaSensor(SomaEntity, SensorEntity): From 7cf422361bad42d9394bf2c512949fdee3c0e527 Mon Sep 17 00:00:00 2001 From: Pierre Hauweele <1010576+antegallya@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:49:40 +0200 Subject: [PATCH 0739/1707] Make the scaffold script ask for the integration type (#167725) --- script/scaffold/__main__.py | 2 +- script/scaffold/gather_info.py | 20 ++++++++++++++++---- script/scaffold/generate.py | 2 ++ script/scaffold/model.py | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 243ea9507f713c..937cbb660904a4 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -84,7 +84,7 @@ def main() -> int: # If it's a new integration and it's not a config flow, # create a config flow too. if not args.template.startswith("config_flow"): - if info.helper: + if info.integration_type == "helper": template = "config_flow_helper" elif info.oauth2: template = "config_flow_oauth2" diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py index c9264aa660fd3f..82bd79adeeae6b 100644 --- a/script/scaffold/gather_info.py +++ b/script/scaffold/gather_info.py @@ -4,6 +4,7 @@ from homeassistant.util import slugify from script.hassfest.manifest import SUPPORTED_IOT_CLASSES +from script.hassfest.model import IntegrationType from .const import COMPONENT_DIR from .error import ExitApp @@ -120,10 +121,21 @@ def gather_new_integration(determine_auth: bool) -> Info: "default": "no", **YES_NO, }, - "helper": { - "prompt": "Is this a helper integration? (yes/no)", - "default": "no", - **YES_NO, + "integration_type": { + "prompt": f"""What is the integration type? + +Valid types are {", ".join(IntegrationType)}. +This field is recommended and required in some cases. To intentionally leave it unset, type 'omit'. + +More info @ https://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type +""", + "validators": [ + [ + f"You need to pick one of {', '.join(IntegrationType)} or 'omit'.", + lambda value: value in IntegrationType or value == "omit", + ] + ], + "converter": lambda value: None if value == "omit" else value, }, "oauth2": { "prompt": "Can the user authenticate the device using OAuth2? (yes/no)", diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index 58e9850e11e2c0..9d210a9fe7772e 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -75,6 +75,8 @@ def _custom_tasks(template, info: Info) -> None: if info.requirement: changes["requirements"] = [info.requirement] + if info.integration_type: + changes["integration_type"] = info.integration_type info.update_manifest(**changes) diff --git a/script/scaffold/model.py b/script/scaffold/model.py index e3a7be210ab504..60c1433d290944 100644 --- a/script/scaffold/model.py +++ b/script/scaffold/model.py @@ -26,7 +26,7 @@ class Info: authentication: str = attr.ib(default=None) discoverable: str = attr.ib(default=None) oauth2: str = attr.ib(default=None) - helper: str = attr.ib(default=None) + integration_type: str = attr.ib(default=None) files_added: set[Path] = attr.ib(factory=set) tests_added: set[Path] = attr.ib(factory=set) From ce9875806df851e3973c89d2b778acaab4ebd4d2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 10 Apr 2026 13:47:37 +0200 Subject: [PATCH 0740/1707] Use runtime_data in launch_library integration (#167887) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/launch_library/__init__.py | 18 +++++++++--------- .../components/launch_library/coordinator.py | 7 +++++-- .../components/launch_library/diagnostics.py | 8 +++----- .../components/launch_library/sensor.py | 7 +++---- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py index 9b29af194e7db9..70095628d14e36 100644 --- a/homeassistant/components/launch_library/__init__.py +++ b/homeassistant/components/launch_library/__init__.py @@ -2,31 +2,31 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LaunchLibraryCoordinator +from .coordinator import LaunchLibraryConfigEntry, LaunchLibraryCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: LaunchLibraryConfigEntry +) -> bool: """Set up this integration using UI.""" coordinator = LaunchLibraryCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: LaunchLibraryConfigEntry +) -> bool: """Handle removal of an entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/launch_library/coordinator.py b/homeassistant/components/launch_library/coordinator.py index b88bc105630ddf..5f06c49e36f439 100644 --- a/homeassistant/components/launch_library/coordinator.py +++ b/homeassistant/components/launch_library/coordinator.py @@ -16,6 +16,9 @@ from .const import DOMAIN +type LaunchLibraryConfigEntry = ConfigEntry[LaunchLibraryCoordinator] + + _LOGGER = logging.getLogger(__name__) @@ -29,12 +32,12 @@ class LaunchLibraryData(TypedDict): class LaunchLibraryCoordinator(DataUpdateCoordinator[LaunchLibraryData]): """Class to manage fetching Launch Library data.""" - config_entry: ConfigEntry + config_entry: LaunchLibraryConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: LaunchLibraryConfigEntry, ) -> None: """Initialize the coordinator.""" super().__init__( diff --git a/homeassistant/components/launch_library/diagnostics.py b/homeassistant/components/launch_library/diagnostics.py index d96d5fed7f54fc..072752cb230782 100644 --- a/homeassistant/components/launch_library/diagnostics.py +++ b/homeassistant/components/launch_library/diagnostics.py @@ -6,20 +6,18 @@ from pylaunches.types import Event, Launch -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LaunchLibraryCoordinator +from .coordinator import LaunchLibraryConfigEntry async def async_get_config_entry_diagnostics( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaunchLibraryConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LaunchLibraryCoordinator = hass.data[DOMAIN] + coordinator = entry.runtime_data if coordinator.data is None: return {} diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index e844744c83463f..5b73f58187f23f 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -14,7 +14,6 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -23,7 +22,7 @@ from homeassistant.util.dt import parse_datetime from .const import DOMAIN -from .coordinator import LaunchLibraryCoordinator +from .coordinator import LaunchLibraryConfigEntry, LaunchLibraryCoordinator DEFAULT_NEXT_LAUNCH_NAME = "Next launch" @@ -118,12 +117,12 @@ class LaunchLibrarySensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaunchLibraryConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" name = entry.data.get(CONF_NAME, DEFAULT_NEXT_LAUNCH_NAME) - coordinator: LaunchLibraryCoordinator = hass.data[DOMAIN] + coordinator = entry.runtime_data async_add_entities( LaunchLibrarySensor( From 7125796aacd4be213e8c5a031a699942b9e807a1 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:16:47 -0400 Subject: [PATCH 0741/1707] Temporarily stop the Z2M app when installing firmwares (#163958) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../homeassistant_hardware/const.py | 5 + .../components/homeassistant_hardware/util.py | 124 +++++--- .../homeassistant_hardware/test_util.py | 279 ++++++++++++++++++ 3 files changed, 372 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/const.py b/homeassistant/components/homeassistant_hardware/const.py index eeeab870514ee8..5d2816dd805e04 100644 --- a/homeassistant/components/homeassistant_hardware/const.py +++ b/homeassistant/components/homeassistant_hardware/const.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey @@ -37,3 +38,7 @@ SILABS_FLASHER_ADDON_SLUG = "core_silabs_flasher" Z2M_EMBER_DOCS_URL = "https://www.zigbee2mqtt.io/guide/adapters/emberznet.html" + +# Community add-ons use an 8-char repository hash prefix in their slug +Z2M_ADDON_NAME = "Zigbee2MQTT" +Z2M_ADDON_SLUG_REGEX = re.compile(r"^[0-9a-f]{8}_zigbee2mqtt(?:_edge)?$") diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index e8e57b2ae482d5..fa0e4e104ee2ae 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -14,7 +14,12 @@ from universal_silabs_flasher.firmware import parse_firmware_image from universal_silabs_flasher.flasher import BaseFlasher, DeviceSpecificFlasher, Flasher -from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.hassio import ( + AddonError, + AddonManager, + AddonState, + get_apps_list, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -26,6 +31,8 @@ OTBR_ADDON_MANAGER_DATA, OTBR_ADDON_NAME, OTBR_ADDON_SLUG, + Z2M_ADDON_NAME, + Z2M_ADDON_SLUG_REGEX, ZIGBEE_FLASHER_ADDON_MANAGER_DATA, ZIGBEE_FLASHER_ADDON_NAME, ZIGBEE_FLASHER_ADDON_SLUG, @@ -84,6 +91,17 @@ def get_zigbee_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager ) +@callback +def get_z2m_addon_manager(hass: HomeAssistant, slug: str) -> WaitingAddonManager: + """Get the Z2M add-on manager.""" + return WaitingAddonManager( + hass, + _LOGGER, + Z2M_ADDON_NAME, + slug, + ) + + @dataclass(kw_only=True) class OwningAddon: """Owning add-on.""" @@ -212,6 +230,32 @@ async def get_otbr_addon_firmware_info( ) +async def get_z2m_addon_firmware_info( + hass: HomeAssistant, z2m_addon_manager: AddonManager +) -> FirmwareInfo | None: + """Get firmware info from a Z2M add-on.""" + try: + z2m_addon_info = await z2m_addon_manager.async_get_addon_info() + except AddonError: + return None + + if z2m_addon_info.state == AddonState.NOT_INSTALLED: + return None + + serial = z2m_addon_info.options.get("serial") + + if not isinstance(serial, dict) or (z2m_port := serial.get("port")) is None: + return None + + return FirmwareInfo( + device=z2m_port, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source=f"zigbee2mqtt ({z2m_addon_manager.addon_slug})", + owners=[OwningAddon(slug=z2m_addon_manager.addon_slug)], + ) + + async def guess_hardware_owners( hass: HomeAssistant, device_path: str ) -> list[FirmwareInfo]: @@ -221,46 +265,54 @@ async def guess_hardware_owners( async for firmware_info in hass.data[DATA_COMPONENT].iter_firmware_info(): device_guesses[firmware_info.device].append(firmware_info) + if not is_hassio(hass): + return device_guesses.get(device_path, []) + # It may be possible for the OTBR addon to be present without the integration - if is_hassio(hass): - otbr_addon_manager = get_otbr_addon_manager(hass) - otbr_addon_fw_info = await get_otbr_addon_firmware_info( - hass, otbr_addon_manager - ) - otbr_path = ( - otbr_addon_fw_info.device if otbr_addon_fw_info is not None else None - ) + otbr_addon_manager = get_otbr_addon_manager(hass) + otbr_addon_fw_info = await get_otbr_addon_firmware_info(hass, otbr_addon_manager) + otbr_path = otbr_addon_fw_info.device if otbr_addon_fw_info is not None else None - # Only create a new entry if there are no existing OTBR ones - if otbr_path is not None and not any( - info.source == "otbr" for info in device_guesses[otbr_path] - ): - assert otbr_addon_fw_info is not None - device_guesses[otbr_path].append(otbr_addon_fw_info) + # Only create a new entry if there are no existing OTBR ones + if otbr_path is not None and not any( + info.source == "otbr" for info in device_guesses[otbr_path] + ): + assert otbr_addon_fw_info is not None + device_guesses[otbr_path].append(otbr_addon_fw_info) - if is_hassio(hass): - multipan_addon_manager = await get_multiprotocol_addon_manager(hass) + multipan_addon_manager = await get_multiprotocol_addon_manager(hass) - try: - multipan_addon_info = await multipan_addon_manager.async_get_addon_info() - except AddonError: - pass - else: - if multipan_addon_info.state != AddonState.NOT_INSTALLED: - multipan_path = multipan_addon_info.options.get("device") - - if multipan_path is not None: - device_guesses[multipan_path].append( - FirmwareInfo( - device=multipan_path, - firmware_type=ApplicationType.CPC, - firmware_version=None, - source="multiprotocol", - owners=[ - OwningAddon(slug=multipan_addon_manager.addon_slug) - ], - ) + try: + multipan_addon_info = await multipan_addon_manager.async_get_addon_info() + except AddonError: + pass + else: + if multipan_addon_info.state != AddonState.NOT_INSTALLED: + multipan_path = multipan_addon_info.options.get("device") + + if multipan_path is not None: + device_guesses[multipan_path].append( + FirmwareInfo( + device=multipan_path, + firmware_type=ApplicationType.CPC, + firmware_version=None, + source="multiprotocol", + owners=[OwningAddon(slug=multipan_addon_manager.addon_slug)], ) + ) + + # Z2M can be provided by one of many add-ons, we match them by name + for app_info in get_apps_list(hass) or []: + slug = app_info.get("slug") + + if not isinstance(slug, str) or Z2M_ADDON_SLUG_REGEX.fullmatch(slug) is None: + continue + + z2m_addon_manager = get_z2m_addon_manager(hass, slug) + z2m_fw_info = await get_z2m_addon_firmware_info(hass, z2m_addon_manager) + + if z2m_fw_info is not None: + device_guesses[z2m_fw_info.device].append(z2m_fw_info) return device_guesses.get(device_path, []) diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index bf3ef2663759b1..70cb55a8aff419 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -27,7 +27,10 @@ async_firmware_flashing_context, async_flash_silabs_firmware, get_otbr_addon_firmware_info, + get_z2m_addon_firmware_info, + get_z2m_addon_manager, guess_firmware_info, + guess_hardware_owners, probe_silabs_firmware_info, probe_silabs_firmware_type, ) @@ -68,6 +71,10 @@ version=4, ) +TEST_Z2M_ADDON_SLUG_1 = "486e6e9b_zigbee2mqtt" +TEST_Z2M_ADDON_SLUG_2 = "45df7312_zigbee2mqtt_edge" +TEST_Z2M_ADDON_SLUG_3 = "b0107004_zigbee2mqtt" + async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" @@ -167,6 +174,185 @@ async def mock_otbr_async_get_firmware_info( ) == otbr_firmware_info1 +async def test_guess_hardware_owners_z2m( + hass: HomeAssistant, +) -> None: + """Test fetching adapter info for a complex Z2M scenario.""" + await async_setup_component(hass, DOMAIN, {}) + + multipan_addon_manager = AsyncMock(spec_set=AddonManager) + multipan_addon_manager.async_get_addon_info.side_effect = AddonError() + + async def mock_z2m_firmware_info( + hass: HomeAssistant, z2m_addon_manager: AddonManager + ) -> FirmwareInfo | None: + slug = z2m_addon_manager.addon_slug + if slug == TEST_Z2M_ADDON_SLUG_1: + return FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source=f"zigbee2mqtt ({TEST_Z2M_ADDON_SLUG_1})", + owners=[OwningAddon(slug=TEST_Z2M_ADDON_SLUG_1)], + ) + if slug == TEST_Z2M_ADDON_SLUG_2: + return FirmwareInfo( + device="/dev/ttyUSB2", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source=f"zigbee2mqtt ({TEST_Z2M_ADDON_SLUG_2})", + owners=[OwningAddon(slug=TEST_Z2M_ADDON_SLUG_2)], + ) + return None + + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", + return_value=AsyncMock(spec_set=AddonManager), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_firmware_info", + return_value=None, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", + return_value=multipan_addon_manager, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_apps_list", + return_value=[ + {"slug": TEST_Z2M_ADDON_SLUG_1, "state": "started"}, + {"slug": TEST_Z2M_ADDON_SLUG_2, "state": "stopped"}, + {"slug": TEST_Z2M_ADDON_SLUG_3, "state": "stopped"}, + {"slug": "unrelated_addon", "state": "started"}, + ], + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_z2m_addon_manager", + side_effect=lambda hass, slug: Mock(addon_slug=slug), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_z2m_addon_firmware_info", + side_effect=mock_z2m_firmware_info, + ), + ): + assert (await guess_hardware_owners(hass, "/dev/ttyUSB1")) == [ + FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source=f"zigbee2mqtt ({TEST_Z2M_ADDON_SLUG_1})", + owners=[OwningAddon(slug=TEST_Z2M_ADDON_SLUG_1)], + ) + ] + assert (await guess_hardware_owners(hass, "/dev/ttyUSB2")) == [ + FirmwareInfo( + device="/dev/ttyUSB2", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source=f"zigbee2mqtt ({TEST_Z2M_ADDON_SLUG_2})", + owners=[OwningAddon(slug=TEST_Z2M_ADDON_SLUG_2)], + ) + ] + assert (await guess_hardware_owners(hass, "/dev/ttyUSB3")) == [] + + +async def test_guess_hardware_owners_otbr(hass: HomeAssistant) -> None: + """Test OTBR addon detection in guess_hardware_owners.""" + await async_setup_component(hass, DOMAIN, {}) + + otbr_addon_fw_info = FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[OwningAddon(slug="core_openthread_border_router")], + ) + + multipan_addon_manager = AsyncMock(spec_set=AddonManager) + multipan_addon_manager.async_get_addon_info.side_effect = AddonError() + + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", + return_value=AsyncMock(spec_set=AddonManager), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_firmware_info", + return_value=otbr_addon_fw_info, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", + return_value=multipan_addon_manager, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_apps_list", + return_value=[], + ), + ): + assert (await guess_hardware_owners(hass, "/dev/ttyUSB1")) == [ + otbr_addon_fw_info, + ] + + +async def test_guess_hardware_owners_multipan(hass: HomeAssistant) -> None: + """Test multiprotocol addon detection in guess_hardware_owners.""" + await async_setup_component(hass, DOMAIN, {}) + + multipan_addon_manager = Mock(spec=AddonManager) + multipan_addon_manager.addon_slug = "core_silabs_multiprotocol" + multipan_addon_manager.async_get_addon_info = AsyncMock( + return_value=AddonInfo( + available=True, + hostname="core_silabs_multiprotocol", + options={"device": "/dev/ttyUSB1"}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + ) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", + return_value=AsyncMock(spec_set=AddonManager), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_firmware_info", + return_value=None, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", + return_value=multipan_addon_manager, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_apps_list", + return_value=[], + ), + ): + assert (await guess_hardware_owners(hass, "/dev/ttyUSB1")) == [ + FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.CPC, + firmware_version=None, + source="multiprotocol", + owners=[OwningAddon(slug="core_silabs_multiprotocol")], + ), + ] + + async def test_owning_addon(hass: HomeAssistant) -> None: """Test `OwningAddon`.""" @@ -445,6 +631,99 @@ async def test_get_otbr_addon_firmware_info_failure_bad_options( assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None +async def test_get_z2m_addon_manager( + hass: HomeAssistant, supervisor_client: AsyncMock +) -> None: + """Test getting the Z2M addon manager.""" + manager = get_z2m_addon_manager(hass, "abc123_zigbee2mqtt") + assert manager.addon_slug == "abc123_zigbee2mqtt" + + +async def test_get_z2m_addon_firmware_info(hass: HomeAssistant) -> None: + """Test getting Z2M addon firmware info.""" + z2m_addon_manager = Mock(spec=AddonManager) + z2m_addon_manager.addon_slug = "abc123_zigbee2mqtt" + + z2m_addon_manager.async_get_addon_info = AsyncMock( + return_value=AddonInfo( + available=True, + hostname="core_abc123_zigbee2mqtt", + options={"serial": {"port": "/dev/ttyUSB0"}}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + ) + assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) == FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zigbee2mqtt (abc123_zigbee2mqtt)", + owners=[OwningAddon(slug="abc123_zigbee2mqtt")], + ) + + +async def test_get_z2m_addon_firmware_info_not_installed(hass: HomeAssistant) -> None: + """Test getting Z2M addon firmware info, addon not installed.""" + z2m_addon_manager = Mock(spec=AddonManager) + z2m_addon_manager.addon_slug = "abc123_zigbee2mqtt" + + z2m_addon_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname=None, + options={}, + state=AddonState.NOT_INSTALLED, + update_available=False, + version=None, + ) + assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) is None + + +async def test_get_z2m_addon_firmware_info_failure(hass: HomeAssistant) -> None: + """Test getting Z2M addon firmware info failure due to bad API call.""" + + z2m_addon_manager = AsyncMock(spec_set=AddonManager) + z2m_addon_manager.async_get_addon_info.side_effect = AddonError() + + assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) is None + + +async def test_get_z2m_addon_firmware_info_failure_bad_options( + hass: HomeAssistant, +) -> None: + """Test getting Z2M addon firmware info failure due to bad addon options.""" + + z2m_addon_manager = AsyncMock(spec_set=AddonManager) + z2m_addon_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, # `serial` is missing + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + + assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) is None + + +async def test_get_z2m_addon_firmware_info_failure_serial_not_dict( + hass: HomeAssistant, +) -> None: + """Test getting Z2M addon firmware info failure when serial is not a dict.""" + + z2m_addon_manager = AsyncMock(spec_set=AddonManager) + z2m_addon_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={"serial": "/dev/ttyUSB0"}, # `serial` is a string, not a dict + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + + assert (await get_z2m_addon_firmware_info(hass, z2m_addon_manager)) is None + + @pytest.mark.parametrize( ("app_type", "firmware_version", "expected_fw_info"), [ From ba09a54a37c71b31d36ded0c781f2ef5ce027fea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:19:02 +0200 Subject: [PATCH 0742/1707] Use runtime_data in tradfri integration (#167896) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/tradfri/__init__.py | 39 ++++++------------- homeassistant/components/tradfri/const.py | 4 -- .../components/tradfri/coordinator.py | 21 +++++++++- homeassistant/components/tradfri/cover.py | 14 +++---- .../components/tradfri/diagnostics.py | 11 +++--- homeassistant/components/tradfri/fan.py | 14 +++---- homeassistant/components/tradfri/light.py | 14 +++---- homeassistant/components/tradfri/sensor.py | 20 +++------- homeassistant/components/tradfri/switch.py | 14 +++---- 9 files changed, 65 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index c3e8938b2449a5..c7712d707c9c76 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Any from pytradfri import Gateway, RequestError from pytradfri.api.aiocoap_api import APIFactory @@ -21,18 +20,12 @@ ) from homeassistant.helpers.event import async_track_time_interval -from .const import ( - CONF_GATEWAY_ID, - CONF_IDENTITY, - CONF_KEY, - COORDINATOR, - COORDINATOR_LIST, - DOMAIN, - FACTORY, - KEY_API, - LOGGER, +from .const import CONF_GATEWAY_ID, CONF_IDENTITY, CONF_KEY, DOMAIN, LOGGER +from .coordinator import ( + TradfriConfigEntry, + TradfriData, + TradfriDeviceDataUpdateCoordinator, ) -from .coordinator import TradfriDeviceDataUpdateCoordinator PLATFORMS = [ Platform.COVER, @@ -47,18 +40,14 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TradfriConfigEntry, ) -> bool: """Create a gateway.""" - tradfri_data: dict[str, Any] = {} - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = tradfri_data - factory = await APIFactory.init( entry.data[CONF_HOST], psk_id=entry.data[CONF_IDENTITY], psk=entry.data[CONF_KEY], ) - tradfri_data[FACTORY] = factory # Used for async_unload_entry async def on_hass_stop(event: Event) -> None: """Close connection when hass stops.""" @@ -98,11 +87,7 @@ async def on_hass_stop(event: Event) -> None: remove_stale_devices(hass, entry, devices) # Setup the device coordinators - coordinator_data = { - CONF_GATEWAY_ID: gateway, - KEY_API: api, - COORDINATOR_LIST: [], - } + tradfri_data = TradfriData(factory=factory, gateway=gateway, api=api) for device in devices: coordinator = TradfriDeviceDataUpdateCoordinator( @@ -113,9 +98,9 @@ async def on_hass_stop(event: Event) -> None: entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_GW, coordinator.set_hub_available) ) - coordinator_data[COORDINATOR_LIST].append(coordinator) + tradfri_data.coordinator_list.append(coordinator) - tradfri_data[COORDINATOR] = coordinator_data + entry.runtime_data = tradfri_data async def async_keep_alive(now: datetime) -> None: if hass.is_stopping: @@ -139,13 +124,11 @@ async def async_keep_alive(now: datetime) -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TradfriConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - tradfri_data = hass.data[DOMAIN].pop(entry.entry_id) - factory = tradfri_data[FACTORY] - await factory.shutdown() + await entry.runtime_data.factory.shutdown() return unload_ok diff --git a/homeassistant/components/tradfri/const.py b/homeassistant/components/tradfri/const.py index e42bb6f5f4d4b7..9a9da766baf751 100644 --- a/homeassistant/components/tradfri/const.py +++ b/homeassistant/components/tradfri/const.py @@ -7,8 +7,4 @@ CONF_GATEWAY_ID = "gateway_id" CONF_IDENTITY = "identity" CONF_KEY = "key" -COORDINATOR = "coordinator" -COORDINATOR_LIST = "coordinator_list" DOMAIN = "tradfri" -FACTORY = "tradfri_factory" -KEY_API = "tradfri_api" diff --git a/homeassistant/components/tradfri/coordinator.py b/homeassistant/components/tradfri/coordinator.py index 4c5c186626e5fc..2a760192eccb7f 100644 --- a/homeassistant/components/tradfri/coordinator.py +++ b/homeassistant/components/tradfri/coordinator.py @@ -3,9 +3,12 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass, field from datetime import timedelta from typing import Any +from pytradfri import Gateway +from pytradfri.api.aiocoap_api import APIFactory from pytradfri.command import Command from pytradfri.device import Device from pytradfri.error import RequestError @@ -18,16 +21,30 @@ SCAN_INTERVAL = 60 # Interval for updating the coordinator +type TradfriConfigEntry = ConfigEntry[TradfriData] + + +@dataclass +class TradfriData: + """Runtime data for a Tradfri config entry.""" + + factory: APIFactory + gateway: Gateway + api: Callable[[Command | list[Command]], Any] + coordinator_list: list[TradfriDeviceDataUpdateCoordinator] = field( + default_factory=list + ) + class TradfriDeviceDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Coordinator to manage data for a specific Tradfri device.""" - config_entry: ConfigEntry + config_entry: TradfriConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, api: Callable[[Command | list[Command]], Any], device: Device, ) -> None: diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index b1fb9b153ad5c3..88d832035bb801 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -8,32 +8,30 @@ from pytradfri.command import Command from homeassistant.components.cover import ATTR_POSITION, CoverEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri covers based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data async_add_entities( TradfriCover( device_coordinator, - api, + tradfri_data.api, gateway_id, ) - for device_coordinator in coordinator_data[COORDINATOR_LIST] + for device_coordinator in tradfri_data.coordinator_list if device_coordinator.device.has_blind_control ) diff --git a/homeassistant/components/tradfri/diagnostics.py b/homeassistant/components/tradfri/diagnostics.py index 4d89fd0081fe23..ac684886791cb1 100644 --- a/homeassistant/components/tradfri/diagnostics.py +++ b/homeassistant/components/tradfri/diagnostics.py @@ -4,19 +4,18 @@ from typing import Any, cast -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN +from .const import CONF_GATEWAY_ID, DOMAIN +from .coordinator import TradfriConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TradfriConfigEntry ) -> dict[str, Any]: """Return diagnostics the Tradfri platform.""" - entry_data = hass.data[DOMAIN][entry.entry_id] - coordinator_data = entry_data[COORDINATOR] + tradfri_data = entry.runtime_data device_registry = dr.async_get(hass) device = cast( @@ -28,7 +27,7 @@ async def async_get_config_entry_diagnostics( device_data: list = [ coordinator.device.device_info.model_number - for coordinator in coordinator_data[COORDINATOR_LIST] + for coordinator in tradfri_data.coordinator_list ] return { diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index e8fb7c050ede7c..d872f3017421b8 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -8,12 +8,11 @@ from pytradfri.command import Command from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity ATTR_AUTO = "Auto" @@ -32,21 +31,20 @@ def _from_fan_speed(fan_speed: int) -> int: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data async_add_entities( TradfriAirPurifierFan( device_coordinator, - api, + tradfri_data.api, gateway_id, ) - for device_coordinator in coordinator_data[COORDINATOR_LIST] + for device_coordinator in tradfri_data.coordinator_list if device_coordinator.device.has_air_purifier_control ) diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index 1aab244888ab41..7be436b17c549e 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -17,33 +17,31 @@ LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri lights based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data async_add_entities( TradfriLight( device_coordinator, - api, + tradfri_data.api, gateway_id, ) - for device_coordinator in coordinator_data[COORDINATOR_LIST] + for device_coordinator in tradfri_data.coordinator_list if device_coordinator.device.has_light_control ) diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index b4a7c335481280..3a305fc53eef9e 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -15,7 +15,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, @@ -26,15 +25,8 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - CONF_GATEWAY_ID, - COORDINATOR, - COORDINATOR_LIST, - DOMAIN, - KEY_API, - LOGGER, -) -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID, DOMAIN, LOGGER +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity @@ -127,17 +119,17 @@ def _migrate_old_unique_ids(hass: HomeAssistant, old_unique_id: str, key: str) - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tradfri config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data + api = tradfri_data.api entities: list[TradfriSensor] = [] - for device_coordinator in coordinator_data[COORDINATOR_LIST]: + for device_coordinator in tradfri_data.coordinator_list: if ( not device_coordinator.device.has_light_control and not device_coordinator.device.has_socket_control diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index a2a1a5b4623684..81fa9c1db4e690 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -8,32 +8,30 @@ from pytradfri.command import Command from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API -from .coordinator import TradfriDeviceDataUpdateCoordinator +from .const import CONF_GATEWAY_ID +from .coordinator import TradfriConfigEntry, TradfriDeviceDataUpdateCoordinator from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TradfriConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] - coordinator_data = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - api = coordinator_data[KEY_API] + tradfri_data = config_entry.runtime_data async_add_entities( TradfriSwitch( device_coordinator, - api, + tradfri_data.api, gateway_id, ) - for device_coordinator in coordinator_data[COORDINATOR_LIST] + for device_coordinator in tradfri_data.coordinator_list if device_coordinator.device.has_socket_control ) From a6a716571d8af312d797b6a98ea29b7db75672be Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:38:04 +0200 Subject: [PATCH 0743/1707] Use runtime_data in tesla_wall_connector (#167893) Co-authored-by: Claude Opus 4.6 (1M context) --- .../tesla_wall_connector/__init__.py | 30 ++++++++++--------- .../tesla_wall_connector/binary_sensor.py | 9 +++--- .../tesla_wall_connector/coordinator.py | 6 ++-- .../components/tesla_wall_connector/sensor.py | 9 +++--- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index f6809c4f416ce4..f55e22691f0e07 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -5,21 +5,25 @@ from tesla_wall_connector import WallConnector from tesla_wall_connector.exceptions import WallConnectorError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import WallConnectorCoordinator, WallConnectorData, get_poll_interval +from .coordinator import ( + WallConnectorConfigEntry, + WallConnectorCoordinator, + WallConnectorData, + get_poll_interval, +) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: WallConnectorConfigEntry +) -> bool: """Set up Tesla Wall Connector from a config entry.""" - hass.data.setdefault(DOMAIN, {}) hostname = entry.data[CONF_HOST] wall_connector = WallConnector(host=hostname, session=async_get_clientsession(hass)) @@ -32,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = WallConnectorCoordinator(hass, entry, hostname, wall_connector) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = WallConnectorData( + entry.runtime_data = WallConnectorData( wall_connector_client=wall_connector, hostname=hostname, part_number=version_data.part_number, @@ -48,15 +52,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: WallConnectorConfigEntry) -> None: """Handle options update.""" - wall_connector_data: WallConnectorData = hass.data[DOMAIN][entry.entry_id] - wall_connector_data.update_coordinator.update_interval = get_poll_interval(entry) + entry.runtime_data.update_coordinator.update_interval = get_poll_interval(entry) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: WallConnectorConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index a1781c8d8fb24f..7d8c681a38451f 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -8,13 +8,12 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS -from .coordinator import WallConnectorData +from .const import WALLCONNECTOR_DATA_VITALS +from .coordinator import WallConnectorConfigEntry, WallConnectorData from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin _LOGGER = logging.getLogger(__name__) @@ -47,11 +46,11 @@ class WallConnectorBinarySensorDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WallConnectorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" - wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] + wall_connector_data = config_entry.runtime_data all_entities = [ WallConnectorBinarySensorEntity(wall_connector_data, description) diff --git a/homeassistant/components/tesla_wall_connector/coordinator.py b/homeassistant/components/tesla_wall_connector/coordinator.py index bc43a0581dcfb0..8fd683ff59abc3 100644 --- a/homeassistant/components/tesla_wall_connector/coordinator.py +++ b/homeassistant/components/tesla_wall_connector/coordinator.py @@ -26,6 +26,8 @@ _LOGGER = logging.getLogger(__name__) +type WallConnectorConfigEntry = ConfigEntry[WallConnectorData] + @dataclass class WallConnectorData: @@ -49,12 +51,12 @@ def get_poll_interval(entry: ConfigEntry) -> timedelta: class WallConnectorCoordinator(DataUpdateCoordinator[dict]): """Class to manage fetching Tesla Wall Connector data.""" - config_entry: ConfigEntry + config_entry: WallConnectorConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: WallConnectorConfigEntry, hostname: str, wall_connector: WallConnector, ) -> None: diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 8a57bb7c2f48bd..7cd1059a8a2174 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -9,7 +9,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfElectricCurrent, @@ -22,8 +21,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS -from .coordinator import WallConnectorData +from .const import WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS +from .coordinator import WallConnectorConfigEntry, WallConnectorData from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin _LOGGER = logging.getLogger(__name__) @@ -196,11 +195,11 @@ class WallConnectorSensorDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WallConnectorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" - wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] + wall_connector_data = config_entry.runtime_data all_entities = [ WallConnectorSensorEntity(wall_connector_data, description) From 68a7cbb62047c5d80c85d835800df3e9a9dbd2a1 Mon Sep 17 00:00:00 2001 From: panosmz Date: Fri, 10 Apr 2026 17:48:11 +0300 Subject: [PATCH 0744/1707] Bump oasatelematics to 0.4 (#167911) --- homeassistant/components/oasa_telematics/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/oasa_telematics/manifest.json b/homeassistant/components/oasa_telematics/manifest.json index 7365081a95913f..194c481b6f1aff 100644 --- a/homeassistant/components/oasa_telematics/manifest.json +++ b/homeassistant/components/oasa_telematics/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["oasatelematics"], "quality_scale": "legacy", - "requirements": ["oasatelematics==0.3"] + "requirements": ["oasatelematics==0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1d99ccf32e8a34..b0e4721491378f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1663,7 +1663,7 @@ numpy==2.3.2 nyt_games==0.5.0 # homeassistant.components.oasa_telematics -oasatelematics==0.3 +oasatelematics==0.4 # homeassistant.components.google oauth2client==4.1.3 From 781b5e1c0e1586cf3f491c6b51e51e47ae5ec5c5 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:57:05 +0200 Subject: [PATCH 0745/1707] Bump qbusmqttapi to 1.4.3 (#167909) --- homeassistant/components/qbus/light.py | 2 +- homeassistant/components/qbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 61225f112434df..81c7a3aa21ac12 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -79,7 +79,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self._async_publish_output_state(state) async def _handle_state_received(self, state: QbusMqttAnalogState) -> None: - percentage = round(state.read_percentage()) + percentage = round(state.read_percentage() or 0) self._set_state(percentage) def _set_state(self, percentage: int) -> None: diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index 15392f6cc97cf5..c14a46eae118c7 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -14,5 +14,5 @@ "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.4.2"] + "requirements": ["qbusmqttapi==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0e4721491378f..1a7261fc5a7b86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2784,7 +2784,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.4.2 +qbusmqttapi==1.4.3 # homeassistant.components.qingping qingping-ble==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91a8962986478d..98ceb1f520bd31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2368,7 +2368,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.4.2 +qbusmqttapi==1.4.3 # homeassistant.components.qingping qingping-ble==1.1.0 From f2f605b425bfc5258b636787cf08a940c1e00504 Mon Sep 17 00:00:00 2001 From: Andrew Brainwood Date: Sat, 11 Apr 2026 01:00:25 +1000 Subject: [PATCH 0746/1707] Add Preset button support for Bond cover devices (#167881) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Joost Lekkerkerker Co-authored-by: Erwin Douna --- homeassistant/components/bond/button.py | 10 +++++ tests/components/bond/test_button.py | 50 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 9cea0251b413ca..af5438599661e9 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -260,6 +260,14 @@ class BondButtonEntityDescription(ButtonEntityDescription): ), ) +PRESET_BUTTON = BondButtonEntityDescription( + key=Action.PRESET, + name="Preset", + translation_key="preset", + mutually_exclusive=None, + argument=None, +) + async def async_setup_entry( hass: HomeAssistant, @@ -285,6 +293,8 @@ async def async_setup_entry( # we only add the stop action button if we add actions # since its not so useful if there are no actions to stop device_entities.append(BondButtonEntity(data, device, STOP_BUTTON)) + if device.has_action(PRESET_BUTTON.key): + device_entities.append(BondButtonEntity(data, device, PRESET_BUTTON)) entities.extend(device_entities) async_add_entities(entities) diff --git a/tests/components/bond/test_button.py b/tests/components/bond/test_button.py index c14bba0d01fa3b..85295dcf031c77 100644 --- a/tests/components/bond/test_button.py +++ b/tests/components/bond/test_button.py @@ -66,6 +66,15 @@ def motorized_shade(name: str): } +def motorized_shade_with_preset(name: str): + """Create a motorized shade with preset action.""" + return { + "name": name, + "type": DeviceType.MOTORIZED_SHADES, + "actions": [Action.OPEN, Action.CLOSE, Action.HOLD, Action.PRESET, Action.STOP], + } + + async def test_entity_registry( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -224,3 +233,44 @@ async def test_motorized_shade_actions(hass: HomeAssistant) -> None: await hass.async_block_till_done() mock_action.assert_called_once_with("test-device-id", Action(Action.CLOSE_NEXT)) + + +async def test_preset_button(hass: HomeAssistant) -> None: + """Tests preset button is created and can be pressed.""" + await setup_platform( + hass, + BUTTON_DOMAIN, + motorized_shade_with_preset("name-1"), + bond_device_id="test-device-id", + ) + + assert hass.states.get("button.name_1_preset") + + with patch_bond_action() as mock_action, patch_bond_device_state(): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.name_1_preset"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_action.assert_called_once_with("test-device-id", Action(Action.PRESET)) + + +async def test_preset_does_not_trigger_stop_button(hass: HomeAssistant) -> None: + """Tests that Preset alone does not cause a Stop Actions button to appear. + + Preset is added independently of the main button list, so it should + not trigger the Stop button logic (which only activates when there + are other button entities). + """ + await setup_platform( + hass, + BUTTON_DOMAIN, + motorized_shade_with_preset("name-1"), + bond_device_id="test-device-id", + ) + + assert hass.states.get("button.name_1_preset") + assert not hass.states.get("button.name_1_stop_actions") From 547830b4503d0b73bb924b390d35053d497967b1 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:00:39 +0300 Subject: [PATCH 0747/1707] Victron GX switch platform (#167859) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/victron_gx/__init__.py | 1 + .../components/victron_gx/binary_sensor.py | 8 +- .../components/victron_gx/strings.json | 65 ++++++++++ homeassistant/components/victron_gx/switch.py | 80 +++++++++++++ .../victron_gx/test_binary_sensor.py | 4 +- tests/components/victron_gx/test_switch.py | 113 ++++++++++++++++++ 6 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/victron_gx/switch.py create mode 100644 tests/components/victron_gx/test_switch.py diff --git a/homeassistant/components/victron_gx/__init__.py b/homeassistant/components/victron_gx/__init__.py index 06a59e8245b7f9..f29851e6e94f37 100644 --- a/homeassistant/components/victron_gx/__init__.py +++ b/homeassistant/components/victron_gx/__init__.py @@ -15,6 +15,7 @@ Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/victron_gx/binary_sensor.py b/homeassistant/components/victron_gx/binary_sensor.py index 72af11eeaa0792..42caed422f4c32 100644 --- a/homeassistant/components/victron_gx/binary_sensor.py +++ b/homeassistant/components/victron_gx/binary_sensor.py @@ -66,16 +66,16 @@ def __init__( """Initialize the binary sensor.""" super().__init__(device, metric, device_info, installation_id) self._attr_device_class = METRIC_TYPE_TO_DEVICE_CLASS.get(metric.metric_type) - self._attr_is_on = self._is_on(metric.value) + self._attr_is_on = self.convert_metric_value_to_is_on(metric.value) @callback def _on_update_cb(self, value: Any) -> None: - self._attr_is_on = self._is_on(value) + self._attr_is_on = self.convert_metric_value_to_is_on(value) self.async_write_ha_state() @staticmethod - def _is_on(value: Any) -> bool | None: - """Convert a Victron binary sensor enum value to a boolean.""" + def convert_metric_value_to_is_on(value: Any) -> bool | None: + """Convert a Victron on/off enum value to a boolean.""" if value is None or not isinstance(value, VictronEnum): return None if value.id == BINARY_SENSOR_ON_ID: diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index c6a6cf463c6623..1e5f937b3a92a7 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -1765,6 +1765,71 @@ "sustain_alt": "Sustain Alt" } } + }, + "switch": { + "digitalinput_settings_invert_translation": { + "name": "Invert digital input" + }, + "evcharger_charge": { + "name": "EV charging" + }, + "generator_autorun": { + "name": "Auto-start enabled" + }, + "generator_gen_id_quiet_hours_enabled": { + "name": "Generator quiet hours enabled" + }, + "generator_gen_id_start_on_soc_enabled": { + "name": "Generator start on SoC enabled" + }, + "generator_gen_id_start_on_temp_enabled": { + "name": "Generator start on high temp enabled" + }, + "generator_gen_id_start_on_voltage_enabled": { + "name": "Generator start on voltage enabled" + }, + "generator_manual_start": { + "name": "Manual start" + }, + "multi_disable_charge": { + "name": "ESS disable charge" + }, + "multi_disable_feed_in": { + "name": "ESS disable feed-in" + }, + "multi_relay0_state": { + "name": "Relay on Multi RS state" + }, + "solarcharger_relay_state": { + "name": "Relay state" + }, + "switch_output_state": { + "name": "Switch {output} state" + }, + "switchable_output_output_state": { + "name": "Switchable output {output} state" + }, + "system_ess_battery_use": { + "name": "ESS only critical loads from battery" + }, + "system_ess_schedule_charge_slot_enabled": { + "name": "ESS BatteryLife schedule charge {slot} enabled" + }, + "system_relay_relay": { + "name": "Relay {relay} state" + }, + "system_settings_overvoltage_feedin": { + "name": "PV DC overvoltage feed-in" + }, + "vebus_device_device_number_power_assist_enabled": { + "name": "{device_number} PowerAssist enabled" + }, + "vebus_inverter_ignoreacin1_onoff_control": { + "name": "Control ignore AC-in-1" + }, + "vebus_inverter_setting_alarm_grid_lost": { + "name": "Grid lost alarm setting" + } } } } diff --git a/homeassistant/components/victron_gx/switch.py b/homeassistant/components/victron_gx/switch.py new file mode 100644 index 00000000000000..5160e86a1ce92a --- /dev/null +++ b/homeassistant/components/victron_gx/switch.py @@ -0,0 +1,80 @@ +"""Support for Victron GX switches.""" + +from typing import TYPE_CHECKING, Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + WritableMetric as VictronVenusWritableMetric, +) + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .binary_sensor import VictronBinarySensor +from .const import BINARY_SENSOR_OFF_ID, BINARY_SENSOR_ON_ID +from .entity import VictronBaseEntity +from .hub import VictronGxConfigEntry + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX switches from a config entry.""" + hub = config_entry.runtime_data + + def on_new_metric( + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Handle new switch metric discovery.""" + if TYPE_CHECKING: + assert isinstance(metric, VictronVenusWritableMetric) + async_add_entities( + [VictronSwitch(device, metric, device_info, installation_id)] + ) + + hub.register_new_metric_callback(MetricKind.SWITCH, on_new_metric) + + +class VictronSwitch(VictronBaseEntity, SwitchEntity): + """Implementation of a Victron GX switch.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusWritableMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the switch.""" + super().__init__(device, metric, device_info, installation_id) + self._attr_is_on = VictronBinarySensor.convert_metric_value_to_is_on( + metric.value + ) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_is_on = VictronBinarySensor.convert_metric_value_to_is_on(value) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + if TYPE_CHECKING: + assert isinstance(self._metric, VictronVenusWritableMetric) + self._metric.set(BINARY_SENSOR_ON_ID) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + if TYPE_CHECKING: + assert isinstance(self._metric, VictronVenusWritableMetric) + self._metric.set(BINARY_SENSOR_OFF_ID) diff --git a/tests/components/victron_gx/test_binary_sensor.py b/tests/components/victron_gx/test_binary_sensor.py index ee3eb2b5673f24..dd8819a1d81021 100644 --- a/tests/components/victron_gx/test_binary_sensor.py +++ b/tests/components/victron_gx/test_binary_sensor.py @@ -80,5 +80,5 @@ async def test_victron_binary_sensor( ], ) def test_is_on_edge_cases(value: object, expected: bool | None) -> None: - """Test _is_on returns None for non-VictronEnum and unknown enum IDs.""" - assert VictronBinarySensor._is_on(value) is expected + """Test convert_metric_value_to_is_on returns None for unknown values.""" + assert VictronBinarySensor.convert_metric_value_to_is_on(value) is expected diff --git a/tests/components/victron_gx/test_switch.py b/tests/components/victron_gx/test_switch.py new file mode 100644 index 00000000000000..02ef2fb28e3b7e --- /dev/null +++ b/tests/components/victron_gx/test_switch.py @@ -0,0 +1,113 @@ +"""Tests for Victron GX MQTT switches.""" + +from __future__ import annotations + +from victron_mqtt import Hub as VictronVenusHub +from victron_mqtt.testing import finalize_injection, inject_message + +from homeassistant.components.victron_gx.const import ( + BINARY_SENSOR_OFF_ID, + BINARY_SENSOR_ON_ID, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import MOCK_INSTALLATION_ID + +from tests.common import MockConfigEntry + + +async def test_victron_switch( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test SWITCH MetricKind - EV charger charge switch is created and updated.""" + victron_hub, mock_config_entry = init_integration + + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/evcharger/0/StartStop", + '{"value": 1}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert len(entities) == 1 + entity = entities[0] + assert entity.entity_id == "switch.ev_charging_station_ev_charging" + assert entity.unique_id == f"{MOCK_INSTALLATION_ID}_evcharger_0_evcharger_charge" + assert entity.translation_key == "evcharger_charge" + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == BINARY_SENSOR_ON_ID + + # Verify device info was registered correctly + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{MOCK_INSTALLATION_ID}_evcharger_0")} + ) + assert device is not None + assert device.manufacturer == "Victron Energy" + + # Update the metric to exercise the entity update callback path. + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/evcharger/0/StartStop", + '{"value": 0}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == BINARY_SENSOR_OFF_ID + + +async def test_victron_switch_actions( + hass: HomeAssistant, + init_integration: tuple[VictronVenusHub, MockConfigEntry], + entity_registry: er.EntityRegistry, +) -> None: + """Test switch turn_on and turn_off service calls update the entity state.""" + victron_hub, mock_config_entry = init_integration + + await inject_message( + victron_hub, + f"N/{MOCK_INSTALLATION_ID}/evcharger/0/StartStop", + '{"value": 0}', + ) + await finalize_injection(victron_hub) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + entity_id = entities[0].entity_id + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == BINARY_SENSOR_OFF_ID + + # Call turn_on and turn_off services and assert they update entity state + await hass.services.async_call( + "switch", "turn_on", {"entity_id": entity_id}, blocking=True + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state is not None + assert state.state == BINARY_SENSOR_ON_ID + + await hass.services.async_call( + "switch", "turn_off", {"entity_id": entity_id}, blocking=True + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state is not None + assert state.state == BINARY_SENSOR_OFF_ID From 97d64ab37cd2848df8034ca5e693aeced010592a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 10 Apr 2026 17:02:51 +0200 Subject: [PATCH 0748/1707] Bump zinvolt to 0.4.3 (#167908) --- homeassistant/components/zinvolt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zinvolt/manifest.json b/homeassistant/components/zinvolt/manifest.json index 53e7b74ed004d6..a73f18e6c80b57 100644 --- a/homeassistant/components/zinvolt/manifest.json +++ b/homeassistant/components/zinvolt/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["zinvolt"], "quality_scale": "bronze", - "requirements": ["zinvolt==0.4.1"] + "requirements": ["zinvolt==0.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a7261fc5a7b86..d80b18a56cca7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3395,7 +3395,7 @@ zhong-hong-hvac==1.0.13 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zinvolt -zinvolt==0.4.1 +zinvolt==0.4.3 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98ceb1f520bd31..a8ed2a5957f211 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2871,7 +2871,7 @@ zeversolar==0.3.2 zha==1.1.2 # homeassistant.components.zinvolt -zinvolt==0.4.1 +zinvolt==0.4.3 # homeassistant.components.zoneminder zm-py==0.5.4 From 9e111b24186b16117137a1b79cd65975cf4974bb Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:07:07 -0500 Subject: [PATCH 0749/1707] Bump aiorussound to 5.0.0 (#167914) --- homeassistant/components/russound_rio/__init__.py | 11 ++++++----- homeassistant/components/russound_rio/config_flow.py | 7 ++++--- homeassistant/components/russound_rio/entity.py | 8 ++++---- homeassistant/components/russound_rio/manifest.json | 2 +- .../components/russound_rio/media_browser.py | 6 +++--- homeassistant/components/russound_rio/media_player.py | 4 ++-- homeassistant/components/russound_rio/number.py | 2 +- homeassistant/components/russound_rio/select.py | 4 ++-- homeassistant/components/russound_rio/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/russound_rio/__init__.py | 2 +- tests/components/russound_rio/conftest.py | 9 +++++---- tests/components/russound_rio/test_init.py | 2 +- tests/components/russound_rio/test_media_player.py | 2 +- tests/components/russound_rio/test_select.py | 2 +- 16 files changed, 35 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index c4dd54644ac702..aef922cbe133ae 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -2,8 +2,9 @@ import logging -from aiorussound import RussoundClient, RussoundTcpConnectionHandler -from aiorussound.models import CallbackType +from aiorussound import RussoundTcpConnectionHandler +from aiorussound.rio import RussoundRIOClient +from aiorussound.rio.models import CallbackType from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform @@ -18,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) -type RussoundConfigEntry = ConfigEntry[RussoundClient] +type RussoundConfigEntry = ConfigEntry[RussoundRIOClient] async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> bool: @@ -26,10 +27,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] - client = RussoundClient(RussoundTcpConnectionHandler(host, port)) + client = RussoundRIOClient(RussoundTcpConnectionHandler(host, port)) async def _connection_update_callback( - _client: RussoundClient, _callback_type: CallbackType + _client: RussoundRIOClient, _callback_type: CallbackType ) -> None: """Call when the device is notified of changes.""" if _callback_type == CallbackType.CONNECTION: diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index edf542b5de2561..ef8ede0f57023a 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -5,7 +5,8 @@ import logging from typing import Any -from aiorussound import RussoundClient, RussoundTcpConnectionHandler +from aiorussound import RussoundTcpConnectionHandler +from aiorussound.rio import RussoundRIOClient import voluptuous as vol from homeassistant.config_entries import ( @@ -45,7 +46,7 @@ async def async_step_zeroconf( self.data[CONF_HOST] = host = discovery_info.host self.data[CONF_PORT] = port = discovery_info.port or 9621 - client = RussoundClient(RussoundTcpConnectionHandler(host, port)) + client = RussoundRIOClient(RussoundTcpConnectionHandler(host, port)) try: await client.connect() controller = client.controllers[1] @@ -90,7 +91,7 @@ async def async_step_user( host = user_input[CONF_HOST] port = user_input[CONF_PORT] - client = RussoundClient(RussoundTcpConnectionHandler(host, port)) + client = RussoundRIOClient(RussoundTcpConnectionHandler(host, port)) try: await client.connect() controller = client.controllers[1] diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 1fe6a7876d18e0..3a5a60512504ef 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -4,9 +4,9 @@ from functools import wraps from typing import Any, Concatenate -from aiorussound import Controller, RussoundClient -from aiorussound.models import CallbackType -from aiorussound.rio import ZoneControlSurface +from aiorussound.rio import RussoundRIOClient +from aiorussound.rio.client import Controller, ZoneControlSurface +from aiorussound.rio.models import CallbackType from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -82,7 +82,7 @@ def _zone(self) -> ZoneControlSurface: return self._controller.zones[self._zone_id] async def _state_update_callback( - self, _client: RussoundClient, _callback_type: CallbackType + self, _client: RussoundRIOClient, _callback_type: CallbackType ) -> None: """Call when the device is notified of changes.""" if _callback_type == CallbackType.CONNECTION: diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 4b55b542a72fbf..43b10eec6cb1e3 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.10.0"], + "requirements": ["aiorussound==5.0.0"], "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/russound_rio/media_browser.py b/homeassistant/components/russound_rio/media_browser.py index 49cd8dae9c47bc..a174b7319f199e 100644 --- a/homeassistant/components/russound_rio/media_browser.py +++ b/homeassistant/components/russound_rio/media_browser.py @@ -1,7 +1,7 @@ """Support for Russound media browsing.""" -from aiorussound import RussoundClient, Zone from aiorussound.const import FeatureFlag +from aiorussound.rio import RussoundRIOClient, Zone from aiorussound.util import is_feature_supported from homeassistant.components.media_player import BrowseMedia, MediaClass @@ -10,7 +10,7 @@ async def async_browse_media( hass: HomeAssistant, - client: RussoundClient, + client: RussoundRIOClient, media_content_id: str | None, media_content_type: str | None, zone: Zone, @@ -80,7 +80,7 @@ async def _presets_payload(presets_by_zone: dict[int, dict[int, str]]) -> Browse def _find_presets_by_zone( - client: RussoundClient, zone: Zone + client: RussoundRIOClient, zone: Zone ) -> dict[int, dict[int, str]]: """Returns a dict by {source_id: {preset_id: preset_name}}.""" assert client.rio_version diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index a09c663a9837a4..4dff8580f8ba1f 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -7,9 +7,9 @@ import logging from typing import TYPE_CHECKING, Any -from aiorussound import Controller from aiorussound.const import FeatureFlag -from aiorussound.models import PlayStatus, Source +from aiorussound.rio import Controller, Source +from aiorussound.rio.models import PlayStatus from aiorussound.util import is_feature_supported from homeassistant.components.media_player import ( diff --git a/homeassistant/components/russound_rio/number.py b/homeassistant/components/russound_rio/number.py index ae13815fa0a262..4027a49964b35b 100644 --- a/homeassistant/components/russound_rio/number.py +++ b/homeassistant/components/russound_rio/number.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from aiorussound.rio import Controller, ZoneControlSurface +from aiorussound.rio.client import Controller, ZoneControlSurface from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/russound_rio/select.py b/homeassistant/components/russound_rio/select.py index 6735851015e8c2..486a0cd06f7245 100644 --- a/homeassistant/components/russound_rio/select.py +++ b/homeassistant/components/russound_rio/select.py @@ -3,8 +3,8 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from aiorussound.models import PartyMode -from aiorussound.rio import Controller, ZoneControlSurface +from aiorussound.rio.client import Controller, ZoneControlSurface +from aiorussound.rio.models import PartyMode from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/russound_rio/switch.py b/homeassistant/components/russound_rio/switch.py index 20ee82ebb5bc63..7e545d4d7bc719 100644 --- a/homeassistant/components/russound_rio/switch.py +++ b/homeassistant/components/russound_rio/switch.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any -from aiorussound.rio import Controller, ZoneControlSurface +from aiorussound.rio.client import Controller, ZoneControlSurface from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory diff --git a/requirements_all.txt b/requirements_all.txt index d80b18a56cca7d..de8e03c146b176 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -392,7 +392,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.10.0 +aiorussound==5.0.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a8ed2a5957f211..0121bffc3f4d40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -377,7 +377,7 @@ aioridwell==2025.09.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.10.0 +aiorussound==5.0.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/tests/components/russound_rio/__init__.py b/tests/components/russound_rio/__init__.py index d8764285dd37e5..a18a065cad15d1 100644 --- a/tests/components/russound_rio/__init__.py +++ b/tests/components/russound_rio/__init__.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiorussound.models import CallbackType +from aiorussound.rio.models import CallbackType from homeassistant.core import HomeAssistant diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 8c1f96b5d00a8b..50400408174126 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -3,8 +3,9 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch -from aiorussound import Controller, RussoundTcpConnectionHandler, Source -from aiorussound.rio import ZoneControlSurface +from aiorussound import RussoundTcpConnectionHandler +from aiorussound.rio import Source +from aiorussound.rio.client import Controller, ZoneControlSurface from aiorussound.util import controller_device_str, zone_device_str import pytest @@ -39,10 +40,10 @@ def mock_russound_client() -> Generator[AsyncMock]: """Mock the Russound RIO client.""" with ( patch( - "homeassistant.components.russound_rio.RussoundClient", autospec=True + "homeassistant.components.russound_rio.RussoundRIOClient", autospec=True ) as mock_client, patch( - "homeassistant.components.russound_rio.config_flow.RussoundClient", + "homeassistant.components.russound_rio.config_flow.RussoundRIOClient", new=mock_client, ), ): diff --git a/tests/components/russound_rio/test_init.py b/tests/components/russound_rio/test_init.py index 935b921b069e38..6c2ca18e731ec9 100644 --- a/tests/components/russound_rio/test_init.py +++ b/tests/components/russound_rio/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, Mock -from aiorussound.models import CallbackType +from aiorussound.rio.models import CallbackType import pytest from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index d8eacd5f30bba0..4c747cf81ce3cf 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -4,7 +4,7 @@ from aiorussound.const import FeatureFlag from aiorussound.exceptions import CommandError -from aiorussound.models import PlayStatus +from aiorussound.rio.models import PlayStatus import pytest from homeassistant.components.media_player import ( diff --git a/tests/components/russound_rio/test_select.py b/tests/components/russound_rio/test_select.py index c8b957c74b30f0..d478361328c715 100644 --- a/tests/components/russound_rio/test_select.py +++ b/tests/components/russound_rio/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from aiorussound.models import PartyMode +from aiorussound.rio.models import PartyMode import pytest from syrupy.assertion import SnapshotAssertion From 64907ad7e28a6d69aa280dde9effddb64563ae9b Mon Sep 17 00:00:00 2001 From: Alex Merkel Date: Fri, 10 Apr 2026 17:10:18 +0200 Subject: [PATCH 0750/1707] [LG Soundbar] Fix incorrect state for some models (#167094) --- homeassistant/components/lg_soundbar/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index f440e0ba4adcbe..fd64eebff6745a 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -41,7 +41,7 @@ class LGDevice(MediaPlayerEntity): """Representation of an LG soundbar device.""" _attr_should_poll = False - _attr_state = MediaPlayerState.OFF + _attr_state = MediaPlayerState.ON # Default to ON to ensure compatibility with models that don't send a powerstatus message _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE From cf87e9ab72a6d4b7c61886c1183ea4ae462bfd5b Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Fri, 10 Apr 2026 08:15:11 -0700 Subject: [PATCH 0751/1707] iaqualink: move custom update logic to DataUpdateCoordinator (#167816) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/iaqualink/__init__.py | 53 +-- .../components/iaqualink/binary_sensor.py | 16 +- homeassistant/components/iaqualink/climate.py | 13 +- .../components/iaqualink/coordinator.py | 45 +++ homeassistant/components/iaqualink/entity.py | 35 +- homeassistant/components/iaqualink/light.py | 13 +- homeassistant/components/iaqualink/sensor.py | 13 +- homeassistant/components/iaqualink/switch.py | 13 +- .../components/iaqualink/test_config_flow.py | 18 +- tests/components/iaqualink/test_init.py | 309 ++++++++++++++---- 10 files changed, 382 insertions(+), 146 deletions(-) create mode 100644 homeassistant/components/iaqualink/coordinator.py diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 1647e880d1107e..a5658388e3af7d 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -4,7 +4,6 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from datetime import datetime from functools import wraps import logging from typing import Any, Concatenate @@ -32,12 +31,11 @@ ConfigEntryNotReady, ) from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.httpx_client import get_async_client from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 -from .const import DOMAIN, UPDATE_INTERVAL +from .const import DOMAIN +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity _LOGGER = logging.getLogger(__name__) @@ -61,6 +59,7 @@ class AqualinkRuntimeData: """Runtime data for Aqualink.""" client: AqualinkClient + coordinators: dict[str, AqualinkDataUpdateCoordinator] # These will contain the initialized devices binary_sensors: list[AqualinkBinarySensor] lights: list[AqualinkLight] @@ -100,15 +99,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> f"Error while attempting to retrieve systems list: {svc_exception}" ) from svc_exception - systems = list(systems.values()) - if not systems: + systems_list = list(systems.values()) + if not systems_list: await aqualink.close() raise ConfigEntryError("No systems detected or supported") runtime_data = AqualinkRuntimeData( - aqualink, binary_sensors=[], lights=[], sensors=[], switches=[], thermostats=[] + aqualink, + coordinators={}, + binary_sensors=[], + lights=[], + sensors=[], + switches=[], + thermostats=[], ) - for system in systems: + for system in systems_list: + coordinator = AqualinkDataUpdateCoordinator(hass, entry, system) + runtime_data.coordinators[system.serial] = coordinator + await coordinator.async_config_entry_first_refresh() + try: devices = await system.get_devices() except AqualinkServiceException as svc_exception: @@ -158,32 +167,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def _async_systems_update(_: datetime) -> None: - """Refresh internal state for all systems.""" - for system in systems: - prev = system.online - - try: - await system.update() - except (AqualinkServiceException, httpx.HTTPError) as svc_exception: - if prev is not None: - _LOGGER.warning( - "Failed to refresh system %s state: %s", - system.serial, - svc_exception, - ) - await system.aqualink.close() - else: - cur = system.online - if cur and not prev: - _LOGGER.warning("System %s reconnected to iAqualink", system.serial) - - async_dispatcher_send(hass, DOMAIN) - - entry.async_on_unload( - async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) - ) - return True @@ -204,6 +187,6 @@ async def wrapper( ) -> None: """Call decorated function and send update signal to all entities.""" await func(self, *args, **kwargs) - async_dispatcher_send(self.hass, DOMAIN) + self.coordinator.async_update_listeners() return wrapper diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 3c260c7ef037ac..d178a83098b37c 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -24,11 +25,10 @@ async def async_setup_entry( ) -> None: """Set up discovered binary sensors.""" async_add_entities( - ( - HassAqualinkBinarySensor(dev) - for dev in config_entry.runtime_data.binary_sensors - ), - True, + HassAqualinkBinarySensor( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.binary_sensors ) @@ -37,9 +37,11 @@ class HassAqualinkBinarySensor( ): """Representation of a binary sensor.""" - def __init__(self, dev: AqualinkBinarySensor) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkBinarySensor + ) -> None: """Initialize AquaLink binary sensor.""" - super().__init__(dev) + super().__init__(coordinator, dev) self._attr_name = dev.label if dev.label == "Freeze Protection": self._attr_device_class = BinarySensorDeviceClass.COLD diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 36aec12976acfb..d9976425618060 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry, refresh_system +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity from .utils import await_or_reraise @@ -34,8 +35,10 @@ async def async_setup_entry( ) -> None: """Set up discovered switches.""" async_add_entities( - (HassAqualinkThermostat(dev) for dev in config_entry.runtime_data.thermostats), - True, + HassAqualinkThermostat( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.thermostats ) @@ -49,9 +52,11 @@ class HassAqualinkThermostat(AqualinkEntity[AqualinkThermostat], ClimateEntity): | ClimateEntityFeature.TURN_ON ) - def __init__(self, dev: AqualinkThermostat) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkThermostat + ) -> None: """Initialize AquaLink thermostat.""" - super().__init__(dev) + super().__init__(coordinator, dev) self._attr_name = dev.label.split(" ")[0] self._attr_temperature_unit = ( UnitOfTemperature.FAHRENHEIT diff --git a/homeassistant/components/iaqualink/coordinator.py b/homeassistant/components/iaqualink/coordinator.py new file mode 100644 index 00000000000000..eb62ea589da1ec --- /dev/null +++ b/homeassistant/components/iaqualink/coordinator.py @@ -0,0 +1,45 @@ +"""Data update coordinator for iaqualink.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx +from iaqualink.exception import AqualinkServiceException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Data coordinator for Aqualink systems.""" + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, system: Any + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{system.serial}", + update_interval=UPDATE_INTERVAL, + ) + self.system = system + + async def _async_update_data(self) -> None: + """Refresh internal state for a system.""" + try: + await self.system.update() + except (AqualinkServiceException, httpx.HTTPError) as err: + raise UpdateFailed( + f"Unable to update iAqualink system {self.system.serial}: {err}" + ) from err + if self.system.online is not True: + raise UpdateFailed(f"iAqualink system {self.system.serial} is offline") diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py index c0f44946b7716b..da8089cd3191fd 100644 --- a/homeassistant/components/iaqualink/entity.py +++ b/homeassistant/components/iaqualink/entity.py @@ -5,26 +5,28 @@ from iaqualink.device import AqualinkDevice from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import AqualinkDataUpdateCoordinator -class AqualinkEntity[AqualinkDeviceT: AqualinkDevice](Entity): +class AqualinkEntity[AqualinkDeviceT: AqualinkDevice]( + CoordinatorEntity[AqualinkDataUpdateCoordinator] +): """Abstract class for all Aqualink platforms. - Entity state is updated via the interval timer within the integration. - Any entity state change via the iaqualink library triggers an internal - state refresh which is then propagated to all the entities in the system - via the refresh_system decorator above to the _update_callback in this - class. + Entity availability and periodic refreshes are driven by the per-system + DataUpdateCoordinator. State changes initiated through the iaqualink + library are propagated back to Home Assistant through the coordinator-aware + entity update flow. """ - _attr_should_poll = False - - def __init__(self, dev: AqualinkDeviceT) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkDeviceT + ) -> None: """Initialize the entity.""" + super().__init__(coordinator) self.dev = dev self._attr_unique_id = f"{dev.system.serial}_{dev.name}" self._attr_device_info = DeviceInfo( @@ -35,18 +37,7 @@ def __init__(self, dev: AqualinkDeviceT) -> None: name=dev.label, ) - async def async_added_to_hass(self) -> None: - """Set up a listener when this entity is added to HA.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) - ) - @property def assumed_state(self) -> bool: """Return whether the state is based on actual reading from the device.""" return self.dev.system.online in [False, None] - - @property - def available(self) -> bool: - """Return whether the device is available or not.""" - return self.dev.system.online is True diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 55b14065cef9d0..94c3aec0a97c02 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -17,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry, refresh_system +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity from .utils import await_or_reraise @@ -30,17 +31,21 @@ async def async_setup_entry( ) -> None: """Set up discovered lights.""" async_add_entities( - (HassAqualinkLight(dev) for dev in config_entry.runtime_data.lights), - True, + HassAqualinkLight( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.lights ) class HassAqualinkLight(AqualinkEntity[AqualinkLight], LightEntity): """Representation of a light.""" - def __init__(self, dev: AqualinkLight) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkLight + ) -> None: """Initialize AquaLink light.""" - super().__init__(dev) + super().__init__(coordinator, dev) self._attr_name = dev.label if dev.supports_effect: self._attr_effect_list = list(dev.supported_effects) diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index baeca799bc3c6c..0a7f46d2a13615 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -10,6 +10,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -22,17 +23,21 @@ async def async_setup_entry( ) -> None: """Set up discovered sensors.""" async_add_entities( - (HassAqualinkSensor(dev) for dev in config_entry.runtime_data.sensors), - True, + HassAqualinkSensor( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.sensors ) class HassAqualinkSensor(AqualinkEntity[AqualinkSensor], SensorEntity): """Representation of a sensor.""" - def __init__(self, dev: AqualinkSensor) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkSensor + ) -> None: """Initialize AquaLink sensor.""" - super().__init__(dev) + super().__init__(coordinator, dev) self._attr_name = dev.label if not dev.name.endswith("_temp"): return diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index 851554a1972971..d346bb602b927d 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry, refresh_system +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity from .utils import await_or_reraise @@ -24,17 +25,21 @@ async def async_setup_entry( ) -> None: """Set up discovered switches.""" async_add_entities( - (HassAqualinkSwitch(dev) for dev in config_entry.runtime_data.switches), - True, + HassAqualinkSwitch( + config_entry.runtime_data.coordinators[dev.system.serial], dev + ) + for dev in config_entry.runtime_data.switches ) class HassAqualinkSwitch(AqualinkEntity[AqualinkSwitch], SwitchEntity): """Representation of a switch.""" - def __init__(self, dev: AqualinkSwitch) -> None: + def __init__( + self, coordinator: AqualinkDataUpdateCoordinator, dev: AqualinkSwitch + ) -> None: """Initialize AquaLink switch.""" - super().__init__(dev) + super().__init__(coordinator, dev) name = self._attr_name = dev.label if name == "Cleaner": self._attr_icon = "mdi:robot-vacuum" diff --git a/tests/components/iaqualink/test_config_flow.py b/tests/components/iaqualink/test_config_flow.py index 26540eb73086ba..8f184cc8bd1173 100644 --- a/tests/components/iaqualink/test_config_flow.py +++ b/tests/components/iaqualink/test_config_flow.py @@ -12,9 +12,13 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + async def test_already_configured( - hass: HomeAssistant, config_entry, config_data + hass: HomeAssistant, + config_entry: MockConfigEntry, + config_data: dict[str, str], ) -> None: """Test config flow when iaqualink component is already setup.""" config_entry.add_to_hass(hass) @@ -40,7 +44,9 @@ async def test_without_config(hass: HomeAssistant) -> None: assert result["errors"] == {} -async def test_with_invalid_credentials(hass: HomeAssistant, config_data) -> None: +async def test_with_invalid_credentials( + hass: HomeAssistant, config_data: dict[str, str] +) -> None: """Test config flow with invalid username and/or password.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass @@ -56,7 +62,9 @@ async def test_with_invalid_credentials(hass: HomeAssistant, config_data) -> Non assert result["errors"] == {"base": "invalid_auth"} -async def test_service_exception(hass: HomeAssistant, config_data) -> None: +async def test_service_exception( + hass: HomeAssistant, config_data: dict[str, str] +) -> None: """Test config flow encountering service exception.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass @@ -72,7 +80,9 @@ async def test_service_exception(hass: HomeAssistant, config_data) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_with_existing_config(hass: HomeAssistant, config_data) -> None: +async def test_with_existing_config( + hass: HomeAssistant, config_data: dict[str, str] +) -> None: """Test config flow with existing configuration.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index 798fae2344cc04..54d88146125ceb 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -1,8 +1,9 @@ """Tests for iAqualink integration.""" -import logging from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory +from iaqualink.client import AqualinkClient from iaqualink.exception import ( AqualinkServiceException, AqualinkServiceUnauthorizedException, @@ -15,7 +16,6 @@ IaquaThermostat, ) from iaqualink.systems.iaqua.system import IaquaSystem -import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN @@ -24,23 +24,152 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ASSUMED_STATE, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from .conftest import get_aqualink_device, get_aqualink_system -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed -async def _ffwd_next_update_interval(hass: HomeAssistant) -> None: - now = dt_util.utcnow() - async_fire_time_changed(hass, now + UPDATE_INTERVAL) - await hass.async_block_till_done() +async def _advance_coordinator_time( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Advance time to trigger coordinator update interval.""" + freezer.tick(delta=UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_system_refresh_failure_marks_entities_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a system refresh failure marks attached entities unavailable.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() + systems = {system.serial: system} + light = get_aqualink_device( + system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} + ) + devices = {light.name: light} + system.get_devices = AsyncMock(return_value=devices) + + with ( + patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + name = f"{LIGHT_DOMAIN}.{light.name}" + state = hass.states.get(name) + assert state is not None + assert state.state == STATE_ON + + async def fail_update() -> None: + system.online = None + raise AqualinkServiceException + + system.update = AsyncMock(side_effect=fail_update) + + await _advance_coordinator_time(hass, freezer) + + state = hass.states.get(name) + assert state is not None + assert state.state == STATE_UNAVAILABLE -async def test_setup_login_service_exception(hass: HomeAssistant, config_entry) -> None: - """Test setup encountering a transient service exception during login.""" +async def test_light_service_calls_update_entity_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, +) -> None: + """Test light service calls update entity state from device properties.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() + systems = {system.serial: system} + light = get_aqualink_device( + system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} + ) + devices = {light.name: light} + system.get_devices = AsyncMock(return_value=devices) + + async def turn_off() -> None: + light.data["state"] = "0" + + async def turn_on() -> None: + light.data["state"] = "1" + + light.turn_off = AsyncMock(side_effect=turn_off) + light.turn_on = AsyncMock(side_effect=turn_on) + + with ( + patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = f"{LIGHT_DOMAIN}.{light.name}" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + +async def test_setup_login_exception( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test setup encountering a login exception.""" config_entry.add_to_hass(hass) with patch( @@ -67,7 +196,9 @@ async def test_setup_login_unauthorized(hass: HomeAssistant, config_entry) -> No assert config_entry.state is ConfigEntryState.SETUP_ERROR -async def test_setup_login_timeout(hass: HomeAssistant, config_entry) -> None: +async def test_setup_login_timeout( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup encountering a timeout while logging in.""" config_entry.add_to_hass(hass) @@ -81,7 +212,9 @@ async def test_setup_login_timeout(hass: HomeAssistant, config_entry) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_systems_exception(hass: HomeAssistant, config_entry) -> None: +async def test_setup_systems_exception( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup encountering an exception while retrieving systems.""" config_entry.add_to_hass(hass) @@ -101,7 +234,9 @@ async def test_setup_systems_exception(hass: HomeAssistant, config_entry) -> Non assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_no_systems_recognized(hass: HomeAssistant, config_entry) -> None: +async def test_setup_no_systems_recognized( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup ending in no systems recognized.""" config_entry.add_to_hass(hass) @@ -122,12 +257,15 @@ async def test_setup_no_systems_recognized(hass: HomeAssistant, config_entry) -> async def test_setup_devices_exception( - hass: HomeAssistant, config_entry, client + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, ) -> None: """Test setup encountering an exception while retrieving devices.""" config_entry.add_to_hass(hass) system = get_aqualink_system(client, cls=IaquaSystem) + system.update = AsyncMock() systems = {system.serial: system} with ( @@ -152,12 +290,16 @@ async def test_setup_devices_exception( async def test_setup_all_good_no_recognized_devices( - hass: HomeAssistant, config_entry, client + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, ) -> None: """Test setup ending in no devices recognized.""" config_entry.add_to_hass(hass) system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() systems = {system.serial: system} device = get_aqualink_device(system, name="dev_1") @@ -196,23 +338,47 @@ async def test_setup_all_good_no_recognized_devices( async def test_setup_all_good_all_device_types( - hass: HomeAssistant, config_entry, client + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, ) -> None: """Test setup ending in one device of each type recognized.""" config_entry.add_to_hass(hass) system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() systems = {system.serial: system} devices = [ - get_aqualink_device(system, name="aux_1", cls=IaquaAuxSwitch), - get_aqualink_device(system, name="freeze_protection", cls=IaquaBinarySensor), - get_aqualink_device(system, name="aux_2", cls=IaquaLightSwitch), - get_aqualink_device(system, name="ph", cls=IaquaSensor), - get_aqualink_device(system, name="pool_set_point", cls=IaquaThermostat), + get_aqualink_device( + system, name="aux_1", cls=IaquaAuxSwitch, data={"state": "0"} + ), + get_aqualink_device( + system, name="freeze_protection", cls=IaquaBinarySensor, data={"state": "0"} + ), + get_aqualink_device( + system, name="aux_2", cls=IaquaLightSwitch, data={"state": "0"} + ), + get_aqualink_device(system, name="ph", cls=IaquaSensor, data={"state": "7.2"}), + get_aqualink_device( + system, name="pool_set_point", cls=IaquaThermostat, data={"state": "0"} + ), ] devices = {d.name: d for d in devices} + pool_heater = get_aqualink_device( + system, name="pool_heater", cls=IaquaAuxSwitch, data={"state": "0"} + ) + pool_temp = get_aqualink_device( + system, name="pool_temp", cls=IaquaSensor, data={"state": "72"} + ) + system.devices = { + **{d.name: d for d in devices.values()}, + pool_heater.name: pool_heater, + pool_temp.name: pool_temp, + } + system.get_devices = AsyncMock(return_value=devices) with ( @@ -243,17 +409,25 @@ async def test_setup_all_good_all_device_types( async def test_multiple_updates( - hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, client + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + freezer: FrozenDateTimeFactory, ) -> None: """Test all possible results of online status transition after update.""" config_entry.add_to_hass(hass) system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() systems = {system.serial: system} - system.get_devices = AsyncMock(return_value={}) + light = get_aqualink_device( + system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} + ) + devices = {light.name: light} - caplog.set_level(logging.WARNING) + system.get_devices = AsyncMock(return_value=devices) with ( patch( @@ -270,80 +444,87 @@ async def test_multiple_updates( assert config_entry.state is ConfigEntryState.LOADED + entity_id = f"{LIGHT_DOMAIN}.{light.name}" + + def assert_state(expected_state: str) -> None: + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected_state + def set_online_to_true(): system.online = True def set_online_to_false(): system.online = False + async def fail_update() -> None: + system.online = None + raise AqualinkServiceException + system.update = AsyncMock() # True -> True system.online = True - caplog.clear() system.update.side_effect = set_online_to_true - await _ffwd_next_update_interval(hass) - assert len(caplog.records) == 0 + await _advance_coordinator_time(hass, freezer) + assert system.update.await_count == 1 + assert_state(STATE_ON) # True -> False system.online = True - caplog.clear() system.update.side_effect = set_online_to_false - await _ffwd_next_update_interval(hass) - assert len(caplog.records) == 0 + await _advance_coordinator_time(hass, freezer) + assert system.update.await_count == 2 + assert_state(STATE_UNAVAILABLE) # True -> None / ServiceException system.online = True - caplog.clear() - system.update.side_effect = AqualinkServiceException - await _ffwd_next_update_interval(hass) - assert len(caplog.records) == 1 - assert "Failed" in caplog.text + system.update.side_effect = fail_update + await _advance_coordinator_time(hass, freezer) + assert system.update.await_count == 3 + assert_state(STATE_UNAVAILABLE) # False -> False system.online = False - caplog.clear() system.update.side_effect = set_online_to_false - await _ffwd_next_update_interval(hass) - assert len(caplog.records) == 0 + await _advance_coordinator_time(hass, freezer) + assert system.update.await_count == 4 + assert_state(STATE_UNAVAILABLE) # False -> True system.online = False - caplog.clear() system.update.side_effect = set_online_to_true - await _ffwd_next_update_interval(hass) - assert len(caplog.records) == 1 - assert "reconnected" in caplog.text + await _advance_coordinator_time(hass, freezer) + assert system.update.await_count == 5 + assert_state(STATE_ON) # False -> None / ServiceException system.online = False - caplog.clear() - system.update.side_effect = AqualinkServiceException - await _ffwd_next_update_interval(hass) - assert len(caplog.records) == 1 - assert "Failed" in caplog.text + system.update.side_effect = fail_update + await _advance_coordinator_time(hass, freezer) + assert system.update.await_count == 6 + assert_state(STATE_UNAVAILABLE) # None -> None / ServiceException system.online = None - caplog.clear() - system.update.side_effect = AqualinkServiceException - await _ffwd_next_update_interval(hass) - assert len(caplog.records) == 0 + system.update.side_effect = fail_update + await _advance_coordinator_time(hass, freezer) + assert system.update.await_count == 7 + assert_state(STATE_UNAVAILABLE) # None -> True system.online = None - caplog.clear() system.update.side_effect = set_online_to_true - await _ffwd_next_update_interval(hass) - assert len(caplog.records) == 1 - assert "reconnected" in caplog.text + await _advance_coordinator_time(hass, freezer) + assert system.update.await_count == 8 + assert_state(STATE_ON) # None -> False system.online = None - caplog.clear() system.update.side_effect = set_online_to_false - await _ffwd_next_update_interval(hass) - assert len(caplog.records) == 0 + await _advance_coordinator_time(hass, freezer) + assert system.update.await_count == 9 + assert_state(STATE_UNAVAILABLE) assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() @@ -352,12 +533,16 @@ def set_online_to_false(): async def test_entity_assumed_and_available( - hass: HomeAssistant, config_entry, client + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + freezer: FrozenDateTimeFactory, ) -> None: """Test assumed_state and_available properties for all values of online.""" config_entry.add_to_hass(hass) system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True systems = {system.serial: system} light = get_aqualink_device( @@ -386,19 +571,19 @@ async def test_entity_assumed_and_available( # None means maybe. light.system.online = None - await _ffwd_next_update_interval(hass) + await _advance_coordinator_time(hass, freezer) state = hass.states.get(name) assert state.state == STATE_UNAVAILABLE assert state.attributes.get(ATTR_ASSUMED_STATE) is True light.system.online = False - await _ffwd_next_update_interval(hass) + await _advance_coordinator_time(hass, freezer) state = hass.states.get(name) assert state.state == STATE_UNAVAILABLE assert state.attributes.get(ATTR_ASSUMED_STATE) is True light.system.online = True - await _ffwd_next_update_interval(hass) + await _advance_coordinator_time(hass, freezer) state = hass.states.get(name) assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) is None From fe5d45ed5770d2515518dc309d598840d61df2c4 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:24:00 +0200 Subject: [PATCH 0752/1707] Fix light on action for qbus integration (#167917) --- homeassistant/components/qbus/light.py | 6 +++-- tests/components/qbus/test_light.py | 35 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 81c7a3aa21ac12..2e43b1d444f9c4 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -79,8 +79,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self._async_publish_output_state(state) async def _handle_state_received(self, state: QbusMqttAnalogState) -> None: - percentage = round(state.read_percentage() or 0) - self._set_state(percentage) + percentage = state.read_percentage() + + if percentage is not None: + self._set_state(round(percentage)) def _set_state(self, percentage: int) -> None: self._attr_is_on = percentage > 0 diff --git a/tests/components/qbus/test_light.py b/tests/components/qbus/test_light.py index 2db2c622289c67..093bb658ade242 100644 --- a/tests/components/qbus/test_light.py +++ b/tests/components/qbus/test_light.py @@ -20,6 +20,7 @@ _PAYLOAD_LIGHT_STATE_BRIGHTNESS = ( '{"id":"UL15","properties":{"value":' + str(_BRIGHTNESS_PCT) + '},"type":"state"}' ) +_PAYLOAD_LIGHT_STATE_EVENT = '{"id":"UL15","action":"on","type":"event"}' _PAYLOAD_LIGHT_STATE_OFF = '{"id":"UL15","properties":{"value":0},"type":"state"}' _PAYLOAD_LIGHT_SET_STATE_ON = '{"id": "UL15", "type": "action", "action": "on"}' @@ -104,3 +105,37 @@ async def test_light( await hass.async_block_till_done() assert hass.states.get(_LIGHT_ENTITY_ID).state == STATE_OFF + + +async def test_light_ignore_missing_percentage( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test ignoring events without percentage.""" + + # Switch ON + mqtt_mock.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _LIGHT_ENTITY_ID}, + blocking=True, + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_ON) + await hass.async_block_till_done() + + entity = hass.states.get(_LIGHT_ENTITY_ID) + brightness = entity.attributes.get(ATTR_BRIGHTNESS) + assert entity.state == STATE_ON + assert brightness > 0 + + # Simulate additional event response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_EVENT) + await hass.async_block_till_done() + + entity = hass.states.get(_LIGHT_ENTITY_ID) + assert entity.state == STATE_ON + assert entity.attributes.get(ATTR_BRIGHTNESS) == brightness From 59827967e6e2ce76228f396d1316e3d3a5be01f9 Mon Sep 17 00:00:00 2001 From: Stef Coene Date: Fri, 10 Apr 2026 17:50:56 +0200 Subject: [PATCH 0753/1707] Velbus reconfigure fix (#167471) Co-authored-by: Claude Sonnet 4.6 --- .../components/velbus/config_flow.py | 24 +++- .../components/velbus/quality_scale.yaml | 2 +- tests/components/velbus/test_config_flow.py | 115 ++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 561b5b26423457..91a80164156704 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -84,10 +84,28 @@ async def async_step_network( if CONF_PASSWORD in user_input and user_input[CONF_PASSWORD] != "": self._device += f"{user_input[CONF_PASSWORD]}@" self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" - self._async_abort_entries_match({CONF_PORT: self._device}) + if self.source != SOURCE_RECONFIGURE: + self._async_abort_entries_match({CONF_PORT: self._device}) if await self._test_connection(): return await self.async_step_vlp() step_errors[CONF_HOST] = "cannot_connect" + elif self.source == SOURCE_RECONFIGURE: + current = self._get_reconfigure_entry().data.get(CONF_PORT, "") + tls = current.startswith("tls://") + current = current.removeprefix("tls://") + if "@" in current: + password, host_port = current.split("@", 1) + else: + password = "" + host_port = current + host, _, port = host_port.rpartition(":") + user_input = { + CONF_TLS: tls, + CONF_HOST: host, + CONF_PORT: int(port) if port.isdigit() else 27015, + } + if password: + user_input[CONF_PASSWORD] = password else: user_input = { CONF_TLS: True, @@ -199,7 +217,7 @@ async def async_step_vlp( old_entry, data={ CONF_VLP_FILE: self._vlp_file, - CONF_PORT: old_entry.data.get(CONF_PORT), + CONF_PORT: self._device, }, ) if not step_errors: @@ -224,7 +242,7 @@ async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration.""" - return await self.async_step_vlp() + return await self.async_step_network() def save_uploaded_vlp_file(hass: HomeAssistant, uploaded_file_id: str) -> str: diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index d4592159d591d0..0550837aed16fa 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -57,7 +57,7 @@ rules: entity-translations: todo exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 50ac696949f825..f44ee75cf9dd46 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -366,6 +366,19 @@ async def test_reconfigure_step( result = await config_entry.start_reconfigure_flow(hass) assert result assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "network" + + # Submit the network step with the same host/port + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: False, + CONF_HOST: "127.0.1.0.1", + CONF_PORT: 3788, + CONF_PASSWORD: "", + }, + ) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "vlp" with ( @@ -389,6 +402,108 @@ async def test_reconfigure_step( assert result["reason"] == "reconfigure_successful" +@pytest.mark.usefixtures("controller") +async def test_reconfigure_step_change_host_port( + hass: HomeAssistant, + mock_process_uploaded_file: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Testcase for the reconfigure step changing host and port.""" + await init_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "network" + + # Submit the network step with a new host/port + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: False, + CONF_HOST: "192.168.0.3", + CONF_PORT: 3788, + CONF_PASSWORD: "", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "vlp" + + with ( + patch( + "velbusaio.vlp_reader.VlpFile.read", + AsyncMock(return_value=True), + ), + patch( + "velbusaio.vlp_reader.VlpFile.get", + return_value=[1, 2, 3, 4], + ), + ): + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {}, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_PORT] == "192.168.0.3:3788" + + +@pytest.mark.usefixtures("controller") +async def test_reconfigure_step_password_preserved( + hass: HomeAssistant, + mock_process_uploaded_file: MagicMock, +) -> None: + """Test that an existing password is pre-filled and preserved during reconfigure.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: "tls://secret@192.168.0.1:27015"}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await entry.start_reconfigure_flow(hass) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "network" + # Verify the password is pre-filled in the suggested values + assert result["data_schema"]( + { + CONF_TLS: True, + CONF_HOST: "192.168.0.1", + CONF_PORT: 27015, + CONF_PASSWORD: "secret", + } + ) + + # Submit without changing the password + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: True, + CONF_HOST: "192.168.0.1", + CONF_PORT: 27015, + CONF_PASSWORD: "secret", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "vlp" + + with ( + patch("velbusaio.vlp_reader.VlpFile.read", AsyncMock(return_value=True)), + patch("velbusaio.vlp_reader.VlpFile.get", return_value=[1, 2, 3, 4]), + ): + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {}, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_PORT] == "tls://secret@192.168.0.1:27015" + + @pytest.mark.usefixtures("controller") async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None: """Test we abort if Velbus is already setup.""" From 86b72501ad73bc12786794e41e72bf942d26ca0a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 10 Apr 2026 17:51:49 +0200 Subject: [PATCH 0754/1707] Add faulty/anomaly binary sensors to Comelit (#167201) --- .../components/comelit/binary_sensor.py | 118 ++++++++++++++---- homeassistant/components/comelit/strings.json | 11 ++ .../components/comelit/test_binary_sensor.py | 95 ++++++++++++++ 3 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 tests/components/comelit/test_binary_sensor.py diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index d512ebc4f3d34e..3d033f0805c090 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -1,15 +1,18 @@ -"""Support for sensors.""" +"""Support for binary sensors.""" from __future__ import annotations -from typing import TYPE_CHECKING, cast +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, cast -from aiocomelit.api import ComelitVedoZoneObject -from aiocomelit.const import ALARM_ZONE, AlarmZoneState +from aiocomelit.api import ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import ALARM_AREA, ALARM_ZONE, AlarmAreaState, AlarmZoneState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,12 +26,68 @@ PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class ComelitBinarySensorEntityDescription(BinarySensorEntityDescription): + """Comelit binary sensor entity description.""" + + object_type: str + is_on_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool] + available_fn: Callable[[ComelitVedoAreaObject | ComelitVedoZoneObject], bool] = ( + lambda obj: True + ) + + +BINARY_SENSOR_TYPES: Final[tuple[ComelitBinarySensorEntityDescription, ...]] = ( + ComelitBinarySensorEntityDescription( + key="anomaly", + translation_key="anomaly", + object_type=ALARM_AREA, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_fn=lambda obj: cast(ComelitVedoAreaObject, obj).anomaly, + available_fn=lambda obj: ( + cast(ComelitVedoAreaObject, obj).human_status != AlarmAreaState.UNKNOWN + ), + ), + ComelitBinarySensorEntityDescription( + key="presence", + translation_key="motion", + object_type=ALARM_ZONE, + device_class=BinarySensorDeviceClass.MOTION, + is_on_fn=lambda obj: cast(ComelitVedoZoneObject, obj).status_api == "0001", + available_fn=lambda obj: ( + cast(ComelitVedoZoneObject, obj).human_status + not in { + AlarmZoneState.FAULTY, + AlarmZoneState.UNAVAILABLE, + AlarmZoneState.UNKNOWN, + } + ), + ), + ComelitBinarySensorEntityDescription( + key="faulty", + translation_key="faulty", + object_type=ALARM_ZONE, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_fn=lambda obj: ( + cast(ComelitVedoZoneObject, obj).human_status == AlarmZoneState.FAULTY + ), + available_fn=lambda obj: ( + cast(ComelitVedoZoneObject, obj).human_status + not in { + AlarmZoneState.UNAVAILABLE, + AlarmZoneState.UNKNOWN, + } + ), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up Comelit VEDO presence sensors.""" + """Set up Comelit VEDO binary sensors.""" coordinator = config_entry.runtime_data is_bridge = isinstance(coordinator, ComelitSerialBridge) @@ -42,13 +101,23 @@ async def async_setup_entry( def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None: """Add entities for new monitors.""" entities = [ - ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) + ComelitVedoBinarySensorEntity( + coordinator, + device, + config_entry.entry_id, + description, + ) + for description in BINARY_SENSOR_TYPES for device in coordinator.data[dev_type].values() + if description.object_type == dev_type if device in new_devices ] if entities: async_add_entities(entities) + config_entry.async_on_unload( + new_device_listener(coordinator, _add_new_entities, ALARM_AREA) + ) config_entry.async_on_unload( new_device_listener(coordinator, _add_new_entities, ALARM_ZONE) ) @@ -59,42 +128,47 @@ class ComelitVedoBinarySensorEntity( ): """Sensor device.""" + entity_description: ComelitBinarySensorEntityDescription + _attr_has_entity_name = True - _attr_device_class = BinarySensorDeviceClass.MOTION def __init__( self, coordinator: ComelitVedoSystem | ComelitSerialBridge, - zone: ComelitVedoZoneObject, + object_data: ComelitVedoAreaObject | ComelitVedoZoneObject, config_entry_entry_id: str, + description: ComelitBinarySensorEntityDescription, ) -> None: """Init sensor entity.""" - self._zone_index = zone.index + self.entity_description = description + self._object_index = object_data.index + self._object_type = description.object_type super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}" - self._attr_device_info = coordinator.platform_device_info(zone, "zone") + self._attr_unique_id = ( + f"{config_entry_entry_id}-{description.key}-{self._object_index}" + ) + self._attr_device_info = coordinator.platform_device_info( + object_data, "area" if self._object_type == ALARM_AREA else "zone" + ) @property - def _zone(self) -> ComelitVedoZoneObject: - """Return zone object.""" + def _object(self) -> ComelitVedoAreaObject | ComelitVedoZoneObject: + """Return alarm object.""" return cast( - ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index] + ComelitVedoAreaObject | ComelitVedoZoneObject, + self.coordinator.data[self._object_type][self._object_index], ) @property def available(self) -> bool: - """Return True if alarm is available.""" - if self._zone.human_status in [ - AlarmZoneState.FAULTY, - AlarmZoneState.UNAVAILABLE, - AlarmZoneState.UNKNOWN, - ]: + """Return True if object is available.""" + if not self.entity_description.available_fn(self._object): return False return super().available @property def is_on(self) -> bool: - """Presence detected.""" - return self._zone.status_api == "0001" + """Return object binary sensor state.""" + return self.entity_description.is_on_fn(self._object) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index d8d2605b172b4e..68375a903952c7 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -64,6 +64,17 @@ } }, "entity": { + "binary_sensor": { + "anomaly": { + "name": "Anomaly" + }, + "faulty": { + "name": "Faulty" + }, + "motion": { + "name": "Motion" + } + }, "climate": { "thermostat": { "state_attributes": { diff --git a/tests/components/comelit/test_binary_sensor.py b/tests/components/comelit/test_binary_sensor.py new file mode 100644 index 00000000000000..232e53cb47f410 --- /dev/null +++ b/tests/components/comelit/test_binary_sensor.py @@ -0,0 +1,95 @@ +"""Tests for Comelit SimpleHome binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitVedoAreaObject, ComelitVedoZoneObject +from aiocomelit.const import ALARM_AREA, ALARM_ZONE, AlarmAreaState, AlarmZoneState +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_binary_sensor_entities_created( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test area and zone binary sensors are created.""" + with patch( + "homeassistant.components.comelit.VEDO_PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_vedo_config_entry) + + anomaly_entity_id = "binary_sensor.area0_anomaly" + motion_entity_id = "binary_sensor.zone0_motion" + faulty_entity_id = "binary_sensor.zone0_faulty" + + assert (state := hass.states.get(anomaly_entity_id)) + assert state.state == STATE_OFF + assert (state := hass.states.get(motion_entity_id)) + assert state.state == STATE_OFF + assert (state := hass.states.get(faulty_entity_id)) + assert state.state == STATE_OFF + + +async def test_binary_sensor_state_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test area anomaly and zone faulty binary sensor state updates.""" + with patch( + "homeassistant.components.comelit.VEDO_PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_vedo_config_entry) + + anomaly_entity_id = "binary_sensor.area0_anomaly" + faulty_entity_id = "binary_sensor.zone0_faulty" + + mock_vedo.get_all_areas_and_zones.return_value = { + ALARM_AREA: { + 0: ComelitVedoAreaObject( + index=0, + name="Area0", + p1=True, + p2=True, + ready=False, + armed=0, + alarm=False, + alarm_memory=False, + sabotage=False, + anomaly=True, + in_time=False, + out_time=False, + human_status=AlarmAreaState.DISARMED, + ) + }, + ALARM_ZONE: { + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.FAULTY, + ) + }, + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(anomaly_entity_id)) + assert state.state == STATE_ON + assert (state := hass.states.get(faulty_entity_id)) + assert state.state == STATE_ON From 62717fd3f503125a9e47fbcaf9cf44b25ed4b46f Mon Sep 17 00:00:00 2001 From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:57:26 +0200 Subject: [PATCH 0755/1707] Add support for encrypted connection to Satel Integra (#167372) --- .../components/satel_integra/__init__.py | 9 + .../components/satel_integra/client.py | 10 +- .../components/satel_integra/config_flow.py | 37 +++- .../components/satel_integra/const.py | 1 + .../components/satel_integra/diagnostics.py | 6 +- .../components/satel_integra/strings.json | 8 +- tests/components/satel_integra/__init__.py | 7 +- tests/components/satel_integra/conftest.py | 5 +- .../snapshots/test_diagnostics.ambr | 1 + .../satel_integra/snapshots/test_init.ambr | 199 ------------------ .../satel_integra/test_config_flow.py | 81 ++++++- tests/components/satel_integra/test_init.py | 46 +++- 12 files changed, 183 insertions(+), 227 deletions(-) diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index 4c695a265618c9..e7ebc5ad7d1aaf 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -9,6 +9,7 @@ from .client import SatelClient from .const import ( + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -139,6 +140,14 @@ def migrate_unique_id(entity_entry: RegistryEntry) -> dict[str, str]: await async_migrate_entries(hass, config_entry.entry_id, migrate_unique_id) hass.config_entries.async_update_entry(config_entry, version=2, minor_version=1) + # 2.2 Added encryption key to config entry data + if config_entry.version == 2 and config_entry.minor_version < 2: + new_data = {**config_entry.data, CONF_ENCRYPTION_KEY: None} + + hass.config_entries.async_update_entry( + config_entry, data=new_data, minor_version=2 + ) + _LOGGER.debug( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/satel_integra/client.py b/homeassistant/components/satel_integra/client.py index db66d8af6fa9f2..af7567640f027a 100644 --- a/homeassistant/components/satel_integra/client.py +++ b/homeassistant/components/satel_integra/client.py @@ -10,6 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import ( + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -61,7 +62,14 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: monitored_outputs = outputs + switchable_outputs - self.controller = AsyncSatel(host, port, zones, monitored_outputs, partitions) + self.controller = AsyncSatel( + host, + port, + zones, + monitored_outputs, + partitions, + integration_key=entry.data[CONF_ENCRYPTION_KEY], + ) async def async_connect( self, diff --git a/homeassistant/components/satel_integra/config_flow.py b/homeassistant/components/satel_integra/config_flow.py index 59c91ec5f8d189..b23e958ad2b8a1 100644 --- a/homeassistant/components/satel_integra/config_flow.py +++ b/homeassistant/components/satel_integra/config_flow.py @@ -24,6 +24,7 @@ from .const import ( CONF_ARM_HOME_MODE, + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -45,6 +46,9 @@ { vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ENCRYPTION_KEY): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), } ) @@ -91,7 +95,7 @@ def __init__(self) -> None: self.connection_data: dict[str, Any] = {} VERSION = 2 - MINOR_VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -123,10 +127,15 @@ async def async_step_user( if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]): + if await self.test_connection( + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input.get(CONF_ENCRYPTION_KEY), + ): self.connection_data = { CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], + CONF_ENCRYPTION_KEY: user_input.get(CONF_ENCRYPTION_KEY), } return await self.async_step_code() @@ -164,12 +173,17 @@ async def async_step_reconfigure( if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + # Normalize user_input to include None for missing optional encryption key + normalized_input = {CONF_ENCRYPTION_KEY: None, **user_input} + if ( reconfigure_entry.state is not ConfigEntryState.LOADED - or reconfigure_entry.data != user_input + or reconfigure_entry.data != normalized_input ): if not await self.test_connection( - user_input[CONF_HOST], user_input[CONF_PORT] + normalized_input[CONF_HOST], + normalized_input[CONF_PORT], + normalized_input.get(CONF_ENCRYPTION_KEY), ): errors["base"] = "cannot_connect" @@ -177,10 +191,11 @@ async def async_step_reconfigure( return self.async_update_reload_and_abort( reconfigure_entry, data_updates={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], + CONF_HOST: normalized_input[CONF_HOST], + CONF_PORT: normalized_input[CONF_PORT], + CONF_ENCRYPTION_KEY: normalized_input.get(CONF_ENCRYPTION_KEY), }, - title=user_input[CONF_HOST], + title=normalized_input[CONF_HOST], ) suggested_values: dict[str, Any] = { @@ -196,12 +211,14 @@ async def async_step_reconfigure( errors=errors, ) - async def test_connection(self, host: str, port: int) -> bool: + async def test_connection( + self, host: str, port: int, integration_key: str | None = None + ) -> bool: """Test a connection to the Satel alarm.""" - controller = AsyncSatel(host, port) + controller = AsyncSatel(host, port, integration_key=integration_key) try: - return await controller.connect(check_busy=False) + return await controller.connect() except Exception: _LOGGER.exception( "Unexpected error during connection test to %s:%s", diff --git a/homeassistant/components/satel_integra/const.py b/homeassistant/components/satel_integra/const.py index 33e9c7a9572bdc..a444bff9aca6c7 100644 --- a/homeassistant/components/satel_integra/const.py +++ b/homeassistant/components/satel_integra/const.py @@ -17,3 +17,4 @@ CONF_ARM_HOME_MODE = "arm_home_mode" CONF_ZONE_TYPE = "type" +CONF_ENCRYPTION_KEY = "encryption_key" diff --git a/homeassistant/components/satel_integra/diagnostics.py b/homeassistant/components/satel_integra/diagnostics.py index 93e9bd104ee6d8..d86ed32a5eb5ad 100644 --- a/homeassistant/components/satel_integra/diagnostics.py +++ b/homeassistant/components/satel_integra/diagnostics.py @@ -9,7 +9,9 @@ from homeassistant.const import CONF_CODE from homeassistant.core import HomeAssistant -TO_REDACT = {CONF_CODE} +from .const import CONF_ENCRYPTION_KEY + +TO_REDACT = {CONF_CODE, CONF_ENCRYPTION_KEY} async def async_get_config_entry_diagnostics( @@ -18,7 +20,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for the config entry.""" diag: dict[str, Any] = {} - diag["config_entry_data"] = dict(entry.data) + diag["config_entry_data"] = async_redact_data(entry.data, TO_REDACT) diag["config_entry_options"] = async_redact_data(entry.options, TO_REDACT) diag["subentries"] = dict(entry.subentries) diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json index 67fe3b94101e47..74d97076861afb 100644 --- a/homeassistant/components/satel_integra/strings.json +++ b/homeassistant/components/satel_integra/strings.json @@ -1,7 +1,9 @@ { "common": { "code": "Access code", - "code_input_description": "Code to toggle switchable outputs" + "code_input_description": "Code to toggle switchable outputs", + "encryption_key": "Integration encryption key", + "encryption_key_description": "If the alarm panel requires encryption, enter the integration encryption key here." }, "config": { "abort": { @@ -22,20 +24,24 @@ }, "reconfigure": { "data": { + "encryption_key": "[%key:component::satel_integra::common::encryption_key%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, "data_description": { + "encryption_key": "[%key:component::satel_integra::common::encryption_key_description%]", "host": "[%key:component::satel_integra::config::step::user::data_description::host%]", "port": "[%key:component::satel_integra::config::step::user::data_description::port%]" } }, "user": { "data": { + "encryption_key": "[%key:component::satel_integra::common::encryption_key%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, "data_description": { + "encryption_key": "[%key:component::satel_integra::common::encryption_key_description%]", "host": "The IP address of the alarm panel", "port": "The port of the alarm panel" } diff --git a/tests/components/satel_integra/__init__.py b/tests/components/satel_integra/__init__.py index 94683453676e44..78de1cee3fdc99 100644 --- a/tests/components/satel_integra/__init__.py +++ b/tests/components/satel_integra/__init__.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.satel_integra.const import ( CONF_ARM_HOME_MODE, + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -26,7 +27,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed MOCK_CODE = "1234" -MOCK_CONFIG_DATA = {CONF_HOST: "192.168.0.2", CONF_PORT: DEFAULT_PORT} +MOCK_CONFIG_DATA = { + CONF_HOST: "192.168.0.2", + CONF_PORT: DEFAULT_PORT, + CONF_ENCRYPTION_KEY: "encryption_key", +} MOCK_CONFIG_OPTIONS = {CONF_CODE: MOCK_CODE} MOCK_ENTRY_ID = "1234567890" diff --git a/tests/components/satel_integra/conftest.py b/tests/components/satel_integra/conftest.py index 10327a5b976c7b..8249bdd6ef14ae 100644 --- a/tests/components/satel_integra/conftest.py +++ b/tests/components/satel_integra/conftest.py @@ -6,6 +6,7 @@ import pytest +from homeassistant.components.satel_integra.config_flow import SatelConfigFlow from homeassistant.components.satel_integra.const import DOMAIN from . import ( @@ -89,8 +90,8 @@ def mock_config_entry() -> MockConfigEntry: data=MOCK_CONFIG_DATA, options=MOCK_CONFIG_OPTIONS, entry_id=MOCK_ENTRY_ID, - version=2, - minor_version=1, + version=SatelConfigFlow.VERSION, + minor_version=SatelConfigFlow.MINOR_VERSION, ) diff --git a/tests/components/satel_integra/snapshots/test_diagnostics.ambr b/tests/components/satel_integra/snapshots/test_diagnostics.ambr index 4668191ec0fc04..9e6d484fc278db 100644 --- a/tests/components/satel_integra/snapshots/test_diagnostics.ambr +++ b/tests/components/satel_integra/snapshots/test_diagnostics.ambr @@ -2,6 +2,7 @@ # name: test_diagnostics dict({ 'config_entry_data': dict({ + 'encryption_key': '**REDACTED**', 'host': '192.168.0.2', 'port': 7094, }), diff --git a/tests/components/satel_integra/snapshots/test_init.ambr b/tests/components/satel_integra/snapshots/test_init.ambr index 2ea04c92eca9c6..9853a728ed612a 100644 --- a/tests/components/satel_integra/snapshots/test_init.ambr +++ b/tests/components/satel_integra/snapshots/test_init.ambr @@ -1,55 +1,4 @@ # serializer version: 1 -# name: test_config_flow_migration_version_1_2[original0-partition_number] - dict({ - 'data': dict({ - 'arm_home_mode': 1, - 'name': 'Home', - 'partition_number': 1, - }), - 'subentry_id': 'ID_PARTITION', - 'subentry_type': 'partition', - 'title': 'Home (1) (1)', - 'unique_id': 'partition_1', - }) -# --- -# name: test_config_flow_migration_version_1_2[original1-zone_number] - dict({ - 'data': dict({ - 'name': 'Zone', - 'type': , - 'zone_number': 1, - }), - 'subentry_id': 'ID_ZONE', - 'subentry_type': 'zone', - 'title': 'Zone (1) (1)', - 'unique_id': 'zone_1', - }) -# --- -# name: test_config_flow_migration_version_1_2[original2-output_number] - dict({ - 'data': dict({ - 'name': 'Output', - 'output_number': 1, - 'type': , - }), - 'subentry_id': 'ID_OUTPUT', - 'subentry_type': 'output', - 'title': 'Output (1) (1)', - 'unique_id': 'output_1', - }) -# --- -# name: test_config_flow_migration_version_1_2[original3-switchable_output_number] - dict({ - 'data': dict({ - 'name': 'Switchable Output', - 'switchable_output_number': 1, - }), - 'subentry_id': 'ID_SWITCHABLE_OUTPUT', - 'subentry_type': 'switchable_output', - 'title': 'Switchable Output (1) (1)', - 'unique_id': 'switchable_output_1', - }) -# --- # name: test_parent_device_exists[parent-device] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -81,151 +30,3 @@ 'via_device_id': None, }) # --- -# name: test_unique_id_migration_from_single_config[alarm_control_panel-satel_alarm_panel_1-1234567890_alarm_panel_1] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'alarm_control_panel', - 'entity_category': None, - 'entity_id': 'alarm_control_panel.satel_integra_satel_alarm_panel_1', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'satel_integra', - 'previous_unique_id': 'satel_alarm_panel_1', - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234567890_alarm_panel_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_unique_id_migration_from_single_config[binary_sensor-satel_output_1-1234567890_output_1] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.satel_integra_satel_output_1', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'satel_integra', - 'previous_unique_id': 'satel_output_1', - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234567890_output_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_unique_id_migration_from_single_config[binary_sensor-satel_zone_1-1234567890_zone_1] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.satel_integra_satel_zone_1', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'satel_integra', - 'previous_unique_id': 'satel_zone_1', - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234567890_zone_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_unique_id_migration_from_single_config[switch-satel_switch_1-1234567890_switch_1] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.satel_integra_satel_switch_1', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'satel_integra', - 'previous_unique_id': 'satel_switch_1', - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234567890_switch_1', - 'unit_of_measurement': None, - }) -# --- diff --git a/tests/components/satel_integra/test_config_flow.py b/tests/components/satel_integra/test_config_flow.py index e43c05a88ccd7e..0ad08c2fe5f9a8 100644 --- a/tests/components/satel_integra/test_config_flow.py +++ b/tests/components/satel_integra/test_config_flow.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.satel_integra.const import ( CONF_ARM_HOME_MODE, + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -51,7 +52,11 @@ ( {CONF_HOST: MOCK_CONFIG_DATA[CONF_HOST]}, {}, - {CONF_HOST: MOCK_CONFIG_DATA[CONF_HOST], CONF_PORT: DEFAULT_PORT}, + { + CONF_HOST: MOCK_CONFIG_DATA[CONF_HOST], + CONF_PORT: DEFAULT_PORT, + CONF_ENCRYPTION_KEY: None, + }, {CONF_CODE: None}, ), ], @@ -362,6 +367,7 @@ async def test_reconfigure_flow_success( assert mock_config_entry.data == { CONF_HOST: "10.0.0.2", CONF_PORT: 4321, + CONF_ENCRYPTION_KEY: None, } await hass.async_block_till_done() @@ -459,6 +465,7 @@ async def test_reconfigure_connection_failed( assert mock_config_entry.data == { CONF_HOST: "1.2.3.4", CONF_PORT: 1234, + CONF_ENCRYPTION_KEY: None, } @@ -483,3 +490,75 @@ async def test_same_host_config_disallowed( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reconfigure_encryption_key_changed( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow when encryption key is changed.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # Change encryption key + user_input = { + CONF_HOST: "192.168.0.2", + CONF_PORT: DEFAULT_PORT, + CONF_ENCRYPTION_KEY: "new_key", + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + expected_data = { + CONF_HOST: "192.168.0.2", + CONF_PORT: DEFAULT_PORT, + CONF_ENCRYPTION_KEY: "new_key", + } + assert mock_config_entry.data == expected_data + + +async def test_reconfigure_encryption_key_removed( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow when encryption key is explicitly removed.""" + # Start with config that has encryption key + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # Explicitly set encryption key to None (empty string in UI) + user_input = { + CONF_HOST: "192.168.0.2", + CONF_PORT: DEFAULT_PORT, + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + expected_data = { + CONF_HOST: "192.168.0.2", + CONF_PORT: DEFAULT_PORT, + CONF_ENCRYPTION_KEY: None, + } + assert mock_config_entry.data == expected_data diff --git a/tests/components/satel_integra/test_init.py b/tests/components/satel_integra/test_init.py index 66fe8945874690..1552cf75d2bd32 100644 --- a/tests/components/satel_integra/test_init.py +++ b/tests/components/satel_integra/test_init.py @@ -8,9 +8,11 @@ from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_PANEL_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.satel_integra.const import DOMAIN +from homeassistant.components.satel_integra.config_flow import SatelConfigFlow +from homeassistant.components.satel_integra.const import CONF_ENCRYPTION_KEY, DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -42,9 +44,8 @@ (MOCK_SWITCHABLE_OUTPUT_SUBENTRY, CONF_SWITCHABLE_OUTPUT_NUMBER), ], ) -async def test_config_flow_migration_version_1_2( +async def test_config_flow_migration_v1_1_to_v1_2( hass: HomeAssistant, - snapshot: SnapshotAssertion, mock_satel: AsyncMock, original: ConfigSubentry, number_property: str, @@ -64,15 +65,13 @@ async def test_config_flow_migration_version_1_2( await setup_integration(hass, config_entry) - assert config_entry.version == 2 - assert config_entry.minor_version == 1 + assert config_entry.version == SatelConfigFlow.VERSION + assert config_entry.minor_version == SatelConfigFlow.MINOR_VERSION subentry = config_entry.subentries.get(original.subentry_id) assert subentry is not None assert subentry.title == f"{original.title} ({original.data[number_property]})" - assert subentry == snapshot - @pytest.mark.parametrize( ("platform", "old_id", "new_id"), @@ -83,9 +82,8 @@ async def test_config_flow_migration_version_1_2( (SWITCH_DOMAIN, "satel_switch_1", f"{MOCK_ENTRY_ID}_switch_1"), ], ) -async def test_unique_id_migration_from_single_config( +async def test_config_flow_migration_v1_to_v2( hass: HomeAssistant, - snapshot: SnapshotAssertion, mock_satel: AsyncMock, entity_registry: EntityRegistry, platform: str, @@ -120,7 +118,35 @@ async def test_unique_id_migration_from_single_config( assert entity is not None assert entity.unique_id == new_id - assert entity == snapshot + assert config_entry.version == SatelConfigFlow.VERSION + assert config_entry.minor_version == SatelConfigFlow.MINOR_VERSION + + +async def test_config_flow_migration_v2_1_to_v2_2( + hass: HomeAssistant, + mock_satel: AsyncMock, +) -> None: + """Test that the encryption key is added to the config entry.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="192.168.0.2", + data={CONF_HOST: "192.168.0.2", CONF_PORT: 7094}, + options=MOCK_CONFIG_OPTIONS, + entry_id=MOCK_ENTRY_ID, + version=2, + minor_version=1, + ) + await setup_integration(hass, config_entry) + + assert config_entry.version == SatelConfigFlow.VERSION + assert config_entry.minor_version == SatelConfigFlow.MINOR_VERSION + + assert config_entry.data == { + CONF_HOST: "192.168.0.2", + CONF_PORT: 7094, + CONF_ENCRYPTION_KEY: None, + } async def test_parent_device_exists( From 23bcde09b0a154710fd6f9cd771790bd138d1a25 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 10 Apr 2026 17:00:30 +0100 Subject: [PATCH 0756/1707] Add Buttons to natively reset the mode of Evohome entities (#167550) --- homeassistant/components/evohome/__init__.py | 3 + homeassistant/components/evohome/button.py | 116 ++++ homeassistant/components/evohome/climate.py | 23 +- homeassistant/components/evohome/entity.py | 20 +- homeassistant/components/evohome/icons.json | 13 + .../components/evohome/water_heater.py | 5 +- tests/components/evohome/conftest.py | 8 +- .../evohome/snapshots/test_button.ambr | 593 ++++++++++++++++++ .../evohome/snapshots/test_climate.ambr | 20 +- tests/components/evohome/test_button.py | 132 ++++ 10 files changed, 900 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/evohome/button.py create mode 100644 tests/components/evohome/snapshots/test_button.ambr create mode 100644 tests/components/evohome/test_button.py diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index c2d2e6aad0a976..65641c10c45ce3 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -104,6 +104,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.async_create_task( async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) ) + hass.async_create_task( + async_load_platform(hass, Platform.BUTTON, DOMAIN, {}, config) + ) if coordinator.tcs.hotwater: hass.async_create_task( async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config) diff --git a/homeassistant/components/evohome/button.py b/homeassistant/components/evohome/button.py new file mode 100644 index 00000000000000..25d4b2149acc86 --- /dev/null +++ b/homeassistant/components/evohome/button.py @@ -0,0 +1,116 @@ +"""Support for Button entities of the Evohome integration.""" + +from __future__ import annotations + +import evohomeasync2 as evo + +from homeassistant.components.button import ButtonEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import EVOHOME_DATA +from .coordinator import EvoDataUpdateCoordinator +from .entity import EvoEntity, is_valid_zone + + +async def async_setup_platform( + hass: HomeAssistant, + _: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the button platform for Evohome.""" + + if discovery_info is None: + return + + coordinator = hass.data[EVOHOME_DATA].coordinator + tcs = hass.data[EVOHOME_DATA].tcs + + entities: list[EvoResetButtonBase] = [EvoResetSystemButton(coordinator, tcs)] + + entities.extend( + [EvoResetZoneButton(coordinator, z) for z in tcs.zones if is_valid_zone(z)] + ) + + if tcs.hotwater: + entities.append(EvoResetDhwButton(coordinator, tcs.hotwater)) + + async_add_entities(entities) + + for entity in entities: + await entity.update_attrs() + + +class EvoResetButtonBase(EvoEntity, ButtonEntity): + """Button entity for system reset.""" + + _attr_entity_category = EntityCategory.CONFIG + + _evo_state_attr_names = () + + def __init__( + self, + coordinator: EvoDataUpdateCoordinator, + evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, + ) -> None: + """Initialize the system reset button.""" + super().__init__(coordinator, evo_device) + + # zones can be renamed, so set name in their property method + if isinstance(evo_device, evo.ControlSystem): + self._attr_name = f"Reset {evo_device.location.name}" + elif not isinstance(evo_device, evo.Zone): + self._attr_name = f"Reset {evo_device.name}" + + self._attr_unique_id = f"{evo_device.id}_reset" + + async def async_press(self) -> None: + """Reset the Evohome entity to its base operating mode.""" + await self.coordinator.call_client_api(self._evo_device.reset()) + + +class EvoResetSystemButton(EvoResetButtonBase): + """Button entity for system reset.""" + + _attr_translation_key = "reset_system_mode" + + _evo_device: evo.ControlSystem + _evo_id_attr = "system_id" + + +class EvoResetDhwButton(EvoResetButtonBase): + """Button entity for DHW override reset.""" + + _attr_translation_key = "clear_dhw_override" + + _evo_device: evo.HotWater + _evo_id_attr = "dhw_id" + + +class EvoResetZoneButton(EvoResetButtonBase): + """Button entity for zone override reset.""" + + _attr_translation_key = "clear_zone_override" + + _evo_device: evo.Zone + _evo_id_attr = "zone_id" + + def __init__( + self, + coordinator: EvoDataUpdateCoordinator, + evo_device: evo.Zone, + ) -> None: + """Initialize the zone reset button.""" + super().__init__(coordinator, evo_device) + + if evo_device.id == evo_device.tcs.id: + # this system does not have a distinct ID for the zone + self._attr_unique_id = f"{evo_device.id}z_reset" + + @property + def name(self) -> str: + """Return the name of the evohome entity.""" + return f"Reset {self._evo_device.name}" diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 846ed245e35258..3dad506b916a2f 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -16,8 +16,6 @@ from evohomeasync2.schemas.const import ( SystemMode as EvoSystemMode, ZoneMode as EvoZoneMode, - ZoneModelType as EvoZoneModelType, - ZoneType as EvoZoneType, ) from homeassistant.components.climate import ( @@ -43,7 +41,7 @@ from .const import ATTR_DURATION, ATTR_PERIOD, DOMAIN, EVOHOME_DATA, EvoService from .coordinator import EvoDataUpdateCoordinator -from .entity import EvoChild, EvoEntity +from .entity import EvoChild, EvoEntity, is_valid_zone _LOGGER = logging.getLogger(__name__) @@ -70,16 +68,16 @@ async def async_setup_platform( hass: HomeAssistant, - config: ConfigType, + _: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Create the evohome Controller, and its Zones, if any.""" + """Set up the climate platform for Evohome.""" + if discovery_info is None: return coordinator = hass.data[EVOHOME_DATA].coordinator - loc_idx = hass.data[EVOHOME_DATA].loc_idx tcs = hass.data[EVOHOME_DATA].tcs _LOGGER.debug( @@ -87,16 +85,13 @@ async def async_setup_platform( tcs.model, tcs.id, tcs.location.name, - loc_idx, + coordinator.loc_idx, ) entities: list[EvoController | EvoZone] = [EvoController(coordinator, tcs)] for zone in tcs.zones: - if ( - zone.model == EvoZoneModelType.HEATING_ZONE - or zone.type == EvoZoneType.THERMOSTAT - ): + if is_valid_zone(zone): _LOGGER.debug( "Adding: %s (%s), id=%s, name=%s", zone.type, @@ -213,9 +208,9 @@ async def async_set_zone_override( ) @property - def name(self) -> str | None: + def name(self) -> str: """Return the name of the evohome entity.""" - return self._evo_device.name # zones can be easily renamed + return self._evo_device.name # zones can be renamed @property def hvac_mode(self) -> HVACMode | None: @@ -330,7 +325,7 @@ class EvoController(EvoClimateEntity): It is assumed there is only one TCS per location, and they are thus synonymous. """ - _attr_icon = "mdi:thermostat" + _attr_icon = "mdi:thermostat-box" _attr_precision = PRECISION_TENTHS _evo_device: evo.ControlSystem diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 0879fe739bc265..61b62d2fb62f30 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -1,4 +1,4 @@ -"""Base for evohome entity.""" +"""Support for entities of the Evohome integration.""" from collections.abc import Mapping from datetime import UTC, datetime @@ -6,6 +6,10 @@ from typing import Any import evohomeasync2 as evo +from evohomeasync2.schemas.const import ( + ZoneModelType as EvoZoneModelType, + ZoneType as EvoZoneType, +) from evohomeasync2.schemas.typedefs import DayOfWeekDhwT from homeassistant.core import callback @@ -18,6 +22,14 @@ _LOGGER = logging.getLogger(__name__) +def is_valid_zone(zone: evo.Zone) -> bool: + """Check if an Evohome zone should have climate and button entities.""" + return ( + zone.model == EvoZoneModelType.HEATING_ZONE + or zone.type == EvoZoneType.THERMOSTAT + ) + + class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): """Base for any evohome-compatible entity (controller, DHW, zone). @@ -75,6 +87,10 @@ def _handle_coordinator_update(self) -> None: super()._handle_coordinator_update() + async def update_attrs(self) -> None: + """Update the entity's extra state attrs.""" + self._handle_coordinator_update() + class EvoChild(EvoEntity): """Base for any evohome-compatible child entity (DHW, zone). @@ -179,4 +195,4 @@ def _handle_coordinator_update(self) -> None: async def update_attrs(self) -> None: """Update the entity's extra state attrs.""" await self._update_schedule() - self._handle_coordinator_update() + await super().update_attrs() diff --git a/homeassistant/components/evohome/icons.json b/homeassistant/components/evohome/icons.json index 440595932f2874..bbb5757d676c58 100644 --- a/homeassistant/components/evohome/icons.json +++ b/homeassistant/components/evohome/icons.json @@ -1,4 +1,17 @@ { + "entity": { + "button": { + "clear_dhw_override": { + "default": "mdi:water-boiler-auto" + }, + "clear_zone_override": { + "default": "mdi:thermostat-auto" + }, + "reset_system_mode": { + "default": "mdi:thermostat-box-auto" + } + } + }, "services": { "clear_zone_override": { "service": "mdi:motion-sensor-off" diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 0095f65ea20bea..ecdcfbb260cd05 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -39,11 +39,12 @@ async def async_setup_platform( hass: HomeAssistant, - config: ConfigType, + _: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Create a DHW controller.""" + """Set up the water heater platform for Evohome.""" + if discovery_info is None: return diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 9e35b3a87244c0..7e11e42544084a 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -8,10 +8,8 @@ from typing import Any from unittest.mock import MagicMock, patch -from evohomeasync2 import EvohomeClient, HotWater +from evohomeasync2 import ControlSystem, EvohomeClient, HotWater, Zone from evohomeasync2.auth import AbstractTokenManager, Auth -from evohomeasync2.control_system import ControlSystem -from evohomeasync2.zone import Zone from freezegun.api import FrozenDateTimeFactory import pytest @@ -225,11 +223,11 @@ def zone_id(evohome: MagicMock, entity_id: Callable[[Platform, str], str]) -> st """Return the entity_id of evohome's first zone (a Climate entity).""" evo: EvohomeClient = evohome.return_value - ctl: ControlSystem = evo.tcs + tcs: ControlSystem = evo.tcs zone: Zone = evo.tcs.zones[0] - return entity_id(Platform.CLIMATE, f"{zone.id}z" if zone.id == ctl.id else zone.id) + return entity_id(Platform.CLIMATE, f"{zone.id}z" if zone.id == tcs.id else zone.id) @pytest.fixture diff --git a/tests/components/evohome/snapshots/test_button.ambr b/tests/components/evohome/snapshots/test_button.ambr new file mode 100644 index 00000000000000..79dc055277c8f3 --- /dev/null +++ b/tests/components/evohome/snapshots/test_button.ambr @@ -0,0 +1,593 @@ +# serializer version: 1 +# name: test_setup_platform[botched][button.reset_bathroom_dn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Bathroom Dn', + 'status': dict({ + 'zone_id': '3432579', + }), + }), + 'context': , + 'entity_id': 'button.reset_bathroom_dn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[botched][button.reset_dead_zone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Dead Zone', + 'status': dict({ + 'zone_id': '3432521', + }), + }), + 'context': , + 'entity_id': 'button.reset_dead_zone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[botched][button.reset_domestic_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Domestic Hot Water', + 'status': dict({ + 'dhw_id': '3933910', + }), + }), + 'context': , + 'entity_id': 'button.reset_domestic_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[botched][button.reset_front_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Front Room', + 'status': dict({ + 'zone_id': '3432577', + }), + }), + 'context': , + 'entity_id': 'button.reset_front_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[botched][button.reset_kids_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Kids Room', + 'status': dict({ + 'zone_id': '3449703', + }), + }), + 'context': , + 'entity_id': 'button.reset_kids_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[botched][button.reset_kitchen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Kitchen', + 'status': dict({ + 'zone_id': '3432578', + }), + }), + 'context': , + 'entity_id': 'button.reset_kitchen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[botched][button.reset_main_bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Main Bedroom', + 'status': dict({ + 'zone_id': '3432580', + }), + }), + 'context': , + 'entity_id': 'button.reset_main_bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[botched][button.reset_main_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Main Room', + 'status': dict({ + 'zone_id': '3432576', + }), + }), + 'context': , + 'entity_id': 'button.reset_main_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[botched][button.reset_my_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset My Home', + 'status': dict({ + 'system_id': '3432522', + }), + }), + 'context': , + 'entity_id': 'button.reset_my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_bathroom_dn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Bathroom Dn', + 'status': dict({ + 'zone_id': '3432579', + }), + }), + 'context': , + 'entity_id': 'button.reset_bathroom_dn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_dead_zone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Dead Zone', + 'status': dict({ + 'zone_id': '3432521', + }), + }), + 'context': , + 'entity_id': 'button.reset_dead_zone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_domestic_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Domestic Hot Water', + 'status': dict({ + 'dhw_id': '3933910', + }), + }), + 'context': , + 'entity_id': 'button.reset_domestic_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_front_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Front Room', + 'status': dict({ + 'zone_id': '3432577', + }), + }), + 'context': , + 'entity_id': 'button.reset_front_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_kids_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Kids Room', + 'status': dict({ + 'zone_id': '3449703', + }), + }), + 'context': , + 'entity_id': 'button.reset_kids_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_kitchen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Kitchen', + 'status': dict({ + 'zone_id': '3432578', + }), + }), + 'context': , + 'entity_id': 'button.reset_kitchen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_main_bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Main Bedroom', + 'status': dict({ + 'zone_id': '3432580', + }), + }), + 'context': , + 'entity_id': 'button.reset_main_bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_main_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Main Room', + 'status': dict({ + 'zone_id': '3432576', + }), + }), + 'context': , + 'entity_id': 'button.reset_main_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_my_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset My Home', + 'status': dict({ + 'system_id': '3432522', + }), + }), + 'context': , + 'entity_id': 'button.reset_my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[default][button.reset_spare_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Spare Room', + 'status': dict({ + 'zone_id': '3450733', + }), + }), + 'context': , + 'entity_id': 'button.reset_spare_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h032585][button.reset_my_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset My Home', + 'status': dict({ + 'system_id': '416856', + }), + }), + 'context': , + 'entity_id': 'button.reset_my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h032585][button.reset_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset THERMOSTAT', + 'status': dict({ + 'zone_id': '416856', + }), + }), + 'context': , + 'entity_id': 'button.reset_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h099625][button.reset_my_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset My Home', + 'status': dict({ + 'system_id': '8557535', + }), + }), + 'context': , + 'entity_id': 'button.reset_my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h099625][button.reset_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset THERMOSTAT', + 'status': dict({ + 'zone_id': '8557539', + }), + }), + 'context': , + 'entity_id': 'button.reset_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h099625][button.reset_thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset THERMOSTAT', + 'status': dict({ + 'zone_id': '8557541', + }), + }), + 'context': , + 'entity_id': 'button.reset_thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h139906][button.reset_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Thermostat', + 'status': dict({ + 'zone_id': '3454854', + }), + }), + 'context': , + 'entity_id': 'button.reset_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h139906][button.reset_thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Thermostat 2', + 'status': dict({ + 'zone_id': '3454855', + }), + }), + 'context': , + 'entity_id': 'button.reset_thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h139906][button.reset_vr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Vr**********', + 'status': dict({ + 'system_id': '3454856', + }), + }), + 'context': , + 'entity_id': 'button.reset_vr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h157546][button.reset_ba-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Ba******', + 'status': dict({ + 'zone_id': '10090505', + }), + }), + 'context': , + 'entity_id': 'button.reset_ba', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h157546][button.reset_ka-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Ka*********', + 'status': dict({ + 'zone_id': '10090507', + }), + }), + 'context': , + 'entity_id': 'button.reset_ka', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h157546][button.reset_ka_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Ka*****', + 'status': dict({ + 'zone_id': '10090508', + }), + }), + 'context': , + 'entity_id': 'button.reset_ka_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h157546][button.reset_kl-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Kl********', + 'status': dict({ + 'system_id': '10090510', + }), + }), + 'context': , + 'entity_id': 'button.reset_kl', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h157546][button.reset_sl-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Sl********', + 'status': dict({ + 'zone_id': '10090506', + }), + }), + 'context': , + 'entity_id': 'button.reset_sl', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[h157546][button.reset_wo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Wo*******', + 'status': dict({ + 'zone_id': '10090509', + }), + }), + 'context': , + 'entity_id': 'button.reset_wo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[minimal][button.reset_main_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Main Room', + 'status': dict({ + 'zone_id': '3432576', + }), + }), + 'context': , + 'entity_id': 'button.reset_main_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[minimal][button.reset_my_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset My Home', + 'status': dict({ + 'system_id': '3432522', + }), + }), + 'context': , + 'entity_id': 'button.reset_my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[sys_004][button.reset_living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Living room', + 'status': dict({ + 'system_id': '4187769', + }), + }), + 'context': , + 'entity_id': 'button.reset_living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_platform[sys_004][button.reset_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Reset Thermostat', + 'status': dict({ + 'zone_id': '4187768', + }), + }), + 'context': , + 'entity_id': 'button.reset_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index 9526bfd6dcb58c..146534d443cfe1 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -834,7 +834,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'preset_mode': 'eco', @@ -873,7 +873,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'preset_mode': 'eco', @@ -1343,7 +1343,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'preset_mode': 'eco', @@ -1711,7 +1711,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'preset_mode': 'eco', @@ -1797,7 +1797,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'status': dict({ @@ -1875,7 +1875,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'preset_mode': None, @@ -2099,7 +2099,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'preset_mode': None, @@ -2276,7 +2276,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'preset_mode': None, @@ -2456,7 +2456,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'preset_mode': 'eco', @@ -2495,7 +2495,7 @@ , , ]), - 'icon': 'mdi:thermostat', + 'icon': 'mdi:thermostat-box', 'max_temp': 35, 'min_temp': 7, 'preset_mode': None, diff --git a/tests/components/evohome/test_button.py b/tests/components/evohome/test_button.py new file mode 100644 index 00000000000000..bf27157a5f4a1b --- /dev/null +++ b/tests/components/evohome/test_button.py @@ -0,0 +1,132 @@ +"""The tests for the button platform of evohome. + +All evohome systems have a controller and at least one zone. +""" + +from __future__ import annotations + +from collections.abc import Callable +from unittest.mock import MagicMock, patch + +from evohomeasync2 import ControlSystem, EvohomeClient, HotWater, Zone +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant + +from .const import TEST_INSTALLS + + +@pytest.fixture +def system_button_id( + evohome: MagicMock, entity_id: Callable[[Platform, str], str] +) -> str: + """Return the entity_id of the system reset button.""" + + evo: EvohomeClient = evohome.return_value + tcs: ControlSystem = evo.tcs + + return entity_id(Platform.BUTTON, f"{tcs.id}_reset") + + +@pytest.fixture +def dhw_button_id(evohome: MagicMock, entity_id: Callable[[Platform, str], str]) -> str: + """Return the entity_id of the DHW reset button.""" + + evo: EvohomeClient = evohome.return_value + dhw: HotWater | None = evo.tcs.hotwater + + assert dhw is not None, "Fixture has no DHW zone" + + return entity_id(Platform.BUTTON, f"{dhw.id}_reset") + + +@pytest.fixture +def zone_button_id( + evohome: MagicMock, entity_id: Callable[[Platform, str], str] +) -> str: + """Return the entity_id of the first zone's reset button.""" + + evo: EvohomeClient = evohome.return_value + tcs: ControlSystem = evo.tcs + + zone: Zone = evo.tcs.zones[0] + + return entity_id( + Platform.BUTTON, + f"{zone.id}z_reset" if zone.id == tcs.id else f"{zone.id}_reset", + ) + + +@pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"]) +@pytest.mark.usefixtures("evohome") +async def test_setup_platform( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test that button entities are created after setup of evohome.""" + + button_states = hass.states.async_all(BUTTON_DOMAIN) + assert button_states + + for x in button_states: + assert x == snapshot(name=f"{x.entity_id}-state") + + +@pytest.mark.parametrize("install", ["default"]) +@pytest.mark.usefixtures("evohome") +async def test_system_reset_button_press( + hass: HomeAssistant, + system_button_id: str, +) -> None: + """Test SERVICE_PRESS on the system reset button.""" + + with patch("evohomeasync2.control_system.ControlSystem.reset") as mock_fcn: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: system_button_id}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +@pytest.mark.usefixtures("evohome") +async def test_zone_reset_button_press( + hass: HomeAssistant, + zone_button_id: str, +) -> None: + """Test SERVICE_PRESS on a zone reset button.""" + + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: zone_button_id}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +@pytest.mark.usefixtures("evohome") +async def test_dhw_reset_button_press( + hass: HomeAssistant, + dhw_button_id: str, +) -> None: + """Test SERVICE_PRESS on the DHW reset button.""" + + with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: dhw_button_id}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() From b670172867cab24c967bc9704e6fff5f00c434ec Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:01:28 +0200 Subject: [PATCH 0757/1707] Bump tuya-device-handlers to 0.0.17 (#167904) --- homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tuya/snapshots/test_diagnostics.ambr | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 15d9402e2e982a..8fdf861618dca9 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -44,7 +44,7 @@ "iot_class": "cloud_push", "loggers": ["tuya_sharing"], "requirements": [ - "tuya-device-handlers==0.0.16", + "tuya-device-handlers==0.0.17", "tuya-device-sharing-sdk==0.2.8" ] } diff --git a/requirements_all.txt b/requirements_all.txt index de8e03c146b176..d8aaf9ac4bf129 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3154,7 +3154,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.16 +tuya-device-handlers==0.0.17 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0121bffc3f4d40..b3de3942bd166c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2666,7 +2666,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.16 +tuya-device-handlers==0.0.17 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.8 diff --git a/tests/components/tuya/snapshots/test_diagnostics.ambr b/tests/components/tuya/snapshots/test_diagnostics.ambr index 69514c235e4512..005a3e3911c63f 100644 --- a/tests/components/tuya/snapshots/test_diagnostics.ambr +++ b/tests/components/tuya/snapshots/test_diagnostics.ambr @@ -204,6 +204,7 @@ 'online': True, 'product_id': 'gyitctrjj1kefxp2', 'product_name': 'Multifunction alarm', + 'quirk': None, 'set_up': True, 'status': dict({ 'alarm_delay_time': 20, @@ -393,6 +394,7 @@ 'online': True, 'product_id': '4iqe2hsfyd86kwwc', 'product_name': 'Gas sensor', + 'quirk': None, 'set_up': True, 'status': dict({ 'alarm_time': 300, @@ -543,6 +545,7 @@ 'online': True, 'product_id': '9htyiowaf5rtdhrv', 'product_name': '1-433', + 'quirk': None, 'set_up': True, 'status': dict({ 'countdown_1': 0, @@ -684,6 +687,7 @@ 'online': True, 'product_id': '4iqe2hsfyd86kwwc', 'product_name': 'Gas sensor', + 'quirk': None, 'set_up': True, 'status': dict({ 'alarm_time': 300, From 212c9b1a94cc07c2ce00fef9995ccf4ed810f349 Mon Sep 17 00:00:00 2001 From: Marcello <58506324+Marcello17@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:03:49 +0200 Subject: [PATCH 0758/1707] Bump fluss-api to 0.2.4 (#167680) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/fluss/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fluss/manifest.json b/homeassistant/components/fluss/manifest.json index fcd7867ed1a95b..83494d8d77fead 100644 --- a/homeassistant/components/fluss/manifest.json +++ b/homeassistant/components/fluss/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["fluss-api"], "quality_scale": "bronze", - "requirements": ["fluss-api==0.1.9.20"] + "requirements": ["fluss-api==0.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index d8aaf9ac4bf129..8fd3365c98acfa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -996,7 +996,7 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.fluss -fluss-api==0.1.9.20 +fluss-api==0.2.4 # homeassistant.components.flux_led flux-led==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3de3942bd166c..97d885fcfebb17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -884,7 +884,7 @@ flexit_bacnet==2.2.3 flipr-api==1.6.1 # homeassistant.components.fluss -fluss-api==0.1.9.20 +fluss-api==0.2.4 # homeassistant.components.flux_led flux-led==1.2.0 From 3ad2c5e5743663aa9ac053c921121bbe97081733 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Apr 2026 18:20:04 +0200 Subject: [PATCH 0759/1707] Fix config validation in trigger and condition tests (#167683) --- tests/components/common.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/components/common.py b/tests/components/common.py index 7b2459dfc9a233..b7fe50e1fd585e 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -34,8 +34,12 @@ from homeassistant.helpers.condition import ( ConditionCheckerTypeOptional, async_from_config as async_condition_from_config, + async_validate_condition_config, +) +from homeassistant.helpers.trigger import ( + async_initialize_triggers, + async_validate_trigger_config, ) -from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.helpers.typing import UNDEFINED, TemplateVarsType, UndefinedType from homeassistant.setup import async_setup_component @@ -952,9 +956,10 @@ def action(run_variables: TemplateVarsType, context: Context | None = None) -> N def log_cb(level: int, msg: str, **kwargs: Any) -> None: logger._log(level, "%s", msg, **kwargs) + validated_config = await async_validate_trigger_config(hass, [trigger_config]) await async_initialize_triggers( hass, - [trigger_config], + validated_config, action, domain="test", name="test_trigger", @@ -971,7 +976,7 @@ async def create_target_condition( condition_options: dict[str, Any] | None = None, ) -> ConditionCheckerTypeOptional: """Create a target condition.""" - return await async_condition_from_config( + validated_config = await async_validate_condition_config( hass, { CONF_CONDITION: condition, @@ -979,6 +984,7 @@ async def create_target_condition( CONF_OPTIONS: {"behavior": behavior, **(condition_options or {})}, }, ) + return await async_condition_from_config(hass, validated_config) def set_or_remove_state( From 44e51c1103d5389f5b8b5336aa0b057809fe0d93 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 10 Apr 2026 10:21:45 -0600 Subject: [PATCH 0760/1707] Bump pylitterbot to 2025.2.1 (#167921) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 2ed1e72704e45a..f217da5b801a95 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -16,5 +16,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "platinum", - "requirements": ["pylitterbot==2025.2.0"] + "requirements": ["pylitterbot==2025.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8fd3365c98acfa..b6a48107c2ece2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2257,7 +2257,7 @@ pyliebherrhomeapi==0.4.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2025.2.0 +pylitterbot==2025.2.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.28.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97d885fcfebb17..1683262f819687 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1934,7 +1934,7 @@ pyliebherrhomeapi==0.4.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2025.2.0 +pylitterbot==2025.2.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.28.0 From b6d4fca477c7844925b31a42fb050ab9d409b161 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 10 Apr 2026 18:46:06 +0200 Subject: [PATCH 0761/1707] Update frontend to 20260325.7 (#167922) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 284e9f4b77fa4a..e9ec83fd8e412d 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.6"] + "requirements": ["home-assistant-frontend==20260325.7"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a6e604fe1a16f4..5b353e8731eb9c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.0.0 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.6 +home-assistant-frontend==20260325.7 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b6a48107c2ece2..2ed11052f2e51a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1232,7 +1232,7 @@ hole==0.9.0 holidays==0.94 # homeassistant.components.frontend -home-assistant-frontend==20260325.6 +home-assistant-frontend==20260325.7 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1683262f819687..607f3e4ea64a1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1096,7 +1096,7 @@ hole==0.9.0 holidays==0.94 # homeassistant.components.frontend -home-assistant-frontend==20260325.6 +home-assistant-frontend==20260325.7 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From 00560abd9c7f0aba83878b0e51578ac7dd33fc65 Mon Sep 17 00:00:00 2001 From: On Freund Date: Fri, 10 Apr 2026 12:47:51 -0400 Subject: [PATCH 0762/1707] Bump pyrisco to 0.6.8 (#167924) --- homeassistant/components/risco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 43d471172d61ae..75fa1261e34f1f 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "requirements": ["pyrisco==0.6.7"] + "requirements": ["pyrisco==0.6.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2ed11052f2e51a..1d349e380ff5ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2433,7 +2433,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.7 +pyrisco==0.6.8 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 607f3e4ea64a1e..0f21beb360e6a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2080,7 +2080,7 @@ pyrainbird==6.3.0 pyrate-limiter==4.1.0 # homeassistant.components.risco -pyrisco==0.6.7 +pyrisco==0.6.8 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.7 From 7690d9570c2f95042c7541bd90912672732a9446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 10 Apr 2026 18:26:31 +0100 Subject: [PATCH 0763/1707] Narrow log check on ring event test (#167927) --- tests/components/event/test_init.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 0df0b152d4260a..aa14f2ece4d15b 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -405,9 +405,12 @@ async def async_setup_entry_init( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert ( - "Entity event.doorbell_without_ring is a doorbell event entity " - "but does not support the 'ring' event type" - ) in caplog.text - assert "event.doorbell_with_ring" not in caplog.text - assert "event.button" not in caplog.text + def get_error_message(entity_id: str) -> str: + return ( + f"Entity {entity_id} is a doorbell event entity but does not support " + "the 'ring' event type" + ) + + assert get_error_message("event.doorbell_without_ring") in caplog.text + assert get_error_message("event.doorbell_with_ring") not in caplog.text + assert get_error_message("event.button") not in caplog.text From 99e4c87f5e9af55521191e04751bfe81065daad5 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 10 Apr 2026 10:55:33 -0700 Subject: [PATCH 0764/1707] Add reauthentication and reconfiguration flows in Google Weather to reach platinum (#166106) --- .../components/google_weather/config_flow.py | 122 ++++++-- .../components/google_weather/coordinator.py | 10 + .../components/google_weather/manifest.json | 2 +- .../google_weather/quality_scale.yaml | 4 +- .../components/google_weather/strings.json | 11 +- .../google_weather/test_config_flow.py | 290 +++++++++++++++++- tests/components/google_weather/test_init.py | 21 +- .../components/google_weather/test_sensor.py | 30 +- 8 files changed, 464 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/google_weather/config_flow.py b/homeassistant/components/google_weather/config_flow.py index 661146ab01d97e..b03890e8a5297a 100644 --- a/homeassistant/components/google_weather/config_flow.py +++ b/homeassistant/components/google_weather/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -9,6 +10,9 @@ import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, ConfigEntry, ConfigEntryState, ConfigFlow, @@ -81,11 +85,16 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema: def _is_location_already_configured( - hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4 + hass: HomeAssistant, + new_data: dict[str, float], + epsilon: float = 1e-4, + exclude_subentry_id: str | None = None, ) -> bool: """Check if the location is already configured.""" for entry in hass.config_entries.async_entries(DOMAIN): for subentry in entry.subentries.values(): + if exclude_subentry_id and subentry.subentry_id == exclude_subentry_id: + continue # A more accurate way is to use the haversine formula, but for simplicity # we use a simple distance check. The epsilon value is small anyway. # This is mostly to capture cases where the user has slightly moved the location pin. @@ -106,7 +115,7 @@ class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle a flow initialized by the user, reauth or reconfigure.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = { "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", @@ -116,21 +125,45 @@ async def async_step_user( api_key = user_input[CONF_API_KEY] referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER) self._async_abort_entries_match({CONF_API_KEY: api_key}) - if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): - return self.async_abort(reason="already_configured") + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) + subentry = next(iter(entry.subentries.values()), None) + if subentry: + latitude = subentry.data[CONF_LATITUDE] + longitude = subentry.data[CONF_LONGITUDE] + else: + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + validation_input = { + CONF_LOCATION: {CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude} + } + else: + if _is_location_already_configured( + self.hass, user_input[CONF_LOCATION] + ): + return self.async_abort(reason="already_configured") + validation_input = user_input + api = GoogleWeatherApi( session=async_get_clientsession(self.hass), api_key=api_key, referrer=referrer, language_code=self.hass.config.language, ) - if await _validate_input(user_input, api, errors, description_placeholders): + if await _validate_input( + validation_input, api, errors, description_placeholders + ): + data = {CONF_API_KEY: api_key, CONF_REFERRER: referrer} + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + return self.async_update_reload_and_abort(entry, data=data) + return self.async_create_entry( title="Google Weather", - data={ - CONF_API_KEY: api_key, - CONF_REFERRER: referrer, - }, + data=data, subentries=[ { "subentry_type": "location", @@ -140,19 +173,47 @@ async def async_step_user( }, ], ) + + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) + if user_input is None: + user_input = { + CONF_API_KEY: entry.data.get(CONF_API_KEY), + SECTION_API_KEY_OPTIONS: { + CONF_REFERRER: entry.data.get(CONF_REFERRER) + }, + } + schema = STEP_USER_DATA_SCHEMA else: - user_input = {} - schema = STEP_USER_DATA_SCHEMA.schema.copy() - schema.update(_get_location_schema(self.hass).schema) + if user_input is None: + user_input = {} + schema_dict = STEP_USER_DATA_SCHEMA.schema.copy() + schema_dict.update(_get_location_schema(self.hass).schema) + schema = vol.Schema(schema_dict) + return self.async_show_form( step_id="user", - data_schema=self.add_suggested_values_to_schema( - vol.Schema(schema), user_input - ), + data_schema=self.add_suggested_values_to_schema(schema, user_input), errors=errors, description_placeholders=description_placeholders, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth flow.""" + return await self.async_step_user() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow.""" + return await self.async_step_user(user_input) + @classmethod @callback def async_get_supported_subentry_types( @@ -165,6 +226,11 @@ def async_get_supported_subentry_types( class LocationSubentryFlowHandler(ConfigSubentryFlow): """Handle a subentry flow for location.""" + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == SOURCE_USER + async def async_step_location( self, user_input: dict[str, Any] | None = None, @@ -176,16 +242,35 @@ async def async_step_location( errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} if user_input is not None: - if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): + exclude_id = ( + None if self._is_new else self._get_reconfigure_subentry().subentry_id + ) + if _is_location_already_configured( + self.hass, user_input[CONF_LOCATION], exclude_subentry_id=exclude_id + ): return self.async_abort(reason="already_configured") api: GoogleWeatherApi = self._get_entry().runtime_data.api if await _validate_input(user_input, api, errors, description_placeholders): - return self.async_create_entry( + if self._is_new: + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input[CONF_LOCATION], + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), title=user_input[CONF_NAME], data=user_input[CONF_LOCATION], ) - else: + elif self._is_new: user_input = {} + else: + subentry = self._get_reconfigure_subentry() + user_input = { + CONF_NAME: subentry.title, + CONF_LOCATION: dict(subentry.data), + } + return self.async_show_form( step_id="location", data_schema=self.add_suggested_values_to_schema( @@ -196,3 +281,4 @@ async def async_step_location( ) async_step_user = async_step_location + async_step_reconfigure = async_step_location diff --git a/homeassistant/components/google_weather/coordinator.py b/homeassistant/components/google_weather/coordinator.py index 695dc5ea19128a..3c66186394e8d9 100644 --- a/homeassistant/components/google_weather/coordinator.py +++ b/homeassistant/components/google_weather/coordinator.py @@ -12,6 +12,7 @@ CurrentConditionsResponse, DailyForecastResponse, GoogleWeatherApi, + GoogleWeatherApiAuthError, GoogleWeatherApiError, HourlyForecastResponse, ) @@ -19,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( TimestampDataUpdateCoordinator, UpdateFailed, @@ -92,6 +94,14 @@ async def _async_update_data(self) -> T: self.subentry.data[CONF_LATITUDE], self.subentry.data[CONF_LONGITUDE], ) + except GoogleWeatherApiAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={ + "error": str(err), + }, + ) from err except GoogleWeatherApiError as err: _LOGGER.error( "Error fetching %s for %s: %s", diff --git a/homeassistant/components/google_weather/manifest.json b/homeassistant/components/google_weather/manifest.json index 4f22a57d875f2e..e7ec2e05563d1e 100644 --- a/homeassistant/components/google_weather/manifest.json +++ b/homeassistant/components/google_weather/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["google_weather_api"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["python-google-weather-api==0.0.6"] } diff --git a/homeassistant/components/google_weather/quality_scale.yaml b/homeassistant/components/google_weather/quality_scale.yaml index ec5e4edbb4177d..4ae4a8358a3997 100644 --- a/homeassistant/components/google_weather/quality_scale.yaml +++ b/homeassistant/components/google_weather/quality_scale.yaml @@ -38,7 +38,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -68,7 +68,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No repairs. diff --git a/homeassistant/components/google_weather/strings.json b/homeassistant/components/google_weather/strings.json index 977adb306fc021..7b8ab5b060cc0b 100644 --- a/homeassistant/components/google_weather/strings.json +++ b/homeassistant/components/google_weather/strings.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}", @@ -38,7 +40,8 @@ "location": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", - "entry_not_loaded": "Cannot add things while the configuration is disabled." + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "Location", "error": { @@ -46,6 +49,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "initiate_flow": { + "reconfigure": "Reconfigure location", "user": "Add location" }, "step": { @@ -100,6 +104,9 @@ } }, "exceptions": { + "auth_error": { + "message": "Authentication failed: {error}" + }, "update_error": { "message": "Error fetching weather data: {error}" } diff --git a/tests/components/google_weather/test_config_flow.py b/tests/components/google_weather/test_config_flow.py index 719c545beb5be8..b32a77b52ffdab 100644 --- a/tests/components/google_weather/test_config_flow.py +++ b/tests/components/google_weather/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from google_weather_api import GoogleWeatherApiError +from google_weather_api import GoogleWeatherApiAuthError, GoogleWeatherApiError import pytest from homeassistant import config_entries @@ -359,6 +359,294 @@ async def test_subentry_flow_location_already_configured( assert len(entry.subentries) == 1 +async def test_reauth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "new-api-key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key" + assert mock_config_entry.data.get(CONF_REFERRER) is None + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("api_exception", "expected_error", "expected_placeholders"), + [ + ( + GoogleWeatherApiAuthError("Invalid API key"), + "cannot_connect", + { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + "error_message": "Invalid API key", + "name": "Google Weather", + }, + ), + ( + ValueError(), + "unknown", + { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + "name": "Google Weather", + }, + ), + ], +) +async def test_reauth_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + api_exception: Exception, + expected_error: str, + expected_placeholders: dict[str, str], +) -> None: + """Test reauth flow with exceptions.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + mock_google_weather_api.async_get_current_conditions.side_effect = api_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "invalid-api-key", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + assert result["description_placeholders"] == expected_placeholders + + mock_google_weather_api.async_get_current_conditions.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "valid-api-key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_same_api_key_different_referrer( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reauth flow with same API key but different referrer.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + SECTION_API_KEY_OPTIONS: { + CONF_REFERRER: "new-referrer", + }, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "test-api-key" + assert mock_config_entry.data.get(CONF_REFERRER) == "new-referrer" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "new-api-key", + SECTION_API_KEY_OPTIONS: { + CONF_REFERRER: "new-referrer", + }, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key" + assert mock_config_entry.data.get(CONF_REFERRER) == "new-referrer" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("api_exception", "expected_error", "expected_placeholders"), + [ + ( + GoogleWeatherApiAuthError("Invalid API key"), + "cannot_connect", + { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + "error_message": "Invalid API key", + }, + ), + ( + ValueError(), + "unknown", + { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + }, + ), + ], +) +async def test_reconfigure_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + api_exception: Exception, + expected_error: str, + expected_placeholders: dict[str, str], +) -> None: + """Test reconfigure flow with exceptions.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + mock_google_weather_api.async_get_current_conditions.side_effect = api_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "invalid-api-key", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + assert result["description_placeholders"] == expected_placeholders + + mock_google_weather_api.async_get_current_conditions.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "valid-api-key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_no_subentries( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reconfigure flow when there are no subentries.""" + mock_config_entry = MockConfigEntry( + title="Google Weather", + domain=DOMAIN, + data={ + CONF_API_KEY: "test-api-key", + }, + ) + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "new-api-key", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new-api-key" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_subentry_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test reconfiguring a location subentry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + subentry = next(iter(mock_config_entry.subentries.values())) + result = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "location" + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_NAME: "New Work", + CONF_LOCATION: { + CONF_LATITUDE: 30.1, + CONF_LONGITUDE: 40.1, + }, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + # Reload the entry to see changes + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + updated_subentry = entry.subentries[subentry.subentry_id] + + assert updated_subentry.title == "New Work" + assert updated_subentry.data == { + CONF_LATITUDE: 30.1, + CONF_LONGITUDE: 40.1, + } + + async def test_subentry_flow_entry_not_loaded( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/google_weather/test_init.py b/tests/components/google_weather/test_init.py index aa3b6629e94a39..6bc1d3ccc89376 100644 --- a/tests/components/google_weather/test_init.py +++ b/tests/components/google_weather/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from google_weather_api import GoogleWeatherApiError +from google_weather_api import GoogleWeatherApiAuthError, GoogleWeatherApiError import pytest from homeassistant.components.google_weather.const import DOMAIN @@ -56,6 +56,25 @@ async def test_config_not_ready( assert "Error fetching weather data: API error" in caplog.text +async def test_setup_auth_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test auth failed during setup.""" + mock_google_weather_api.async_get_current_conditions.side_effect = ( + GoogleWeatherApiAuthError() + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any( + flow["step_id"] == "user" + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + ) + + async def test_unload_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/google_weather/test_sensor.py b/tests/components/google_weather/test_sensor.py index 73d170e71fd55d..aa5b69fc98fea3 100644 --- a/tests/components/google_weather/test_sensor.py +++ b/tests/components/google_weather/test_sensor.py @@ -4,14 +4,16 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from google_weather_api import GoogleWeatherApiError +from google_weather_api import GoogleWeatherApiAuthError, GoogleWeatherApiError import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.google_weather.const import DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -170,3 +172,29 @@ async def test_state_update( state = hass.states.get(entity_id) assert state assert state.state == "15.0" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_auth_failure_during_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure that we start a reauth flow when auth fails during an update.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_google_weather_api.async_get_current_conditions.side_effect = ( + GoogleWeatherApiAuthError() + ) + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert any( + flow["step_id"] == "user" + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + ) From 4658f4246d00ce46c04a850238a6b76160a260be Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:59:05 +0200 Subject: [PATCH 0765/1707] Allow frontend-handled issues to omit description in strings (#167928) --- homeassistant/components/sensor/strings.json | 3 -- homeassistant/components/vacuum/strings.json | 1 - homeassistant/helpers/issue_registry.py | 7 +++ script/hassfest/translations.py | 45 +++++++++++--------- tests/components/conftest.py | 6 ++- 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 6f8ef1ae530a16..33b56f1b0f1df1 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -330,15 +330,12 @@ }, "issues": { "mean_type_changed": { - "description": "", "title": "The mean type of {statistic_id} has changed" }, "state_class_removed": { - "description": "", "title": "{statistic_id} no longer has a state class" }, "units_changed": { - "description": "", "title": "The unit of {statistic_id} has changed" } }, diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 364a4bfef0ee79..02a8605c369cc0 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -89,7 +89,6 @@ }, "issues": { "segments_changed": { - "description": "", "title": "Vacuum segments have changed for {entity_id}" } }, diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index ce12d1f19da760..46686ee7f110a7 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -29,6 +29,13 @@ STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MINOR = 2 +# Issues that are handled entirely by the frontend and don't need +# a description or fix_flow. +FRONTEND_HANDLED_ISSUES: dict[str, set[str]] = { + "sensor": {"mean_type_changed", "state_class_removed", "units_changed"}, + "vacuum": {"segments_changed"}, +} + class EventIssueRegistryUpdatedData(TypedDict): """Event data for when the issue registry is updated.""" diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 14993dd8df1c9c..0fcb5809b1c77d 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -12,6 +12,7 @@ from voluptuous.humanize import humanize_error import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import FRONTEND_HANDLED_ISSUES from script.translations import upload from .model import Config, Integration, IntegrationType @@ -305,25 +306,31 @@ def name_validator(value: dict[str, Any]) -> dict[str, Any]: def gen_issues_schema(config: Config, integration: Integration) -> dict[str, Any]: """Generate the issues schema.""" - return { - str: vol.All( - cv.has_at_least_one_key("description", "fix_flow"), - vol.Schema( - { - vol.Required("title"): translation_value_validator, - vol.Exclusive( - "description", "fixable" - ): translation_value_validator, - vol.Exclusive("fix_flow", "fixable"): gen_data_entry_schema( - config=config, - integration=integration, - flow_title=UNDEFINED, - require_step_title=False, - ), - }, - ), - ) - } + issue_schema = vol.All( + cv.has_at_least_one_key("description", "fix_flow"), + vol.Schema( + { + vol.Required("title"): translation_value_validator, + vol.Exclusive("description", "fixable"): translation_value_validator, + vol.Exclusive("fix_flow", "fixable"): gen_data_entry_schema( + config=config, + integration=integration, + flow_title=UNDEFINED, + require_step_title=False, + ), + }, + ), + ) + + frontend_issue_schema = vol.Schema( + {vol.Required("title"): translation_value_validator} + ) + + schema: dict[str, Any] = {} + for key in FRONTEND_HANDLED_ISSUES.get(integration.domain, ()): + schema[vol.Optional(key)] = frontend_issue_schema + schema[str] = issue_schema + return schema _EXCEPTIONS_SCHEMA = { diff --git a/tests/components/conftest.py b/tests/components/conftest.py index f516fb25fa5119..52b3445fb1c89b 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1157,7 +1157,11 @@ async def _check_create_issue_translations( f"{issue.translation_key}.title", issue.translation_placeholders, ) - if not issue.is_fixable: + if ( + not issue.is_fixable + and issue.translation_key + not in ir.FRONTEND_HANDLED_ISSUES.get(issue.domain, ()) + ): # Description is required for non-fixable issues await _validate_translation( issue_registry.hass, From fb90237ae35838512a62b6fe0758245e101f8c41 Mon Sep 17 00:00:00 2001 From: potelux Date: Fri, 10 Apr 2026 13:08:09 -0500 Subject: [PATCH 0766/1707] Proxy Jellyfin artwork through HA so thumbnails work over HTTPS (#167238) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- homeassistant/components/jellyfin/media_player.py | 3 ++- tests/components/jellyfin/test_media_player.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 2be3090410e19a..893d35677d1c38 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -57,6 +57,8 @@ def handle_coordinator_update() -> None: class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): """Represents a Jellyfin Player device.""" + _attr_media_image_remotely_accessible = False + def __init__( self, coordinator: JellyfinDataUpdateCoordinator, @@ -168,7 +170,6 @@ def _update_from_session_data(self) -> None: self._attr_media_duration = media_duration self._attr_media_position = media_position self._attr_media_position_updated_at = media_position_updated - self._attr_media_image_remotely_accessible = True @property def media_image_url(self) -> str | None: diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index f24de820f93d05..d3645bd4d0171c 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -126,10 +126,12 @@ async def test_media_player_music( assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) is None assert state.attributes.get(ATTR_MEDIA_SEASON) is None assert state.attributes.get(ATTR_MEDIA_EPISODE) is None - assert ( - state.attributes.get(ATTR_ENTITY_PICTURE) - == "http://localhost/Items/ALBUM-UUID/Images/Primary.jpg" + entity_picture = state.attributes.get(ATTR_ENTITY_PICTURE) + assert entity_picture is not None + assert entity_picture.startswith( + "/api/media_player_proxy/media_player.jellyfin_device_four?token=" ) + assert "cache=7f15194cd71877c7" in entity_picture entry = entity_registry.async_get(state.entity_id) assert entry From d633ac8120efe0b35c221a56bf0c3d38123b0261 Mon Sep 17 00:00:00 2001 From: Nick Haghiri <59633028+ElCruncharino@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:12:24 -0400 Subject: [PATCH 0767/1707] Improve error logging for Backblaze B2 upload failures (#167721) --- .../components/backblaze_b2/backup.py | 31 +++---- tests/components/backblaze_b2/test_backup.py | 88 ++++++++++++++++++- 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/backblaze_b2/backup.py b/homeassistant/components/backblaze_b2/backup.py index ec92a41a5dce2c..5098c96b0eeca8 100644 --- a/homeassistant/components/backblaze_b2/backup.py +++ b/homeassistant/components/backblaze_b2/backup.py @@ -101,8 +101,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> T: try: return await func(*args, **kwargs) except B2Error as err: - error_msg = f"Failed during {func.__name__}" - raise BackupAgentError(error_msg) from err + raise BackupAgentError(f"Failed during {func.__name__}: {err}") from err return wrapper @@ -170,8 +169,7 @@ def _is_cache_valid(self, expiration_time: float) -> bool: async def _cleanup_failed_upload(self, filename: str) -> None: """Clean up a partially uploaded file after upload failure.""" _LOGGER.warning( - "Attempting to delete partially uploaded main backup file %s " - "due to metadata upload failure", + "Attempting to delete partially uploaded backup file %s", filename, ) try: @@ -180,11 +178,10 @@ async def _cleanup_failed_upload(self, filename: str) -> None: ) await self._hass.async_add_executor_job(uploaded_main_file_info.delete) except B2Error: - _LOGGER.debug( - "Failed to clean up partially uploaded main backup file %s. " - "Manual intervention may be required to delete it from Backblaze B2", + _LOGGER.warning( + "Failed to clean up partially uploaded backup file %s;" + " manual deletion from Backblaze B2 may be required", filename, - exc_info=True, ) else: _LOGGER.debug( @@ -256,9 +253,10 @@ async def async_upload_backup( prefixed_metadata_filename, ) - upload_successful = False + tar_uploaded = False try: await self._upload_backup_file(prefixed_tar_filename, open_stream, {}) + tar_uploaded = True _LOGGER.debug( "Main backup file upload finished for %s", prefixed_tar_filename ) @@ -270,15 +268,14 @@ async def async_upload_backup( _LOGGER.debug( "Metadata file upload finished for %s", prefixed_metadata_filename ) - upload_successful = True - finally: - if upload_successful: - _LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename) - self._invalidate_caches( - backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename - ) - else: + _LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename) + self._invalidate_caches( + backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename + ) + except B2Error: + if tar_uploaded: await self._cleanup_failed_upload(prefixed_tar_filename) + raise def _upload_metadata_file_sync( self, metadata_content: bytes, filename: str diff --git a/tests/components/backblaze_b2/test_backup.py b/tests/components/backblaze_b2/test_backup.py index 32bbd8866e895b..f076c472928216 100644 --- a/tests/components/backblaze_b2/test_backup.py +++ b/tests/components/backblaze_b2/test_backup.py @@ -510,7 +510,93 @@ async def test_upload_with_cleanup_failure( assert resp.status == 201 assert any( - "Failed to clean up partially uploaded main backup file" in msg + "Failed to clean up partially uploaded backup file" in msg + for msg in caplog.messages + ) + + +async def test_tar_upload_failure_skips_cleanup( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that cleanup is not attempted when tar upload itself fails.""" + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + patch.object( + BucketSimulator, + "upload_unbound_stream", + side_effect=B2Error("Connection reset"), + ), + patch.object( + BucketSimulator, + "get_file_info_by_name", + ) as mock_get_file_info, + caplog.at_level(logging.DEBUG), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + mock_get_file_info.assert_not_called() + assert not any( + "Attempting to delete partially uploaded" in msg for msg in caplog.messages + ) + assert any("Connection reset" in msg for msg in caplog.messages) + + +async def test_handle_b2_errors_logs_root_cause( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the actual B2 error is logged when upload fails.""" + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + patch.object( + BucketSimulator, + "upload_bytes", + side_effect=B2Error("Service unavailable"), + ), + patch.object( + BucketSimulator, + "get_file_info_by_name", + return_value=Mock(delete=Mock()), + ), + caplog.at_level(logging.ERROR), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert any( + "Failed during async_upload_backup: Service unavailable" in msg for msg in caplog.messages ) From e88022c2cc1aadc3005d6a5301c8ca08f1f57cda Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:13:11 -0400 Subject: [PATCH 0768/1707] Add remote platform to Vizio integration (#165820) Co-authored-by: Claude Opus 4.6 --- homeassistant/components/vizio/__init__.py | 2 +- homeassistant/components/vizio/remote.py | 89 ++++++ homeassistant/components/vizio/strings.json | 5 + tests/components/vizio/conftest.py | 10 + tests/components/vizio/test_remote.py | 285 ++++++++++++++++++++ 5 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vizio/remote.py create mode 100644 tests/components/vizio/test_remote.py diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index ecf0342ae2f86d..43a8f51fadcbee 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -31,7 +31,7 @@ DATA_APPS: HassKey[VizioAppsDataUpdateCoordinator] = HassKey(f"{DOMAIN}_apps") CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/vizio/remote.py b/homeassistant/components/vizio/remote.py new file mode 100644 index 00000000000000..5a17fc525eded1 --- /dev/null +++ b/homeassistant/components/vizio/remote.py @@ -0,0 +1,89 @@ +"""Remote platform for Vizio SmartCast devices.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.components.remote import ( + ATTR_DELAY_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + RemoteEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import VizioConfigEntry, VizioDeviceCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VizioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up a Vizio remote entity.""" + async_add_entities([VizioRemote(config_entry)]) + + +class VizioRemote(CoordinatorEntity[VizioDeviceCoordinator], RemoteEntity): + """Remote entity for Vizio SmartCast devices.""" + + _attr_has_entity_name = True + + def __init__(self, config_entry: VizioConfigEntry) -> None: + """Initialize the remote entity.""" + coordinator = config_entry.runtime_data.device_coordinator + super().__init__(coordinator) + self._attr_unique_id = unique_id = config_entry.unique_id + # Guard against config entries missing unique_id, which should never happen + if TYPE_CHECKING: + assert unique_id is not None + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) + self._device = coordinator.device + valid_keys = set(self._device.get_remote_keys_list()) + self._command_map: dict[str, str] = {key.lower(): key for key in valid_keys} + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.coordinator.data.is_on + + def _resolve_command(self, command: str) -> str: + """Resolve an lowercased command string to a pyvizio key name.""" + if resolved := self._command_map.get(command): + return resolved + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unknown_command", + translation_placeholders={"command": command}, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + await self._device.pow_on(log_api_exception=False) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + await self._device.pow_off(log_api_exception=False) + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send remote commands to the device.""" + num_repeats: int = kwargs.get(ATTR_NUM_REPEATS, 1) + delay: float = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + resolved = [vol.All(vol.Lower, self._resolve_command)(cmd) for cmd in command] + + for i in range(num_repeats): + for cmd in resolved: + await self._device.remote(cmd, log_api_exception=False) + if i < num_repeats - 1: + await asyncio.sleep(delay) diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 04fb7e9863b438..f305f4da410d87 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -40,6 +40,11 @@ } } }, + "exceptions": { + "unknown_command": { + "message": "Unknown remote command `{command}`. Valid commands for this device are listed in the integration documentation." + } + }, "options": { "step": { "init": { diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index 50a04b100d2aa6..aec5b5e906f1a4 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -61,6 +61,16 @@ def vizio_data_coordinator_update_fixture() -> Generator[None]: yield +@pytest.fixture(autouse=True) +def no_delay_secs() -> Generator[None]: + """Patch default delay between remote command repeats to 0.""" + with patch( + "homeassistant.components.vizio.remote.DEFAULT_DELAY_SECS", + 0, + ): + yield + + @pytest.fixture(name="vizio_data_coordinator_update_failure") def vizio_data_coordinator_update_failure_fixture() -> Generator[None]: """Mock get data coordinator update failure.""" diff --git a/tests/components/vizio/test_remote.py b/tests/components/vizio/test_remote.py new file mode 100644 index 00000000000000..bf7538a10f517d --- /dev/null +++ b/tests/components/vizio/test_remote.py @@ -0,0 +1,285 @@ +"""Tests for Vizio remote platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from homeassistant.components.remote import ( + ATTR_COMMAND, + ATTR_DELAY_SECS, + ATTR_NUM_REPEATS, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.components.vizio.const import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify + +from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, NAME, UNIQUE_ID + +from tests.common import MockConfigEntry + +REMOTE_ENTITY_ID = f"{REMOTE_DOMAIN}.{slugify(NAME)}" + + +async def _setup_entry( + hass: HomeAssistant, config: dict, unique_id: str = UNIQUE_ID +) -> MockConfigEntry: + """Set up a Vizio config entry and return it.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=config, unique_id=unique_id) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return config_entry + + +@pytest.mark.parametrize( + "config", + [MOCK_USER_VALID_TV_CONFIG, MOCK_SPEAKER_CONFIG], + ids=["tv", "speaker"], +) +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_remote_entity_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config: dict, +) -> None: + """Test remote entity is created for TV and speaker.""" + await _setup_entry(hass, config) + state = hass.states.get(REMOTE_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + entry = entity_registry.async_get(REMOTE_ENTITY_ID) + assert entry is not None + assert entry.unique_id == UNIQUE_ID + + +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_remote_is_off_when_device_off(hass: HomeAssistant) -> None: + """Test remote state is off when device is off.""" + with patch( + "homeassistant.components.vizio.VizioAsync.get_power_state", + return_value=False, + ): + await _setup_entry(hass, MOCK_SPEAKER_CONFIG) + state = hass.states.get(REMOTE_ENTITY_ID) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_TURN_ON, "pow_on"), + (SERVICE_TURN_OFF, "pow_off"), + ], +) +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_turn_on_off(hass: HomeAssistant, service: str, mock_method: str) -> None: + """Test turning on/off the remote sends the correct power command.""" + await _setup_entry(hass, MOCK_SPEAKER_CONFIG) + with patch( + f"homeassistant.components.vizio.VizioAsync.{mock_method}", + ) as mock_power: + await hass.services.async_call( + REMOTE_DOMAIN, + service, + {ATTR_ENTITY_ID: REMOTE_ENTITY_ID}, + blocking=True, + ) + mock_power.assert_called_once_with(log_api_exception=False) + + +@pytest.mark.parametrize( + ("command", "expected_key"), + [ + # Native keys (lowercase tested for a few to verify case-insensitivity) + ("BACK", "BACK"), + ("CC_TOGGLE", "CC_TOGGLE"), + ("ch_up", "CH_UP"), + ("menu", "MENU"), + ("SMARTCAST", "SMARTCAST"), + ], +) +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_send_command_tv_valid( + hass: HomeAssistant, command: str, expected_key: str +) -> None: + """Test send_command resolves valid TV commands.""" + await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + with patch( + "homeassistant.components.vizio.VizioAsync.remote", + ) as mock_remote: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: REMOTE_ENTITY_ID, + ATTR_COMMAND: [command], + }, + blocking=True, + ) + mock_remote.assert_called_once_with(expected_key, log_api_exception=False) + + +@pytest.mark.parametrize("command", ["INVALID_KEY", "not_a_key"]) +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_send_command_tv_invalid(hass: HomeAssistant, command: str) -> None: + """Test send_command raises error for invalid TV commands.""" + await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: REMOTE_ENTITY_ID, + ATTR_COMMAND: [command], + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("command", "expected_key"), + [ + # Native keys (lowercase tested for a couple) + ("MUTE_OFF", "MUTE_OFF"), + ("MUTE_ON", "MUTE_ON"), + ("MUTE_TOGGLE", "MUTE_TOGGLE"), + ("pause", "PAUSE"), + ("PLAY", "PLAY"), + ("POW_OFF", "POW_OFF"), + ("POW_ON", "POW_ON"), + ("POW_TOGGLE", "POW_TOGGLE"), + ("vol_down", "VOL_DOWN"), + ("VOL_UP", "VOL_UP"), + ], +) +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_send_command_speaker_valid( + hass: HomeAssistant, command: str, expected_key: str +) -> None: + """Test send_command resolves valid speaker commands.""" + await _setup_entry(hass, MOCK_SPEAKER_CONFIG) + with patch( + "homeassistant.components.vizio.VizioAsync.remote", + ) as mock_remote: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: REMOTE_ENTITY_ID, + ATTR_COMMAND: [command], + }, + blocking=True, + ) + mock_remote.assert_called_once_with(expected_key, log_api_exception=False) + + +@pytest.mark.parametrize( + "command", + [ + # TV-only native keys + "MENU", + "CH_UP", + "INPUT_NEXT", + # Completely invalid + "INVALID_KEY", + ], +) +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_send_command_speaker_invalid(hass: HomeAssistant, command: str) -> None: + """Test speaker remote rejects TV-only and invalid keys.""" + await _setup_entry(hass, MOCK_SPEAKER_CONFIG) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: REMOTE_ENTITY_ID, + ATTR_COMMAND: [command], + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_send_command_multiple(hass: HomeAssistant) -> None: + """Test send_command with multiple commands in one call.""" + await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + with patch( + "homeassistant.components.vizio.VizioAsync.remote", + ) as mock_remote: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: REMOTE_ENTITY_ID, + ATTR_COMMAND: ["UP", "OK"], + }, + blocking=True, + ) + assert mock_remote.call_count == 2 + mock_remote.assert_any_call("UP", log_api_exception=False) + mock_remote.assert_any_call("OK", log_api_exception=False) + + +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_send_command_invalid_skips_valid(hass: HomeAssistant) -> None: + """Test that no commands are sent when one command in the list is invalid.""" + await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + with ( + patch( + "homeassistant.components.vizio.VizioAsync.remote", + ) as mock_remote, + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: REMOTE_ENTITY_ID, + ATTR_COMMAND: ["UP", "INVALID_KEY"], + }, + blocking=True, + ) + mock_remote.assert_not_called() + + +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_send_command_delay_between_repeats(hass: HomeAssistant) -> None: + """Test delay is applied between repeats but not after the last one.""" + await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + with ( + patch( + "homeassistant.components.vizio.VizioAsync.remote", + ) as mock_remote, + patch( + "homeassistant.components.vizio.remote.asyncio.sleep", + ) as mock_sleep, + ): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: REMOTE_ENTITY_ID, + ATTR_COMMAND: ["UP"], + ATTR_NUM_REPEATS: 3, + ATTR_DELAY_SECS: 0.5, + }, + blocking=True, + ) + assert mock_remote.call_count == 3 + assert mock_sleep.call_count == 2 + mock_sleep.assert_called_with(0.5) From c9ee533916bb9ed397adaf0fc08a0df35d58ec0d Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:17:04 +0300 Subject: [PATCH 0769/1707] Update Liebherr to platinum (#167836) --- .strict-typing | 1 + homeassistant/components/liebherr/config_flow.py | 8 +++++--- homeassistant/components/liebherr/light.py | 9 +++++---- homeassistant/components/liebherr/manifest.json | 2 +- homeassistant/components/liebherr/quality_scale.yaml | 2 +- mypy.ini | 10 ++++++++++ 6 files changed, 23 insertions(+), 9 deletions(-) diff --git a/.strict-typing b/.strict-typing index 5e1549256616c9..695c7faa99d436 100644 --- a/.strict-typing +++ b/.strict-typing @@ -332,6 +332,7 @@ homeassistant.components.letpot.* homeassistant.components.lg_infrared.* homeassistant.components.libre_hardware_monitor.* homeassistant.components.lidarr.* +homeassistant.components.liebherr.* homeassistant.components.lifx.* homeassistant.components.light.* homeassistant.components.linkplay.* diff --git a/homeassistant/components/liebherr/config_flow.py b/homeassistant/components/liebherr/config_flow.py index 8aa1f5628934d0..5f0686f1113cf7 100644 --- a/homeassistant/components/liebherr/config_flow.py +++ b/homeassistant/components/liebherr/config_flow.py @@ -6,7 +6,7 @@ import logging from typing import Any -from pyliebherrhomeapi import LiebherrClient +from pyliebherrhomeapi import Device, LiebherrClient from pyliebherrhomeapi.exceptions import ( LiebherrAuthenticationError, LiebherrConnectionError, @@ -31,10 +31,12 @@ class LiebherrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for liebherr.""" - async def _validate_api_key(self, api_key: str) -> tuple[list, dict[str, str]]: + async def _validate_api_key( + self, api_key: str + ) -> tuple[list[Device], dict[str, str]]: """Validate the API key and return devices and errors.""" errors: dict[str, str] = {} - devices: list = [] + devices: list[Device] = [] client = LiebherrClient( api_key=api_key, session=async_get_clientsession(self.hass), diff --git a/homeassistant/components/liebherr/light.py b/homeassistant/components/liebherr/light.py index f952e04c7aaf0e..9665bf4822f139 100644 --- a/homeassistant/components/liebherr/light.py +++ b/homeassistant/components/liebherr/light.py @@ -6,7 +6,10 @@ from typing import TYPE_CHECKING, Any from pyliebherrhomeapi import PresentationLightControl -from pyliebherrhomeapi.const import CONTROL_PRESENTATION_LIGHT +from pyliebherrhomeapi.const import ( + CONTROL_PRESENTATION_LIGHT, + DEFAULT_PRESENTATION_LIGHT_MAX_BRIGHTNESS, +) from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback @@ -17,8 +20,6 @@ from .coordinator import LiebherrConfigEntry, LiebherrCoordinator from .entity import LiebherrEntity -DEFAULT_MAX_BRIGHTNESS_LEVEL = 5 - PARALLEL_UPDATES = 1 @@ -108,7 +109,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: control = self._light_control if TYPE_CHECKING: assert control is not None - max_level = control.max or DEFAULT_MAX_BRIGHTNESS_LEVEL + max_level = control.max or DEFAULT_PRESENTATION_LIGHT_MAX_BRIGHTNESS if ATTR_BRIGHTNESS in kwargs: target = max(1, round(kwargs[ATTR_BRIGHTNESS] * max_level / 255)) diff --git a/homeassistant/components/liebherr/manifest.json b/homeassistant/components/liebherr/manifest.json index 9130562f3d8c58..97ae7558bd5fb7 100644 --- a/homeassistant/components/liebherr/manifest.json +++ b/homeassistant/components/liebherr/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyliebherrhomeapi"], - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["pyliebherrhomeapi==0.4.1"], "zeroconf": [ { diff --git a/homeassistant/components/liebherr/quality_scale.yaml b/homeassistant/components/liebherr/quality_scale.yaml index 712bedd1c2a109..5639ae68962fe1 100644 --- a/homeassistant/components/liebherr/quality_scale.yaml +++ b/homeassistant/components/liebherr/quality_scale.yaml @@ -73,4 +73,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/mypy.ini b/mypy.ini index 0ca25a2f94ba2b..9ec8a76c2188d5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3075,6 +3075,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.liebherr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lifx.*] check_untyped_defs = true disallow_incomplete_defs = true From d17cb0e09640848eefda825244df9ac3140c6046 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:22:55 -0400 Subject: [PATCH 0770/1707] Fix Victron BLE storage errors caused by non-serializable value_fn callable in sensor entity description (#167819) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/victron_ble/sensor.py | 10 +++----- tests/components/victron_ble/test_sensor.py | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/victron_ble/sensor.py b/homeassistant/components/victron_ble/sensor.py index 18a112ab7005b0..f547cef8a3f97a 100644 --- a/homeassistant/components/victron_ble/sensor.py +++ b/homeassistant/components/victron_ble/sensor.py @@ -1,6 +1,5 @@ """Sensor platform for Victron BLE.""" -from collections.abc import Callable from dataclasses import dataclass import logging from typing import Any @@ -182,10 +181,6 @@ def error_to_state(value: float | str | None) -> str | None: class VictronBLESensorEntityDescription(SensorEntityDescription): """Describes Victron BLE sensor entity.""" - value_fn: Callable[[float | int | str | None], float | int | str | None] = ( - lambda x: x - ) - SENSOR_DESCRIPTIONS = { Keys.AC_IN_POWER: VictronBLESensorEntityDescription( @@ -258,7 +253,6 @@ class VictronBLESensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENUM, translation_key="charger_error", options=CHARGER_ERROR_OPTIONS, - value_fn=error_to_state, ), Keys.CONSUMED_AMPERE_HOURS: VictronBLESensorEntityDescription( key=Keys.CONSUMED_AMPERE_HOURS, @@ -538,4 +532,6 @@ def native_value(self) -> float | int | str | None: """Return the state of the sensor.""" value = self.processor.entity_data.get(self.entity_key) - return self.entity_description.value_fn(value) + if self.entity_description.key == Keys.CHARGER_ERROR: + return error_to_state(value) + return value diff --git a/tests/components/victron_ble/test_sensor.py b/tests/components/victron_ble/test_sensor.py index ad8565cbe07cbd..b987b5b3653fbe 100644 --- a/tests/components/victron_ble/test_sensor.py +++ b/tests/components/victron_ble/test_sensor.py @@ -1,16 +1,21 @@ """Test updating sensors in the victron_ble integration.""" +import json import time from home_assistant_bluetooth import BluetoothServiceInfo import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bluetooth.passive_update_processor import ( + serialize_entity_description, +) from homeassistant.components.victron_ble.const import ( DOMAIN, REAUTH_AFTER_FAILURES, VICTRON_IDENTIFIER, ) +from homeassistant.components.victron_ble.sensor import SENSOR_DESCRIPTIONS from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -60,6 +65,26 @@ } +def test_sensor_descriptions_are_json_serializable() -> None: + """Ensure entity descriptions contain no non-JSON-serializable fields. + + The passive Bluetooth processor persists entity descriptions to storage + between HA restarts via serialize_entity_description(). Fields that are + Python callables (e.g. a value_fn lambda) cannot be serialized and cause + repeated 'Bad data' errors in the homeassistant.helpers.storage logger. + + Regression test for https://github.com/home-assistant/core/issues/167224 + """ + for key, description in SENSOR_DESCRIPTIONS.items(): + serialized = serialize_entity_description(description) + try: + json.dumps(serialized) + except TypeError as err: + raise AssertionError( + f"SENSOR_DESCRIPTIONS[{key!r}] produced a non-serializable value: {err}" + ) from err + + @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.parametrize( ( From 2f91c6b05068f0172bb6fe5ce6fb84e86937f325 Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:24:54 +0300 Subject: [PATCH 0771/1707] Promote victron_gx integration to silver quality scale (#167789) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- homeassistant/components/victron_gx/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index 2cb8f3a5c943be..80394cb0188aaa 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/victron_gx", "integration_type": "hub", "iot_class": "local_push", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["victron-mqtt==2026.4.3"], "ssdp": [ { From 53ed4b2c773e514b2258401552e7c34dfd56323e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:19:25 -0400 Subject: [PATCH 0772/1707] Refactor Vizio tests: shared fixtures, snapshot_platform, reduced parametrize (#167935) Co-authored-by: Claude Opus 4.6 (1M context) --- tests/components/vizio/conftest.py | 34 +++ .../vizio/snapshots/test_media_player.ambr | 157 +++++++++++++ .../vizio/snapshots/test_remote.ambr | 103 +++++++++ tests/components/vizio/test_init.py | 81 +++---- tests/components/vizio/test_media_player.py | 214 ++++++++++-------- tests/components/vizio/test_remote.py | 132 +++++------ 6 files changed, 513 insertions(+), 208 deletions(-) create mode 100644 tests/components/vizio/snapshots/test_media_player.ambr create mode 100644 tests/components/vizio/snapshots/test_remote.ambr diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index aec5b5e906f1a4..bfb260a1e7824c 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -7,6 +7,9 @@ from pyvizio.api.apps import AppConfig from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME +from homeassistant.components.vizio.const import DOMAIN +from homeassistant.core import HomeAssistant + from .const import ( ACCESS_TOKEN, APP_LIST, @@ -17,6 +20,8 @@ EQ_LIST, INPUT_LIST, INPUT_LIST_WITH_APPS, + MOCK_SPEAKER_CONFIG, + MOCK_USER_VALID_TV_CONFIG, MODEL, RESPONSE_TOKEN, UNIQUE_ID, @@ -26,6 +31,8 @@ MockStartPairingResponse, ) +from tests.common import MockConfigEntry + class MockInput: """Mock Vizio device input.""" @@ -41,6 +48,33 @@ def get_mock_inputs(input_list) -> list[MockInput]: return [MockInput(device_input) for device_input in input_list] +@pytest.fixture +def mock_tv_config_entry() -> MockConfigEntry: + """Return a mock TV config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=MOCK_USER_VALID_TV_CONFIG, + unique_id=UNIQUE_ID, + ) + + +@pytest.fixture +def mock_speaker_config_entry() -> MockConfigEntry: + """Return a mock speaker config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=MOCK_SPEAKER_CONFIG, + unique_id=UNIQUE_ID, + ) + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Add config entry to hass and set up the integration.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + @pytest.fixture(name="vizio_get_unique_id", autouse=True) def vizio_get_unique_id_fixture() -> Generator[None]: """Mock get vizio unique ID.""" diff --git a/tests/components/vizio/snapshots/test_media_player.ambr b/tests/components/vizio/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..1e7f3eb651164a --- /dev/null +++ b/tests/components/vizio/snapshots/test_media_player.ambr @@ -0,0 +1,157 @@ +# serializer version: 1 +# name: test_media_player_entity_setup[speaker][media_player.vizio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Music', + 'Movie', + ]), + 'source_list': list([ + 'HDMI', + 'USB', + 'Bluetooth', + 'AUX', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.vizio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'vizio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'testid', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_entity_setup[speaker][media_player.vizio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Vizio', + 'is_volume_muted': False, + 'last_non_buffering_state': , + 'sound_mode': 'Music', + 'sound_mode_list': list([ + 'Music', + 'Movie', + ]), + 'source': 'HDMI', + 'source_list': list([ + 'HDMI', + 'USB', + 'Bluetooth', + 'AUX', + ]), + 'supported_features': , + 'volume_level': 0.4838709677419355, + }), + 'context': , + 'entity_id': 'media_player.vizio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_media_player_entity_setup[tv][media_player.vizio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Music', + 'Movie', + ]), + 'source_list': list([ + 'HDMI', + 'USB', + 'Bluetooth', + 'AUX', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.vizio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'vizio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'testid', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_entity_setup[tv][media_player.vizio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': 'Vizio', + 'is_volume_muted': False, + 'last_non_buffering_state': , + 'sound_mode': 'Music', + 'sound_mode_list': list([ + 'Music', + 'Movie', + ]), + 'source': 'HDMI', + 'source_list': list([ + 'HDMI', + 'USB', + 'Bluetooth', + 'AUX', + ]), + 'supported_features': , + 'volume_level': 0.15, + }), + 'context': , + 'entity_id': 'media_player.vizio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/vizio/snapshots/test_remote.ambr b/tests/components/vizio/snapshots/test_remote.ambr new file mode 100644 index 00000000000000..d1e008473f88e5 --- /dev/null +++ b/tests/components/vizio/snapshots/test_remote.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_remote_entity_setup[speaker][remote.vizio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'remote', + 'entity_category': None, + 'entity_id': 'remote.vizio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'vizio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'testid', + 'unit_of_measurement': None, + }) +# --- +# name: test_remote_entity_setup[speaker][remote.vizio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vizio', + 'supported_features': , + }), + 'context': , + 'entity_id': 'remote.vizio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_remote_entity_setup[tv][remote.vizio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'remote', + 'entity_category': None, + 'entity_id': 'remote.vizio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'vizio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'testid', + 'unit_of_measurement': None, + }) +# --- +# name: test_remote_entity_setup[tv][remote.vizio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vizio', + 'supported_features': , + }), + 'context': , + 'entity_id': 'remote.vizio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index cea0c06aef6ff0..68c809f6d95d4c 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -20,33 +20,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import ( - APP_LIST, - HOST2, - MOCK_SPEAKER_CONFIG, - MOCK_USER_VALID_TV_CONFIG, - MODEL, - NAME2, - UNIQUE_ID, - VERSION, -) +from .conftest import setup_integration +from .const import APP_LIST, HOST2, MODEL, NAME2, UNIQUE_ID, VERSION from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_tv_load_and_unload(hass: HomeAssistant) -> None: +async def test_tv_load_and_unload( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test loading and unloading TV entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID - ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_tv_config_entry) assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 assert DATA_APPS in hass.data - assert await hass.config_entries.async_unload(config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_tv_config_entry.entry_id) await hass.async_block_till_done() entities = hass.states.async_entity_ids(Platform.MEDIA_PLAYER) assert len(entities) == 1 @@ -56,17 +45,14 @@ async def test_tv_load_and_unload(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_speaker_load_and_unload(hass: HomeAssistant) -> None: +async def test_speaker_load_and_unload( + hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry +) -> None: """Test loading and unloading speaker entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID - ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_speaker_config_entry) assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 - assert await hass.config_entries.async_unload(config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_speaker_config_entry.entry_id) await hass.async_block_till_done() entities = hass.states.async_entity_ids(Platform.MEDIA_PLAYER) assert len(entities) == 1 @@ -79,16 +65,12 @@ async def test_speaker_load_and_unload(hass: HomeAssistant) -> None: ) async def test_coordinator_update_failure( hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, ) -> None: """Test coordinator update failure after 10 days.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID - ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_tv_config_entry) assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 assert DATA_APPS in hass.data @@ -105,12 +87,11 @@ async def test_coordinator_update_failure( @pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") async def test_apps_coordinator_persists_until_last_tv_unloads( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test shared apps coordinator is not shut down until the last TV entry unloads.""" - config_entry_1 = MockConfigEntry( - domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID - ) config_entry_2 = MockConfigEntry( domain=DOMAIN, data={ @@ -121,9 +102,7 @@ async def test_apps_coordinator_persists_until_last_tv_unloads( }, unique_id="testid2", ) - config_entry_1.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry_1.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_tv_config_entry) config_entry_2.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry_2.entry_id) @@ -131,7 +110,7 @@ async def test_apps_coordinator_persists_until_last_tv_unloads( assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 2 # Unload first TV — coordinator should still be fetching apps - assert await hass.config_entries.async_unload(config_entry_1.entry_id) + assert await hass.config_entries.async_unload(mock_tv_config_entry.entry_id) await hass.async_block_till_done() with patch( @@ -159,15 +138,12 @@ async def test_apps_coordinator_persists_until_last_tv_unloads( @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_device_registry_model_and_version( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test that coordinator populates device registry with model and version.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID - ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_tv_config_entry) device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)}) assert device is not None @@ -178,15 +154,12 @@ async def test_device_registry_model_and_version( @pytest.mark.usefixtures("vizio_connect", "vizio_bypass_update") async def test_device_registry_without_model_or_version( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test device registry when model and version are unavailable.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID - ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_tv_config_entry) device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)}) assert device is not None diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 99c19a6354f601..1c893036f52694 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Generator from contextlib import asynccontextmanager from datetime import timedelta from typing import Any @@ -19,6 +19,7 @@ MAX_VOLUME, UNKNOWN_APP, ) +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, @@ -51,10 +52,18 @@ ) from homeassistant.components.vizio.services import SERVICE_UPDATE_SETTING from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util +from .conftest import setup_integration from .const import ( ADDITIONAL_APP_CONFIG, APP_LIST, @@ -68,26 +77,45 @@ EQ_LIST, INPUT_LIST, INPUT_LIST_WITH_APPS, - MOCK_SPEAKER_CONFIG, MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, MOCK_TV_WITH_EXCLUDE_CONFIG, MOCK_TV_WITH_INCLUDE_CONFIG, - MOCK_USER_VALID_TV_CONFIG, NAME, UNIQUE_ID, UNKNOWN_APP_CONFIG, VOLUME_STEP, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def _add_config_entry_to_hass( - hass: HomeAssistant, config_entry: MockConfigEntry +@pytest.fixture(autouse=True) +def media_player_only() -> Generator[None]: + """Only set up the media_player platform.""" + with patch( + "homeassistant.components.vizio.PLATFORMS", + [Platform.MEDIA_PLAYER], + ): + yield + + +@pytest.mark.parametrize( + "mock_config_entry_fixture", + ["mock_tv_config_entry", "mock_speaker_config_entry"], + ids=["tv", "speaker"], +) +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_media_player_entity_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry_fixture: str, + request: pytest.FixtureRequest, ) -> None: - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + """Test media player entity is created for TV and speaker.""" + config_entry: MockConfigEntry = request.getfixturevalue(mock_config_entry_fixture) + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) def _get_ha_power_state(vizio_power_state: bool) -> str: @@ -143,16 +171,12 @@ async def _cm_for_test_setup_without_apps( yield -async def _test_setup_tv(hass: HomeAssistant, vizio_power_state: bool) -> None: +async def _test_setup_tv( + hass: HomeAssistant, config_entry: MockConfigEntry, vizio_power_state: bool +) -> None: """Test Vizio TV entity setup.""" ha_power_state = _get_ha_power_state(vizio_power_state) - config_entry = MockConfigEntry( - domain=DOMAIN, - data=MOCK_USER_VALID_TV_CONFIG, - unique_id=UNIQUE_ID, - ) - async with _cm_for_test_setup_without_apps( { "volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2), @@ -161,7 +185,7 @@ async def _test_setup_tv(hass: HomeAssistant, vizio_power_state: bool) -> None: }, vizio_power_state, ): - await _add_config_entry_to_hass(hass, config_entry) + await setup_integration(hass, config_entry) attr = _get_attr_and_assert_base_attr( hass, MediaPlayerDeviceClass.TV, ha_power_state @@ -171,16 +195,12 @@ async def _test_setup_tv(hass: HomeAssistant, vizio_power_state: bool) -> None: assert attr[ATTR_SOUND_MODE] == CURRENT_EQ -async def _test_setup_speaker(hass: HomeAssistant, vizio_power_state: bool) -> None: +async def _test_setup_speaker( + hass: HomeAssistant, config_entry: MockConfigEntry, vizio_power_state: bool +) -> None: """Test Vizio Speaker entity setup.""" ha_power_state = _get_ha_power_state(vizio_power_state) - config_entry = MockConfigEntry( - domain=DOMAIN, - data=MOCK_SPEAKER_CONFIG, - unique_id=UNIQUE_ID, - ) - audio_settings = { "volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_SPEAKER] / 2), "mute": "Off", @@ -191,7 +211,7 @@ async def _test_setup_speaker(hass: HomeAssistant, vizio_power_state: bool) -> N audio_settings, vizio_power_state, ): - await _add_config_entry_to_hass(hass, config_entry) + await setup_integration(hass, config_entry) attr = _get_attr_and_assert_base_attr( hass, MediaPlayerDeviceClass.SPEAKER, ha_power_state @@ -203,13 +223,9 @@ async def _test_setup_speaker(hass: HomeAssistant, vizio_power_state: bool) -> N @asynccontextmanager async def _cm_for_test_setup_tv_with_apps( - hass: HomeAssistant, device_config: dict[str, Any], app_config: dict[str, Any] + hass: HomeAssistant, config_entry: MockConfigEntry, app_config: dict[str, Any] ) -> AsyncIterator[None]: """Context manager to setup test for Vizio TV with support for apps.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=device_config, unique_id=UNIQUE_ID - ) - async with _cm_for_test_setup_without_apps( {"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2), "mute": "Off"}, True, @@ -218,7 +234,7 @@ async def _cm_for_test_setup_tv_with_apps( "homeassistant.components.vizio.VizioAsync.get_current_app_config", return_value=AppConfig(**app_config), ): - await _add_config_entry_to_hass(hass, config_entry) + await setup_integration(hass, config_entry) attr = _get_attr_and_assert_base_attr( hass, MediaPlayerDeviceClass.TV, STATE_ON @@ -274,57 +290,65 @@ async def _test_service( @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_speaker_on(hass: HomeAssistant) -> None: +async def test_speaker_on( + hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry +) -> None: """Test Vizio Speaker entity setup when on.""" - await _test_setup_speaker(hass, True) + await _test_setup_speaker(hass, mock_speaker_config_entry, True) @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_speaker_off(hass: HomeAssistant) -> None: +async def test_speaker_off( + hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry +) -> None: """Test Vizio Speaker entity setup when off.""" - await _test_setup_speaker(hass, False) + await _test_setup_speaker(hass, mock_speaker_config_entry, False) @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_init_tv_on(hass: HomeAssistant) -> None: +async def test_init_tv_on( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test Vizio TV entity setup when on.""" - await _test_setup_tv(hass, True) + await _test_setup_tv(hass, mock_tv_config_entry, True) @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_init_tv_off(hass: HomeAssistant) -> None: +async def test_init_tv_off( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test Vizio TV entity setup when off.""" - await _test_setup_tv(hass, False) + await _test_setup_tv(hass, mock_tv_config_entry, False) @pytest.mark.usefixtures("vizio_cant_connect") -async def test_setup_unavailable_speaker(hass: HomeAssistant) -> None: +async def test_setup_unavailable_speaker( + hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry +) -> None: """Test speaker config entry retries setup when device is unavailable.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID - ) - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) + mock_speaker_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_speaker_config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_speaker_config_entry.state is ConfigEntryState.SETUP_RETRY @pytest.mark.usefixtures("vizio_cant_connect") -async def test_setup_unavailable_tv(hass: HomeAssistant) -> None: +async def test_setup_unavailable_tv( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test TV config entry retries setup when device is unavailable.""" - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID - ) - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) + mock_tv_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_tv_config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_tv_config_entry.state is ConfigEntryState.SETUP_RETRY @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_services(hass: HomeAssistant) -> None: +async def test_services( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test all Vizio media player entity services.""" - await _test_setup_tv(hass, True) + await _test_setup_tv(hass, mock_tv_config_entry, True) await _test_service(hass, MP_DOMAIN, "pow_on", SERVICE_TURN_ON, None) await _test_service(hass, MP_DOMAIN, "pow_off", SERVICE_TURN_OFF, None) @@ -410,9 +434,11 @@ async def test_services(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_options_update(hass: HomeAssistant) -> None: +async def test_options_update( + hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry +) -> None: """Test when config entry update event fires.""" - await _test_setup_speaker(hass, True) + await _test_setup_speaker(hass, mock_speaker_config_entry, True) config_entry = hass.config_entries.async_entries(DOMAIN)[0] assert config_entry.options new_options = config_entry.options.copy() @@ -432,10 +458,11 @@ async def test_options_update(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_update_available_to_unavailable( hass: HomeAssistant, + mock_speaker_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test device becomes unavailable after being available.""" - await _test_setup_speaker(hass, True) + await _test_setup_speaker(hass, mock_speaker_config_entry, True) # Simulate device becoming unreachable with patch( @@ -451,10 +478,11 @@ async def test_update_available_to_unavailable( @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_update_unavailable_to_available( hass: HomeAssistant, + mock_speaker_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test device becomes available after being unavailable.""" - await _test_setup_speaker(hass, True) + await _test_setup_speaker(hass, mock_speaker_config_entry, True) # First, make device unavailable with patch( @@ -480,11 +508,12 @@ async def test_update_unavailable_to_available( @pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps") async def test_setup_with_apps( hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test device setup with apps.""" async with _cm_for_test_setup_tv_with_apps( - hass, MOCK_USER_VALID_TV_CONFIG, CURRENT_APP_CONFIG + hass, mock_tv_config_entry, CURRENT_APP_CONFIG ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) @@ -510,9 +539,10 @@ async def test_setup_with_apps_include( caplog: pytest.LogCaptureFixture, ) -> None: """Test device setup with apps and apps["include"] in config.""" - async with _cm_for_test_setup_tv_with_apps( - hass, MOCK_TV_WITH_INCLUDE_CONFIG, CURRENT_APP_CONFIG - ): + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_TV_WITH_INCLUDE_CONFIG, unique_id=UNIQUE_ID + ) + async with _cm_for_test_setup_tv_with_apps(hass, config_entry, CURRENT_APP_CONFIG): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps([*INPUT_LIST_WITH_APPS, CURRENT_APP], attr) assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] @@ -527,9 +557,10 @@ async def test_setup_with_apps_exclude( caplog: pytest.LogCaptureFixture, ) -> None: """Test device setup with apps and apps["exclude"] in config.""" - async with _cm_for_test_setup_tv_with_apps( - hass, MOCK_TV_WITH_EXCLUDE_CONFIG, CURRENT_APP_CONFIG - ): + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_TV_WITH_EXCLUDE_CONFIG, unique_id=UNIQUE_ID + ) + async with _cm_for_test_setup_tv_with_apps(hass, config_entry, CURRENT_APP_CONFIG): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps([*INPUT_LIST_WITH_APPS, CURRENT_APP], attr) assert CURRENT_APP in attr[ATTR_INPUT_SOURCE_LIST] @@ -544,9 +575,12 @@ async def test_setup_with_apps_additional_apps_config( caplog: pytest.LogCaptureFixture, ) -> None: """Test device setup with apps and apps["additional_configs"] in config.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, unique_id=UNIQUE_ID + ) async with _cm_for_test_setup_tv_with_apps( hass, - MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, + config_entry, ADDITIONAL_APP_CONFIG["config"], ): attr = hass.states.get(ENTITY_ID).attributes @@ -608,11 +642,12 @@ async def test_setup_with_apps_additional_apps_config( @pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps") async def test_setup_with_unknown_app_config( hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test device setup with apps where app config returned is unknown.""" async with _cm_for_test_setup_tv_with_apps( - hass, MOCK_USER_VALID_TV_CONFIG, UNKNOWN_APP_CONFIG + hass, mock_tv_config_entry, UNKNOWN_APP_CONFIG ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) @@ -624,11 +659,12 @@ async def test_setup_with_unknown_app_config( @pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps") async def test_setup_with_no_running_app( hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test device setup with apps where no app is running.""" async with _cm_for_test_setup_tv_with_apps( - hass, MOCK_USER_VALID_TV_CONFIG, vars(AppConfig()) + hass, mock_tv_config_entry, vars(AppConfig()) ): attr = hass.states.get(ENTITY_ID).attributes _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr) @@ -638,19 +674,15 @@ async def test_setup_with_no_running_app( @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_setup_tv_without_mute(hass: HomeAssistant) -> None: +async def test_setup_tv_without_mute( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test Vizio TV entity setup when mute property isn't returned by Vizio API.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=MOCK_USER_VALID_TV_CONFIG, - unique_id=UNIQUE_ID, - ) - async with _cm_for_test_setup_without_apps( {"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2)}, True, ): - await _add_config_entry_to_hass(hass, config_entry) + await setup_integration(hass, mock_tv_config_entry) attr = _get_attr_and_assert_base_attr(hass, MediaPlayerDeviceClass.TV, STATE_ON) _assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_TV) @@ -661,6 +693,7 @@ async def test_setup_tv_without_mute(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps") async def test_apps_update( hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test device setup with apps where no app is running.""" @@ -669,7 +702,7 @@ async def test_apps_update( return_value=None, ): async with _cm_for_test_setup_tv_with_apps( - hass, MOCK_USER_VALID_TV_CONFIG, vars(AppConfig()) + hass, mock_tv_config_entry, vars(AppConfig()) ): # Check source list, remove TV inputs, and verify that the integration is # using the default APPS list @@ -693,14 +726,11 @@ async def test_apps_update( @pytest.mark.usefixtures("vizio_connect", "vizio_update_with_apps_on_input") -async def test_vizio_update_with_apps_on_input(hass: HomeAssistant) -> None: +async def test_vizio_update_with_apps_on_input( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test a vizio TV with apps that is on a TV input.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=MOCK_USER_VALID_TV_CONFIG, - unique_id=UNIQUE_ID, - ) - await _add_config_entry_to_hass(hass, config_entry) + await setup_integration(hass, mock_tv_config_entry) attr = _get_attr_and_assert_base_attr(hass, MediaPlayerDeviceClass.TV, STATE_ON) # app ID should not be in the attributes assert "app_id" not in attr @@ -709,10 +739,11 @@ async def test_vizio_update_with_apps_on_input(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_coordinator_update_on_to_off( hass: HomeAssistant, + mock_speaker_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test device transitions from on to off during coordinator refresh.""" - await _test_setup_speaker(hass, True) + await _test_setup_speaker(hass, mock_speaker_config_entry, True) attr = _get_attr_and_assert_base_attr( hass, MediaPlayerDeviceClass.SPEAKER, STATE_ON ) @@ -738,10 +769,11 @@ async def test_coordinator_update_on_to_off( @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_coordinator_update_off_to_on( hass: HomeAssistant, + mock_speaker_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test device transitions from off to on during coordinator refresh.""" - await _test_setup_speaker(hass, False) + await _test_setup_speaker(hass, mock_speaker_config_entry, False) assert hass.states.get(ENTITY_ID).state == STATE_OFF # Device turns on @@ -758,10 +790,11 @@ async def test_coordinator_update_off_to_on( @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_sound_mode_feature_toggling( hass: HomeAssistant, + mock_speaker_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test sound mode feature is added when present and removed when absent.""" - await _test_setup_speaker(hass, True) + await _test_setup_speaker(hass, mock_speaker_config_entry, True) attr = _get_attr_and_assert_base_attr( hass, MediaPlayerDeviceClass.SPEAKER, STATE_ON ) @@ -798,10 +831,11 @@ async def test_sound_mode_feature_toggling( @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_sound_mode_list_cached( hass: HomeAssistant, + mock_speaker_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test sound mode list is cached after first retrieval.""" - await _test_setup_speaker(hass, True) + await _test_setup_speaker(hass, mock_speaker_config_entry, True) attr = hass.states.get(ENTITY_ID).attributes assert attr["sound_mode_list"] == EQ_LIST diff --git a/tests/components/vizio/test_remote.py b/tests/components/vizio/test_remote.py index bf7538a10f517d..56c5900d186966 100644 --- a/tests/components/vizio/test_remote.py +++ b/tests/components/vizio/test_remote.py @@ -2,9 +2,11 @@ from __future__ import annotations +from collections.abc import Generator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.remote import ( ATTR_COMMAND, @@ -13,67 +15,65 @@ DOMAIN as REMOTE_DOMAIN, SERVICE_SEND_COMMAND, ) -from homeassistant.components.vizio.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, - STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify -from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, NAME, UNIQUE_ID +from .conftest import setup_integration +from .const import NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform REMOTE_ENTITY_ID = f"{REMOTE_DOMAIN}.{slugify(NAME)}" -async def _setup_entry( - hass: HomeAssistant, config: dict, unique_id: str = UNIQUE_ID -) -> MockConfigEntry: - """Set up a Vizio config entry and return it.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=config, unique_id=unique_id) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - return config_entry +@pytest.fixture(autouse=True) +def remote_only() -> Generator[None]: + """Only set up the remote platform.""" + with patch( + "homeassistant.components.vizio.PLATFORMS", + [Platform.REMOTE], + ): + yield @pytest.mark.parametrize( - "config", - [MOCK_USER_VALID_TV_CONFIG, MOCK_SPEAKER_CONFIG], + "config_entry_fixture", + ["mock_tv_config_entry", "mock_speaker_config_entry"], ids=["tv", "speaker"], ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_remote_entity_setup( hass: HomeAssistant, entity_registry: er.EntityRegistry, - config: dict, + snapshot: SnapshotAssertion, + config_entry_fixture: str, + request: pytest.FixtureRequest, ) -> None: """Test remote entity is created for TV and speaker.""" - await _setup_entry(hass, config) - state = hass.states.get(REMOTE_ENTITY_ID) - assert state is not None - assert state.state == STATE_ON - - entry = entity_registry.async_get(REMOTE_ENTITY_ID) - assert entry is not None - assert entry.unique_id == UNIQUE_ID + config_entry: MockConfigEntry = request.getfixturevalue(config_entry_fixture) + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_remote_is_off_when_device_off(hass: HomeAssistant) -> None: +async def test_remote_is_off_when_device_off( + hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry +) -> None: """Test remote state is off when device is off.""" with patch( "homeassistant.components.vizio.VizioAsync.get_power_state", return_value=False, ): - await _setup_entry(hass, MOCK_SPEAKER_CONFIG) + await setup_integration(hass, mock_speaker_config_entry) state = hass.states.get(REMOTE_ENTITY_ID) assert state.state == STATE_OFF @@ -86,9 +86,14 @@ async def test_remote_is_off_when_device_off(hass: HomeAssistant) -> None: ], ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_turn_on_off(hass: HomeAssistant, service: str, mock_method: str) -> None: +async def test_turn_on_off( + hass: HomeAssistant, + mock_speaker_config_entry: MockConfigEntry, + service: str, + mock_method: str, +) -> None: """Test turning on/off the remote sends the correct power command.""" - await _setup_entry(hass, MOCK_SPEAKER_CONFIG) + await setup_integration(hass, mock_speaker_config_entry) with patch( f"homeassistant.components.vizio.VizioAsync.{mock_method}", ) as mock_power: @@ -104,20 +109,20 @@ async def test_turn_on_off(hass: HomeAssistant, service: str, mock_method: str) @pytest.mark.parametrize( ("command", "expected_key"), [ - # Native keys (lowercase tested for a few to verify case-insensitivity) ("BACK", "BACK"), - ("CC_TOGGLE", "CC_TOGGLE"), ("ch_up", "CH_UP"), - ("menu", "MENU"), ("SMARTCAST", "SMARTCAST"), ], ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_send_command_tv_valid( - hass: HomeAssistant, command: str, expected_key: str + hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, + command: str, + expected_key: str, ) -> None: """Test send_command resolves valid TV commands.""" - await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + await setup_integration(hass, mock_tv_config_entry) with patch( "homeassistant.components.vizio.VizioAsync.remote", ) as mock_remote: @@ -135,9 +140,13 @@ async def test_send_command_tv_valid( @pytest.mark.parametrize("command", ["INVALID_KEY", "not_a_key"]) @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_send_command_tv_invalid(hass: HomeAssistant, command: str) -> None: +async def test_send_command_tv_invalid( + hass: HomeAssistant, + mock_tv_config_entry: MockConfigEntry, + command: str, +) -> None: """Test send_command raises error for invalid TV commands.""" - await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + await setup_integration(hass, mock_tv_config_entry) with pytest.raises(ServiceValidationError): await hass.services.async_call( REMOTE_DOMAIN, @@ -153,25 +162,20 @@ async def test_send_command_tv_invalid(hass: HomeAssistant, command: str) -> Non @pytest.mark.parametrize( ("command", "expected_key"), [ - # Native keys (lowercase tested for a couple) - ("MUTE_OFF", "MUTE_OFF"), - ("MUTE_ON", "MUTE_ON"), ("MUTE_TOGGLE", "MUTE_TOGGLE"), ("pause", "PAUSE"), - ("PLAY", "PLAY"), - ("POW_OFF", "POW_OFF"), - ("POW_ON", "POW_ON"), - ("POW_TOGGLE", "POW_TOGGLE"), - ("vol_down", "VOL_DOWN"), ("VOL_UP", "VOL_UP"), ], ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_send_command_speaker_valid( - hass: HomeAssistant, command: str, expected_key: str + hass: HomeAssistant, + mock_speaker_config_entry: MockConfigEntry, + command: str, + expected_key: str, ) -> None: """Test send_command resolves valid speaker commands.""" - await _setup_entry(hass, MOCK_SPEAKER_CONFIG) + await setup_integration(hass, mock_speaker_config_entry) with patch( "homeassistant.components.vizio.VizioAsync.remote", ) as mock_remote: @@ -187,21 +191,15 @@ async def test_send_command_speaker_valid( mock_remote.assert_called_once_with(expected_key, log_api_exception=False) -@pytest.mark.parametrize( - "command", - [ - # TV-only native keys - "MENU", - "CH_UP", - "INPUT_NEXT", - # Completely invalid - "INVALID_KEY", - ], -) +@pytest.mark.parametrize("command", ["MENU", "CH_UP", "INVALID_KEY"]) @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_send_command_speaker_invalid(hass: HomeAssistant, command: str) -> None: +async def test_send_command_speaker_invalid( + hass: HomeAssistant, + mock_speaker_config_entry: MockConfigEntry, + command: str, +) -> None: """Test speaker remote rejects TV-only and invalid keys.""" - await _setup_entry(hass, MOCK_SPEAKER_CONFIG) + await setup_integration(hass, mock_speaker_config_entry) with pytest.raises(ServiceValidationError): await hass.services.async_call( REMOTE_DOMAIN, @@ -215,9 +213,11 @@ async def test_send_command_speaker_invalid(hass: HomeAssistant, command: str) - @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_send_command_multiple(hass: HomeAssistant) -> None: +async def test_send_command_multiple( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test send_command with multiple commands in one call.""" - await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + await setup_integration(hass, mock_tv_config_entry) with patch( "homeassistant.components.vizio.VizioAsync.remote", ) as mock_remote: @@ -236,9 +236,11 @@ async def test_send_command_multiple(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_send_command_invalid_skips_valid(hass: HomeAssistant) -> None: +async def test_send_command_invalid_skips_valid( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test that no commands are sent when one command in the list is invalid.""" - await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + await setup_integration(hass, mock_tv_config_entry) with ( patch( "homeassistant.components.vizio.VizioAsync.remote", @@ -258,9 +260,11 @@ async def test_send_command_invalid_skips_valid(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("vizio_connect", "vizio_update") -async def test_send_command_delay_between_repeats(hass: HomeAssistant) -> None: +async def test_send_command_delay_between_repeats( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: """Test delay is applied between repeats but not after the last one.""" - await _setup_entry(hass, MOCK_USER_VALID_TV_CONFIG) + await setup_integration(hass, mock_tv_config_entry) with ( patch( "homeassistant.components.vizio.VizioAsync.remote", From 63a0b5d2ff4c754c7b85505076e78d0966655110 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Fri, 10 Apr 2026 22:19:39 +0200 Subject: [PATCH 0773/1707] Bump python-duco-client to 0.3.0 (#167936) --- homeassistant/components/duco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index 4ccdb930d01214..f4acccd6034f09 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["duco"], "quality_scale": "bronze", - "requirements": ["python-duco-client==0.2.0"] + "requirements": ["python-duco-client==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1d349e380ff5ad..99f6cf0d911590 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2566,7 +2566,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.3 # homeassistant.components.duco -python-duco-client==0.2.0 +python-duco-client==0.3.0 # homeassistant.components.ecobee python-ecobee-api==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f21beb360e6a0..f5c2ca2751f7e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2186,7 +2186,7 @@ python-bsblan==5.1.3 python-dropbox-api==0.1.3 # homeassistant.components.duco -python-duco-client==0.2.0 +python-duco-client==0.3.0 # homeassistant.components.ecobee python-ecobee-api==0.3.2 From a202742fc6ff0e79ab069822e237be81ae6af32c Mon Sep 17 00:00:00 2001 From: Tomeamis Date: Fri, 10 Apr 2026 22:45:36 +0200 Subject: [PATCH 0774/1707] Z-Wave.me: Make Light support the Transition feature (#167840) --- homeassistant/components/zwave_me/light.py | 44 ++++++++++++++++------ 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_me/light.py b/homeassistant/components/zwave_me/light.py index f8ed397ea2550a..1651b855aa5202 100644 --- a/homeassistant/components/zwave_me/light.py +++ b/homeassistant/components/zwave_me/light.py @@ -9,8 +9,10 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_RGB_COLOR, + ATTR_TRANSITION, ColorMode, LightEntity, + LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -66,6 +68,7 @@ def __init__( self._attr_color_mode = ColorMode.RGB else: self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_features = LightEntityFeature.TRANSITION self._attr_supported_color_modes: set[ColorMode] = {self._attr_color_mode} def turn_off(self, **kwargs: Any) -> None: @@ -74,19 +77,38 @@ def turn_off(self, **kwargs: Any) -> None: def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - color = kwargs.get(ATTR_RGB_COLOR) + color: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) + brightness = kwargs.get(ATTR_BRIGHTNESS) + transition: float | None = kwargs.get(ATTR_TRANSITION) + + command_id = "exact" + command_args: dict[str, str] = {} + + # set color levels + if color is not None: + if not any(color): + color = (255, 255, 255) + command_args.update( + {"red": str(color[0]), "green": str(color[1]), "blue": str(color[2])} + ) + elif brightness is not None: + command_args["level"] = str(round(brightness / 2.55)) + elif transition is not None: + command_args["level"] = "100" + else: + command_id = "on" - if color is None: - brightness = kwargs.get(ATTR_BRIGHTNESS) - if brightness is None: - self.controller.zwave_api.send_command(self.device.id, "on") + if transition is not None: + command_id = "exactSmooth" + if transition < 127: + duration = round(transition) else: - self.controller.zwave_api.send_command( - self.device.id, f"exact?level={round(brightness / 2.55)}" - ) - return - red, green, blue = color if any(color) else (255, 255, 255) - cmd = f"exact?red={red}&green={green}&blue={blue}" + duration = min(127, round((transition) / 60)) + 127 + command_args["duration"] = str(duration) + + cmd = command_id + if command_args: + cmd = f"{command_id}?{'&'.join(f'{argId}={argVal}' for argId, argVal in command_args.items())}" self.controller.zwave_api.send_command(self.device.id, cmd) @property From f050407bfa915e636ceef641f3a06f2415d681ec Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 10 Apr 2026 22:50:36 +0200 Subject: [PATCH 0775/1707] Fix tibber price sensor first state update (#167938) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tibber/sensor.py | 8 ++++++-- tests/components/tibber/test_sensor.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 39accbaf9bb9aa..0ac0114a1c88ac 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -769,9 +769,15 @@ def __init__( self._model = "Price Sensor" self._device_name = self._home_name + self._update_attributes() @callback def _handle_coordinator_update(self) -> None: + self._update_attributes() + super()._handle_coordinator_update() + + @callback + def _update_attributes(self) -> None: """Handle updated data from the coordinator.""" data = self.coordinator.data if not data or ( @@ -779,7 +785,6 @@ def _handle_coordinator_update(self) -> None: or (current_price := home_data.get("current_price")) is None ): self._attr_available = False - self.async_write_ha_state() return self._attr_native_unit_of_measurement = home_data.get( @@ -801,7 +806,6 @@ def _handle_coordinator_update(self) -> None: "estimated_annual_consumption" ] self._attr_available = True - self.async_write_ha_state() class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): diff --git a/tests/components/tibber/test_sensor.py b/tests/components/tibber/test_sensor.py index fa8c84821b82e4..e29464287512b4 100644 --- a/tests/components/tibber/test_sensor.py +++ b/tests/components/tibber/test_sensor.py @@ -82,6 +82,21 @@ async def test_price_sensor_state_unit_and_attributes( entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, home.home_id) assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 1.25 + assert state.attributes["unit_of_measurement"] == "NOK/kWh" + assert state.attributes["app_nickname"] == "Home" + assert state.attributes["grid_company"] == "GridCo" + assert state.attributes["estimated_annual_consumption"] == 12000 + assert state.attributes["intraday_price_ranking"] == 0.4 + assert state.attributes["max_price"] == 1.8 + assert state.attributes["avg_price"] == 1.2 + assert state.attributes["min_price"] == 0.8 + assert state.attributes["off_peak_1"] == 0.9 + assert state.attributes["peak"] == 1.7 + assert state.attributes["off_peak_2"] == 1.0 + await async_update_entity(hass, entity_id) await hass.async_block_till_done() From 47cc31067c58c7820cb7f2207b71299e51f60a16 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 11 Apr 2026 00:06:17 +0300 Subject: [PATCH 0776/1707] Check if model exists in Anthropic config flow (#167844) --- .../components/anthropic/config_flow.py | 57 ++++---- .../components/anthropic/coordinator.py | 10 +- homeassistant/components/anthropic/entity.py | 2 +- homeassistant/components/anthropic/repairs.py | 21 ++- .../components/anthropic/strings.json | 8 ++ .../components/anthropic/test_config_flow.py | 98 ++++++++++++-- tests/components/anthropic/test_repairs.py | 128 +++++++++--------- 7 files changed, 213 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index f99cee25710759..fc485e222450fc 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -105,22 +105,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: await client.models.list(timeout=10.0) -async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionDict]: - """Get list of available models.""" - try: - models = (await client.models.list()).data - except anthropic.AnthropicError: - models = [] - _LOGGER.debug("Available models: %s", models) - return [ - SelectOptionDict( - label=model_info.display_name, - value=model_alias(model_info.id), - ) - for model_info in models - ] - - class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" @@ -217,6 +201,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): """Flow for managing conversation subentries.""" options: dict[str, Any] + model_info: anthropic.types.ModelInfo @property def _is_new(self) -> bool: @@ -330,15 +315,14 @@ async def async_step_advanced( ) -> SubentryFlowResult: """Manage advanced options.""" errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} step_schema: VolDictType = { vol.Optional( CONF_CHAT_MODEL, default=DEFAULT[CONF_CHAT_MODEL], ): SelectSelector( - SelectSelectorConfig( - options=await self._get_model_list(), custom_value=True - ) + SelectSelectorConfig(options=self._get_model_list(), custom_value=True) ), vol.Optional( CONF_MAX_TOKENS, @@ -363,6 +347,25 @@ async def async_step_advanced( if user_input is not None: self.options.update(user_input) + coordinator = self._get_entry().runtime_data + self.model_info, status = coordinator.get_model_info( + self.options[CONF_CHAT_MODEL] + ) + if not status: + # Couldn't find the model in the cached list, try to fetch it directly + client = coordinator.client + try: + self.model_info = await client.models.retrieve( + self.options[CONF_CHAT_MODEL], timeout=10.0 + ) + except anthropic.NotFoundError: + errors[CONF_CHAT_MODEL] = "model_not_found" + except anthropic.AnthropicError as err: + errors[CONF_CHAT_MODEL] = "api_error" + description_placeholders["message"] = ( + err.message if isinstance(err, anthropic.APIError) else str(err) + ) + if not errors: return await self.async_step_model() @@ -372,6 +375,7 @@ async def async_step_advanced( vol.Schema(step_schema), self.options ), errors=errors, + description_placeholders=description_placeholders, ) async def async_step_model( @@ -501,13 +505,16 @@ async def async_step_model( last_step=True, ) - async def _get_model_list(self) -> list[SelectOptionDict]: + def _get_model_list(self) -> list[SelectOptionDict]: """Get list of available models.""" - client = anthropic.AsyncAnthropic( - api_key=self._get_entry().data[CONF_API_KEY], - http_client=get_async_client(self.hass), - ) - return await get_model_list(client) + coordinator = self._get_entry().runtime_data + return [ + SelectOptionDict( + label=model_info.display_name, + value=model_alias(model_info.id), + ) + for model_info in coordinator.data or [] + ] async def _get_location_data(self) -> dict[str, str]: """Get approximate location data of the user.""" diff --git a/homeassistant/components/anthropic/coordinator.py b/homeassistant/components/anthropic/coordinator.py index 5dc7ddfd4a3a6a..e5421f224afe17 100644 --- a/homeassistant/components/anthropic/coordinator.py +++ b/homeassistant/components/anthropic/coordinator.py @@ -95,21 +95,21 @@ def mark_connection_error(self) -> None: self._schedule_refresh() @callback - def get_model_info(self, model_id: str) -> anthropic.types.ModelInfo: + def get_model_info(self, model_id: str) -> tuple[anthropic.types.ModelInfo, bool]: """Get model info for a given model ID.""" # First try: exact name match for model in self.data or []: if model.id == model_id: - return model + return model, True # Second try: match by alias alias = model_alias(model_id) for model in self.data or []: if model_alias(model.id) == alias: - return model + return model, True # Model not found, return safe defaults return anthropic.types.ModelInfo( type="model", id=model_id, created_at=datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC), - display_name=model_id, - ) + display_name=alias, + ), False diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 15b8fe30435099..ca499128158daa 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -690,7 +690,7 @@ def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> Non self.entry = entry self.subentry = subentry coordinator = entry.runtime_data - self.model_info = coordinator.get_model_info( + self.model_info, _ = coordinator.get_model_info( subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]) ) self._attr_unique_id = subentry.subentry_id diff --git a/homeassistant/components/anthropic/repairs.py b/homeassistant/components/anthropic/repairs.py index a00a7f977e8f23..2775a45f4043a9 100644 --- a/homeassistant/components/anthropic/repairs.py +++ b/homeassistant/components/anthropic/repairs.py @@ -5,6 +5,7 @@ from collections.abc import Iterator from typing import TYPE_CHECKING +import anthropic import voluptuous as vol from homeassistant import data_entry_flow @@ -18,8 +19,8 @@ SelectSelectorConfig, ) -from .config_flow import get_model_list from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN +from .coordinator import model_alias if TYPE_CHECKING: from . import AnthropicConfigEntry @@ -61,7 +62,7 @@ async def async_step_init( client = entry.runtime_data.client model_list = [ model_option - for model_option in await get_model_list(client) + for model_option in await self.get_model_list(client) if not model_option["value"].startswith(tuple(DEPRECATED_MODELS)) ] self._model_list_cache[entry.entry_id] = model_list @@ -107,6 +108,22 @@ async def async_step_init( }, ) + async def get_model_list( + self, client: anthropic.AsyncAnthropic + ) -> list[SelectOptionDict]: + """Get list of available models.""" + try: + models = (await client.models.list(timeout=10.0)).data + except anthropic.AnthropicError: + models = [] + return [ + SelectOptionDict( + label=model_info.display_name, + value=model_alias(model_info.id), + ) + for model_info in models + ] + def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]: """Yield entry/subentry pairs that use deprecated models.""" for entry in self.hass.config_entries.async_entries(DOMAIN): diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 7978cc768b7690..f40f3564edd479 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -38,6 +38,10 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "AI task", + "error": { + "api_error": "[%key:component::anthropic::config_subentries::conversation::error::api_error%]", + "model_not_found": "[%key:component::anthropic::config_subentries::conversation::error::model_not_found%]" + }, "initiate_flow": { "reconfigure": "Reconfigure AI task", "user": "Add AI task" @@ -98,6 +102,10 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "Conversation agent", + "error": { + "api_error": "Unable to get model info: {message}", + "model_not_found": "Model not found" + }, "initiate_flow": { "reconfigure": "Reconfigure conversation agent", "user": "Add conversation agent" diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index e8265e56d00dd1..1c77a2fa6c3104 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Anthropic config flow.""" +import datetime from unittest.mock import AsyncMock, patch from anthropic import ( @@ -9,8 +10,10 @@ AuthenticationError, BadRequestError, InternalServerError, + NotFoundError, types, ) +from anthropic.types import ModelInfo from httpx import URL, Request, Response import pytest from syrupy.assertion import SnapshotAssertion @@ -365,21 +368,33 @@ async def test_model_list( assert options["data_schema"].schema["chat_model"].config["options"] == snapshot -async def test_model_list_error( - hass: HomeAssistant, mock_config_entry, mock_init_component +async def test_invalid_model( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component: None ) -> None: - """Test exception handling during fetching the list of models.""" - subentry = next(iter(mock_config_entry.subentries.values())) - options_flow = await mock_config_entry.start_subentry_reconfigure_flow( - hass, subentry.subentry_id + """Test exceptions during fetching model info.""" + options = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, ) # Configure initial step + options = await hass.config_entries.subentries.async_configure( + options["flow_id"], + { + CONF_NAME: "Mock name", + **DEFAULT_CONVERSATION_OPTIONS, + CONF_RECOMMENDED: False, + }, + ) + assert options["type"] is FlowResultType.FORM + assert options["step_id"] == "advanced" + + # Configure advanced step but with api error with patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.retrieve", new_callable=AsyncMock, side_effect=InternalServerError( - message=None, + message="Mock server error", response=Response( status_code=500, request=Request(method="POST", url=URL()), @@ -388,15 +403,72 @@ async def test_model_list_error( ), ): options = await hass.config_entries.subentries.async_configure( - options_flow["flow_id"], + options["flow_id"], { - "prompt": "You are a helpful assistant", - "recommended": False, + CONF_CHAT_MODEL: "invalid-model-2-0", }, ) assert options["type"] is FlowResultType.FORM - assert options["step_id"] == "advanced" - assert options["data_schema"].schema["chat_model"].config["options"] == [] + assert options["errors"] == {"chat_model": "api_error"} + assert options["description_placeholders"] == {"message": "Mock server error"} + + # Try again + with patch( + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.retrieve", + new_callable=AsyncMock, + side_effect=NotFoundError( + message="Model not found", + response=Response( + status_code=404, + request=Request(method="GET", url=URL()), + ), + body={ + "type": "error", + "error": { + "type": "not_found_error", + "message": "model: invalid-model-2-0", + }, + }, + ), + ): + options = await hass.config_entries.subentries.async_configure( + options["flow_id"], + { + CONF_CHAT_MODEL: "invalid-model-2-0", + }, + ) + assert options["type"] is FlowResultType.FORM + assert options["errors"] == {"chat_model": "model_not_found"} + + # Try again with a valid model + with patch( + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.retrieve", + new_callable=AsyncMock, + return_value=ModelInfo( + type="model", + id="valid-model-4-5", + created_at=datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC), + display_name="Valid Model 4-5", + ), + ): + options = await hass.config_entries.subentries.async_configure( + options["flow_id"], + { + CONF_CHAT_MODEL: "valid-model-4-5", + }, + ) + + assert options["type"] is FlowResultType.FORM + assert not options["errors"] + assert options["step_id"] == "model" + + options = await hass.config_entries.subentries.async_configure( + options["flow_id"], + {}, + ) + + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"][CONF_CHAT_MODEL] == "valid-model-4-5" @pytest.mark.parametrize( diff --git a/tests/components/anthropic/test_repairs.py b/tests/components/anthropic/test_repairs.py index 431601673abc17..9ef4d7adb87587 100644 --- a/tests/components/anthropic/test_repairs.py +++ b/tests/components/anthropic/test_repairs.py @@ -2,8 +2,11 @@ from __future__ import annotations +from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock + +from anthropic.pagination import AsyncPage from homeassistant.components.anthropic.const import CONF_CHAT_MODEL, DOMAIN from homeassistant.config_entries import ConfigEntryState, ConfigSubentry @@ -12,6 +15,8 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from . import model_list + from tests.common import MockConfigEntry from tests.components.repairs import ( async_process_repairs_platforms, @@ -38,7 +43,11 @@ def _make_entry( ) entry.add_to_hass(hass) object.__setattr__(entry, "state", ConfigEntryState.LOADED) - entry.runtime_data = MagicMock() + entry.runtime_data = SimpleNamespace( + client=MagicMock( + models=MagicMock(list=AsyncMock(return_value=AsyncPage(data=model_list))) + ) + ) return entry @@ -112,73 +121,62 @@ async def test_repair_flow_iterates_subentries( await _setup_repairs(hass) client = await hass_client() - model_options: list[dict[str, str]] = [ - {"label": "Claude Haiku 4.5", "value": "claude-haiku-4-5"}, - {"label": "Claude Sonnet 4.6", "value": "claude-sonnet-4-6"}, - {"label": "Claude Opus 4.6", "value": "claude-opus-4-6"}, - ] - - with patch( - "homeassistant.components.anthropic.repairs.get_model_list", - new_callable=AsyncMock, - return_value=model_options, - ): - result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated") - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - placeholders = result["description_placeholders"] - assert placeholders["entry_name"] == entry_one.title - assert placeholders["subentry_name"] == "Conversation One" - assert placeholders["subentry_type"] == "Conversation agent" - - flow_id = result["flow_id"] - - result = await process_repair_fix_flow( - client, - flow_id, - json={CONF_CHAT_MODEL: "claude-haiku-4-5"}, - ) - assert result["type"] == FlowResultType.FORM - assert ( - _get_subentry(entry_one, "conversation").data[CONF_CHAT_MODEL] - == "claude-haiku-4-5" - ) + result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated") + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + placeholders = result["description_placeholders"] + assert placeholders["entry_name"] == entry_one.title + assert placeholders["subentry_name"] == "Conversation One" + assert placeholders["subentry_type"] == "Conversation agent" + + flow_id = result["flow_id"] + + result = await process_repair_fix_flow( + client, + flow_id, + json={CONF_CHAT_MODEL: "claude-haiku-4-5"}, + ) + assert result["type"] == FlowResultType.FORM + assert ( + _get_subentry(entry_one, "conversation").data[CONF_CHAT_MODEL] + == "claude-haiku-4-5" + ) - placeholders = result["description_placeholders"] - assert placeholders["entry_name"] == entry_one.title - assert placeholders["subentry_name"] == "AI task One" - assert placeholders["subentry_type"] == "AI task" + placeholders = result["description_placeholders"] + assert placeholders["entry_name"] == entry_one.title + assert placeholders["subentry_name"] == "AI task One" + assert placeholders["subentry_type"] == "AI task" - result = await process_repair_fix_flow( - client, - flow_id, - json={CONF_CHAT_MODEL: "claude-sonnet-4-6"}, - ) - assert result["type"] == FlowResultType.FORM - assert ( - _get_subentry(entry_one, "ai_task_data").data[CONF_CHAT_MODEL] - == "claude-sonnet-4-6" - ) - assert ( - _get_subentry(entry_one, "conversation").data[CONF_CHAT_MODEL] - == "claude-haiku-4-5" - ) + result = await process_repair_fix_flow( + client, + flow_id, + json={CONF_CHAT_MODEL: "claude-sonnet-4-6"}, + ) + assert result["type"] == FlowResultType.FORM + assert ( + _get_subentry(entry_one, "ai_task_data").data[CONF_CHAT_MODEL] + == "claude-sonnet-4-6" + ) + assert ( + _get_subentry(entry_one, "conversation").data[CONF_CHAT_MODEL] + == "claude-haiku-4-5" + ) - placeholders = result["description_placeholders"] - assert placeholders["entry_name"] == entry_two.title - assert placeholders["subentry_name"] == "Conversation Two" - assert placeholders["subentry_type"] == "Conversation agent" + placeholders = result["description_placeholders"] + assert placeholders["entry_name"] == entry_two.title + assert placeholders["subentry_name"] == "Conversation Two" + assert placeholders["subentry_type"] == "Conversation agent" - result = await process_repair_fix_flow( - client, - flow_id, - json={CONF_CHAT_MODEL: "claude-opus-4-6"}, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert ( - _get_subentry(entry_two, "conversation").data[CONF_CHAT_MODEL] - == "claude-opus-4-6" - ) + result = await process_repair_fix_flow( + client, + flow_id, + json={CONF_CHAT_MODEL: "claude-opus-4-6"}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert ( + _get_subentry(entry_two, "conversation").data[CONF_CHAT_MODEL] + == "claude-opus-4-6" + ) assert issue_registry.async_get_issue(DOMAIN, "model_deprecated") is None From 299562d6ee0d7c4edde9175153df63771e587bd7 Mon Sep 17 00:00:00 2001 From: James <38914183+barneyonline@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:58:57 +1000 Subject: [PATCH 0777/1707] Set integer display precision for Yardian duration sensors (#165896) Co-authored-by: barneyonline --- homeassistant/components/yardian/sensor.py | 3 +++ tests/components/yardian/snapshots/test_sensor.ambr | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yardian/sensor.py b/homeassistant/components/yardian/sensor.py index 3be0ddee76b326..7fe4069b0d869e 100644 --- a/homeassistant/components/yardian/sensor.py +++ b/homeassistant/components/yardian/sensor.py @@ -56,6 +56,7 @@ def _zone_delay_value(coordinator: YardianUpdateCoordinator) -> StateType: device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, value_fn=lambda coordinator: coordinator.data.oper_info.get("iRainDelay"), ), YardianSensorEntityDescription( @@ -71,6 +72,7 @@ def _zone_delay_value(coordinator: YardianUpdateCoordinator) -> StateType: native_unit_of_measurement=UnitOfTime.SECONDS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + suggested_display_precision=0, value_fn=_zone_delay_value, ), YardianSensorEntityDescription( @@ -80,6 +82,7 @@ def _zone_delay_value(coordinator: YardianUpdateCoordinator) -> StateType: native_unit_of_measurement=UnitOfTime.SECONDS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + suggested_display_precision=0, value_fn=lambda coordinator: coordinator.data.oper_info.get( "iWaterHammerDuration" ), diff --git a/tests/components/yardian/snapshots/test_sensor.ambr b/tests/components/yardian/snapshots/test_sensor.ambr index 8d727512e79513..42d6a973e5b7ff 100644 --- a/tests/components/yardian/snapshots/test_sensor.ambr +++ b/tests/components/yardian/snapshots/test_sensor.ambr @@ -80,7 +80,7 @@ 'object_id_base': 'Rain delay', 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -136,7 +136,7 @@ 'object_id_base': 'Water hammer reduction', 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -191,7 +191,7 @@ 'object_id_base': 'Zone delay', 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , From a5b830cc343bb422f94c9237a4b7deafc1167f5b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:04:23 +0200 Subject: [PATCH 0778/1707] Don't create cpu temperature sensor when not supported in FRITZ!Box Tools (#167905) --- homeassistant/components/fritz/sensor.py | 108 +- .../fritz/snapshots/test_sensor.ambr | 2574 +++++++++++++++++ tests/components/fritz/test_sensor.py | 39 + 3 files changed, 2688 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 8aa48b216cb367..41fa3fca056dca 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -8,6 +8,7 @@ import logging from fritzconnection.lib.fritzstatus import FritzStatus +from requests.exceptions import RequestException from homeassistant.components.sensor import ( SensorDeviceClass, @@ -145,46 +146,65 @@ def _retrieve_link_attenuation_received_state( def _retrieve_cpu_temperature_state( status: FritzStatus, last_value: float | None -) -> float: +) -> float | None: """Return the first CPU temperature value.""" - return status.get_cpu_temperatures()[0] # type: ignore[no-any-return] + try: + return status.get_cpu_temperatures()[0] # type: ignore[no-any-return] + except RequestException: + return None + + +def _is_suitable_cpu_temperature(status: FritzStatus) -> bool: + """Return whether the CPU temperature sensor is suitable.""" + try: + cpu_temp = status.get_cpu_temperatures()[0] + except RequestException, IndexError: + _LOGGER.debug("CPU temperature not supported by the device") + return False + if cpu_temp == 0: + _LOGGER.debug("CPU temperature returns 0°C, treating as not supported") + return False + return True @dataclass(frozen=True, kw_only=True) -class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): - """Describes Fritz sensor entity.""" +class FritzConnectionSensorEntityDescription( + SensorEntityDescription, FritzEntityDescription +): + """Describes Fritz connection sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled -SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( - FritzSensorEntityDescription( +@dataclass(frozen=True, kw_only=True) +class FritzDeviceSensorEntityDescription( + SensorEntityDescription, FritzEntityDescription +): + """Describes Fritz device sensor entity.""" + + is_suitable: Callable[[FritzStatus], bool] = lambda status: True + + +CONNECTION_SENSOR_TYPES: tuple[FritzConnectionSensorEntityDescription, ...] = ( + FritzConnectionSensorEntityDescription( key="external_ip", translation_key="external_ip", value_fn=_retrieve_external_ip_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="external_ipv6", translation_key="external_ipv6", value_fn=_retrieve_external_ipv6_state, is_suitable=lambda info: info.ipv6_active, ), - FritzSensorEntityDescription( - key="device_uptime", - translation_key="device_uptime", - device_class=SensorDeviceClass.TIMESTAMP, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=_retrieve_device_uptime_state, - is_suitable=lambda info: True, - ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="connection_uptime", translation_key="connection_uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_connection_uptime_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="kb_s_sent", translation_key="kb_s_sent", state_class=SensorStateClass.MEASUREMENT, @@ -192,7 +212,7 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti device_class=SensorDeviceClass.DATA_RATE, value_fn=_retrieve_kb_s_sent_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="kb_s_received", translation_key="kb_s_received", state_class=SensorStateClass.MEASUREMENT, @@ -200,21 +220,21 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti device_class=SensorDeviceClass.DATA_RATE, value_fn=_retrieve_kb_s_received_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="max_kb_s_sent", translation_key="max_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, value_fn=_retrieve_max_kb_s_sent_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="max_kb_s_received", translation_key="max_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, value_fn=_retrieve_max_kb_s_received_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="gb_sent", translation_key="gb_sent", state_class=SensorStateClass.TOTAL_INCREASING, @@ -222,7 +242,7 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti device_class=SensorDeviceClass.DATA_SIZE, value_fn=_retrieve_gb_sent_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="gb_received", translation_key="gb_received", state_class=SensorStateClass.TOTAL_INCREASING, @@ -230,7 +250,7 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti device_class=SensorDeviceClass.DATA_SIZE, value_fn=_retrieve_gb_received_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_kb_s_sent", translation_key="link_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -238,7 +258,7 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_sent_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_kb_s_received", translation_key="link_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, @@ -246,7 +266,7 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_link_kb_s_received_state, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_noise_margin_sent", translation_key="link_noise_margin_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -255,7 +275,7 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti value_fn=_retrieve_link_noise_margin_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_noise_margin_received", translation_key="link_noise_margin_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -264,7 +284,7 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti value_fn=_retrieve_link_noise_margin_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_attenuation_sent", translation_key="link_attenuation_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -273,7 +293,7 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti value_fn=_retrieve_link_attenuation_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), - FritzSensorEntityDescription( + FritzConnectionSensorEntityDescription( key="link_attenuation_received", translation_key="link_attenuation_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -282,7 +302,17 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti value_fn=_retrieve_link_attenuation_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), - FritzSensorEntityDescription( +) + +DEVICE_SENSOR_TYPES: tuple[FritzDeviceSensorEntityDescription, ...] = ( + FritzDeviceSensorEntityDescription( + key="device_uptime", + translation_key="device_uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_retrieve_device_uptime_state, + ), + FritzDeviceSensorEntityDescription( key="cpu_temperature", translation_key="cpu_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -290,7 +320,7 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=_retrieve_cpu_temperature_state, - is_suitable=lambda info: True, + is_suitable=_is_suitable_cpu_temperature, ), ) @@ -305,20 +335,32 @@ async def async_setup_entry( avm_wrapper = entry.runtime_data connection_info = await avm_wrapper.async_get_connection_info() - entities = [ FritzBoxSensor(avm_wrapper, entry.title, description) - for description in SENSOR_TYPES + for description in CONNECTION_SENSOR_TYPES if description.is_suitable(connection_info) ] + fritz_status = avm_wrapper.fritz_status + + def _generate_device_sensors() -> list[FritzBoxSensor]: + return [ + FritzBoxSensor(avm_wrapper, entry.title, description) + for description in DEVICE_SENSOR_TYPES + if description.is_suitable(fritz_status) + ] + + entities += await hass.async_add_executor_job(_generate_device_sensors) + async_add_entities(entities) class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" - entity_description: FritzSensorEntityDescription + entity_description: ( + FritzConnectionSensorEntityDescription | FritzDeviceSensorEntityDescription + ) @property def native_value(self) -> StateType: diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index 74cd49f6e400a7..d820dda43ee9e1 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -1,4 +1,2578 @@ # serializer version: 1 +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_connection_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Connection uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection uptime', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_uptime', + 'unique_id': '1CED6F123411-connection_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_connection_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Connection uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-01T10:11:33+00:00', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_received', + 'unique_id': '1CED6F123411-kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Download throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67.6', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_external_ip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External IP', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IP', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_ip', + 'unique_id': '1CED6F123411-external_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_external_ip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IP', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2.3.4', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_external_ipv6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ipv6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External IPv6', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IPv6', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_ipv6', + 'unique_id': '1CED6F123411-external_ipv6', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_external_ipv6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IPv6', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ipv6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fec0::1', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_gb_received-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_received', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'GB received', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB received', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gb_received', + 'unique_id': '1CED6F123411-gb_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_gb_received-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB received', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_received', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.2', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_gb_sent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_sent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'GB sent', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB sent', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gb_sent', + 'unique_id': '1CED6F123411-gb_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_gb_sent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB sent', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_sent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.7', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_last_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_last_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last restart', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_uptime', + 'unique_id': '1CED6F123411-device_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_last_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Last restart', + }), + 'context': , + 'entity_id': 'sensor.mock_title_last_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-08-03T16:30:21+00:00', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_download_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download noise margin', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_received', + 'unique_id': '1CED6F123411-link_noise_margin_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_download_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_download_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download power attenuation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_received', + 'unique_id': '1CED6F123411-link_attenuation_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_download_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_received', + 'unique_id': '1CED6F123411-link_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '318557.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_upload_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload noise margin', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_sent', + 'unique_id': '1CED6F123411-link_noise_margin_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_upload_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_upload_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload power attenuation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_sent', + 'unique_id': '1CED6F123411-link_attenuation_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_upload_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_sent', + 'unique_id': '1CED6F123411-link_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_link_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51805.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_max_connection_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Max connection download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_received', + 'unique_id': '1CED6F123411-max_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_max_connection_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10087.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_max_connection_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Max connection upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_sent', + 'unique_id': '1CED6F123411-max_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_max_connection_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2105.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_sent', + 'unique_id': '1CED6F123411-kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Upload throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.4', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_connection_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Connection uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection uptime', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_uptime', + 'unique_id': '1CED6F123411-connection_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_connection_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Connection uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-01T10:11:33+00:00', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_received', + 'unique_id': '1CED6F123411-kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Download throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67.6', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_external_ip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External IP', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IP', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_ip', + 'unique_id': '1CED6F123411-external_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_external_ip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IP', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2.3.4', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_external_ipv6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ipv6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External IPv6', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IPv6', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_ipv6', + 'unique_id': '1CED6F123411-external_ipv6', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_external_ipv6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IPv6', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ipv6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fec0::1', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_gb_received-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_received', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'GB received', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB received', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gb_received', + 'unique_id': '1CED6F123411-gb_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_gb_received-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB received', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_received', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.2', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_gb_sent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_sent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'GB sent', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB sent', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gb_sent', + 'unique_id': '1CED6F123411-gb_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_gb_sent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB sent', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_sent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.7', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_last_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_last_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last restart', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_uptime', + 'unique_id': '1CED6F123411-device_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_last_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Last restart', + }), + 'context': , + 'entity_id': 'sensor.mock_title_last_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-08-03T16:30:21+00:00', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_download_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download noise margin', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_received', + 'unique_id': '1CED6F123411-link_noise_margin_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_download_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_download_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download power attenuation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_received', + 'unique_id': '1CED6F123411-link_attenuation_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_download_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_received', + 'unique_id': '1CED6F123411-link_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '318557.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_upload_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload noise margin', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_sent', + 'unique_id': '1CED6F123411-link_noise_margin_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_upload_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_upload_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload power attenuation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_sent', + 'unique_id': '1CED6F123411-link_attenuation_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_upload_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_sent', + 'unique_id': '1CED6F123411-link_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_link_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51805.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_max_connection_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Max connection download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_received', + 'unique_id': '1CED6F123411-max_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_max_connection_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10087.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_max_connection_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Max connection upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_sent', + 'unique_id': '1CED6F123411-max_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_max_connection_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2105.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_sent', + 'unique_id': '1CED6F123411-kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Upload throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.4', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_connection_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Connection uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection uptime', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_uptime', + 'unique_id': '1CED6F123411-connection_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_connection_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Connection uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-01T10:11:33+00:00', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_received', + 'unique_id': '1CED6F123411-kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Download throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67.6', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_external_ip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External IP', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IP', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_ip', + 'unique_id': '1CED6F123411-external_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_external_ip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IP', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2.3.4', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_external_ipv6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ipv6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External IPv6', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IPv6', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_ipv6', + 'unique_id': '1CED6F123411-external_ipv6', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_external_ipv6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IPv6', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ipv6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fec0::1', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_gb_received-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_received', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'GB received', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB received', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gb_received', + 'unique_id': '1CED6F123411-gb_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_gb_received-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB received', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_received', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.2', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_gb_sent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_sent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'GB sent', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB sent', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gb_sent', + 'unique_id': '1CED6F123411-gb_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_gb_sent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB sent', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_sent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.7', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_last_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_last_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last restart', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_uptime', + 'unique_id': '1CED6F123411-device_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_last_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Last restart', + }), + 'context': , + 'entity_id': 'sensor.mock_title_last_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-08-03T16:30:21+00:00', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_download_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download noise margin', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_received', + 'unique_id': '1CED6F123411-link_noise_margin_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_download_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_download_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download power attenuation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_received', + 'unique_id': '1CED6F123411-link_attenuation_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_download_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_received', + 'unique_id': '1CED6F123411-link_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '318557.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_upload_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload noise margin', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_sent', + 'unique_id': '1CED6F123411-link_noise_margin_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_upload_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_upload_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload power attenuation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_sent', + 'unique_id': '1CED6F123411-link_attenuation_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_upload_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Link upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_sent', + 'unique_id': '1CED6F123411-link_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_link_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51805.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_max_connection_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Max connection download throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_received', + 'unique_id': '1CED6F123411-max_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_max_connection_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10087.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_max_connection_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Max connection upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_sent', + 'unique_id': '1CED6F123411-max_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_max_connection_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2105.0', + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upload throughput', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_sent', + 'unique_id': '1CED6F123411-kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Upload throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.4', + }) +# --- # name: test_sensor_setup[sensor.mock_title_connection_uptime-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 4b6af0d55d51c1..d00327994d633c 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -8,6 +8,7 @@ from freezegun.api import FrozenDateTimeFactory from fritzconnection.core.exceptions import FritzConnectionException import pytest +from requests.exceptions import RequestException from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL, UPTIME_DEVIATION @@ -112,3 +113,41 @@ async def test_sensor_uptime_spike( assert (new_state := hass.states.get(entity_id)) assert new_state.state == "2026-01-16T06:00:21+00:00" + + +@pytest.mark.freeze_time(datetime(2024, 9, 1, 20, tzinfo=UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("side_effect", "return_values"), + [(RequestException("boom"), None), (None, [0, 0, 0]), (None, [])], +) +async def test_sensor_cpu_temp_not_supported( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + side_effect, + return_values, + fc_class_mock, + fh_class_mock, + fs_class_mock, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of Fritz!Tools sensors.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.fritz.PLATFORMS", [Platform.SENSOR]), + patch( + "homeassistant.components.fritz.coordinator.FritzStatus", fs_class_mock + ) as mock_status, + ): + mock_status.get_cpu_temperatures.side_effect = side_effect + mock_status.get_cpu_temperatures.return_value = return_values + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + assert not entity_registry.async_is_registered( + "sensor.mock_title_cpu_temperature" + ) From 59248e5414a018b1a9f8a992dbf898d9339cbb1d Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sat, 11 Apr 2026 01:07:18 +0200 Subject: [PATCH 0779/1707] Bump music-assistant-client to 1.3.5 (#167947) --- homeassistant/components/music_assistant/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index c59f88aa5e7fa8..e89498303860f4 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["music_assistant"], "quality_scale": "bronze", - "requirements": ["music-assistant-client==1.3.4"], + "requirements": ["music-assistant-client==1.3.5"], "zeroconf": ["_mass._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 99f6cf0d911590..b6ae3c8a7b08dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1567,7 +1567,7 @@ mozart-api==5.3.1.108.2 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.3.4 +music-assistant-client==1.3.5 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5c2ca2751f7e4..0c9fd643205dfe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1380,7 +1380,7 @@ mozart-api==5.3.1.108.2 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.3.4 +music-assistant-client==1.3.5 # homeassistant.components.tts mutagen==1.47.0 From b93cdc64f338d434aad925d2019def673e9f26f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:27:52 -1000 Subject: [PATCH 0780/1707] Bump bleak-esphome to 3.7.3 (#167953) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8d9daaedd1c41f..5e980da229ab08 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -19,7 +19,7 @@ "requirements": [ "aioesphomeapi==44.13.1", "esphome-dashboard-api==1.3.0", - "bleak-esphome==3.7.1" + "bleak-esphome==3.7.3" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b6ae3c8a7b08dc..6b536d85c1f381 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,7 +644,7 @@ beautifulsoup4==4.13.3 bizkaibus==0.1.1 # homeassistant.components.esphome -bleak-esphome==3.7.1 +bleak-esphome==3.7.3 # homeassistant.components.bluetooth bleak-retry-connector==4.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c9fd643205dfe..ae5d9606862074 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -581,7 +581,7 @@ base36==0.1.1 beautifulsoup4==4.13.3 # homeassistant.components.esphome -bleak-esphome==3.7.1 +bleak-esphome==3.7.3 # homeassistant.components.bluetooth bleak-retry-connector==4.6.0 From 054b8ad53444b7daba47e6004e1b43ac811dfdf7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 16:48:34 -1000 Subject: [PATCH 0781/1707] Bump aioesphomeapi to 44.13.2 (#167952) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5e980da229ab08..2690cf0235ed0b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==44.13.1", + "aioesphomeapi==44.13.2", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.7.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 6b536d85c1f381..c196e191b896fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -251,7 +251,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==44.13.1 +aioesphomeapi==44.13.2 # homeassistant.components.matrix # homeassistant.components.slack diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae5d9606862074..dd5d76f3299364 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -242,7 +242,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==44.13.1 +aioesphomeapi==44.13.2 # homeassistant.components.matrix # homeassistant.components.slack From 9f1c396407178de95196481c005b57e97687620c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:49:54 +0200 Subject: [PATCH 0782/1707] Unlink tomorrowio coordinator from config entry (#167901) --- .../components/tomorrowio/__init__.py | 3 ++- .../components/tomorrowio/coordinator.py | 21 ++++++++++++------- homeassistant/components/weather/__init__.py | 8 ++++--- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 7d6b9ed3f73de7..06eaad85e65419 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -29,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # we will not use the class's lat and long so we can pass in garbage # lats and longs api = TomorrowioV4(api_key, 361.0, 361.0, unit_system="metric", session=session) - coordinator = TomorrowioDataUpdateCoordinator(hass, entry, api) + coordinator = TomorrowioDataUpdateCoordinator(hass, api) hass.data[DOMAIN][api_key] = coordinator await coordinator.async_setup_entry(entry) @@ -49,6 +49,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> coordinator: TomorrowioDataUpdateCoordinator = hass.data[DOMAIN][api_key] # If this is true, we can remove the coordinator if await coordinator.async_unload_entry(config_entry): + await coordinator.async_shutdown() hass.data[DOMAIN].pop(api_key) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/tomorrowio/coordinator.py b/homeassistant/components/tomorrowio/coordinator.py index 2a6b3675792d21..7894066317f8f7 100644 --- a/homeassistant/components/tomorrowio/coordinator.py +++ b/homeassistant/components/tomorrowio/coordinator.py @@ -24,6 +24,7 @@ CONF_LONGITUDE, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -116,11 +117,9 @@ def async_set_update_interval( class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an object to hold Tomorrow.io data.""" - config_entry: ConfigEntry + config_entry: None - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: TomorrowioV4 - ) -> None: + def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: """Initialize.""" self._api = api self.data = {CURRENT: {}, FORECASTS: {}} @@ -130,7 +129,7 @@ def __init__( super().__init__( hass, LOGGER, - config_entry=config_entry, + config_entry=None, name=f"{DOMAIN}_{self._api.api_key_masked}", ) @@ -158,7 +157,15 @@ async def async_setup_entry(self, entry: ConfigEntry) -> None: "Loaded %s entries, initiating first refresh", len(self.entry_id_to_location_dict), ) - await self.async_config_entry_first_refresh() + await self._async_refresh( + log_failures=False, + raise_on_auth_failed=True, + raise_on_entry_error=True, + ) + if not self.last_update_success: + ex = ConfigEntryNotReady() + ex.__cause__ = self.last_exception + raise ex self._coordinator_ready.set() else: # If we have an event, we need to wait for it to be set before we proceed @@ -184,7 +191,7 @@ async def async_setup_entry(self, entry: ConfigEntry) -> None: if self._listeners: self._schedule_refresh() - async def async_unload_entry(self, entry: ConfigEntry) -> bool | None: + async def async_unload_entry(self, entry: ConfigEntry) -> bool: """Unload a config entry from coordinator. Returns whether coordinator can be removed as well because there are no diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index df98636d12dd5c..15aef99047e9db 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1223,7 +1223,9 @@ def __init__( def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" super()._handle_coordinator_update() - assert self.coordinator.config_entry - self.coordinator.config_entry.async_create_task( - self.hass, self.async_update_listeners(None) + if entry := self.coordinator.config_entry: + entry.async_create_task(self.hass, self.async_update_listeners(None)) + return + self.hass.async_create_task( + self.async_update_listeners(None), f"{self.coordinator.name}" ) From 03d6f5a756b3ed8ab21ba75af2bd3246a38151bd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 11 Apr 2026 10:00:37 +0200 Subject: [PATCH 0783/1707] Update cryptography to 46.0.7 (#167960) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5b353e8731eb9c..7c1a87048a5852 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ cached-ipaddress==1.0.1 certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 -cryptography==46.0.6 +cryptography==46.0.7 dbus-fast==4.0.4 file-read-backwards==2.0.0 fnv-hash-fast==2.0.0 diff --git a/pyproject.toml b/pyproject.toml index 883072535e373e..3d100ae5e1c64f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==46.0.6", + "cryptography==46.0.7", "Pillow==12.1.1", "propcache==0.4.1", "pyOpenSSL==26.0.0", diff --git a/requirements.txt b/requirements.txt index 47240294575165..7da4bc32560717 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ bcrypt==5.0.0 certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 -cryptography==46.0.6 +cryptography==46.0.7 fnv-hash-fast==2.0.0 ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 From 974047664cac39f026806df3141c123c091275d1 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:26:15 +0200 Subject: [PATCH 0784/1707] Bump unifi-discovery to version 1.4.0 (#167958) Co-authored-by: RaHehl --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b74946294ffc71..bd8316887a5540 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.2.3", "unifi-discovery==1.3.0"], + "requirements": ["uiprotect==10.2.3", "unifi-discovery==1.4.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index c196e191b896fb..854506e89d39e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3184,7 +3184,7 @@ uiprotect==10.2.3 ultraheat-api==0.5.7 # homeassistant.components.unifiprotect -unifi-discovery==1.3.0 +unifi-discovery==1.4.0 # homeassistant.components.unifi_direct unifi_ap==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd5d76f3299364..de9921d48e4934 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2696,7 +2696,7 @@ uiprotect==10.2.3 ultraheat-api==0.5.7 # homeassistant.components.unifiprotect -unifi-discovery==1.3.0 +unifi-discovery==1.4.0 # homeassistant.components.homeassistant_hardware universal-silabs-flasher==1.0.3 From fdf1b6536a1ce60400978bacbae409878a78efb2 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:27:43 +0200 Subject: [PATCH 0785/1707] Follow-up to player options: text entities in Music Assistant (#167962) --- .../components/music_assistant/text.py | 26 +++++++------------ .../music_assistant/fixtures/players.json | 4 +-- tests/components/music_assistant/test_text.py | 6 ++--- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/music_assistant/text.py b/homeassistant/components/music_assistant/text.py index 23093a8e5d144b..b8699640d32ef2 100644 --- a/homeassistant/components/music_assistant/text.py +++ b/homeassistant/components/music_assistant/text.py @@ -13,13 +13,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MusicAssistantConfigEntry -from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX from .entity import MusicAssistantPlayerOptionEntity from .helpers import catch_musicassistant_error -PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT: Final[list[str]] = [ - "network_name", -] +PLAYER_OPTIONS_TEXT: Final[dict[str, bool]] = { + # translation_key: enabled_by_default + "network_name": True +} async def async_setup_entry( @@ -42,19 +42,8 @@ def add_player(player_id: str) -> None: and player_option.type == PlayerOptionType.STRING and not player_option.options # these we map to select ): - # the MA translation key must have the format player_options. # we ignore entities with unknown translation keys. - if ( - player_option.translation_key is None - or not player_option.translation_key.startswith( - PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX - ) - ): - continue - translation_key = player_option.translation_key[ - len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) : - ] - if translation_key not in PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT: + if player_option.translation_key not in PLAYER_OPTIONS_TEXT: continue entities.append( @@ -64,7 +53,10 @@ def add_player(player_id: str) -> None: player_option=player_option, entity_description=TextEntityDescription( key=player_option.key, - translation_key=translation_key, + translation_key=player_option.translation_key, + entity_registry_enabled_default=PLAYER_OPTIONS_TEXT[ + player_option.translation_key + ], ), ) ) diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index cb078259dfbc7e..6f589ae1cbb0ec 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -97,7 +97,7 @@ "key": "network_name", "name": "Network Name", "type": "string", - "translation_key": "player_options.network_name", + "translation_key": "network_name", "translation_params": null, "value": "receiver", "read_only": false, @@ -110,7 +110,7 @@ "key": "network_name_ro", "name": "Network Name RO", "type": "string", - "translation_key": "player_options.network_name", + "translation_key": "network_name", "translation_params": null, "value": "receiver ro", "read_only": true, diff --git a/tests/components/music_assistant/test_text.py b/tests/components/music_assistant/test_text.py index 1bbe41dcc31551..6dc2a3bbec3cf2 100644 --- a/tests/components/music_assistant/test_text.py +++ b/tests/components/music_assistant/test_text.py @@ -6,9 +6,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.music_assistant.const import DOMAIN -from homeassistant.components.music_assistant.text import ( - PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT, -) +from homeassistant.components.music_assistant.text import PLAYER_OPTIONS_TEXT from homeassistant.components.text import ( ATTR_VALUE, DOMAIN as TEXT_DOMAIN, @@ -128,7 +126,7 @@ async def test_name_translation_availability( hass, language=LOCALE_EN, category="entity", integrations=[DOMAIN] ) prefix = f"component.{DOMAIN}.entity.{Platform.TEXT.value}." - for translation_key in PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT: + for translation_key in PLAYER_OPTIONS_TEXT: assert translations.get(f"{prefix}{translation_key}.name") is not None, ( f"{translation_key} is missing in strings.json for platform text" ) From 640fea89e0992b0131b9936cea156c75e4298184 Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:33:27 +0200 Subject: [PATCH 0786/1707] Follow-up to player options: number entities in Music Assistant (#167963) --- .../components/music_assistant/number.py | 42 ++++++++----------- .../music_assistant/fixtures/players.json | 6 +-- .../components/music_assistant/test_number.py | 6 +-- 3 files changed, 22 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/music_assistant/number.py b/homeassistant/components/music_assistant/number.py index 622c9adebcd435..626c05a9cd11cc 100644 --- a/homeassistant/components/music_assistant/number.py +++ b/homeassistant/components/music_assistant/number.py @@ -13,21 +13,21 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MusicAssistantConfigEntry -from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX from .entity import MusicAssistantPlayerOptionEntity from .helpers import catch_musicassistant_error -PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER: Final[list[str]] = [ - "bass", - "dialogue_level", - "dialogue_lift", - "dts_dialogue_control", - "equalizer_high", - "equalizer_low", - "equalizer_mid", - "subwoofer_volume", - "treble", -] +PLAYER_OPTIONS_NUMBER: Final[dict[str, bool]] = { + # translation_key: enabled_by_default + "bass": True, + "dialogue_level": False, + "dialogue_lift": False, + "dts_dialogue_control": False, + "equalizer_high": False, + "equalizer_low": False, + "equalizer_mid": False, + "subwoofer_volume": True, + "treble": True, +} async def async_setup_entry( @@ -54,19 +54,8 @@ def add_player(player_id: str) -> None: ) and not player_option.options # these we map to select ): - # the MA translation key must have the format player_options. # we ignore entities with unknown translation keys. - if ( - player_option.translation_key is None - or not player_option.translation_key.startswith( - PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX - ) - ): - continue - translation_key = player_option.translation_key[ - len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) : - ] - if translation_key not in PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER: + if player_option.translation_key not in PLAYER_OPTIONS_NUMBER: continue entities.append( @@ -76,7 +65,10 @@ def add_player(player_id: str) -> None: player_option=player_option, entity_description=NumberEntityDescription( key=player_option.key, - translation_key=translation_key, + translation_key=player_option.translation_key, + entity_registry_enabled_default=PLAYER_OPTIONS_NUMBER[ + player_option.translation_key + ], ), ) ) diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 6f589ae1cbb0ec..07e684d9394fa6 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -32,7 +32,7 @@ "key": "treble", "name": "Treble", "type": "integer", - "translation_key": "player_options.treble", + "translation_key": "treble", "translation_params": null, "value": -6, "read_only": false, @@ -45,7 +45,7 @@ "key": "bass", "name": "Bass", "type": "float", - "translation_key": "player_options.bass", + "translation_key": "bass", "translation_params": null, "value": -6.0, "read_only": false, @@ -58,7 +58,7 @@ "key": "treble_ro", "name": "Treble RO", "type": "integer", - "translation_key": "player_options.treble", + "translation_key": "treble", "translation_params": null, "value": -6, "read_only": true, diff --git a/tests/components/music_assistant/test_number.py b/tests/components/music_assistant/test_number.py index a037c4e6b432d2..5c5d4e155c0deb 100644 --- a/tests/components/music_assistant/test_number.py +++ b/tests/components/music_assistant/test_number.py @@ -7,9 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.music_assistant.const import DOMAIN -from homeassistant.components.music_assistant.number import ( - PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER, -) +from homeassistant.components.music_assistant.number import PLAYER_OPTIONS_NUMBER from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -147,7 +145,7 @@ async def test_name_translation_availability( hass, language=LOCALE_EN, category="entity", integrations=[DOMAIN] ) prefix = f"component.{DOMAIN}.entity.{Platform.NUMBER.value}." - for translation_key in PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER: + for translation_key in PLAYER_OPTIONS_NUMBER: assert translations.get(f"{prefix}{translation_key}.name") is not None, ( f"{translation_key} is missing in strings.json for platform number" ) From ac4b253a2ff6e899a92260173c1f3075982cf101 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 11 Apr 2026 18:37:37 +1000 Subject: [PATCH 0787/1707] Add LoginRequired exception handling to Teslemetry coordinators (#167959) --- homeassistant/components/teslemetry/coordinator.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 11d6a95d796a92..819c99ba60d9df 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -10,6 +10,7 @@ GatewayTimeout, InvalidResponse, InvalidToken, + LoginRequired, RateLimited, ServiceUnavailable, SubscriptionRequired, @@ -85,7 +86,7 @@ async def _async_update_data(self) -> dict[str, Any]: """Fetch latest metadata for subscription status.""" try: data = await self.teslemetry.metadata() - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -136,7 +137,7 @@ async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Teslemetry API.""" try: data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -186,7 +187,7 @@ async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" try: data: dict[str, Any] = (await self.api.live_status())["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -233,7 +234,7 @@ async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" try: data = (await self.api.site_info())["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -279,7 +280,7 @@ async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" try: data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( From f34ed8f8bae3b834ff3a79ce026f973d8ab6114b Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:46:20 +0200 Subject: [PATCH 0788/1707] Follow-up to player options: switch entities in Music Assistant (#167964) --- .../components/music_assistant/const.py | 1 - .../components/music_assistant/switch.py | 18 +++--------------- .../music_assistant/fixtures/players.json | 4 ++-- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 2a823c48cf5745..e27ba068e399da 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -81,5 +81,4 @@ LOGGER = logging.getLogger(__package__) -PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX = "player_options." SOUND_MODES_TRANSLATION_KEY_PREFIX = "player_sound_mode." diff --git a/homeassistant/components/music_assistant/switch.py b/homeassistant/components/music_assistant/switch.py index 9d9822257aeac6..dfc76540dc0592 100644 --- a/homeassistant/components/music_assistant/switch.py +++ b/homeassistant/components/music_assistant/switch.py @@ -13,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MusicAssistantConfigEntry -from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX from .entity import MusicAssistantPlayerOptionEntity from .helpers import catch_musicassistant_error @@ -51,19 +50,8 @@ def add_player(player_id: str) -> None: not player_option.read_only and player_option.type == PlayerOptionType.BOOLEAN ): - # the MA translation key must have the format player_options. # we ignore entities with unknown translation keys. - if ( - player_option.translation_key is None - or not player_option.translation_key.startswith( - PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX - ) - ): - continue - translation_key = player_option.translation_key[ - len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) : - ] - if translation_key not in PLAYER_OPTIONS_SWITCH: + if player_option.translation_key not in PLAYER_OPTIONS_SWITCH: continue entities.append( @@ -73,9 +61,9 @@ def add_player(player_id: str) -> None: player_option=player_option, entity_description=SwitchEntityDescription( key=player_option.key, - translation_key=translation_key, + translation_key=player_option.translation_key, entity_registry_enabled_default=PLAYER_OPTIONS_SWITCH[ - translation_key + player_option.translation_key ], ), ) diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 07e684d9394fa6..8c989fed6ac80b 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -71,7 +71,7 @@ "key": "enhancer", "name": "Enhancer", "type": "boolean", - "translation_key": "player_options.enhancer", + "translation_key": "enhancer", "translation_params": null, "value": false, "read_only": false, @@ -84,7 +84,7 @@ "key": "enhancer_ro", "name": "Enhancer RO", "type": "boolean", - "translation_key": "player_options.enhancer", + "translation_key": "enhancer", "translation_params": null, "value": false, "read_only": true, From 966eadad692d76b977f3d0c867c308f843c4e38f Mon Sep 17 00:00:00 2001 From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:07:52 +0200 Subject: [PATCH 0789/1707] Follow up to adding support for sound modes to Music Assistant (#167929) --- .../components/music_assistant/const.py | 2 -- .../music_assistant/media_player.py | 23 +++++++------------ .../components/music_assistant/strings.json | 3 ++- .../music_assistant/fixtures/players.json | 17 ++++++++++---- .../snapshots/test_media_player.ambr | 14 +++++------ .../music_assistant/test_media_player.py | 22 ++++++++++++++++-- 6 files changed, 49 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index e27ba068e399da..035da439db6120 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -80,5 +80,3 @@ ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha" LOGGER = logging.getLogger(__package__) - -SOUND_MODES_TRANSLATION_KEY_PREFIX = "player_sound_mode." diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 2c1d8f5fec36f6..6268e10d70d532 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -60,7 +60,6 @@ ATTR_REPEAT_MODE, ATTR_SHUFFLE_ENABLED, DOMAIN, - SOUND_MODES_TRANSLATION_KEY_PREFIX, ) from .entity import MusicAssistantEntity from .helpers import catch_musicassistant_error @@ -132,7 +131,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): _attr_name = None _attr_media_image_remotely_accessible = True _attr_media_content_type = HAMediaType.MUSIC - _attr_translation_key = "ma_media_player" + _attr_translation_key = "media_player" def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: """Initialize MediaPlayer entity.""" @@ -221,28 +220,22 @@ async def async_on_update(self) -> None: self._source_list_mapping = source_mappings self._attr_source = active_source_name - # same for sound modes + # translation_key, sound_mode.id sound_mode_mappings: dict[str, str] = {} + active_sound_mode_translation_key: str | None = None for sound_mode in player.sound_mode_list: if sound_mode.passive: # ignore passive sound_mode because HA does not differentiate between # active and passive sound mode continue - if ( - sound_mode.translation_key is None - or SOUND_MODES_TRANSLATION_KEY_PREFIX not in sound_mode.translation_key - ): - # MA's data class initializes the translation_key to - # player_sound_mode. automatically if it is not given, so we should - # always have a non None value - continue - translation_key = sound_mode.translation_key[ - len(SOUND_MODES_TRANSLATION_KEY_PREFIX) : - ] + translation_key = sound_mode.translation_key + if player.active_sound_mode == sound_mode.id: + active_sound_mode_translation_key = translation_key sound_mode_mappings[translation_key] = sound_mode.id + self._attr_sound_mode_list = list(sound_mode_mappings.keys()) self._sound_mode_list_mapping = sound_mode_mappings - self._attr_sound_mode = player.active_sound_mode + self._attr_sound_mode = active_sound_mode_translation_key group_members: list[str] = [] if player.group_members: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 88a14b49ecf328..7b403a4ff96344 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -55,7 +55,7 @@ } }, "media_player": { - "ma_media_player": { + "media_player": { "state_attributes": { "sound_mode": { "state": { @@ -69,6 +69,7 @@ "all_ch_stereo": "All ch stereo", "amsterdam": "Hall in Amsterdam", "arena": "Arena", + "bass_booster": "Bass booster", "bottom_line": "The Bottom Line", "cellar_club": "Cellar club", "chamber": "Chamber", diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 8c989fed6ac80b..996e13cdbacf67 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -195,18 +195,25 @@ "can_next_previous": false } ], + "active_sound_mode": "munich_id", "sound_mode_list": [ { - "id": "munich", + "id": "munich_id", "name": "Munich", "passive": false, - "translation_key": "player_sound_mode.munich" + "translation_key": "munich_translation" }, { - "id": "vienna", - "name": "Vienna", + "id": "stuttgart_id", + "name": "Stuttgart", "passive": false, - "translation_key": "player_sound_mode.vienna" + "translation_key": "stuttgart_translation" + }, + { + "id": "passive_sound_mode_id", + "name": "Passive", + "passive": true, + "translation_key": "passive_sound_mode_translation" } ] }, diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index f4cff652b0b9a9..f55320e808c86c 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -32,7 +32,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': 'ma_media_player', + 'translation_key': 'media_player', 'unique_id': '00:00:00:00:00:02', 'unit_of_measurement': None, }) @@ -103,7 +103,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': 'ma_media_player', + 'translation_key': 'media_player', 'unique_id': 'test_group_player_1', 'unit_of_measurement': None, }) @@ -153,8 +153,8 @@ 'area_id': None, 'capabilities': dict({ 'sound_mode_list': list([ - 'munich', - 'vienna', + 'munich_translation', + 'stuttgart_translation', ]), 'source_list': list([ 'Music Assistant Queue', @@ -186,7 +186,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': 'ma_media_player', + 'translation_key': 'media_player', 'unique_id': '00:00:00:00:00:01', 'unit_of_measurement': None, }) @@ -203,8 +203,8 @@ 'last_non_buffering_state': , 'mass_player_type': 'player', 'sound_mode_list': list([ - 'munich', - 'vienna', + 'munich_translation', + 'stuttgart_translation', ]), 'source_list': list([ 'Music Assistant Queue', diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 57107c933ffa2f..6b9f6eece971d0 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -670,16 +670,34 @@ async def test_media_player_select_sound_mode_action( SERVICE_SELECT_SOUND_MODE, { ATTR_ENTITY_ID: entity_id, - ATTR_SOUND_MODE: "munich", + ATTR_SOUND_MODE: "munich_translation", }, blocking=True, ) assert music_assistant_client.send_command.call_count == 1 assert music_assistant_client.send_command.call_args == call( - "players/cmd/select_sound_mode", player_id=mass_player_id, sound_mode="munich" + "players/cmd/select_sound_mode", + player_id=mass_player_id, + sound_mode="munich_id", ) +async def test_passive_sound_mode_ignored( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Verify, that a passive sound mode is ignored.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + passive_sound_mode_translation_key = "passive_sound_mode_translation" + active_sound_mode_translation_key = "munich_translation" + state = hass.states.get(entity_id) + assert state + sound_modes = state.attributes["sound_mode_list"] + assert active_sound_mode_translation_key in sound_modes + assert passive_sound_mode_translation_key not in sound_modes + + async def test_media_player_supported_features( hass: HomeAssistant, music_assistant_client: MagicMock, From ba7a9597277620e323fa38baf0249484f27beb78 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 11 Apr 2026 11:23:33 +0100 Subject: [PATCH 0790/1707] Remove unused constant from Evohome's const.py (#167969) --- homeassistant/components/evohome/const.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index f601ebbfecbd17..4af19934b67588 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -19,8 +19,6 @@ CONF_LOCATION_IDX: Final = "location_idx" -USER_DATA: Final = "user_data" - SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) From 938eacd777c355928fc9c8bd88154bcb2db743bc Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 11 Apr 2026 04:04:25 -0700 Subject: [PATCH 0791/1707] Bump opower to 0.18.1 (#167967) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index eaec3a5ed8954b..b28ba65606cf93 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "platinum", - "requirements": ["opower==0.18.0"] + "requirements": ["opower==0.18.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 854506e89d39e0..05d99b7458da1f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1732,7 +1732,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.18.0 +opower==0.18.1 # homeassistant.components.oralb oralb-ble==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de9921d48e4934..8f5b9340c72dcd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1512,7 +1512,7 @@ openrgb-python==0.3.6 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.18.0 +opower==0.18.1 # homeassistant.components.oralb oralb-ble==1.1.0 From fe1e12a29810770a9ed01ef1ad71c49be9f9b14c Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Sat, 11 Apr 2026 04:06:49 -0700 Subject: [PATCH 0792/1707] Improve iaqualink reauthentication flow (#167931) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/iaqualink/__init__.py | 16 ++- .../components/iaqualink/config_flow.py | 89 ++++++++++---- .../components/iaqualink/coordinator.py | 8 +- .../components/iaqualink/strings.json | 11 ++ .../components/iaqualink/test_config_flow.py | 90 ++++++++++++++ tests/components/iaqualink/test_init.py | 115 +++++++++++++++++- 6 files changed, 301 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index a5658388e3af7d..e65a8ca0568e31 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -93,6 +93,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> try: systems = await aqualink.get_systems() + except AqualinkServiceUnauthorizedException as auth_exception: + await aqualink.close() + raise ConfigEntryAuthFailed( + "Invalid credentials for iAqualink" + ) from auth_exception except AqualinkServiceException as svc_exception: await aqualink.close() raise ConfigEntryNotReady( @@ -116,10 +121,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> for system in systems_list: coordinator = AqualinkDataUpdateCoordinator(hass, entry, system) runtime_data.coordinators[system.serial] = coordinator - await coordinator.async_config_entry_first_refresh() + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryAuthFailed: + await aqualink.close() + raise try: devices = await system.get_devices() + except AqualinkServiceUnauthorizedException as auth_exception: + await aqualink.close() + raise ConfigEntryAuthFailed( + "Invalid credentials for iAqualink" + ) from auth_exception except AqualinkServiceException as svc_exception: await aqualink.close() raise ConfigEntryNotReady( diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index b828c25c945aa6..5b4ae9ffc0dc57 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any import httpx @@ -19,12 +20,39 @@ from .const import DOMAIN +CREDENTIALS_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN): """Aqualink config flow.""" VERSION = 1 + async def _async_test_credentials( + self, user_input: dict[str, Any] + ) -> dict[str, str]: + """Validate credentials against iAqualink.""" + try: + async with AqualinkClient( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + httpx_client=get_async_client( + self.hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2 + ), + ): + pass + except AqualinkServiceUnauthorizedException: + return {"base": "invalid_auth"} + except AqualinkServiceException, httpx.HTTPError: + return {"base": "cannot_connect"} + + return {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -32,32 +60,45 @@ async def async_step_user( errors = {} if user_input is not None: - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - - try: - async with AqualinkClient( - username, - password, - httpx_client=get_async_client( - self.hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2 - ), - ): - pass - except AqualinkServiceUnauthorizedException: - errors["base"] = "invalid_auth" - except AqualinkServiceException, httpx.HTTPError: - errors["base"] = "cannot_connect" - else: - return self.async_create_entry(title=username, data=user_input) + errors = await self._async_test_credentials(user_input) + if not errors: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - ), + data_schema=CREDENTIALS_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle flow triggered by an authentication failure.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle confirmation of reauthentication.""" + errors = {} + + reauth_entry = self._get_reauth_entry() + if user_input is not None: + errors = await self._async_test_credentials(user_input) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + title=user_input[CONF_USERNAME], + data_updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=CREDENTIALS_DATA_SCHEMA, errors=errors, ) diff --git a/homeassistant/components/iaqualink/coordinator.py b/homeassistant/components/iaqualink/coordinator.py index eb62ea589da1ec..4e842f9453cecd 100644 --- a/homeassistant/components/iaqualink/coordinator.py +++ b/homeassistant/components/iaqualink/coordinator.py @@ -6,10 +6,14 @@ from typing import Any import httpx -from iaqualink.exception import AqualinkServiceException +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, UPDATE_INTERVAL @@ -37,6 +41,8 @@ async def _async_update_data(self) -> None: """Refresh internal state for a system.""" try: await self.system.update() + except AqualinkServiceUnauthorizedException as err: + raise ConfigEntryAuthFailed("Invalid credentials for iAqualink") from err except (AqualinkServiceException, httpx.HTTPError) as err: raise UpdateFailed( f"Unable to update iAqualink system {self.system.serial}: {err}" diff --git a/homeassistant/components/iaqualink/strings.json b/homeassistant/components/iaqualink/strings.json index 5b00a9424de666..a8629e4d6b36ee 100644 --- a/homeassistant/components/iaqualink/strings.json +++ b/homeassistant/components/iaqualink/strings.json @@ -1,10 +1,21 @@ { "config": { + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "description": "Please enter the username and password for your iAqualink account.", + "title": "Reauthenticate iAqualink" + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/tests/components/iaqualink/test_config_flow.py b/tests/components/iaqualink/test_config_flow.py index 8f184cc8bd1173..eca09f88171e54 100644 --- a/tests/components/iaqualink/test_config_flow.py +++ b/tests/components/iaqualink/test_config_flow.py @@ -9,6 +9,7 @@ from homeassistant.components.iaqualink import DOMAIN, config_flow from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -97,3 +98,92 @@ async def test_with_existing_config( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == config_data["username"] assert result["data"] == config_data + + +async def test_reauth_success(hass: HomeAssistant, config_data: dict[str, str]) -> None: + """Test successful reauthentication.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=config_data[CONF_USERNAME], + data=config_data, + ) + entry.add_to_hass(hass) + + new_username = "updated@example.com" + + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: new_username, CONF_PASSWORD: "new_password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.title == new_username + assert dict(entry.data) == { + **config_data, + CONF_USERNAME: new_username, + CONF_PASSWORD: "new_password", + } + + +async def test_reauth_invalid_auth( + hass: HomeAssistant, config_data: dict[str, str] +) -> None: + """Test reauthentication with invalid credentials.""" + entry = MockConfigEntry(domain=DOMAIN, data=config_data) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + with patch( + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + side_effect=AqualinkServiceUnauthorizedException, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: config_data[CONF_USERNAME], CONF_PASSWORD: "bad_password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_cannot_connect( + hass: HomeAssistant, config_data: dict[str, str] +) -> None: + """Test reauthentication when the service cannot be reached.""" + entry = MockConfigEntry(domain=DOMAIN, data=config_data) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + with patch( + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + side_effect=AqualinkServiceException, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: config_data[CONF_USERNAME], CONF_PASSWORD: "new_password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index 54d88146125ceb..f4285c87885ff9 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -23,7 +23,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, @@ -182,7 +182,9 @@ async def test_setup_login_exception( assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_login_unauthorized(hass: HomeAssistant, config_entry) -> None: +async def test_setup_login_unauthorized( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup encountering an unauthorized exception during login.""" config_entry.add_to_hass(hass) @@ -195,6 +197,10 @@ async def test_setup_login_unauthorized(hass: HomeAssistant, config_entry) -> No assert config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + async def test_setup_login_timeout( hass: HomeAssistant, config_entry: MockConfigEntry @@ -234,6 +240,67 @@ async def test_setup_systems_exception( assert config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_systems_unauthorized( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test setup encountering an unauthorized exception while retrieving systems.""" + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + side_effect=AqualinkServiceUnauthorizedException, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + +async def test_setup_first_refresh_unauthorized_closes_client( + hass: HomeAssistant, config_entry: MockConfigEntry, client: AqualinkClient +) -> None: + """Test setup closes the client when first refresh triggers reauthentication.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client, cls=IaquaSystem) + system.update = AsyncMock(side_effect=AqualinkServiceUnauthorizedException) + systems = {system.serial: system} + + with ( + patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.close", + new_callable=AsyncMock, + ) as mock_close, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + mock_close.assert_awaited_once() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + async def test_setup_no_systems_recognized( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: @@ -587,3 +654,47 @@ async def test_entity_assumed_and_available( state = hass.states.get(name) assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) is None + + +async def test_system_refresh_unauthorized_triggers_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test an unauthorized refresh starts reauthentication.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() + systems = {system.serial: system} + + light = get_aqualink_device( + system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} + ) + system.get_devices = AsyncMock(return_value={light.name: light}) + + with ( + patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + system.update = AsyncMock(side_effect=AqualinkServiceUnauthorizedException) + + await _advance_coordinator_time(hass, freezer) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["context"]["entry_id"] == config_entry.entry_id From e23da7a5f02042ec7bdedfc7d74bed003dfcfa52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 11 Apr 2026 13:55:29 +0200 Subject: [PATCH 0793/1707] Bump aiohomeconnect to 0.36.0 (#167973) --- homeassistant/components/home_connect/climate.py | 8 +++----- homeassistant/components/home_connect/fan.py | 4 ++-- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/home_connect/climate.py b/homeassistant/components/home_connect/climate.py index eda016342e5d7f..eccb3301f9f93b 100644 --- a/homeassistant/components/home_connect/climate.py +++ b/homeassistant/components/home_connect/climate.py @@ -179,13 +179,13 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.coordinator.async_add_listener( self._handle_coordinator_update_fan_mode, - EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_FAN_SPEED_MODE, ) ) self.async_on_remove( self.coordinator.async_add_listener( self._handle_coordinator_update, - EventKey(SettingKey.BSH_COMMON_POWER_STATE), + EventKey.BSH_COMMON_SETTING_POWER_STATE, ) ) @@ -215,9 +215,7 @@ def fan_mode(self) -> str | None: """Return the fan setting.""" option_value = None if event := self.appliance.events.get( - EventKey( - OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE - ) + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_FAN_SPEED_MODE ): option_value = event.value return ( diff --git a/homeassistant/components/home_connect/fan.py b/homeassistant/components/home_connect/fan.py index 5188fc34daf352..e8410a9aaa20ce 100644 --- a/homeassistant/components/home_connect/fan.py +++ b/homeassistant/components/home_connect/fan.py @@ -84,7 +84,7 @@ def __init__( coordinator, AIR_CONDITIONER_ENTITY_DESCRIPTION, context_override=( - EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_FAN_SPEED_PERCENTAGE ), ) self.update_preset_mode() @@ -104,7 +104,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.coordinator.async_add_listener( self._handle_coordinator_update_preset_mode, - EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE, + EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_OPTION_FAN_SPEED_MODE, ) ) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 9dbb60de095641..da7dcb7822bfad 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -23,6 +23,6 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "quality_scale": "platinum", - "requirements": ["aiohomeconnect==0.34.0"], + "requirements": ["aiohomeconnect==0.36.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 05d99b7458da1f..ccc585d6abaa45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -279,7 +279,7 @@ aioharmony==0.5.3 aiohasupervisor==0.4.3 # homeassistant.components.home_connect -aiohomeconnect==0.34.0 +aiohomeconnect==0.36.0 # homeassistant.components.homekit_controller aiohomekit==3.2.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f5b9340c72dcd..e97123a4ef0eb3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -267,7 +267,7 @@ aioharmony==0.5.3 aiohasupervisor==0.4.3 # homeassistant.components.home_connect -aiohomeconnect==0.34.0 +aiohomeconnect==0.36.0 # homeassistant.components.homekit_controller aiohomekit==3.2.20 From 84f5cd8a12aad210347ab4ad97aee14926b3dd3d Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:32:01 +0200 Subject: [PATCH 0794/1707] Bump uiprotect to 10.2.6 (#167978) Co-authored-by: RaHehl --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index bd8316887a5540..23407b2787edef 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.2.3", "unifi-discovery==1.4.0"], + "requirements": ["uiprotect==10.2.6", "unifi-discovery==1.4.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index ccc585d6abaa45..8787939f63b991 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3178,7 +3178,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.2.3 +uiprotect==10.2.6 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e97123a4ef0eb3..c8b33b0fae61a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2690,7 +2690,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==10.2.3 +uiprotect==10.2.6 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 483265a7077552d350e90f5d7b6266449d28f35a Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 11 Apr 2026 16:21:16 +0200 Subject: [PATCH 0795/1707] Portainer fix fetching swarm stacks (#167979) --- .../components/portainer/coordinator.py | 23 +++++++++++++++++-- .../portainer/fixtures/docker_info.json | 4 +++- tests/components/portainer/test_init.py | 15 ++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index 5e36c8337c3a4e..c83b4a87eeeb3e 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -170,15 +170,34 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: docker_version, docker_info, docker_system_df, - stacks, ) = await asyncio.gather( self.portainer.get_containers(endpoint.id), self.portainer.docker_version(endpoint.id), self.portainer.docker_info(endpoint.id), self.portainer.docker_system_df(endpoint.id), - self.portainer.get_stacks(endpoint.id), ) + stack_requests = [self.portainer.get_stacks(endpoint_id=endpoint.id)] + swarm_id = ( + docker_info.swarm.cluster.get("ID") + if docker_info.swarm + and docker_info.swarm.control_available + and docker_info.swarm.cluster + else None + ) + if swarm_id: + stack_requests.append( + self.portainer.get_stacks( + endpoint_id=endpoint.id, swarm_id=swarm_id + ) + ) + + stacks = [ + stack + for result in await asyncio.gather(*stack_requests) + for stack in result + ] + prev_endpoint = self.data.get(endpoint.id) if self.data else None container_map: dict[str, PortainerContainerData] = {} stack_map: dict[str, PortainerStackData] = { diff --git a/tests/components/portainer/fixtures/docker_info.json b/tests/components/portainer/fixtures/docker_info.json index 53e7297e207c4d..ee6ceb34f9efa2 100644 --- a/tests/components/portainer/fixtures/docker_info.json +++ b/tests/components/portainer/fixtures/docker_info.json @@ -76,7 +76,9 @@ "RemoteManagers": [], "Nodes": 4, "Managers": 3, - "Cluster": {} + "Cluster": { + "ID": "swarm-cluster-id" + } }, "LiveRestoreEnabled": false, "Isolation": "default", diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index 174994620ad726..d4b8de417cf431 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -363,6 +363,21 @@ async def test_new_container_callback( ) > len(entities) +async def test_swarm_stacks_fetched_by_swarm_id( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that on a Swarm manager get_stacks is called with both endpoint_id and swarm_id.""" + await setup_integration(hass, mock_config_entry) + + calls = mock_portainer_client.get_stacks.call_args_list + # Expect exactly two calls: one by endpoint_id, one by swarm_id + assert len(calls) == 2 + assert calls[0].kwargs == {"endpoint_id": 1} + assert calls[1].kwargs == {"endpoint_id": 1, "swarm_id": "swarm-cluster-id"} + + async def test_new_stack_callback( hass: HomeAssistant, mock_portainer_client: AsyncMock, From 8a43d1a12ca46fca950764bd16df7822b9740c43 Mon Sep 17 00:00:00 2001 From: Andres Ruiz Date: Sat, 11 Apr 2026 10:22:19 -0400 Subject: [PATCH 0796/1707] Add remote start/stop button for supported Subaru vehicles (#167100) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/subaru/button.py | 99 ++++++ homeassistant/components/subaru/const.py | 3 + homeassistant/components/subaru/icons.json | 8 + .../components/subaru/remote_service.py | 4 +- homeassistant/components/subaru/strings.json | 8 + tests/components/subaru/test_button.py | 301 ++++++++++++++++++ 6 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/subaru/button.py create mode 100644 tests/components/subaru/test_button.py diff --git a/homeassistant/components/subaru/button.py b/homeassistant/components/subaru/button.py new file mode 100644 index 00000000000000..b0587bcb5a2246 --- /dev/null +++ b/homeassistant/components/subaru/button.py @@ -0,0 +1,99 @@ +"""Support for Subaru remote service buttons.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from subarulink import Controller as SubaruAPI + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import get_device_info +from .const import ( + SERVICE_REMOTE_START, + SERVICE_REMOTE_STOP, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_START, + VEHICLE_VIN, +) +from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .remote_service import async_call_remote_service + + +@dataclass(frozen=True, kw_only=True) +class SubaruButtonEntityDescription(ButtonEntityDescription): + """Describes a Subaru button entity.""" + + arg: Callable[[dict[str, Any]], str | None] | None = None + + +REMOTE_BUTTONS = [ + SubaruButtonEntityDescription( + key=SERVICE_REMOTE_START, + translation_key="remote_start", + arg=lambda _: "Auto", + ), + SubaruButtonEntityDescription( + key=SERVICE_REMOTE_STOP, + translation_key="remote_stop", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SubaruConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Subaru remote service buttons by config_entry.""" + coordinator = config_entry.runtime_data.coordinator + controller = config_entry.runtime_data.controller + vehicle_info = config_entry.runtime_data.vehicles + async_add_entities( + SubaruButton(vehicle, controller, coordinator, description) + for vehicle in vehicle_info.values() + if vehicle[VEHICLE_HAS_REMOTE_START] or vehicle[VEHICLE_HAS_EV] + for description in REMOTE_BUTTONS + ) + + +class SubaruButton(ButtonEntity): + """Class for a Subaru button.""" + + _attr_has_entity_name = True + entity_description: SubaruButtonEntityDescription + + def __init__( + self, + vehicle_info: dict[str, Any], + controller: SubaruAPI, + coordinator: SubaruDataUpdateCoordinator, + description: SubaruButtonEntityDescription, + ) -> None: + """Initialize the button for the vehicle.""" + self.controller = controller + self.coordinator = coordinator + self.vehicle_info = vehicle_info + self.entity_description = description + vin = vehicle_info[VEHICLE_VIN] + self._attr_unique_id = f"{vin}_{description.key}" + self._attr_device_info = get_device_info(vehicle_info) + + async def async_press(self) -> None: + """Press the button.""" + arg = ( + self.entity_description.arg(self.vehicle_info) + if self.entity_description.arg + else None + ) + await async_call_remote_service( + self.controller, + self.entity_description.key, + self.vehicle_info, + arg, + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index c9a02e09f62762..0ff9d6bec2cb3d 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -32,12 +32,15 @@ MANUFACTURER = "Subaru" PLATFORMS = [ + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SENSOR, ] SERVICE_LOCK = "lock" +SERVICE_REMOTE_START = "remote_start" +SERVICE_REMOTE_STOP = "remote_stop" SERVICE_UNLOCK = "unlock" SERVICE_UNLOCK_SPECIFIC_DOOR = "unlock_specific_door" diff --git a/homeassistant/components/subaru/icons.json b/homeassistant/components/subaru/icons.json index be9628303b7f5f..ffae30aecd30e3 100644 --- a/homeassistant/components/subaru/icons.json +++ b/homeassistant/components/subaru/icons.json @@ -1,5 +1,13 @@ { "entity": { + "button": { + "remote_start": { + "default": "mdi:power" + }, + "remote_stop": { + "default": "mdi:stop-circle-outline" + } + }, "device_tracker": { "location": { "default": "mdi:car" diff --git a/homeassistant/components/subaru/remote_service.py b/homeassistant/components/subaru/remote_service.py index acd71e186da409..1a20ad04d7235b 100644 --- a/homeassistant/components/subaru/remote_service.py +++ b/homeassistant/components/subaru/remote_service.py @@ -6,7 +6,7 @@ from homeassistant.exceptions import HomeAssistantError -from .const import SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN +from .const import SERVICE_REMOTE_START, SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ async def async_call_remote_service(controller, cmd, vehicle_info, arg=None): success = False err_msg = "" try: - if cmd == SERVICE_UNLOCK: + if cmd in (SERVICE_UNLOCK, SERVICE_REMOTE_START): success = await getattr(controller, cmd)(vin, arg) else: success = await getattr(controller, cmd)(vin) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 699dca1f05d9f3..5e72848e46b3d6 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -47,6 +47,14 @@ } }, "entity": { + "button": { + "remote_start": { + "name": "Remote start" + }, + "remote_stop": { + "name": "Remote stop" + } + }, "lock": { "door_locks": { "name": "Door locks" diff --git a/tests/components/subaru/test_button.py b/tests/components/subaru/test_button.py new file mode 100644 index 00000000000000..09e30054b2fecf --- /dev/null +++ b/tests/components/subaru/test_button.py @@ -0,0 +1,301 @@ +"""Test Subaru buttons.""" + +from unittest.mock import patch + +import pytest +from subarulink import SubaruException + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.subaru.const import ( + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_START, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .api_responses import ( + TEST_VIN_2_EV, + TEST_VIN_3_G3, + VEHICLE_DATA, + VEHICLE_STATUS_EV, + VEHICLE_STATUS_G3, +) +from .conftest import ( + MOCK_API, + MOCK_API_FETCH, + MOCK_API_GET_DATA, + setup_subaru_config_entry, +) + +from tests.common import MockConfigEntry + +MOCK_API_REMOTE_START = f"{MOCK_API}remote_start" +MOCK_API_REMOTE_STOP = f"{MOCK_API}remote_stop" + +VEHICLE_BUTTONS = { + TEST_VIN_2_EV: { + "remote_start": "button.test_vehicle_2_remote_start", + "remote_stop": "button.test_vehicle_2_remote_stop", + }, + TEST_VIN_3_G3: { + "remote_start": "button.test_vehicle_3_remote_start", + "remote_stop": "button.test_vehicle_3_remote_stop", + }, +} + + +@pytest.mark.parametrize("vin", [TEST_VIN_2_EV, TEST_VIN_3_G3]) +async def test_device_exists( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + subaru_config_entry: MockConfigEntry, + vin: str, +) -> None: + """Test subaru remote button entities exist.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + entry = entity_registry.async_get(VEHICLE_BUTTONS[vin]["remote_start"]) + assert entry + entry = entity_registry.async_get(VEHICLE_BUTTONS[vin]["remote_stop"]) + assert entry + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_start( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote start button.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch(MOCK_API_REMOTE_START) as mock_remote_start, + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_start"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_remote_start.assert_called_once() + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_stop( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote stop button.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch(MOCK_API_REMOTE_STOP) as mock_remote_stop, + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_stop"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_remote_stop.assert_called_once() + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_start_fails( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote start button failure.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch(MOCK_API_REMOTE_START, return_value=False), + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_start"]}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_start_exception( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote start button with SubaruException.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch( + MOCK_API_REMOTE_START, + side_effect=SubaruException("Remote service failed"), + ), + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_start"]}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_stop_fails( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote stop button failure.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch(MOCK_API_REMOTE_STOP, return_value=False), + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_stop"]}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_stop_exception( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote stop button with SubaruException.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch( + MOCK_API_REMOTE_STOP, + side_effect=SubaruException("Remote service failed"), + ), + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_stop"]}, + blocking=True, + ) + + +async def test_no_buttons_without_remote_start( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + subaru_config_entry: MockConfigEntry, +) -> None: + """Test no buttons created for vehicle without remote start or EV.""" + vehicle_data = { + **VEHICLE_DATA[TEST_VIN_3_G3], + VEHICLE_HAS_REMOTE_START: False, + VEHICLE_HAS_EV: False, + } + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[TEST_VIN_3_G3], + vehicle_data=vehicle_data, + ) + entry = entity_registry.async_get(VEHICLE_BUTTONS[TEST_VIN_3_G3]["remote_start"]) + assert entry is None + entry = entity_registry.async_get(VEHICLE_BUTTONS[TEST_VIN_3_G3]["remote_stop"]) + assert entry is None From 2fa0bdb2dc4355c19207cbabd28717b3049846e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 04:24:50 -1000 Subject: [PATCH 0797/1707] Fix ESPHome cold/warm white color temperature read-back (#167972) --- homeassistant/components/esphome/light.py | 11 +++++++---- tests/components/esphome/test_light.py | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 8fc52d2477d0b7..3afcb29485d737 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -259,15 +259,18 @@ async def async_turn_on(self, **kwargs: Any) -> None: if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: # Do not use kelvin_to_mired here to prevent precision loss color_temp_mired = 1_000_000.0 / color_temp_k + data["color_temperature"] = color_temp_mired if color_temp_modes := _filter_color_modes( color_modes, LightColorCapability.COLOR_TEMPERATURE ): - data["color_temperature"] = color_temp_mired color_modes = color_temp_modes else: - # Convert color temperature to explicit cold/warm white - # values to avoid ESPHome applying brightness to both - # master brightness and white channels (b² effect). + # Also send explicit cold/warm white values to avoid + # ESPHome applying brightness to both master brightness + # and white channels (b² effect). The firmware skips + # deriving cwww from color_temperature when the channels + # are already set explicitly, but still stores + # color_temperature so HA can read it back. data["cold_white"], data["warm_white"] = self._color_temp_to_cold_warm( color_temp_mired ) diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index c03cbd300b6379..c34d834ee119d1 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -1913,6 +1913,7 @@ async def test_only_cold_warm_white_support( key=1, state=True, color_mode=color_modes, + color_temperature=pytest.approx(400.0), cold_white=pytest.approx(0.0), warm_white=pytest.approx(1.0), device_id=0, @@ -1944,6 +1945,7 @@ async def test_only_cold_warm_white_support( state=True, brightness=pytest.approx(0.4980392156862745), color_mode=color_modes, + color_temperature=pytest.approx(277.7777777777778), cold_white=pytest.approx(0.9798, abs=1e-3), warm_white=pytest.approx(1.0), device_id=0, @@ -2008,6 +2010,7 @@ async def test_cold_warm_white_no_mireds_set( key=1, state=True, color_mode=color_modes, + color_temperature=pytest.approx(277.7777777777778), cold_white=1.0, warm_white=1.0, device_id=0, From 822fae227a7afb6173a145ad11297dff806a9249 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sat, 11 Apr 2026 16:28:02 +0200 Subject: [PATCH 0798/1707] Add base_coords for OptionsFlow and action call in waze_travel_time (#166642) --- .../components/waze_travel_time/__init__.py | 28 +++ .../waze_travel_time/config_flow.py | 20 +- .../components/waze_travel_time/const.py | 5 +- .../waze_travel_time/coordinator.py | 8 + .../components/waze_travel_time/helpers.py | 20 ++ .../components/waze_travel_time/services.yaml | 6 + .../components/waze_travel_time/strings.json | 8 + tests/components/waze_travel_time/__init__.py | 20 ++ tests/components/waze_travel_time/conftest.py | 1 + .../waze_travel_time/test_config_flow.py | 52 ++++- .../components/waze_travel_time/test_init.py | 211 ++++++++++++++++-- 11 files changed, 348 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 4dd901e8bdcc32..fa36af1a13c21e 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -21,6 +21,7 @@ BooleanSelector, DurationSelector, DurationSelectorConfig, + LocationSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -33,6 +34,7 @@ CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, + CONF_BASE_COORDINATES, CONF_DESTINATION, CONF_EXCL_FILTER, CONF_INCL_FILTER, @@ -52,6 +54,7 @@ VEHICLE_TYPES, ) from .coordinator import WazeTravelTimeCoordinator, async_get_travel_times +from .helpers import base_coordinates_to_tuple, default_base_coordinates_for_region PLATFORMS = [Platform.SENSOR] @@ -103,6 +106,7 @@ vol.Optional(CONF_TIME_DELTA): DurationSelector( DurationSelectorConfig(allow_negative=True, enable_second=False) ), + vol.Optional(CONF_BASE_COORDINATES): LocationSelector(), } ) @@ -137,6 +141,9 @@ async def async_get_travel_times_service(service: ServiceCall) -> ServiceRespons origin = origin_coordinates or service.data[CONF_ORIGIN] destination = destination_coordinates or service.data[CONF_DESTINATION] + base_coordinates = base_coordinates_to_tuple( + service.data.get(CONF_BASE_COORDINATES) + ) time_delta = int( timedelta( @@ -158,6 +165,7 @@ async def async_get_travel_times_service(service: ServiceCall) -> ServiceRespons incl_filters=service.data.get(CONF_INCL_FILTER, DEFAULT_FILTER), excl_filters=service.data.get(CONF_EXCL_FILTER, DEFAULT_FILTER), time_delta=time_delta, + base_coordinates=base_coordinates, ) return {"routes": [vars(route) for route in response]} @@ -218,4 +226,24 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.minor_version, ) + if config_entry.version == 2 and config_entry.minor_version == 2: + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + options = dict(config_entry.options) + options.setdefault( + CONF_BASE_COORDINATES, + default_base_coordinates_for_region(config_entry.data[CONF_REGION]), + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=3 + ) + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index 1b97bed0a8847d..e15f65393654cf 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -13,12 +13,14 @@ ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_NAME, CONF_REGION +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_REGION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( BooleanSelector, DurationSelector, DurationSelectorConfig, + LocationSelector, + LocationSelectorConfig, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -32,6 +34,7 @@ CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, + CONF_BASE_COORDINATES, CONF_DESTINATION, CONF_EXCL_FILTER, CONF_INCL_FILTER, @@ -92,6 +95,9 @@ enable_second=False, ) ), + vol.Optional(CONF_BASE_COORDINATES): LocationSelector( + LocationSelectorConfig(radius=False) + ), } ) @@ -114,18 +120,24 @@ def default_options( hass: HomeAssistant, -) -> dict[str, str | bool | list[str] | dict[str, int]]: +) -> dict[str, str | bool | list[str] | dict[str, int] | dict[str, float]]: """Get the default options.""" defaults = DEFAULT_OPTIONS.copy() if hass.config.units is US_CUSTOMARY_SYSTEM: defaults[CONF_UNITS] = IMPERIAL_UNITS + defaults[CONF_BASE_COORDINATES] = { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + } return defaults class WazeOptionsFlow(OptionsFlow): """Handle an options flow for Waze Travel Time.""" - async def async_step_init(self, user_input=None) -> ConfigFlowResult: + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: if user_input.get(CONF_INCL_FILTER) is None: @@ -151,7 +163,7 @@ class WazeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Waze Travel Time.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 894c8a6c0a8280..fca801d054d7d5 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -5,6 +5,7 @@ DOMAIN = "waze_travel_time" SEMAPHORE = "semaphore" +CONF_BASE_COORDINATES = "base_coordinates" CONF_DESTINATION = "destination" CONF_ORIGIN = "origin" CONF_INCL_FILTER = "incl_filter" @@ -33,7 +34,9 @@ REGIONS = ["us", "na", "eu", "il", "au"] VEHICLE_TYPES = ["car", "taxi", "motorcycle"] -DEFAULT_OPTIONS: dict[str, str | bool | list[str] | dict[str, int]] = { +DEFAULT_OPTIONS: dict[ + str, str | bool | list[str] | dict[str, int] | dict[str, float] +] = { CONF_REALTIME: DEFAULT_REALTIME, CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, CONF_UNITS: METRIC_UNITS, diff --git a/homeassistant/components/waze_travel_time/coordinator.py b/homeassistant/components/waze_travel_time/coordinator.py index 0cf4f4ef78359c..f3bdc24a20bb7d 100644 --- a/homeassistant/components/waze_travel_time/coordinator.py +++ b/homeassistant/components/waze_travel_time/coordinator.py @@ -20,6 +20,7 @@ CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, + CONF_BASE_COORDINATES, CONF_DESTINATION, CONF_EXCL_FILTER, CONF_INCL_FILTER, @@ -32,6 +33,7 @@ IMPERIAL_UNITS, SEMAPHORE, ) +from .helpers import base_coordinates_to_tuple _LOGGER = logging.getLogger(__name__) @@ -53,6 +55,7 @@ async def async_get_travel_times( incl_filters: Collection[str] | None = None, excl_filters: Collection[str] | None = None, time_delta: int = 0, + base_coordinates: tuple[float, float] | None = None, ) -> list[CalcRoutesResponse]: """Get all available routes.""" @@ -77,6 +80,7 @@ async def async_get_travel_times( real_time=realtime, alternatives=3, time_delta=time_delta, + base_coords=base_coordinates, ) if len(routes) < 1: @@ -211,6 +215,9 @@ async def _async_update_data(self) -> WazeTravelTimeData: timedelta(**self.config_entry.options[CONF_TIME_DELTA]).total_seconds() / 60 ) + base_coordinates = base_coordinates_to_tuple( + self.config_entry.options.get(CONF_BASE_COORDINATES) + ) routes = await async_get_travel_times( self.client, @@ -225,6 +232,7 @@ async def _async_update_data(self) -> WazeTravelTimeData: incl_filter, excl_filter, time_delta, + base_coordinates, ) if len(routes) < 1: travel_data = WazeTravelTimeData( diff --git a/homeassistant/components/waze_travel_time/helpers.py b/homeassistant/components/waze_travel_time/helpers.py index c6fe4d0c9bdce4..7bee77e8e4fdf0 100644 --- a/homeassistant/components/waze_travel_time/helpers.py +++ b/homeassistant/components/waze_travel_time/helpers.py @@ -4,6 +4,7 @@ from pywaze.route_calculator import WazeRouteCalculator, WRCError +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates @@ -11,6 +12,25 @@ _LOGGER = logging.getLogger(__name__) +def base_coordinates_to_tuple( + base_coordinates: dict[str, float] | None, +) -> tuple[float, float] | None: + """Convert Home Assistant location data to Waze base coordinates.""" + if base_coordinates is None: + return None + + return (base_coordinates[CONF_LATITUDE], base_coordinates[CONF_LONGITUDE]) + + +def default_base_coordinates_for_region(region: str) -> dict[str, float]: + """Return pywaze's default base coordinates for a region.""" + base_coordinates = WazeRouteCalculator.BASE_COORDS[region.upper()] + return { + CONF_LATITUDE: base_coordinates["lat"], + CONF_LONGITUDE: base_coordinates["lon"], + } + + async def is_valid_config_entry( hass: HomeAssistant, origin: str, destination: str, region: str ) -> bool: diff --git a/homeassistant/components/waze_travel_time/services.yaml b/homeassistant/components/waze_travel_time/services.yaml index 6d1faf2904510a..857728ac0a17c2 100644 --- a/homeassistant/components/waze_travel_time/services.yaml +++ b/homeassistant/components/waze_travel_time/services.yaml @@ -69,3 +69,9 @@ get_travel_times: required: false selector: duration: + base_coordinates: + required: false + example: '{"latitude": -27.9699373, "longitude": 153.4081865}' + selector: + location: + radius: false diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index 55bb7cf995b163..221b0af5ccfbda 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -26,6 +26,7 @@ "avoid_ferries": "Avoid ferries?", "avoid_subscription_roads": "Avoid roads needing a vignette / subscription?", "avoid_toll_roads": "Avoid toll roads?", + "base_coordinates": "Base coordinates", "excl_filter": "Exact street name which must NOT be part of the selected route", "incl_filter": "Exact street name which must be part of the selected route", "realtime": "Realtime travel time?", @@ -33,6 +34,9 @@ "units": "Units", "vehicle_type": "Vehicle type" }, + "data_description": { + "base_coordinates": "When Waze finds multiple matching locations for an address, it selects the one closest to these coordinates." + }, "description": "Some options will allow you to force the integration to use a particular route or avoid a particular route in its time travel calculation." } } @@ -77,6 +81,10 @@ "description": "Whether to avoid toll roads.", "name": "[%key:component::waze_travel_time::options::step::init::data::avoid_toll_roads%]" }, + "base_coordinates": { + "description": "[%key:component::waze_travel_time::options::step::init::data_description::base_coordinates%]", + "name": "[%key:component::waze_travel_time::options::step::init::data::base_coordinates%]" + }, "destination": { "description": "The destination of the route.", "name": "[%key:component::waze_travel_time::config::step::user::data::destination%]" diff --git a/tests/components/waze_travel_time/__init__.py b/tests/components/waze_travel_time/__init__.py index 1df3d9314d07ee..f485ec719281a9 100644 --- a/tests/components/waze_travel_time/__init__.py +++ b/tests/components/waze_travel_time/__init__.py @@ -1 +1,21 @@ """Tests for the Waze Travel Time integration.""" + +from homeassistant.components.waze_travel_time.const import ( + CONF_BASE_COORDINATES, + DEFAULT_OPTIONS, +) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + + +def get_default_options( + hass: HomeAssistant, +) -> dict[str, str | bool | list[str] | dict[str, int] | dict[str, float]]: + """Return the default options for Waze Travel Time.""" + return { + **DEFAULT_OPTIONS, + CONF_BASE_COORDINATES: { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + }, + } diff --git a/tests/components/waze_travel_time/conftest.py b/tests/components/waze_travel_time/conftest.py index fbaa7519ea8485..2fb35053ea27fc 100644 --- a/tests/components/waze_travel_time/conftest.py +++ b/tests/components/waze_travel_time/conftest.py @@ -21,6 +21,7 @@ async def mock_config_fixture(hass: HomeAssistant, data, options): options=options, entry_id="test", version=WazeConfigFlow.VERSION, + minor_version=WazeConfigFlow.MINOR_VERSION, ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/waze_travel_time/test_config_flow.py b/tests/components/waze_travel_time/test_config_flow.py index 3e7702f11ed273..e2306c27bc3dcb 100644 --- a/tests/components/waze_travel_time/test_config_flow.py +++ b/tests/components/waze_travel_time/test_config_flow.py @@ -8,6 +8,7 @@ CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, + CONF_BASE_COORDINATES, CONF_DESTINATION, CONF_EXCL_FILTER, CONF_INCL_FILTER, @@ -21,10 +22,11 @@ DOMAIN, IMPERIAL_UNITS, ) -from homeassistant.const import CONF_NAME, CONF_REGION +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_REGION from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import get_default_options from .const import CONFIG_FLOW_USER_INPUT, MOCK_CONFIG from tests.common import MockConfigEntry @@ -63,6 +65,7 @@ async def test_reconfigure(hass: HomeAssistant) -> None: data=MOCK_CONFIG, options=DEFAULT_OPTIONS, version=WazeConfigFlow.VERSION, + minor_version=WazeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -100,8 +103,9 @@ async def test_options(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG, - options=DEFAULT_OPTIONS, + options=get_default_options(hass), version=WazeConfigFlow.VERSION, + minor_version=WazeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -118,6 +122,10 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, + CONF_BASE_COORDINATES: { + CONF_LATITUDE: 1.123, + CONF_LONGITUDE: -1.123, + }, CONF_EXCL_FILTER: ["ExcludeThis"], CONF_INCL_FILTER: ["IncludeThis"], CONF_REALTIME: False, @@ -132,6 +140,10 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, + CONF_BASE_COORDINATES: { + CONF_LATITUDE: 1.123, + CONF_LONGITUDE: -1.123, + }, CONF_EXCL_FILTER: ["ExcludeThis"], CONF_INCL_FILTER: ["IncludeThis"], CONF_REALTIME: False, @@ -144,6 +156,10 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, + CONF_BASE_COORDINATES: { + CONF_LATITUDE: 1.123, + CONF_LONGITUDE: -1.123, + }, CONF_EXCL_FILTER: ["ExcludeThis"], CONF_INCL_FILTER: ["IncludeThis"], CONF_REALTIME: False, @@ -219,6 +235,7 @@ async def test_reset_filters(hass: HomeAssistant) -> None: options=options, entry_id="test", version=WazeConfigFlow.VERSION, + minor_version=WazeConfigFlow.MINOR_VERSION, ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -251,3 +268,34 @@ async def test_reset_filters(hass: HomeAssistant) -> None: CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", } + + +@pytest.mark.usefixtures("mock_update") +async def test_reset_base_coordinates(hass: HomeAssistant) -> None: + """Test clearing base coordinates in the options flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + options=get_default_options(hass), + version=WazeConfigFlow.VERSION, + minor_version=WazeConfigFlow.MINOR_VERSION, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id, data=None) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_AVOID_FERRIES: False, + CONF_AVOID_SUBSCRIPTION_ROADS: False, + CONF_AVOID_TOLL_ROADS: False, + CONF_REALTIME: True, + CONF_UNITS: IMPERIAL_UNITS, + CONF_VEHICLE_TYPE: "taxi", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert CONF_BASE_COORDINATES not in entry.options diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py index 2a20b46f476f28..bd9d63e8c11813 100644 --- a/tests/components/waze_travel_time/test_init.py +++ b/tests/components/waze_travel_time/test_init.py @@ -6,6 +6,7 @@ CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, + CONF_BASE_COORDINATES, CONF_EXCL_FILTER, CONF_INCL_FILTER, CONF_REALTIME, @@ -24,6 +25,7 @@ METRIC_UNITS, ) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_REGION from homeassistant.core import HomeAssistant from .const import MOCK_CONFIG @@ -31,15 +33,56 @@ from tests.common import MockConfigEntry +async def call_service_get_travel_times( + hass: HomeAssistant, + origin: str, + destination: str, + vehicle_type: str, + region: str, + units: str, + incl_filter: list[str] | None = None, + time_delta: dict[str, int] | None = None, + base_coordinates: dict[str, float] | None = None, +) -> dict: + """Call the get_travel_times service.""" + params = { + "origin": origin, + "destination": destination, + "vehicle_type": vehicle_type, + "region": region, + "units": units, + "incl_filter": incl_filter or [], + "time_delta": time_delta or {}, + } + if base_coordinates is not None: + params["base_coordinates"] = base_coordinates + return await hass.services.async_call( + "waze_travel_time", + "get_travel_times", + params, + blocking=True, + return_response=True, + ) + + @pytest.mark.parametrize( ("data", "options"), [(MOCK_CONFIG, DEFAULT_OPTIONS)], ) @pytest.mark.parametrize( - ("time_delta", "expected_time_delta"), + ("time_delta", "expected_time_delta", "base_coordinates", "expected_base_coords"), [ - pytest.param({"hours": 1, "minutes": 30}, 90, id="positive"), - pytest.param({"hours": -1, "minutes": -30}, -90, id="negative"), + pytest.param({"hours": 1, "minutes": 30}, 90, None, None, id="positive"), + pytest.param( + {"hours": -1, "minutes": -30}, + -90, + {CONF_LATITUDE: 40.7128, CONF_LONGITUDE: -74.0060}, + ( + 40.7128, + -74.0060, + ), + id="negative_with_base_coordinates", + ), ], ) @pytest.mark.usefixtures("mock_update", "mock_config") @@ -48,22 +91,20 @@ async def test_service_get_travel_times( mock_update, time_delta: dict[str, int], expected_time_delta: int, + base_coordinates: dict[str, float] | None, + expected_base_coords: tuple[float, float] | None, ) -> None: """Test service get_travel_times.""" - response_data = await hass.services.async_call( - "waze_travel_time", - "get_travel_times", - { - "origin": "location1", - "destination": "location2", - "vehicle_type": "car", - "region": "us", - "units": "imperial", - "incl_filter": ["IncludeThis"], - "time_delta": time_delta, - }, - blocking=True, - return_response=True, + response_data = await call_service_get_travel_times( + hass, + origin="location1", + destination="location2", + vehicle_type="car", + region="us", + units="imperial", + incl_filter=["IncludeThis"], + time_delta=time_delta, + base_coordinates=base_coordinates, ) assert response_data == { "routes": [ @@ -76,6 +117,7 @@ async def test_service_get_travel_times( ] } assert mock_update.call_args_list[-1].kwargs["time_delta"] == expected_time_delta + assert mock_update.call_args_list[-1].kwargs["base_coords"] == expected_base_coords @pytest.mark.parametrize( @@ -106,8 +148,8 @@ async def test_service_get_travel_times_empty_response( @pytest.mark.usefixtures("mock_update") -async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: - """Test successful migration of entry data from v1 to v2.2.""" +async def test_migrate_entry_v1_to_v2_3(hass: HomeAssistant) -> None: + """Test successful migration of entry data from v1 to v2.3.""" mock_entry = MockConfigEntry( domain=DOMAIN, version=1, @@ -130,10 +172,14 @@ async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: assert updated_entry.state is ConfigEntryState.LOADED assert updated_entry.version == 2 - assert updated_entry.minor_version == 2 + assert updated_entry.minor_version == 3 assert updated_entry.options[CONF_INCL_FILTER] == DEFAULT_FILTER assert updated_entry.options[CONF_EXCL_FILTER] == DEFAULT_FILTER assert updated_entry.options[CONF_TIME_DELTA] == DEFAULT_TIME_DELTA + assert updated_entry.options[CONF_BASE_COORDINATES] == { + CONF_LATITUDE: 40.713, + CONF_LONGITUDE: -74.006, + } mock_entry = MockConfigEntry( domain=DOMAIN, @@ -159,15 +205,19 @@ async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: assert updated_entry.state is ConfigEntryState.LOADED assert updated_entry.version == 2 - assert updated_entry.minor_version == 2 + assert updated_entry.minor_version == 3 assert updated_entry.options[CONF_INCL_FILTER] == ["IncludeThis"] assert updated_entry.options[CONF_EXCL_FILTER] == ["ExcludeThis"] assert updated_entry.options[CONF_TIME_DELTA] == DEFAULT_TIME_DELTA + assert updated_entry.options[CONF_BASE_COORDINATES] == { + CONF_LATITUDE: 40.713, + CONF_LONGITUDE: -74.006, + } @pytest.mark.usefixtures("mock_update") -async def test_migrate_entry_v2_1_to_v2_2(hass: HomeAssistant) -> None: - """Test successful migration of entry from version 2.1 to 2.2.""" +async def test_migrate_entry_v2_1_to_v2_3(hass: HomeAssistant) -> None: + """Test successful migration of entry from version 2.1 to 2.3.""" mock_entry = MockConfigEntry( domain=DOMAIN, version=2, @@ -193,5 +243,118 @@ async def test_migrate_entry_v2_1_to_v2_2(hass: HomeAssistant) -> None: assert updated_entry.state is ConfigEntryState.LOADED assert updated_entry.version == 2 - assert updated_entry.minor_version == 2 + assert updated_entry.minor_version == 3 assert updated_entry.options[CONF_TIME_DELTA] == DEFAULT_TIME_DELTA + assert updated_entry.options[CONF_BASE_COORDINATES] == { + CONF_LATITUDE: 40.713, + CONF_LONGITUDE: -74.006, + } + + +@pytest.mark.parametrize( + ("region", "expected_base_coordinates"), + [ + pytest.param( + "US", + {CONF_LATITUDE: 40.713, CONF_LONGITUDE: -74.006}, + id="us", + ), + pytest.param( + "NA", + {CONF_LATITUDE: 40.713, CONF_LONGITUDE: -74.006}, + id="na", + ), + pytest.param( + "EU", + {CONF_LATITUDE: 47.498, CONF_LONGITUDE: 19.040}, + id="eu", + ), + pytest.param( + "IL", + {CONF_LATITUDE: 31.768, CONF_LONGITUDE: 35.214}, + id="il", + ), + pytest.param( + "AU", + {CONF_LATITUDE: -35.281, CONF_LONGITUDE: 149.128}, + id="au", + ), + ], +) +@pytest.mark.usefixtures("mock_update") +async def test_migrate_entry_v2_2_to_v2_3_adds_region_base_coordinates( + hass: HomeAssistant, + region: str, + expected_base_coordinates: dict[str, float], +) -> None: + """Test migration adds pywaze's default base coordinates for each region.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=2, + minor_version=2, + data={**MOCK_CONFIG, CONF_REGION: region}, + options={ + CONF_REALTIME: DEFAULT_REALTIME, + CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, + CONF_UNITS: METRIC_UNITS, + CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, + CONF_INCL_FILTER: DEFAULT_FILTER, + CONF_EXCL_FILTER: DEFAULT_FILTER, + CONF_TIME_DELTA: DEFAULT_TIME_DELTA, + }, + ) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.minor_version == 3 + assert updated_entry.options[CONF_BASE_COORDINATES] == expected_base_coordinates + + +@pytest.mark.usefixtures("mock_update") +async def test_migrate_entry_v2_2_to_v2_3_preserves_existing_base_coordinates( + hass: HomeAssistant, +) -> None: + """Test migration preserves configured base coordinates.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=2, + minor_version=2, + data=MOCK_CONFIG, + options={ + CONF_REALTIME: DEFAULT_REALTIME, + CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, + CONF_UNITS: METRIC_UNITS, + CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, + CONF_INCL_FILTER: DEFAULT_FILTER, + CONF_EXCL_FILTER: DEFAULT_FILTER, + CONF_TIME_DELTA: DEFAULT_TIME_DELTA, + CONF_BASE_COORDINATES: { + CONF_LATITUDE: 1.23, + CONF_LONGITUDE: 4.56, + }, + }, + ) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.minor_version == 3 + assert updated_entry.options[CONF_BASE_COORDINATES] == { + CONF_LATITUDE: 1.23, + CONF_LONGITUDE: 4.56, + } From 3b1fa609f70ccac9666698c3dc69496f87caf559 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 04:28:29 -1000 Subject: [PATCH 0799/1707] Bump aioesphomeapi to 44.13.3 (#167966) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 2690cf0235ed0b..cbece3735e4667 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==44.13.2", + "aioesphomeapi==44.13.3", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.7.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 8787939f63b991..d06fe2f85e664f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -251,7 +251,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==44.13.2 +aioesphomeapi==44.13.3 # homeassistant.components.matrix # homeassistant.components.slack diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8b33b0fae61a3..f27cb5a19f190a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -242,7 +242,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==44.13.2 +aioesphomeapi==44.13.3 # homeassistant.components.matrix # homeassistant.components.slack From ab7b2577852d5508dff04a0a6f496ecd49004005 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:35:04 +0200 Subject: [PATCH 0800/1707] Add eurotronic cometblue integration (#165626) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 2 + .../eurotronic_cometblue/__init__.py | 80 ++++++ .../eurotronic_cometblue/climate.py | 185 ++++++++++++++ .../eurotronic_cometblue/config_flow.py | 186 ++++++++++++++ .../components/eurotronic_cometblue/const.py | 7 + .../eurotronic_cometblue/coordinator.py | 132 ++++++++++ .../components/eurotronic_cometblue/entity.py | 33 +++ .../eurotronic_cometblue/manifest.json | 19 ++ .../eurotronic_cometblue/quality_scale.yaml | 88 +++++++ .../eurotronic_cometblue/strings.json | 33 +++ homeassistant/generated/bluetooth.py | 5 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../eurotronic_cometblue/__init__.py | 1 + .../eurotronic_cometblue/conftest.py | 166 +++++++++++++ .../components/eurotronic_cometblue/const.py | 29 +++ .../eurotronic_cometblue/test_config_flow.py | 229 ++++++++++++++++++ 19 files changed, 1208 insertions(+) create mode 100644 homeassistant/components/eurotronic_cometblue/__init__.py create mode 100644 homeassistant/components/eurotronic_cometblue/climate.py create mode 100644 homeassistant/components/eurotronic_cometblue/config_flow.py create mode 100644 homeassistant/components/eurotronic_cometblue/const.py create mode 100644 homeassistant/components/eurotronic_cometblue/coordinator.py create mode 100644 homeassistant/components/eurotronic_cometblue/entity.py create mode 100644 homeassistant/components/eurotronic_cometblue/manifest.json create mode 100644 homeassistant/components/eurotronic_cometblue/quality_scale.yaml create mode 100644 homeassistant/components/eurotronic_cometblue/strings.json create mode 100644 tests/components/eurotronic_cometblue/__init__.py create mode 100644 tests/components/eurotronic_cometblue/conftest.py create mode 100644 tests/components/eurotronic_cometblue/const.py create mode 100644 tests/components/eurotronic_cometblue/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 48c5d6a029fce3..9f3637c6c1706e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -505,6 +505,8 @@ CLAUDE.md @home-assistant/core /tests/components/essent/ @jaapp /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 +/homeassistant/components/eurotronic_cometblue/ @rikroe +/tests/components/eurotronic_cometblue/ @rikroe /homeassistant/components/event/ @home-assistant/core /tests/components/event/ @home-assistant/core /homeassistant/components/evohome/ @zxdavb diff --git a/homeassistant/components/eurotronic_cometblue/__init__.py b/homeassistant/components/eurotronic_cometblue/__init__.py new file mode 100644 index 00000000000000..93052c50218e0c --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/__init__.py @@ -0,0 +1,80 @@ +"""Comet Blue Bluetooth integration.""" + +from __future__ import annotations + +from bleak.exc import BleakError +from eurotronic_cometblue_ha import AsyncCometBlue + +from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_PIN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.CLIMATE, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: CometBlueConfigEntry) -> bool: + """Set up Eurotronic Comet Blue from a config entry.""" + + address = entry.data[CONF_ADDRESS] + + ble_device = async_ble_device_from_address(hass, entry.data[CONF_ADDRESS]) + + if not ble_device: + raise ConfigEntryNotReady( + f"Couldn't find a nearby device for address: {entry.data[CONF_ADDRESS]}" + ) + + cometblue_device = AsyncCometBlue( + device=ble_device, + pin=int(entry.data[CONF_PIN]), + ) + try: + async with cometblue_device: + ble_device_info = await cometblue_device.get_device_info_async() + try: + # Device only returns battery level if PIN is correct + await cometblue_device.get_battery_async() + except TimeoutError as ex: + # This likely means PIN was incorrect on Linux and ESPHome backends + raise ConfigEntryError( + "Failed to read battery level, likely due to incorrect PIN" + ) from ex + except BleakError as ex: + raise ConfigEntryNotReady( + f"Failed to get device info from '{cometblue_device.device.address}'" + ) from ex + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, address)}, + name=f"{ble_device_info['model']} {cometblue_device.device.address}", + manufacturer=ble_device_info["manufacturer"], + model=ble_device_info["model"], + sw_version=ble_device_info["version"], + ) + + coordinator = CometBlueDataUpdateCoordinator( + hass, + entry, + cometblue_device, + ) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/eurotronic_cometblue/climate.py b/homeassistant/components/eurotronic_cometblue/climate.py new file mode 100644 index 00000000000000..5df02bec17aa39 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/climate.py @@ -0,0 +1,185 @@ +"""Comet Blue climate integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator +from .entity import CometBlueBluetoothEntity + +PARALLEL_UPDATES = 1 +MIN_TEMP = 7.5 +MAX_TEMP = 28.5 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CometBlueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the client entities.""" + + coordinator = entry.runtime_data + async_add_entities([CometBlueClimateEntity(coordinator)]) + + +class CometBlueClimateEntity(CometBlueBluetoothEntity, ClimateEntity): + """A Comet Blue Climate climate entity.""" + + _attr_min_temp = MIN_TEMP + _attr_max_temp = MAX_TEMP + _attr_name = None + _attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] + _attr_preset_modes = [ + PRESET_COMFORT, + PRESET_ECO, + PRESET_BOOST, + PRESET_AWAY, + PRESET_NONE, + ] + _attr_supported_features: ClimateEntityFeature = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, coordinator: CometBlueDataUpdateCoordinator) -> None: + """Initialize CometBlueClimateEntity.""" + + super().__init__(coordinator) + self._attr_unique_id = coordinator.address + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.coordinator.data.temperatures["currentTemp"] + + @property + def target_temperature(self) -> float | None: + """Return the temperature currently set to be reached.""" + return self.coordinator.data.temperatures["manualTemp"] + + @property + def target_temperature_high(self) -> float | None: + """Return the upper bound target temperature.""" + return self.coordinator.data.temperatures["targetTempHigh"] + + @property + def target_temperature_low(self) -> float | None: + """Return the lower bound target temperature.""" + return self.coordinator.data.temperatures["targetTempLow"] + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac operation mode.""" + if self.target_temperature == MIN_TEMP: + return HVACMode.OFF + if self.target_temperature == MAX_TEMP: + return HVACMode.HEAT + return HVACMode.AUTO + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + # presets have an order in which they are displayed on TRV: + # away, boost, comfort, eco, none (manual) + if ( + self.coordinator.data.holiday.get("start") is None + and self.coordinator.data.holiday.get("end") is not None + and self.target_temperature + == self.coordinator.data.holiday.get("temperature") + ): + return PRESET_AWAY + if self.target_temperature == MAX_TEMP: + return PRESET_BOOST + if self.target_temperature == self.target_temperature_high: + return PRESET_COMFORT + if self.target_temperature == self.target_temperature_low: + return PRESET_ECO + return PRESET_NONE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + + if self.preset_mode == PRESET_AWAY: + raise ServiceValidationError( + "Cannot adjust TRV remotely, manually disable 'holiday' mode on TRV first" + ) + + await self.coordinator.send_command( + self.coordinator.device.set_temperature_async, + { + "values": { + # manual temperature always needs to be set, otherwise TRV will turn OFF + "manualTemp": kwargs.get(ATTR_TEMPERATURE) + or self.target_temperature, + # other temperatures can be left unchanged by setting them to None + "targetTempLow": kwargs.get(ATTR_TARGET_TEMP_LOW), + "targetTempHigh": kwargs.get(ATTR_TARGET_TEMP_HIGH), + } + }, + ) + await self.coordinator.async_request_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + + if self.preset_modes and preset_mode not in self.preset_modes: + raise ServiceValidationError(f"Unsupported preset_mode '{preset_mode}'") + if preset_mode in [PRESET_NONE, PRESET_AWAY]: + raise ServiceValidationError( + f"Unable to set preset '{preset_mode}', display only." + ) + if preset_mode == PRESET_ECO: + return await self.async_set_temperature( + temperature=self.target_temperature_low + ) + if preset_mode == PRESET_COMFORT: + return await self.async_set_temperature( + temperature=self.target_temperature_high + ) + if preset_mode == PRESET_BOOST: + return await self.async_set_temperature(temperature=MAX_TEMP) + return None + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + + if hvac_mode == HVACMode.OFF: + return await self.async_set_temperature(temperature=MIN_TEMP) + if hvac_mode == HVACMode.HEAT: + return await self.async_set_temperature(temperature=MAX_TEMP) + if hvac_mode == HVACMode.AUTO: + return await self.async_set_temperature( + temperature=self.target_temperature_low + ) + raise ServiceValidationError(f"Unknown HVAC mode '{hvac_mode}'") + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self.async_set_hvac_mode(HVACMode.AUTO) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/eurotronic_cometblue/config_flow.py b/homeassistant/components/eurotronic_cometblue/config_flow.py new file mode 100644 index 00000000000000..f218ee38e0ffc9 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/config_flow.py @@ -0,0 +1,186 @@ +"""Config flow for CometBlue.""" + +from __future__ import annotations + +import logging +from typing import Any + +from bleak.exc import BleakError +from eurotronic_cometblue_ha import AsyncCometBlue +from eurotronic_cometblue_ha.const import SERVICE +from habluetooth import BluetoothServiceInfoBleak +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + async_ble_device_from_address, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_PIN +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +LOGGER = logging.getLogger(__name__) + + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_PIN, default="000000"): vol.All( + TextSelector(TextSelectorConfig(type=TextSelectorType.NUMBER)), + vol.Length(min=6, max=6), + ), + } +) + + +def name_from_discovery(discovery: BluetoothServiceInfoBleak | None) -> str: + """Get the name from a discovery.""" + if discovery is None: + return "Comet Blue" + if discovery.name == str(discovery.address): + return discovery.address + return f"{discovery.name} {discovery.address}" + + +class CometBlueConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for CometBlue.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def _try_connect(self, user_input: dict[str, Any]) -> dict[str, str]: + """Verify connection to the device with the provided PIN and read initial data.""" + device_address = self._discovery_info.address if self._discovery_info else "" + try: + ble_device = async_ble_device_from_address(self.hass, device_address) + LOGGER.info("Testing connection for device at address %s", device_address) + if not ble_device: + return {"base": "cannot_connect"} + + cometblue_device = AsyncCometBlue( + device=ble_device, + pin=int(user_input[CONF_PIN]), + ) + + async with cometblue_device: + try: + # Device only returns battery level if PIN is correct + await cometblue_device.get_battery_async() + except TimeoutError: + # This likely means PIN was incorrect on Linux and ESPHome backends + LOGGER.debug( + "Failed to read battery level, likely due to incorrect PIN", + exc_info=True, + ) + return {"base": "invalid_pin"} + except TimeoutError: + LOGGER.debug("Connection to device timed out", exc_info=True) + return {"base": "timeout_connect"} + except BleakError: + LOGGER.debug("Failed to connect to device", exc_info=True) + return {"base": "cannot_connect"} + except Exception: # noqa: BLE001 + LOGGER.debug("Unknown error", exc_info=True) + return {"base": "unknown"} + return {} + + def _create_entry( + self, + pin: str, + ) -> ConfigFlowResult: + """Create an entry for a discovered device.""" + + entry_data = { + CONF_ADDRESS: self._discovery_info.address + if self._discovery_info + else None, + CONF_PIN: pin, + } + + return self.async_create_entry( + title=name_from_discovery(self._discovery_info), data=entry_data + ) + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user-confirmation of discovered device.""" + + errors: dict[str, str] = {} + + if user_input is not None: + errors = await self._try_connect(user_input) + if not errors: + return self._create_entry(user_input[CONF_PIN]) + + return self.async_show_form( + step_id="bluetooth_confirm", + data_schema=DATA_SCHEMA, + errors=errors, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle a flow initialized by Bluetooth discovery.""" + address = discovery_info.address + + await self.async_set_unique_id(format_mac(address)) + self._abort_if_unique_id_configured(updates={CONF_ADDRESS: address}) + + self._discovery_info = discovery_info + + self.context["title_placeholders"] = { + "name": name_from_discovery(self._discovery_info) + } + return await self.async_step_bluetooth_confirm() + + async def async_step_pick_device( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the step to pick discovered device.""" + + current_addresses = self._async_current_ids() + self._discovered_devices = { + discovery_info.address: discovery_info + for discovery_info in async_discovered_service_info( + self.hass, connectable=True + ) + if SERVICE in discovery_info.service_uuids + and discovery_info.address not in current_addresses + } + + if user_input is not None: + address = user_input[CONF_ADDRESS] + + await self.async_set_unique_id(format_mac(address)) + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices.get(address) + return await self.async_step_bluetooth_confirm() + # Check if there is at least one device + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="pick_device", + data_schema=vol.Schema( + {vol.Required(CONF_ADDRESS): vol.In(list(self._discovered_devices))} + ), + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + + return await self.async_step_pick_device() diff --git a/homeassistant/components/eurotronic_cometblue/const.py b/homeassistant/components/eurotronic_cometblue/const.py new file mode 100644 index 00000000000000..352baa83b38847 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/const.py @@ -0,0 +1,7 @@ +"""Constants for Cometblue BLE thermostats.""" + +from typing import Final + +DOMAIN: Final = "eurotronic_cometblue" + +MAX_RETRIES: Final = 3 diff --git a/homeassistant/components/eurotronic_cometblue/coordinator.py b/homeassistant/components/eurotronic_cometblue/coordinator.py new file mode 100644 index 00000000000000..4b76f52c2df92e --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/coordinator.py @@ -0,0 +1,132 @@ +"""Provides the DataUpdateCoordinator for Comet Blue.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from datetime import timedelta +import logging +from typing import Any + +from bleak.exc import BleakError +from eurotronic_cometblue_ha import AsyncCometBlue, InvalidByteValueError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import MAX_RETRIES + +SCAN_INTERVAL = timedelta(minutes=5) +LOGGER = logging.getLogger(__name__) +COMMAND_RETRY_INTERVAL = 2.5 + +type CometBlueConfigEntry = ConfigEntry[CometBlueDataUpdateCoordinator] + + +@dataclass +class CometBlueCoordinatorData: + """Data stored by the coordinator.""" + + temperatures: dict[str, float | int] = field(default_factory=dict) + holiday: dict = field(default_factory=dict) + + +class CometBlueDataUpdateCoordinator(DataUpdateCoordinator[CometBlueCoordinatorData]): + """Class to manage fetching data.""" + + def __init__( + self, + hass: HomeAssistant, + entry: CometBlueConfigEntry, + cometblue: AsyncCometBlue, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass=hass, + config_entry=entry, + logger=LOGGER, + name=f"Comet Blue {cometblue.client.address}", + update_interval=SCAN_INTERVAL, + ) + self.device = cometblue + self.address = cometblue.client.address + + async def send_command( + self, + function: Callable[..., Awaitable[dict[str, Any] | None]], + payload: dict[str, Any], + ) -> dict[str, Any] | None: + """Send command to device.""" + + LOGGER.debug("Updating device %s with '%s'", self.name, payload) + retry_count = 0 + while retry_count < MAX_RETRIES: + try: + async with self.device: + return await function(**payload) + except (InvalidByteValueError, TimeoutError, BleakError) as ex: + retry_count += 1 + if retry_count >= MAX_RETRIES: + raise HomeAssistantError( + f"Error sending command to '{self.name}': {ex}" + ) from ex + LOGGER.info( + "Retry sending command to %s after %s (%s)", + self.name, + type(ex).__name__, + ex, + ) + await asyncio.sleep(COMMAND_RETRY_INTERVAL) + except ValueError as ex: + raise ServiceValidationError( + f"Invalid payload '{payload}' for '{self.name}': {ex}" + ) from ex + return None + + async def _async_update_data(self) -> CometBlueCoordinatorData: + """Poll the device.""" + data: CometBlueCoordinatorData = CometBlueCoordinatorData() + + retry_count = 0 + + while retry_count < MAX_RETRIES and not data.temperatures: + try: + async with self.device: + # temperatures are required and must trigger a retry if not available + if not data.temperatures: + data.temperatures = await self.device.get_temperature_async() + # holiday is optional and should not trigger a retry + try: + if not data.holiday: + data.holiday = await self.device.get_holiday_async(1) or {} + except InvalidByteValueError as ex: + LOGGER.warning( + "Failed to retrieve optional data for %s: %s (%s)", + self.name, + type(ex).__name__, + ex, + ) + except (InvalidByteValueError, TimeoutError, BleakError) as ex: + retry_count += 1 + if retry_count >= MAX_RETRIES: + raise UpdateFailed( + f"Error retrieving data: {ex}", retry_after=30 + ) from ex + LOGGER.info( + "Retry updating %s after error: %s (%s)", + self.name, + type(ex).__name__, + ex, + ) + await asyncio.sleep(COMMAND_RETRY_INTERVAL) + except Exception as ex: + raise UpdateFailed( + f"({type(ex).__name__}) {ex}", retry_after=30 + ) from ex + + # If one value was not retrieved correctly, keep the old value + LOGGER.debug("Received data for %s: %s", self.name, data) + return data diff --git a/homeassistant/components/eurotronic_cometblue/entity.py b/homeassistant/components/eurotronic_cometblue/entity.py new file mode 100644 index 00000000000000..e0321e409e6bda --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/entity.py @@ -0,0 +1,33 @@ +"""Coordinator entity base class for CometBlue.""" + +from homeassistant.components import bluetooth +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DOMAIN +from .coordinator import CometBlueDataUpdateCoordinator + + +class CometBlueBluetoothEntity(CoordinatorEntity[CometBlueDataUpdateCoordinator]): + """Coordinator entity for CometBlue.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: CometBlueDataUpdateCoordinator) -> None: + """Initialize coordinator entity.""" + super().__init__(coordinator) + # Full DeviceInfo is added to DeviceRegistry in __init__.py, so we only + # set identifiers here to link the entity to the device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.coordinator.address)}, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + # As long the device is currently connectable via Bluetooth it is available, even if the last update failed. + # This is because Bluetooth connectivity can be intermittent and a failed update doesn't necessarily mean the device is unavailable. + # The BluetoothManager will check every 300s (same interval as DataUpdateCoordinator) if the device is still present and connectable. + return bluetooth.async_address_present( + self.hass, address=self.coordinator.address, connectable=True + ) diff --git a/homeassistant/components/eurotronic_cometblue/manifest.json b/homeassistant/components/eurotronic_cometblue/manifest.json new file mode 100644 index 00000000000000..1d39f1f8bc5de0 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "eurotronic_cometblue", + "name": "Eurotronic Comet Blue", + "bluetooth": [ + { + "connectable": true, + "service_uuid": "47e9ee00-47e9-11e4-8939-164230d1df67" + } + ], + "codeowners": ["@rikroe"], + "config_flow": true, + "dependencies": ["bluetooth"], + "documentation": "https://www.home-assistant.io/integrations/eurotronic_cometblue", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["eurotronic_cometblue_ha"], + "quality_scale": "bronze", + "requirements": ["eurotronic-cometblue-ha==1.4.0"] +} diff --git a/homeassistant/components/eurotronic_cometblue/quality_scale.yaml b/homeassistant/components/eurotronic_cometblue/quality_scale.yaml new file mode 100644 index 00000000000000..7ebb9bc0559d9e --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: This integration does not subscribe to any events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not login to any device or service. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration relies on MAC-based BLE connections. + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: done + entity-category: + status: exempt + comment: This integration only provides one primary entity. + entity-device-class: + status: exempt + comment: This integration does not provide sensors. + entity-disabled-by-default: + status: exempt + comment: This integration only provides one primary entity. + entity-translations: + status: exempt + comment: This integration only provides one primary entity. + exception-translations: todo + icon-translations: + status: exempt + comment: Not required. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Not required. + stale-devices: + status: exempt + comment: Only single device per config entry. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration does not make any HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/eurotronic_cometblue/strings.json b/homeassistant/components/eurotronic_cometblue/strings.json new file mode 100644 index 00000000000000..4646c337675897 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "No Comet Blue Bluetooth TRVs discovered.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_pin": "Invalid device PIN", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "bluetooth_confirm": { + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "6-digit device PIN" + } + }, + "pick_device": { + "data": { + "address": "Discovered devices" + }, + "data_description": { + "address": "Select device to continue." + } + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 8abd999eedf908..a09bdfa4d9deda 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -133,6 +133,11 @@ "domain": "eufylife_ble", "local_name": "eufy T9149", }, + { + "connectable": True, + "domain": "eurotronic_cometblue", + "service_uuid": "47e9ee00-47e9-11e4-8939-164230d1df67", + }, { "connectable": False, "domain": "fjaraskupan", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 532c4fe74707b0..f2d084e6a60ab0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -207,6 +207,7 @@ "esphome", "essent", "eufylife_ble", + "eurotronic_cometblue", "evil_genius_labs", "ezviz", "faa_delays", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 11e5784078baa7..e544f83988a0bb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1925,6 +1925,12 @@ } } }, + "eurotronic_cometblue": { + "name": "Eurotronic Comet Blue", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "eve": { "name": "Eve", "iot_standards": [ diff --git a/requirements_all.txt b/requirements_all.txt index d06fe2f85e664f..8254e2d43e84e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -944,6 +944,9 @@ eternalegypt==0.0.18 # homeassistant.components.eufylife_ble eufylife-ble-client==0.1.8 +# homeassistant.components.eurotronic_cometblue +eurotronic-cometblue-ha==1.4.0 + # homeassistant.components.keyboard_remote # evdev==1.9.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f27cb5a19f190a..2ba1eb8209b42e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -838,6 +838,9 @@ eternalegypt==0.0.18 # homeassistant.components.eufylife_ble eufylife-ble-client==0.1.8 +# homeassistant.components.eurotronic_cometblue +eurotronic-cometblue-ha==1.4.0 + # homeassistant.components.evohome evohome-async==1.2.0 diff --git a/tests/components/eurotronic_cometblue/__init__.py b/tests/components/eurotronic_cometblue/__init__.py new file mode 100644 index 00000000000000..c30c83f3e01472 --- /dev/null +++ b/tests/components/eurotronic_cometblue/__init__.py @@ -0,0 +1 @@ +"""Tests for the Eurotronic Comet Blue integration.""" diff --git a/tests/components/eurotronic_cometblue/conftest.py b/tests/components/eurotronic_cometblue/conftest.py new file mode 100644 index 00000000000000..801ab15af529a9 --- /dev/null +++ b/tests/components/eurotronic_cometblue/conftest.py @@ -0,0 +1,166 @@ +"""Session fixtures.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch +import uuid + +from bleak.backends.characteristic import BleakGATTCharacteristic +from bleak.backends.scanner import AdvertisementData +from bleak.exc import BleakCharacteristicNotFoundError +from eurotronic_cometblue_ha import CometBlueBleakClient +import pytest + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.eurotronic_cometblue.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.helpers.device_registry import format_mac + +from .const import ( + FIXTURE_DEVICE_NAME, + FIXTURE_GATT_CHARACTERISTICS, + FIXTURE_MAC, + FIXTURE_RSSI, + FIXTURE_SERVICE_UUID, + FIXTURE_USER_INPUT, +) + +from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_ble_device + +# CometBlue device specific mocks and fixtures + +FAKE_BLE_DEVICE = generate_ble_device( + address=FIXTURE_MAC, name=FIXTURE_DEVICE_NAME, details={"path": "/dev/test"} +) + +FAKE_SERVICE_INFO = BluetoothServiceInfoBleak( + name=FIXTURE_DEVICE_NAME, + address=FIXTURE_MAC, + rssi=FIXTURE_RSSI, + manufacturer_data={}, + service_data={}, + service_uuids=[FIXTURE_SERVICE_UUID], + source="local", + connectable=True, + time=0, + device=FAKE_BLE_DEVICE, + advertisement=AdvertisementData( + local_name=FIXTURE_DEVICE_NAME, + manufacturer_data={}, + service_data={}, + service_uuids=[FIXTURE_SERVICE_UUID], + rssi=FIXTURE_RSSI, + tx_power=-127, + platform_data=(), + ), + tx_power=-127, +) + + +class MockCometBlueBleakClient(CometBlueBleakClient): + """Mock BleakClient.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Mock init.""" + super().__init__(*args, **kwargs) + self._device_path = "/dev/test" + + @property + def is_connected(self) -> bool: + """Mock connected.""" + return True + + async def connect(self, *args, **kwargs): + """Mock connect.""" + + async def disconnect(self, *args, **kwargs): + """Mock disconnect.""" + + async def get_services(self, *args, **kwargs): + """Mock get_services.""" + return [] + + async def clear_cache(self, *args, **kwargs): + """Mock clear_cache.""" + return True + + def set_disconnected_callback(self, callback, **kwargs): + """Mock set_disconnected_callback.""" + + async def read_gatt_char( + self, + char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, + **kwargs: Any, + ) -> bytearray: + """Mock read_gatt_char.""" + if not isinstance(char_specifier, (BleakGATTCharacteristic, str, uuid.UUID)): + raise BleakCharacteristicNotFoundError(char_specifier) + if isinstance(char_specifier, BleakGATTCharacteristic): + char_specifier = char_specifier.uuid + if not isinstance(char_specifier, uuid.UUID): + char_specifier = uuid.UUID(char_specifier) + try: + return FIXTURE_GATT_CHARACTERISTICS[char_specifier] + except KeyError: + raise BleakCharacteristicNotFoundError(char_specifier) + + +@pytest.fixture +def mock_service_info() -> Generator[None]: + """Patch async_discovered_service_info a mocked device info.""" + with patch( + "homeassistant.components.eurotronic_cometblue.config_flow.async_discovered_service_info", + return_value=[FAKE_SERVICE_INFO], + ): + yield + + +@pytest.fixture(autouse=True) +def mock_ble_device() -> Generator[None]: + """Mock BLE device.""" + with ( + patch( + "homeassistant.components.eurotronic_cometblue.async_ble_device_from_address", + return_value=FAKE_BLE_DEVICE, + ), + patch( + "homeassistant.components.eurotronic_cometblue.config_flow.async_ble_device_from_address", + return_value=FAKE_BLE_DEVICE, + ), + ): + yield + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth: None) -> Generator[None]: + """Auto mock bluetooth.""" + + with patch( + "eurotronic_cometblue_ha.CometBlueBleakClient", MockCometBlueBleakClient + ): + yield + + +# Home Assistant related fixtures +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create config entry mock from data.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: FIXTURE_MAC, + **FIXTURE_USER_INPUT, + }, + unique_id=format_mac(FIXTURE_MAC), + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Patch async setup entry to return True.""" + with patch( + "homeassistant.components.eurotronic_cometblue.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup diff --git a/tests/components/eurotronic_cometblue/const.py b/tests/components/eurotronic_cometblue/const.py new file mode 100644 index 00000000000000..5e061e287b0164 --- /dev/null +++ b/tests/components/eurotronic_cometblue/const.py @@ -0,0 +1,29 @@ +"""Constants for Eurotronic CometBlue tests.""" + +from uuid import UUID + +from homeassistant.const import CONF_PIN + +FIXTURE_DEVICE_NAME = "Comet Blue" +FIXTURE_MAC = "aa:bb:cc:dd:ee:ff" +FIXTURE_RSSI = -60 +FIXTURE_SERVICE_UUID = "47e9ee00-47e9-11e4-8939-164230d1df67" + +FIXTURE_GATT_CHARACTERISTICS = { + UUID("00002a24-0000-1000-8000-00805f9b34fb"): bytearray(b"Comet Blue"), # model + UUID("00002a26-0000-1000-8000-00805f9b34fb"): bytearray(b"0.0.10"), # version + UUID("00002a29-0000-1000-8000-00805f9b34fb"): bytearray( + b"Eurotronic GmbH" + ), # manufacturer + UUID("47e9ee20-47e9-11e4-8939-164230d1df67"): bytearray( + b'\x80\x1b\x0b\x16\x80\x1b\x0b\x16"' + ), # holiday 1 + UUID("47e9ee2b-47e9-11e4-8939-164230d1df67"): bytearray( + b"/999\x00\x04\n" + ), # temperature + UUID("47e9ee2c-47e9-11e4-8939-164230d1df67"): bytearray(b"48"), # battery +} + +FIXTURE_USER_INPUT = { + CONF_PIN: "000000", +} diff --git a/tests/components/eurotronic_cometblue/test_config_flow.py b/tests/components/eurotronic_cometblue/test_config_flow.py new file mode 100644 index 00000000000000..e69b623d50ac29 --- /dev/null +++ b/tests/components/eurotronic_cometblue/test_config_flow.py @@ -0,0 +1,229 @@ +"""Test the eurotronic_cometblue config flow.""" + +from copy import deepcopy +from unittest.mock import AsyncMock, patch + +from bleak.exc import BleakDeviceNotFoundError +import pytest +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.eurotronic_cometblue.config_flow import ( + name_from_discovery, +) +from homeassistant.components.eurotronic_cometblue.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_PIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import FAKE_SERVICE_INFO +from .const import FIXTURE_DEVICE_NAME, FIXTURE_MAC, FIXTURE_USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_user_step_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle no devices found.""" + with patch( + "homeassistant.components.eurotronic_cometblue.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + mock_setup_entry.assert_not_called() + + +async def test_user_step_discovered_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_service_info: None +) -> None: + """Test we properly handle device picking.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pick_device" + + with pytest.raises(vol.Invalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "wrong_address"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: FIXTURE_MAC} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=FIXTURE_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].title == f"{FIXTURE_DEVICE_NAME} {FIXTURE_MAC}" + assert result["result"].unique_id == FIXTURE_MAC + assert result["result"].data == { + CONF_ADDRESS: FIXTURE_MAC, + CONF_PIN: FIXTURE_USER_INPUT[CONF_PIN], + } + mock_setup_entry.assert_called_once() + + +async def test_user_step_with_existing_device( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test we properly handle device picking if entry exists.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=FAKE_SERVICE_INFO, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_setup_entry.call_count == 0 + + +async def test_bluetooth_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we can handle a bluetooth discovery flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=FAKE_SERVICE_INFO, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].title == f"{FIXTURE_DEVICE_NAME} {FIXTURE_MAC}" + assert result["result"].unique_id == FIXTURE_MAC + assert result["result"].data == { + CONF_ADDRESS: FIXTURE_MAC, + CONF_PIN: FIXTURE_USER_INPUT[CONF_PIN], + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("patch_target", "side_effect", "expected_error"), + [ + ( + "get_battery_async", + TimeoutError(), + {"base": "invalid_pin"}, + ), + ( + "connect_async", + TimeoutError(), + {"base": "timeout_connect"}, + ), + ( + "connect_async", + BleakDeviceNotFoundError(FAKE_SERVICE_INFO.address), + {"base": "cannot_connect"}, + ), + ( + "connect_async", + OSError("Something totally unexpected"), + {"base": "unknown"}, + ), + ], +) +async def test_bluetooth_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + patch_target: str, + side_effect: Exception, + expected_error: dict, +) -> None: + """Test we can handle a bluetooth discovery flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=FAKE_SERVICE_INFO, + ) + + with patch( + f"homeassistant.components.eurotronic_cometblue.config_flow.AsyncCometBlue.{patch_target}", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] == expected_error + + # now retry without side effect, simulating a user correcting the issue (e.g. entering correct PIN) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].title == f"{FIXTURE_DEVICE_NAME} {FIXTURE_MAC}" + assert result["result"].unique_id == FIXTURE_MAC + assert result["result"].data == { + CONF_ADDRESS: FIXTURE_MAC, + CONF_PIN: FIXTURE_USER_INPUT[CONF_PIN], + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_bluetooth_flow_no_device( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we can handle a bluetooth discovery flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=FAKE_SERVICE_INFO, + ) + + with patch( + "homeassistant.components.eurotronic_cometblue.config_flow.async_ble_device_from_address", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_name_from_discovery() -> None: + """Test we can create a name from discovery info.""" + # If for some reason no name can be derived, just return the default name + assert name_from_discovery(None) == "Comet Blue" + + # If the name is the same as the address, just return the address to avoid long names + fake_info = deepcopy(FAKE_SERVICE_INFO) + fake_info.name = str(fake_info.address) + assert name_from_discovery(fake_info) == str(fake_info.address) + + fake_info = deepcopy(FAKE_SERVICE_INFO) + assert name_from_discovery(fake_info) == f"{FIXTURE_DEVICE_NAME} {FIXTURE_MAC}" From d695250507cbe1ff4b6b9b397083b6a685faac6a Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 11 Apr 2026 18:44:03 +0200 Subject: [PATCH 0801/1707] Fix gardena entity categories and percentage values (#167986) --- .../components/gardena_bluetooth/number.py | 2 + .../components/gardena_bluetooth/select.py | 2 + .../components/gardena_bluetooth/sensor.py | 6 +- .../snapshots/test_number.ambr | 72 +++++++++++++++++++ .../snapshots/test_select.ambr | 2 +- .../snapshots/test_sensor.ambr | 2 +- .../gardena_bluetooth/test_number.py | 20 +++++- 7 files changed, 100 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index ef0c751cc50b72..bba0fc6827e72c 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -142,6 +142,7 @@ def context(self) -> set[str]: native_min_value=0.0, native_max_value=359.0, native_step=1.0, + entity_category=EntityCategory.CONFIG, char=Spray.sector, ), GardenaBluetoothNumberEntityDescription( @@ -153,6 +154,7 @@ def context(self) -> set[str]: native_max_value=100.0, native_step=0.1, char=Spray.distance, + entity_category=EntityCategory.CONFIG, scale=10.0, ), ) diff --git a/homeassistant/components/gardena_bluetooth/select.py b/homeassistant/components/gardena_bluetooth/select.py index 931517e3e4dfa6..9de329529dab63 100644 --- a/homeassistant/components/gardena_bluetooth/select.py +++ b/homeassistant/components/gardena_bluetooth/select.py @@ -13,6 +13,7 @@ from gardena_bluetooth.parse import CharacteristicInt from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -61,6 +62,7 @@ def context(self) -> set[str]: translation_key="operation_mode", char=AquaContour.operation_mode, option_to_number=_enum_to_int(AquaContour.operation_mode.enum), + entity_category=EntityCategory.CONFIG, ), GardenaBluetoothSelectEntityDescription( translation_key="active_position", diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index d31a00f73da331..fc1ce58a5ad629 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -47,10 +47,10 @@ def _get_timestamp(value: datetime | None): return value.replace(tzinfo=dt_util.get_default_time_zone()) -def _get_distance_ratio(value: int | None): +def _get_distance_percentage(value: int | None) -> float | None: if value is None: return None - return value / 1000 + return value / 10 @dataclass(frozen=True) @@ -169,7 +169,7 @@ def context(self) -> set[str]: entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, char=Spray.current_distance, - get=_get_distance_ratio, + get=_get_distance_percentage, ), GardenaBluetoothSensorEntityDescription( key=Spray.current_sector.unique_id, diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index 49e7576791ded4..9ba8eecb2ed7b8 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -358,3 +358,75 @@ 'state': 'unavailable', }) # --- +# name: test_setup[service_info5-98bd0112-0b0e-421a-84e5-ddbf75dc6de4-raw5-number.mock_title_sector] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Sector', + 'max': 359.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.mock_title_sector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '359.0', + }) +# --- +# name: test_setup[service_info5-98bd0112-0b0e-421a-84e5-ddbf75dc6de4-raw5-number.mock_title_sector].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Sector', + 'max': 359.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.mock_title_sector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_setup[service_info6-98bd0111-0b0e-421a-84e5-ddbf75dc6de4-raw6-number.mock_title_distance] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Distance', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_title_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_setup[service_info6-98bd0111-0b0e-421a-84e5-ddbf75dc6de4-raw6-number.mock_title_distance].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Distance', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_title_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- diff --git a/tests/components/gardena_bluetooth/snapshots/test_select.ambr b/tests/components/gardena_bluetooth/snapshots/test_select.ambr index 0f63fab80bb734..efaa50c363a798 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_select.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_select.ambr @@ -84,7 +84,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': None, + 'entity_category': , 'entity_id': 'select.mock_title_operation_mode', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr index 775e05fc10823c..7abded37218bd3 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -134,7 +134,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.333', + 'state': '33.3', }) # --- # name: test_sensors[aqua_contour][sensor.mock_title_current_flow-entry] diff --git a/tests/components/gardena_bluetooth/test_number.py b/tests/components/gardena_bluetooth/test_number.py index 1af772a36f5e73..0e7de5c26ff218 100644 --- a/tests/components/gardena_bluetooth/test_number.py +++ b/tests/components/gardena_bluetooth/test_number.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import Mock, call -from gardena_bluetooth.const import AquaContourWatering, Sensor, Valve +from gardena_bluetooth.const import AquaContourWatering, Sensor, Spray, Valve from gardena_bluetooth.exceptions import ( CharacteristicNoAccess, GardenaBluetoothException, @@ -76,6 +76,24 @@ ], "number.mock_title_remaining_watering_time", ), + ( + AQUA_CONTOUR_SERVICE_INFO, + Spray.sector.uuid, + [ + Spray.sector.encode(359), + Spray.sector.encode(10), + ], + "number.mock_title_sector", + ), + ( + AQUA_CONTOUR_SERVICE_INFO, + Spray.distance.uuid, + [ + Spray.distance.encode(1000), + Spray.distance.encode(10), + ], + "number.mock_title_distance", + ), ], ) async def test_setup( From 1e1e37637f982d849a8cc3abfb48a171583f4fbf Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Sat, 11 Apr 2026 18:45:56 +0200 Subject: [PATCH 0802/1707] Bump python-bsblan to version 5.1.4 (#167987) --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 97423b009c445c..6da7ab41aeae12 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["bsblan"], "quality_scale": "silver", - "requirements": ["python-bsblan==5.1.3"], + "requirements": ["python-bsblan==5.1.4"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/requirements_all.txt b/requirements_all.txt index 8254e2d43e84e7..21f262b57e969f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2554,7 +2554,7 @@ python-awair==0.2.5 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==5.1.3 +python-bsblan==5.1.4 # homeassistant.components.citybikes python-citybikes==0.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ba1eb8209b42e..f0d76882b6bbcb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2183,7 +2183,7 @@ python-MotionMount==2.3.0 python-awair==0.2.5 # homeassistant.components.bsblan -python-bsblan==5.1.3 +python-bsblan==5.1.4 # homeassistant.components.dropbox python-dropbox-api==0.1.3 From dac27777290cca961681fabe60350f15caf81774 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 11 Apr 2026 21:56:01 +0200 Subject: [PATCH 0803/1707] Mark entity-translations rule as done for Twente Milieu (#168001) --- homeassistant/components/twentemilieu/calendar.py | 1 - homeassistant/components/twentemilieu/quality_scale.yaml | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index 19e3f4f3337672..ffe542885187be 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -27,7 +27,6 @@ async def async_setup_entry( class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): """Defines a Twente Milieu calendar.""" - _attr_has_entity_name = True _attr_name = None _attr_translation_key = "calendar" diff --git a/homeassistant/components/twentemilieu/quality_scale.yaml b/homeassistant/components/twentemilieu/quality_scale.yaml index 42ff152cb4d420..c4390b966108e5 100644 --- a/homeassistant/components/twentemilieu/quality_scale.yaml +++ b/homeassistant/components/twentemilieu/quality_scale.yaml @@ -55,10 +55,7 @@ rules: This integration does not have an options flow. # Gold - entity-translations: - status: todo - comment: | - The calendar entity name isn't translated yet. + entity-translations: done entity-device-class: done devices: done entity-category: done From 2e648aca8b1f82c0bc8ccea1fa91e11888a62770 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 11 Apr 2026 21:56:24 +0200 Subject: [PATCH 0804/1707] Mark exception-translations rule as done for Peblar (#167997) --- homeassistant/components/peblar/quality_scale.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/peblar/quality_scale.yaml b/homeassistant/components/peblar/quality_scale.yaml index 91f9bb7af55645..a67344cf7b41e0 100644 --- a/homeassistant/components/peblar/quality_scale.yaml +++ b/homeassistant/components/peblar/quality_scale.yaml @@ -61,10 +61,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: - status: exempt - comment: | - The coordinator needs translation when the update failed. + exception-translations: done icon-translations: done reconfiguration-flow: done repair-issues: From 322dc2adeb4dd3b7825205db8c32d88c4ddacc5c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 11 Apr 2026 22:26:22 +0200 Subject: [PATCH 0805/1707] Add DHCP discovery for known Elgato devices (#168002) --- .../components/elgato/config_flow.py | 29 ++++++- homeassistant/components/elgato/manifest.json | 5 ++ .../components/elgato/quality_scale.yaml | 6 +- homeassistant/components/elgato/strings.json | 3 +- homeassistant/generated/dhcp.py | 4 + tests/components/elgato/test_config_flow.py | 79 ++++++++++++++++++- 6 files changed, 118 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index a47f039384ca2b..e2832554a22108 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -12,6 +12,8 @@ from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -23,7 +25,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 host: str - port: int serial_number: str mac: str | None = None @@ -70,6 +71,32 @@ async def async_step_zeroconf_confirm( """Handle a flow initiated by zeroconf.""" return self._async_create_entry() + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery of a known Elgato device. + + Only devices already configured (matched via ``registered_devices``) + reach this step. It is used to keep the stored host in sync with the + current IP address of the device. + """ + mac = format_mac(discovery_info.macaddress) + + for entry in self._async_current_entries(): + if (entry_mac := entry.data.get(CONF_MAC)) is None or format_mac( + entry_mac + ) != mac: + continue + if entry.data[CONF_HOST] != discovery_info.ip: + self.hass.config_entries.async_update_entry( + entry, + data=entry.data | {CONF_HOST: discovery_info.ip}, + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + + return self.async_abort(reason="no_devices_found") + @callback def _async_show_setup_form( self, errors: dict[str, str] | None = None diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 734ad5ec930086..5717c822a333cc 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -3,6 +3,11 @@ "name": "Elgato Light", "codeowners": ["@frenck"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/elgato", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml index 531f0447f708eb..b1a881827a571c 100644 --- a/homeassistant/components/elgato/quality_scale.yaml +++ b/homeassistant/components/elgato/quality_scale.yaml @@ -39,11 +39,7 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: - status: todo - comment: | - The integration doesn't update the device info based on DHCP discovery - of known existing devices. + discovery-update-info: done discovery: done docs-data-update: todo docs-examples: todo diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index 18bd156833661d..afa7f303ee3a5d 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8fa8aff1b186dc..75b08711f1f433 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -173,6 +173,10 @@ "domain": "dlink", "hostname": "dsp-w215", }, + { + "domain": "elgato", + "registered_devices": True, + }, { "domain": "elkm1", "registered_devices": True, diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index c647d36902a02a..e3ed2c10818adc 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -7,10 +7,11 @@ import pytest from homeassistant.components.elgato.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -254,3 +255,79 @@ async def test_zeroconf_during_onboarding( assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato.info.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_elgato") +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery of a known device updates its stored host.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="elgato", + ip="127.0.0.42", + macaddress="aabbccddeeff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.42" + + +@pytest.mark.usefixtures("mock_elgato") +async def test_dhcp_discovery_same_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery does nothing when the host is already up to date.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="elgato", + ip="127.0.0.1", + macaddress="aabbccddeeff", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +@pytest.mark.usefixtures("mock_elgato") +async def test_dhcp_discovery_no_match( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery aborts when no matching entry is configured.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="elgato", + ip="127.0.0.42", + macaddress="001122334455", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" From 4926ea9ef09767bb8352c827092bdaa6c801b38a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 11 Apr 2026 22:26:36 +0200 Subject: [PATCH 0806/1707] Set parallel updates to 0 for RDW platforms (#168003) --- homeassistant/components/rdw/binary_sensor.py | 2 ++ homeassistant/components/rdw/sensor.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 855bd2eec4162c..c62f3122efc6c6 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -20,6 +20,8 @@ from .const import DOMAIN from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RDWBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index 9dd393bd21393f..a4b8bf98659b8d 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -21,6 +21,8 @@ from .const import CONF_LICENSE_PLATE, DOMAIN from .coordinator import RDWConfigEntry, RDWDataUpdateCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RDWSensorEntityDescription(SensorEntityDescription): From df734655f6e279ff0d7d755546fd1ac37f08bcaa Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 11 Apr 2026 22:27:04 +0200 Subject: [PATCH 0807/1707] Remove unused service constants from Twente Milieu (#168000) --- homeassistant/components/twentemilieu/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index 1359e707601282..822e28a4c392be 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -2,17 +2,11 @@ from __future__ import annotations -import voluptuous as vol - -from homeassistant.const import CONF_ID, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from .coordinator import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator -SERVICE_UPDATE = "update" -SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_ID): cv.string}) - PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] From af69e9b5de51c11eaf053424c6593cbd21515c48 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 11 Apr 2026 23:05:47 +0200 Subject: [PATCH 0808/1707] Translate exceptions raised by Elgato (#168004) --- homeassistant/components/elgato/button.py | 12 ++--- .../components/elgato/coordinator.py | 20 +++++++- homeassistant/components/elgato/helpers.py | 43 +++++++++++++++++ homeassistant/components/elgato/light.py | 46 ++++++------------- .../components/elgato/quality_scale.yaml | 2 +- homeassistant/components/elgato/strings.json | 8 ++++ homeassistant/components/elgato/switch.py | 26 ++++------- tests/components/elgato/test_button.py | 19 +++++++- tests/components/elgato/test_light.py | 13 ++++-- tests/components/elgato/test_switch.py | 6 ++- 10 files changed, 126 insertions(+), 69 deletions(-) create mode 100644 homeassistant/components/elgato/helpers.py diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 23ed65ded331e7..e7752bfb3e3764 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Any -from elgato import Elgato, ElgatoError +from elgato import Elgato from homeassistant.components.button import ( ButtonDeviceClass, @@ -15,11 +15,11 @@ ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +from .helpers import elgato_exception_handler PARALLEL_UPDATES = 1 @@ -80,11 +80,7 @@ def __init__( f"{coordinator.data.info.serial_number}_{description.key}" ) + @elgato_exception_handler async def async_press(self) -> None: """Trigger button press on the Elgato device.""" - try: - await self.entity_description.press_fn(self.coordinator.client) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while communicating with the Elgato Light" - ) from error + await self.entity_description.press_fn(self.coordinator.client) diff --git a/homeassistant/components/elgato/coordinator.py b/homeassistant/components/elgato/coordinator.py index 5e1ba0a64947e5..484b134593c324 100644 --- a/homeassistant/components/elgato/coordinator.py +++ b/homeassistant/components/elgato/coordinator.py @@ -2,7 +2,15 @@ from dataclasses import dataclass -from elgato import BatteryInfo, Elgato, ElgatoConnectionError, Info, Settings, State +from elgato import ( + BatteryInfo, + Elgato, + ElgatoConnectionError, + ElgatoError, + Info, + Settings, + State, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -59,4 +67,12 @@ async def _async_update_data(self) -> ElgatoData: state=await self.client.state(), ) except ElgatoConnectionError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + except ElgatoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err diff --git a/homeassistant/components/elgato/helpers.py b/homeassistant/components/elgato/helpers.py new file mode 100644 index 00000000000000..2edcb49d2ed883 --- /dev/null +++ b/homeassistant/components/elgato/helpers.py @@ -0,0 +1,43 @@ +"""Helpers for Elgato.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from elgato import ElgatoConnectionError, ElgatoError + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import ElgatoEntity + + +def elgato_exception_handler[_ElgatoEntityT: ElgatoEntity, **_P]( + func: Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_ElgatoEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Elgato calls to handle Elgato exceptions. + + A decorator that wraps the passed in function, catches Elgato errors, + and raises a translated ``HomeAssistantError``. + """ + + async def handler( + self: _ElgatoEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + except ElgatoConnectionError as error: + self.coordinator.last_update_success = False + self.coordinator.async_update_listeners() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from error + except ElgatoError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from error + + return handler diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 429f6d1db018de..45f7302a12b564 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -4,8 +4,6 @@ from typing import Any -from elgato import ElgatoError - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -14,12 +12,12 @@ LightEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +from .helpers import elgato_exception_handler PARALLEL_UPDATES = 1 @@ -94,17 +92,13 @@ def is_on(self) -> bool: """Return the state of the light.""" return self.coordinator.data.state.on + @elgato_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - try: - await self.coordinator.client.light(on=False) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while updating the Elgato Light" - ) from error - finally: - await self.coordinator.async_refresh() + await self.coordinator.client.light(on=False) + await self.coordinator.async_request_refresh() + @elgato_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" temperature_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) @@ -137,26 +131,16 @@ async def async_turn_on(self, **kwargs: Any) -> None: else color_util.color_temperature_kelvin_to_mired(temperature_kelvin) ) - try: - await self.coordinator.client.light( - on=True, - brightness=brightness, - hue=hue, - saturation=saturation, - temperature=temperature, - ) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while updating the Elgato Light" - ) from error - finally: - await self.coordinator.async_refresh() + await self.coordinator.client.light( + on=True, + brightness=brightness, + hue=hue, + saturation=saturation, + temperature=temperature, + ) + await self.coordinator.async_request_refresh() + @elgato_exception_handler async def async_identify(self) -> None: """Identify the light, will make it blink.""" - try: - await self.coordinator.client.identify() - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while identifying the Elgato Light" - ) from error + await self.coordinator.client.identify() diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml index b1a881827a571c..4649f4ad134ed0 100644 --- a/homeassistant/components/elgato/quality_scale.yaml +++ b/homeassistant/components/elgato/quality_scale.yaml @@ -60,7 +60,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index afa7f303ee3a5d..ae8d5abf962312 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -49,6 +49,14 @@ } } }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Elgato device." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Elgato device." + } + }, "services": { "identify": { "description": "Identifies an Elgato Light. Blinks the light, which can be useful for, e.g., a visual notification.", diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 1b24f621807462..d79acfbd417f20 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -6,16 +6,16 @@ from dataclasses import dataclass from typing import Any -from elgato import Elgato, ElgatoError +from elgato import Elgato from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity +from .helpers import elgato_exception_handler PARALLEL_UPDATES = 1 @@ -92,24 +92,14 @@ def is_on(self) -> bool | None: """Return state of the switch.""" return self.entity_description.is_on_fn(self.coordinator.data) + @elgato_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - try: - await self.entity_description.set_fn(self.coordinator.client, True) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while updating the Elgato Light" - ) from error - finally: - await self.coordinator.async_refresh() + await self.entity_description.set_fn(self.coordinator.client, True) + await self.coordinator.async_request_refresh() + @elgato_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - try: - await self.entity_description.set_fn(self.coordinator.client, False) - except ElgatoError as error: - raise HomeAssistantError( - "An error occurred while updating the Elgato Light" - ) from error - finally: - await self.coordinator.async_refresh() + await self.entity_description.set_fn(self.coordinator.client, False) + await self.coordinator.async_request_refresh() diff --git a/tests/components/elgato/test_button.py b/tests/components/elgato/test_button.py index ab2169b623e405..f2d828bbb56b37 100644 --- a/tests/components/elgato/test_button.py +++ b/tests/components/elgato/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from elgato import ElgatoError +from elgato import ElgatoConnectionError, ElgatoError import pytest from syrupy.assertion import SnapshotAssertion @@ -65,7 +65,7 @@ async def test_buttons( with pytest.raises( HomeAssistantError, - match="An error occurred while communicating with the Elgato Light", + match="An unknown error occurred while communicating with the Elgato device", ): await hass.services.async_call( BUTTON_DOMAIN, @@ -75,3 +75,18 @@ async def test_buttons( ) assert len(mocked_method.mock_calls) == 2 + + mocked_method.side_effect = ElgatoConnectionError + + with pytest.raises( + HomeAssistantError, + match="An error occurred while communicating with the Elgato device", + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 3 diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index 1931e705e3f5d3..3c7a5eb408fab3 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from elgato import ElgatoError +from elgato import ElgatoConnectionError, ElgatoError import pytest from syrupy.assertion import SnapshotAssertion @@ -128,10 +128,12 @@ async def test_light_unavailable( hass: HomeAssistant, mock_elgato: MagicMock, service: str ) -> None: """Test error/unavailable handling of an Elgato Light.""" - mock_elgato.state.side_effect = ElgatoError - mock_elgato.light.side_effect = ElgatoError + mock_elgato.light.side_effect = ElgatoConnectionError - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="An error occurred while communicating with the Elgato device", + ): await hass.services.async_call( LIGHT_DOMAIN, service, @@ -160,7 +162,8 @@ async def test_light_identify(hass: HomeAssistant, mock_elgato: MagicMock) -> No mock_elgato.identify.side_effect = ElgatoError with pytest.raises( - HomeAssistantError, match="An error occurred while identifying the Elgato Light" + HomeAssistantError, + match="An unknown error occurred while communicating with the Elgato device", ): await hass.services.async_call( DOMAIN, diff --git a/tests/components/elgato/test_switch.py b/tests/components/elgato/test_switch.py index fc6dbfb182836f..e257b359b9823c 100644 --- a/tests/components/elgato/test_switch.py +++ b/tests/components/elgato/test_switch.py @@ -73,7 +73,8 @@ async def test_switches( mocked_method.side_effect = ElgatoError with pytest.raises( - HomeAssistantError, match="An error occurred while updating the Elgato Light" + HomeAssistantError, + match="An unknown error occurred while communicating with the Elgato device", ): await hass.services.async_call( SWITCH_DOMAIN, @@ -85,7 +86,8 @@ async def test_switches( assert len(mocked_method.mock_calls) == 3 with pytest.raises( - HomeAssistantError, match="An error occurred while updating the Elgato Light" + HomeAssistantError, + match="An unknown error occurred while communicating with the Elgato device", ): await hass.services.async_call( SWITCH_DOMAIN, From 4f255c23dd41fc55df1e76262f8b5d49efaee327 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 11 Apr 2026 23:05:55 +0200 Subject: [PATCH 0809/1707] Translate coordinator exceptions for Twente Milieu (#168005) --- .../components/twentemilieu/coordinator.py | 22 ++++++++++++++++--- .../twentemilieu/quality_scale.yaml | 5 +---- .../components/twentemilieu/strings.json | 8 +++++++ tests/components/twentemilieu/test_init.py | 15 ++++++++----- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/twentemilieu/coordinator.py b/homeassistant/components/twentemilieu/coordinator.py index d2cf5a887ef04f..a96c266c3a5028 100644 --- a/homeassistant/components/twentemilieu/coordinator.py +++ b/homeassistant/components/twentemilieu/coordinator.py @@ -4,12 +4,17 @@ from datetime import date -from twentemilieu import TwenteMilieu, WasteType +from twentemilieu import ( + TwenteMilieu, + TwenteMilieuConnectionError, + TwenteMilieuError, + WasteType, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_HOUSE_LETTER, @@ -46,4 +51,15 @@ def __init__(self, hass: HomeAssistant, entry: TwenteMilieuConfigEntry) -> None: async def _async_update_data(self) -> dict[WasteType, list[date]]: """Fetch Twente Milieu data.""" - return await self.twentemilieu.update() + try: + return await self.twentemilieu.update() + except TwenteMilieuConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + except TwenteMilieuError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err diff --git a/homeassistant/components/twentemilieu/quality_scale.yaml b/homeassistant/components/twentemilieu/quality_scale.yaml index c4390b966108e5..b1792d778f15c2 100644 --- a/homeassistant/components/twentemilieu/quality_scale.yaml +++ b/homeassistant/components/twentemilieu/quality_scale.yaml @@ -70,10 +70,7 @@ rules: comment: | This integration has a fixed single device which represents the service. diagnostics: done - exception-translations: - status: todo - comment: | - The coordinator raises, and currently, doesn't provide a translation for it. + exception-translations: done icon-translations: done reconfiguration-flow: todo dynamic-devices: diff --git a/homeassistant/components/twentemilieu/strings.json b/homeassistant/components/twentemilieu/strings.json index 06d4be585de682..db54581525e47d 100644 --- a/homeassistant/components/twentemilieu/strings.json +++ b/homeassistant/components/twentemilieu/strings.json @@ -41,5 +41,13 @@ "name": "Paper waste pickup" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Twente Milieu service." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Twente Milieu service." + } } } diff --git a/tests/components/twentemilieu/test_init.py b/tests/components/twentemilieu/test_init.py index 5cc09e6875dc92..85e23a3b9db309 100644 --- a/tests/components/twentemilieu/test_init.py +++ b/tests/components/twentemilieu/test_init.py @@ -1,8 +1,9 @@ """Tests for the Twente Milieu integration.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest +from twentemilieu import TwenteMilieuConnectionError, TwenteMilieuError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -28,19 +29,21 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -@patch( - "homeassistant.components.twentemilieu.coordinator.TwenteMilieu.update", - side_effect=RuntimeError, +@pytest.mark.parametrize( + "side_effect", [TwenteMilieuConnectionError, TwenteMilieuError] ) async def test_config_entry_not_ready( - mock_request: MagicMock, hass: HomeAssistant, + mock_twentemilieu: MagicMock, mock_config_entry: MockConfigEntry, + side_effect: type[Exception], ) -> None: """Test the Twente Milieu configuration entry not ready.""" + mock_twentemilieu.update.side_effect = side_effect + mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_request.call_count == 1 + assert mock_twentemilieu.update.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 4e13731838172fe5c9c203b1dd3639b8ec74d538 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 12 Apr 2026 10:00:53 +0200 Subject: [PATCH 0810/1707] Extract entity template functions into an entity Jinja2 extension (#167992) --- homeassistant/helpers/template/__init__.py | 34 +------ .../helpers/template/extensions/__init__.py | 2 + .../helpers/template/extensions/entities.py | 58 ++++++++++++ .../template/extensions/test_entities.py | 88 +++++++++++++++++++ tests/helpers/template/test_init.py | 86 +----------------- 5 files changed, 150 insertions(+), 118 deletions(-) create mode 100644 homeassistant/helpers/template/extensions/entities.py create mode 100644 tests/helpers/template/extensions/test_entities.py diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index b799ba63ec646f..7d585bfafeba89 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -1289,26 +1289,6 @@ def distance(hass: HomeAssistant, *args: Any) -> float | None: ) -def entity_name(hass: HomeAssistant, entity_id: str) -> str | None: - """Get the name of an entity from its entity ID.""" - ent_reg = er.async_get(hass) - if (entry := ent_reg.async_get(entity_id)) is not None: - return er.async_get_unprefixed_name(hass, entry) - - # Fall back to state for entities without a unique_id (not in the registry) - if (state := hass.states.get(entity_id)) is not None: - return state.name - - return None - - -def is_hidden_entity(hass: HomeAssistant, entity_id: str) -> bool: - """Test if an entity is hidden.""" - entity_reg = er.async_get(hass) - entry = entity_reg.async_get(entity_id) - return entry is not None and entry.hidden - - def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> bool: """Test if a state is a specific value.""" state_obj = _get_state(hass, entity_id) @@ -1482,6 +1462,7 @@ def __init__( "homeassistant.helpers.template.extensions.DateTimeExtension" ) self.add_extension("homeassistant.helpers.template.extensions.DeviceExtension") + self.add_extension("homeassistant.helpers.template.extensions.EntityExtension") self.add_extension("homeassistant.helpers.template.extensions.FloorExtension") self.add_extension( "homeassistant.helpers.template.extensions.FunctionalExtension" @@ -1537,10 +1518,8 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: hass_globals = [ "closest", "distance", - "entity_name", "expand", "has_value", - "is_hidden_entity", "is_state_attr", "is_state", "state_attr", @@ -1550,7 +1529,6 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: ] hass_filters = [ "closest", - "entity_name", "expand", "has_value", "state_attr", @@ -1560,7 +1538,6 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: ] hass_tests = [ "has_value", - "is_hidden_entity", "is_state_attr", "is_state", ] @@ -1583,15 +1560,6 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: self.tests["has_value"] = hassfunction(has_value, pass_eval_context) - # Entity extensions - - self.globals["entity_name"] = hassfunction(entity_name) - self.filters["entity_name"] = self.globals["entity_name"] - self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = hassfunction( - is_hidden_entity, pass_eval_context - ) - # State extensions self.globals["is_state_attr"] = hassfunction(is_state_attr) diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index 9dfaaf715059a0..f3de266cb2cd38 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -7,6 +7,7 @@ from .crypto import CryptoExtension from .datetime import DateTimeExtension from .devices import DeviceExtension +from .entities import EntityExtension from .floors import FloorExtension from .functional import FunctionalExtension from .issues import IssuesExtension @@ -26,6 +27,7 @@ "CryptoExtension", "DateTimeExtension", "DeviceExtension", + "EntityExtension", "FloorExtension", "FunctionalExtension", "IssuesExtension", diff --git a/homeassistant/helpers/template/extensions/entities.py b/homeassistant/helpers/template/extensions/entities.py new file mode 100644 index 00000000000000..c9ebb0799feb4a --- /dev/null +++ b/homeassistant/helpers/template/extensions/entities.py @@ -0,0 +1,58 @@ +"""Entity functions for Home Assistant templates.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.helpers import entity_registry as er + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class EntityExtension(BaseTemplateExtension): + """Jinja2 extension for entity functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the entity extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "entity_name", + self.entity_name, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "is_hidden_entity", + self.is_hidden_entity, + as_global=True, + as_test=True, + requires_hass=True, + limited_ok=False, + ), + ], + ) + + def entity_name(self, entity_id: str) -> str | None: + """Get the name of an entity from its entity ID.""" + ent_reg = er.async_get(self.hass) + if (entry := ent_reg.async_get(entity_id)) is not None: + return er.async_get_unprefixed_name(self.hass, entry) + + # Fall back to state for entities without a unique_id (not in the registry) + if (state := self.hass.states.get(entity_id)) is not None: + return state.name + + return None + + def is_hidden_entity(self, entity_id: str) -> bool: + """Test if an entity is hidden.""" + entity_reg = er.async_get(self.hass) + entry = entity_reg.async_get(entity_id) + return entry is not None and entry.hidden diff --git a/tests/helpers/template/extensions/test_entities.py b/tests/helpers/template/extensions/test_entities.py new file mode 100644 index 00000000000000..ebe25c6d6ff372 --- /dev/null +++ b/tests/helpers/template/extensions/test_entities.py @@ -0,0 +1,88 @@ +"""Test entity functions for Home Assistant templates.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry +from tests.helpers.template.helpers import render + + +def test_entity_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test entity_name method.""" + assert render(hass, "{{ entity_name('sensor.fake') }}") is None + + entry = entity_registry.async_get_or_create( + "sensor", "test", "unique_1", original_name="Registry Sensor" + ) + assert render(hass, f"{{{{ entity_name('{entry.entity_id}') }}}}") == ( + "Registry Sensor" + ) + assert render(hass, f"{{{{ '{entry.entity_id}' | entity_name }}}}") == ( + "Registry Sensor" + ) + + entity_registry.async_update_entity(entry.entity_id, name="My Custom Sensor") + assert render(hass, f"{{{{ entity_name('{entry.entity_id}') }}}}") == ( + "My Custom Sensor" + ) + + # Falls back to state for entities not in the registry + hass.states.async_set( + "light.no_unique_id", "on", {"friendly_name": "No Unique ID Light"} + ) + assert render(hass, "{{ entity_name('light.no_unique_id') }}") == ( + "No Unique ID Light" + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="My Device", + ) + entry2 = entity_registry.async_get_or_create( + "sensor", + "test", + "unique_2", + config_entry=config_entry, + device_id=device_entry.id, + has_entity_name=True, + original_name="Temperature", + ) + assert render(hass, f"{{{{ entity_name('{entry2.entity_id}') }}}}") == ( + "Temperature" + ) + + # Strips device name prefix + entity_registry.async_update_entity( + entry2.entity_id, name="My Device Custom Sensor" + ) + assert render(hass, f"{{{{ entity_name('{entry2.entity_id}') }}}}") == ( + "Custom Sensor" + ) + + +def test_is_hidden_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test is_hidden_entity method.""" + hidden_entity = entity_registry.async_get_or_create( + "sensor", "mock", "hidden", hidden_by=er.RegistryEntryHider.USER + ) + visible_entity = entity_registry.async_get_or_create("sensor", "mock", "visible") + assert render(hass, f"{{{{ is_hidden_entity('{hidden_entity.entity_id}') }}}}") + + assert not render(hass, f"{{{{ is_hidden_entity('{visible_entity.entity_id}') }}}}") + + assert not render( + hass, + f"{{{{ ['{visible_entity.entity_id}'] | select('is_hidden_entity') | first }}}}", + ) diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index d8178cc25ddbbe..2247f2f614e605 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -26,12 +26,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - template, - translation, -) +from homeassistant.helpers import entity_registry as er, template, translation from homeassistant.helpers.json import json_dumps from homeassistant.helpers.template.render_info import ( ALL_STATES_RATE_LIMIT, @@ -403,85 +398,6 @@ def test_if_state_exists(hass: HomeAssistant) -> None: assert result == "exists" -def test_entity_name( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, -) -> None: - """Test entity_name method.""" - assert render(hass, "{{ entity_name('sensor.fake') }}") is None - - entry = entity_registry.async_get_or_create( - "sensor", "test", "unique_1", original_name="Registry Sensor" - ) - assert render(hass, f"{{{{ entity_name('{entry.entity_id}') }}}}") == ( - "Registry Sensor" - ) - assert render(hass, f"{{{{ '{entry.entity_id}' | entity_name }}}}") == ( - "Registry Sensor" - ) - - entity_registry.async_update_entity(entry.entity_id, name="My Custom Sensor") - assert render(hass, f"{{{{ entity_name('{entry.entity_id}') }}}}") == ( - "My Custom Sensor" - ) - - # Falls back to state for entities not in the registry - hass.states.async_set( - "light.no_unique_id", "on", {"friendly_name": "No Unique ID Light"} - ) - assert render(hass, "{{ entity_name('light.no_unique_id') }}") == ( - "No Unique ID Light" - ) - - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - name="My Device", - ) - entry2 = entity_registry.async_get_or_create( - "sensor", - "test", - "unique_2", - config_entry=config_entry, - device_id=device_entry.id, - has_entity_name=True, - original_name="Temperature", - ) - assert render(hass, f"{{{{ entity_name('{entry2.entity_id}') }}}}") == ( - "Temperature" - ) - - # Strips device name prefix - entity_registry.async_update_entity( - entry2.entity_id, name="My Device Custom Sensor" - ) - assert render(hass, f"{{{{ entity_name('{entry2.entity_id}') }}}}") == ( - "Custom Sensor" - ) - - -def test_is_hidden_entity( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, -) -> None: - """Test is_hidden_entity method.""" - hidden_entity = entity_registry.async_get_or_create( - "sensor", "mock", "hidden", hidden_by=er.RegistryEntryHider.USER - ) - visible_entity = entity_registry.async_get_or_create("sensor", "mock", "visible") - assert render(hass, f"{{{{ is_hidden_entity('{hidden_entity.entity_id}') }}}}") - - assert not render(hass, f"{{{{ is_hidden_entity('{visible_entity.entity_id}') }}}}") - - assert not render( - hass, - f"{{{{ ['{visible_entity.entity_id}'] | select('is_hidden_entity') | first }}}}", - ) - - def test_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" hass.states.async_set("test.object", "available") From ba62b6cbdafda16a7f54ca82b8d62137ed2daeb6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 12 Apr 2026 10:11:13 +0200 Subject: [PATCH 0811/1707] Handle connection errors in Peblar zeroconf confirm step (#167998) --- homeassistant/components/peblar/config_flow.py | 2 ++ tests/components/peblar/test_config_flow.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py index b9b42cd6ca52f0..786e56c0a208ff 100644 --- a/homeassistant/components/peblar/config_flow.py +++ b/homeassistant/components/peblar/config_flow.py @@ -165,6 +165,8 @@ async def async_step_zeroconf_confirm( await peblar.login(password=user_input[CONF_PASSWORD]) except PeblarAuthenticationError: errors[CONF_PASSWORD] = "invalid_auth" + except PeblarConnectionError: + errors["base"] = "cannot_connect" except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/tests/components/peblar/test_config_flow.py b/tests/components/peblar/test_config_flow.py index 9f0806f0591f83..ea3573511bfe44 100644 --- a/tests/components/peblar/test_config_flow.py +++ b/tests/components/peblar/test_config_flow.py @@ -291,7 +291,7 @@ async def test_zeroconf_flow_abort_no_serial(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("side_effect", "expected_error"), [ - (PeblarConnectionError, {"base": "unknown"}), + (PeblarConnectionError, {"base": "cannot_connect"}), (PeblarAuthenticationError, {CONF_PASSWORD: "invalid_auth"}), (Exception, {"base": "unknown"}), ], From efb0162c6fbc0b364ee0201ba57c8148abc24987 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 12 Apr 2026 11:13:13 +0200 Subject: [PATCH 0812/1707] Set parallel updates for Tailwind platforms (#168025) --- homeassistant/components/tailwind/binary_sensor.py | 2 ++ homeassistant/components/tailwind/button.py | 2 ++ homeassistant/components/tailwind/cover.py | 2 ++ homeassistant/components/tailwind/number.py | 2 ++ homeassistant/components/tailwind/quality_scale.yaml | 2 +- 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index 4d927b0769e292..14368f1647168b 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -19,6 +19,8 @@ from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity +PARALLEL_UPDATES = 0 + @dataclass(kw_only=True, frozen=True) class TailwindDoorBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index 380eb7ccd7eb3d..cbaba324f1b0e0 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -22,6 +22,8 @@ from .coordinator import TailwindConfigEntry from .entity import TailwindEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class TailwindButtonEntityDescription(ButtonEntityDescription): diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 84f38c7d579868..307e6bcd169901 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -26,6 +26,8 @@ from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index ca6b610c3519cf..5bc4852b388373 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -18,6 +18,8 @@ from .coordinator import TailwindConfigEntry from .entity import TailwindEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class TailwindNumberEntityDescription(NumberEntityDescription): diff --git a/homeassistant/components/tailwind/quality_scale.yaml b/homeassistant/components/tailwind/quality_scale.yaml index 90c5d0d5837afe..4f4b84187c65a8 100644 --- a/homeassistant/components/tailwind/quality_scale.yaml +++ b/homeassistant/components/tailwind/quality_scale.yaml @@ -32,7 +32,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done # Gold From f521838bf1da9aa2375cb17f1c8e9448bb72fa92 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 12 Apr 2026 11:50:52 +0200 Subject: [PATCH 0813/1707] Add reconfiguration flow to Tailwind (#168033) --- .../components/tailwind/config_flow.py | 58 +++++++++- .../components/tailwind/quality_scale.yaml | 2 +- .../components/tailwind/strings.json | 13 +++ tests/components/tailwind/test_config_flow.py | 102 ++++++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index daf0fbd32b7bb7..df680abe36e5c9 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -15,7 +15,12 @@ ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -143,6 +148,46 @@ async def async_step_zeroconf_confirm( errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing Tailwind device.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + try: + return await self._async_step_create_entry( + host=user_input[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except AbortFlow: + raise + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors[CONF_HOST] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, + default=reconfigure_entry.data[CONF_HOST], + ): TextSelector(TextSelectorConfig(autocomplete="off")), + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -219,6 +264,17 @@ async def _async_step_create_entry( }, ) + if self.source == SOURCE_RECONFIGURE: + await self.async_set_unique_id(format_mac(status.mac_address)) + self._abort_if_unique_id_mismatch(reason="different_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={ + CONF_HOST: host, + CONF_TOKEN: token, + }, + ) + await self.async_set_unique_id( format_mac(status.mac_address), raise_on_progress=False ) diff --git a/homeassistant/components/tailwind/quality_scale.yaml b/homeassistant/components/tailwind/quality_scale.yaml index 4f4b84187c65a8..77d5d8711f4fc8 100644 --- a/homeassistant/components/tailwind/quality_scale.yaml +++ b/homeassistant/components/tailwind/quality_scale.yaml @@ -60,7 +60,7 @@ rules: comment: | The coordinator needs translation when the update failed. icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index 8cb059a74d09ec..0e4850c701e9d1 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -3,8 +3,10 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "different_device": "The entered information is for a different Tailwind device.", "no_device_id": "The discovered Tailwind device did not provide a device ID.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." }, @@ -23,6 +25,17 @@ }, "description": "Reauthenticate with your Tailwind garage door opener.\n\nTo do so, you will need to get your new local control key of your Tailwind device. For more details, see the description below the field down below." }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "host": "[%key:component::tailwind::config::step::user::data_description::host%]", + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + }, + "description": "Reconfigure your Tailwind garage door opener.\n\nThis allows you to change the IP address and local control key of your Tailwind device." + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index 2e8a8e7a727b74..872e278d14504a 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -393,6 +393,108 @@ async def test_reauth_flow_errors( assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("mock_tailwind") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reconfiguration flow updates an existing entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.42", + CONF_TOKEN: "987654", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert mock_config_entry.data[CONF_HOST] == "127.0.0.42" + assert mock_config_entry.data[CONF_TOKEN] == "987654" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {CONF_HOST: "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test the reconfiguration flow recovers from errors.""" + mock_config_entry.add_to_hass(hass) + mock_tailwind.status.side_effect = side_effect + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.42", + CONF_TOKEN: "987654", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == expected_error + + mock_tailwind.status.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.42", + CONF_TOKEN: "987654", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_different_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test reconfigure aborts when the new device has a different MAC.""" + mock_config_entry.add_to_hass(hass) + mock_tailwind.status.return_value = MagicMock( + mac_address="aa:bb:cc:dd:ee:ff", + product="iQ3", + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.42", + CONF_TOKEN: "987654", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "different_device" + + async def test_dhcp_discovery_updates_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 4ebf0bf0b69974944b5889a33a82547f5a5785c4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 12 Apr 2026 12:20:12 +0200 Subject: [PATCH 0814/1707] Fix untranslated button error in Tailwind (#168031) --- homeassistant/components/tailwind/button.py | 1 - tests/components/tailwind/test_button.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index cbaba324f1b0e0..66d6cf9f908ab4 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -68,7 +68,6 @@ async def async_press(self) -> None: await self.entity_description.press_fn(self.coordinator.tailwind) except TailwindError as exc: raise HomeAssistantError( - str(exc), translation_domain=DOMAIN, translation_key="communication_error", ) from exc diff --git a/tests/components/tailwind/test_button.py b/tests/components/tailwind/test_button.py index 55c0e3f5a9e488..5eb0e0deaa21b5 100644 --- a/tests/components/tailwind/test_button.py +++ b/tests/components/tailwind/test_button.py @@ -54,7 +54,10 @@ async def test_number_entities( # Test error handling mock_tailwind.identify.side_effect = TailwindError("Some error") - with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + with pytest.raises( + HomeAssistantError, + match="An error occurred while communicating with the Tailwind device", + ) as excinfo: await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, From eb64589115f47ba8a61bc59dccedc599d63da41a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 12 Apr 2026 18:45:37 +0200 Subject: [PATCH 0815/1707] Translate coordinator exceptions for Tailwind (#168027) --- .../components/tailwind/coordinator.py | 11 ++++++++++- .../components/tailwind/quality_scale.yaml | 5 +---- homeassistant/components/tailwind/strings.json | 3 +++ tests/components/tailwind/test_init.py | 18 ++++++++++++++++-- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py index 770751ccc3b5fd..10daaec8ac9851 100644 --- a/homeassistant/components/tailwind/coordinator.py +++ b/homeassistant/components/tailwind/coordinator.py @@ -5,6 +5,7 @@ from gotailwind import ( Tailwind, TailwindAuthenticationError, + TailwindConnectionError, TailwindDeviceStatus, TailwindError, ) @@ -45,5 +46,13 @@ async def _async_update_data(self) -> TailwindDeviceStatus: return await self.tailwind.status() except TailwindAuthenticationError as err: raise ConfigEntryAuthFailed from err + except TailwindConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err except TailwindError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err diff --git a/homeassistant/components/tailwind/quality_scale.yaml b/homeassistant/components/tailwind/quality_scale.yaml index 77d5d8711f4fc8..595ef4a20e0b9c 100644 --- a/homeassistant/components/tailwind/quality_scale.yaml +++ b/homeassistant/components/tailwind/quality_scale.yaml @@ -55,10 +55,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: - status: exempt - comment: | - The coordinator needs translation when the update failed. + exception-translations: done icon-translations: done reconfiguration-flow: done repair-issues: diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index 0e4850c701e9d1..ca7aac47564171 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -83,6 +83,9 @@ }, "door_locked_out": { "message": "The door is locked out and cannot be operated." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Tailwind device." } } } diff --git a/tests/components/tailwind/test_init.py b/tests/components/tailwind/test_init.py index 8e075a26279f8e..6265345e6ca41a 100644 --- a/tests/components/tailwind/test_init.py +++ b/tests/components/tailwind/test_init.py @@ -2,7 +2,12 @@ from unittest.mock import MagicMock -from gotailwind import TailwindAuthenticationError, TailwindConnectionError +from gotailwind import ( + TailwindAuthenticationError, + TailwindConnectionError, + TailwindError, +) +import pytest from homeassistant.components.tailwind.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState @@ -31,13 +36,22 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("side_effect", "expected_translation_key"), + [ + (TailwindConnectionError, "communication_error"), + (TailwindError, "unknown_error"), + ], +) async def test_config_entry_not_ready( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tailwind: MagicMock, + side_effect: type[Exception], + expected_translation_key: str, ) -> None: """Test the Tailwind configuration entry not ready.""" - mock_tailwind.status.side_effect = TailwindConnectionError + mock_tailwind.status.side_effect = side_effect mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) From 5c51820869299d4c9f8ddaf9a99521f11ddc65dc Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Mon, 13 Apr 2026 05:49:12 +0200 Subject: [PATCH 0816/1707] Add Heatbox3 to ViCare unsupported devices list (#168067) --- homeassistant/components/vicare/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index aeb52bd28ae678..bf147950b92825 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -18,6 +18,7 @@ UNSUPPORTED_DEVICES = [ "Heatbox1", "Heatbox2_SRC", + "Heatbox3", "E3_TCU10_x07", "E3_TCU41_x04", "E3_RoomControl_One_522", From e5c49b6455503fdd684609acefa97621be208c09 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Apr 2026 06:11:16 +0200 Subject: [PATCH 0817/1707] Set parallel updates to 0 for Sensor.Community (#168063) --- homeassistant/components/luftdaten/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 07500f2e10c0c1..6481d1709d855c 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -27,6 +27,8 @@ from .const import ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN from .coordinator import LuftdatenConfigEntry, LuftdatenDataUpdateCoordinator +PARALLEL_UPDATES = 0 + SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", From a061e47bece6abe4afed9b5670cd50a5220f578e Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:16:22 +0200 Subject: [PATCH 0818/1707] Improve eurotronic_cometblue tests (#168046) --- .../eurotronic_cometblue/__init__.py | 66 ++++ .../eurotronic_cometblue/conftest.py | 96 ++++- .../components/eurotronic_cometblue/const.py | 29 -- .../snapshots/test_climate.ambr | 88 +++++ .../eurotronic_cometblue/test_climate.py | 331 ++++++++++++++++++ .../eurotronic_cometblue/test_config_flow.py | 2 +- 6 files changed, 568 insertions(+), 44 deletions(-) delete mode 100644 tests/components/eurotronic_cometblue/const.py create mode 100644 tests/components/eurotronic_cometblue/snapshots/test_climate.ambr create mode 100644 tests/components/eurotronic_cometblue/test_climate.py diff --git a/tests/components/eurotronic_cometblue/__init__.py b/tests/components/eurotronic_cometblue/__init__.py index c30c83f3e01472..5962e6da5bbcf1 100644 --- a/tests/components/eurotronic_cometblue/__init__.py +++ b/tests/components/eurotronic_cometblue/__init__.py @@ -1 +1,67 @@ """Tests for the Eurotronic Comet Blue integration.""" + +from eurotronic_cometblue_ha import const as cometblue_const + +from homeassistant.const import CONF_PIN + +FIXTURE_DEVICE_NAME = "Comet Blue" +FIXTURE_MAC = "aa:bb:cc:dd:ee:ff" +FIXTURE_RSSI = -60 +FIXTURE_SERVICE_UUID = "47e9ee00-47e9-11e4-8939-164230d1df67" + +WRITEABLE_CHARACTERISTICS = [ + cometblue_const.CHARACTERISTIC_DATETIME, + cometblue_const.CHARACTERISTIC_MONDAY, + cometblue_const.CHARACTERISTIC_TUESDAY, + cometblue_const.CHARACTERISTIC_WEDNESDAY, + cometblue_const.CHARACTERISTIC_THURSDAY, + cometblue_const.CHARACTERISTIC_FRIDAY, + cometblue_const.CHARACTERISTIC_SATURDAY, + cometblue_const.CHARACTERISTIC_SUNDAY, + cometblue_const.CHARACTERISTIC_HOLIDAY_1, + cometblue_const.CHARACTERISTIC_SETTINGS, + cometblue_const.CHARACTERISTIC_TEMPERATURE, + cometblue_const.CHARACTERISTIC_PIN, +] +WRITEABLE_CHARACTERISTICS_ALLOW_UNCHANGED = [ + cometblue_const.CHARACTERISTIC_SETTINGS, + cometblue_const.CHARACTERISTIC_TEMPERATURE, +] + +FIXTURE_DEFAULT_CHARACTERISTICS = { + cometblue_const.CHARACTERISTIC_MODEL: b"Comet Blue", + cometblue_const.CHARACTERISTIC_VERSION: b"0.0.10", + cometblue_const.CHARACTERISTIC_MANUFACTURER: b"Eurotronic GmbH", + cometblue_const.CHARACTERISTIC_HOLIDAY_1: [ + 128, + 27, + 11, + 22, + 128, + 27, + 11, + 22, + 34, + ], + cometblue_const.CHARACTERISTIC_TEMPERATURE: [ + 41, + 40, + 34, + 42, + 0, + 4, + 10, + ], + cometblue_const.CHARACTERISTIC_BATTERY: b"48", + cometblue_const.CHARACTERISTIC_MONDAY: [37, 137, 0, 0, 0, 0, 0, 0], + cometblue_const.CHARACTERISTIC_TUESDAY: [37, 137, 0, 0, 0, 0, 0, 0], + cometblue_const.CHARACTERISTIC_WEDNESDAY: [37, 137, 0, 0, 0, 0, 0, 0], + cometblue_const.CHARACTERISTIC_THURSDAY: [37, 137, 0, 0, 0, 0, 0, 0], + cometblue_const.CHARACTERISTIC_FRIDAY: [0, 1, 10, 20, 21, 130, 140, 143], + cometblue_const.CHARACTERISTIC_SATURDAY: [37, 137, 0, 0, 0, 0, 0, 0], + cometblue_const.CHARACTERISTIC_SUNDAY: [37, 137, 0, 0, 0, 0, 0, 0], +} + +FIXTURE_USER_INPUT = { + CONF_PIN: "000000", +} diff --git a/tests/components/eurotronic_cometblue/conftest.py b/tests/components/eurotronic_cometblue/conftest.py index 801ab15af529a9..08d2f08b05b5fa 100644 --- a/tests/components/eurotronic_cometblue/conftest.py +++ b/tests/components/eurotronic_cometblue/conftest.py @@ -1,6 +1,6 @@ """Session fixtures.""" -from collections.abc import Generator +from collections.abc import Buffer, Generator from typing import Any from unittest.mock import AsyncMock, patch import uuid @@ -12,17 +12,21 @@ import pytest from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.eurotronic_cometblue import PLATFORMS from homeassistant.components.eurotronic_cometblue.const import DOMAIN -from homeassistant.const import CONF_ADDRESS +from homeassistant.const import CONF_ADDRESS, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac -from .const import ( +from . import ( + FIXTURE_DEFAULT_CHARACTERISTICS, FIXTURE_DEVICE_NAME, - FIXTURE_GATT_CHARACTERISTICS, FIXTURE_MAC, FIXTURE_RSSI, FIXTURE_SERVICE_UUID, FIXTURE_USER_INPUT, + WRITEABLE_CHARACTERISTICS, + WRITEABLE_CHARACTERISTICS_ALLOW_UNCHANGED, ) from tests.common import MockConfigEntry @@ -58,9 +62,24 @@ ) +def _normalize_characteristic( + char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, +) -> uuid.UUID: + """Normalize a characteristic specifier to UUID.""" + if not isinstance(char_specifier, (BleakGATTCharacteristic, str, uuid.UUID)): + raise BleakCharacteristicNotFoundError(char_specifier) + if isinstance(char_specifier, BleakGATTCharacteristic): + char_specifier = char_specifier.uuid + if not isinstance(char_specifier, uuid.UUID): + char_specifier = uuid.UUID(char_specifier) + return char_specifier + + class MockCometBlueBleakClient(CometBlueBleakClient): """Mock BleakClient.""" + characteristics: dict[uuid.UUID, bytearray] = {} + def __init__(self, *args: Any, **kwargs: Any) -> None: """Mock init.""" super().__init__(*args, **kwargs) @@ -94,17 +113,40 @@ async def read_gatt_char( **kwargs: Any, ) -> bytearray: """Mock read_gatt_char.""" - if not isinstance(char_specifier, (BleakGATTCharacteristic, str, uuid.UUID)): - raise BleakCharacteristicNotFoundError(char_specifier) - if isinstance(char_specifier, BleakGATTCharacteristic): - char_specifier = char_specifier.uuid - if not isinstance(char_specifier, uuid.UUID): - char_specifier = uuid.UUID(char_specifier) + char_specifier = _normalize_characteristic(char_specifier) try: - return FIXTURE_GATT_CHARACTERISTICS[char_specifier] + return bytearray(self.characteristics[char_specifier]) except KeyError: raise BleakCharacteristicNotFoundError(char_specifier) + async def write_gatt_char( + self, + char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, + data: Buffer, + response: bool | None = None, + ) -> None: + """Mock write_gatt_char.""" + char_specifier = _normalize_characteristic(char_specifier) + if char_specifier not in WRITEABLE_CHARACTERISTICS: + raise BleakCharacteristicNotFoundError(char_specifier) + data = bytearray(data) + # when writing temperature it is possible that 128 will be sent, meaning "no change" + # we have to restore the original value in this case to keep tests working + if char_specifier in WRITEABLE_CHARACTERISTICS_ALLOW_UNCHANGED: + for i, byte in enumerate(data): + if byte == 128: + data[i] = self.characteristics[char_specifier][i] + self.characteristics[char_specifier] = data + + +@pytest.fixture +def mock_gatt_characteristics() -> dict[uuid.UUID, bytearray]: + """Provide a mutable per-test GATT characteristic store.""" + return { + characteristic: bytearray(value) + for characteristic, value in FIXTURE_DEFAULT_CHARACTERISTICS.items() + } + @pytest.fixture def mock_service_info() -> Generator[None]: @@ -133,13 +175,26 @@ def mock_ble_device() -> Generator[None]: @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth: None) -> Generator[None]: +def mock_bluetooth( + enable_bluetooth: None, + mock_gatt_characteristics: dict[uuid.UUID, bytearray], +) -> Generator[None]: """Auto mock bluetooth.""" - with patch( - "eurotronic_cometblue_ha.CometBlueBleakClient", MockCometBlueBleakClient + MockCometBlueBleakClient.characteristics = mock_gatt_characteristics + with ( + patch( + "homeassistant.components.eurotronic_cometblue.entity.bluetooth.async_address_present", + return_value=True, + ), + patch( + "homeassistant.components.eurotronic_cometblue.coordinator.COMMAND_RETRY_INTERVAL", + 0, + ), + patch("eurotronic_cometblue_ha.CometBlueBleakClient", MockCometBlueBleakClient), ): yield + MockCometBlueBleakClient.characteristics = {} # Home Assistant related fixtures @@ -164,3 +219,16 @@ def mock_setup_entry() -> Generator[AsyncMock]: return_value=True, ) as mock_setup: yield mock_setup + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] | None = None +) -> None: + """Set up the Eurotronic Comet Blue integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch( + "homeassistant.components.eurotronic_cometblue.PLATFORMS", + platforms or PLATFORMS, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/eurotronic_cometblue/const.py b/tests/components/eurotronic_cometblue/const.py deleted file mode 100644 index 5e061e287b0164..00000000000000 --- a/tests/components/eurotronic_cometblue/const.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Constants for Eurotronic CometBlue tests.""" - -from uuid import UUID - -from homeassistant.const import CONF_PIN - -FIXTURE_DEVICE_NAME = "Comet Blue" -FIXTURE_MAC = "aa:bb:cc:dd:ee:ff" -FIXTURE_RSSI = -60 -FIXTURE_SERVICE_UUID = "47e9ee00-47e9-11e4-8939-164230d1df67" - -FIXTURE_GATT_CHARACTERISTICS = { - UUID("00002a24-0000-1000-8000-00805f9b34fb"): bytearray(b"Comet Blue"), # model - UUID("00002a26-0000-1000-8000-00805f9b34fb"): bytearray(b"0.0.10"), # version - UUID("00002a29-0000-1000-8000-00805f9b34fb"): bytearray( - b"Eurotronic GmbH" - ), # manufacturer - UUID("47e9ee20-47e9-11e4-8939-164230d1df67"): bytearray( - b'\x80\x1b\x0b\x16\x80\x1b\x0b\x16"' - ), # holiday 1 - UUID("47e9ee2b-47e9-11e4-8939-164230d1df67"): bytearray( - b"/999\x00\x04\n" - ), # temperature - UUID("47e9ee2c-47e9-11e4-8939-164230d1df67"): bytearray(b"48"), # battery -} - -FIXTURE_USER_INPUT = { - CONF_PIN: "000000", -} diff --git a/tests/components/eurotronic_cometblue/snapshots/test_climate.ambr b/tests/components/eurotronic_cometblue/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..88b82ad1a83536 --- /dev/null +++ b/tests/components/eurotronic_cometblue/snapshots/test_climate.ambr @@ -0,0 +1,88 @@ +# serializer version: 1 +# name: test_climate_state[climate.comet_blue_aa_bb_cc_dd_ee_ff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 28.5, + 'min_temp': 7.5, + 'preset_modes': list([ + 'comfort', + 'eco', + 'boost', + 'away', + 'none', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.comet_blue_aa_bb_cc_dd_ee_ff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'eurotronic_cometblue', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state[climate.comet_blue_aa_bb_cc_dd_ee_ff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Comet Blue aa:bb:cc:dd:ee:ff', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 28.5, + 'min_temp': 7.5, + 'preset_mode': 'none', + 'preset_modes': list([ + 'comfort', + 'eco', + 'boost', + 'away', + 'none', + ]), + 'supported_features': , + 'target_temp_high': 21.0, + 'target_temp_low': 17.0, + 'target_temp_step': 0.5, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.comet_blue_aa_bb_cc_dd_ee_ff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/eurotronic_cometblue/test_climate.py b/tests/components/eurotronic_cometblue/test_climate.py new file mode 100644 index 00000000000000..2c3972f7a33107 --- /dev/null +++ b/tests/components/eurotronic_cometblue/test_climate.py @@ -0,0 +1,331 @@ +"""Test the eurotronic_cometblue climate platform.""" + +from unittest.mock import patch +import uuid + +from eurotronic_cometblue_ha import const as cometblue_const +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.components.eurotronic_cometblue.climate import MAX_TEMP, MIN_TEMP +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "climate.comet_blue_aa_bb_cc_dd_ee_ff" + + +async def test_climate_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate entity state and registry data.""" + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("temperature_values", "expected_hvac_mode", "expected_preset"), + [ + ([47, 15, 34, 42, 0, 4, 10], HVACMode.OFF, PRESET_NONE), + ([47, 40, 34, 42, 0, 4, 10], HVACMode.AUTO, PRESET_NONE), + ([47, 42, 34, 42, 0, 4, 10], HVACMode.AUTO, PRESET_COMFORT), + ([47, 34, 34, 42, 0, 4, 10], HVACMode.AUTO, PRESET_ECO), + ([47, 57, 57, 57, 0, 4, 10], HVACMode.HEAT, PRESET_BOOST), + ], +) +async def test_climate_hvac_and_preset_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gatt_characteristics: dict[uuid.UUID, bytearray], + temperature_values: list[int], + expected_hvac_mode: HVACMode, + expected_preset: str, +) -> None: + """Test climate state mapping from device temperatures.""" + mock_gatt_characteristics[cometblue_const.CHARACTERISTIC_TEMPERATURE] = bytearray( + temperature_values + ) + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == expected_hvac_mode + assert state.attributes[ATTR_PRESET_MODE] == expected_preset + + +async def test_set_temperature( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting target temperature.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == 20.0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.5 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 21.0}, + blocking=True, + ) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == 21.0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.5 + + +async def test_climate_preset_away_active( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gatt_characteristics: dict[uuid.UUID, bytearray], +) -> None: + """Test away preset detection from holiday data.""" + # Holiday active if start hour >= 128 and end hour < 128 + mock_gatt_characteristics[cometblue_const.CHARACTERISTIC_HOLIDAY_1] = bytearray( + [128, 1, 1, 26, 10, 2, 1, 26, 34] + ) + # Current target temperature must match holiday temperature for away preset to be active + mock_gatt_characteristics[cometblue_const.CHARACTERISTIC_TEMPERATURE] = bytearray( + [47, 34, 34, 42, 0, 4, 10] + ) + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY + + with pytest.raises( + ServiceValidationError, + match="Cannot adjust TRV remotely", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 21.0}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("preset_mode", "expected_temperature", "expected_state"), + [ + (PRESET_ECO, 17.0, HVACMode.AUTO), + (PRESET_COMFORT, 21.0, HVACMode.AUTO), + (PRESET_BOOST, MAX_TEMP, HVACMode.HEAT), + ], +) +async def test_set_preset_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + preset_mode: str, + expected_temperature: float, + expected_state: HVACMode, +) -> None: + """Test setting preset modes.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == expected_temperature + assert state.attributes[ATTR_PRESET_MODE] == preset_mode + assert state.state == expected_state + + +@pytest.mark.parametrize("preset_mode", [PRESET_NONE, PRESET_AWAY]) +async def test_set_preset_mode_display_only_raises( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + preset_mode: str, +) -> None: + """Test display-only presets cannot be set.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + with pytest.raises(ServiceValidationError, match="Unable to set preset"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("hvac_mode", "expected_temperature", "expected_preset"), + [ + (HVACMode.OFF, MIN_TEMP, PRESET_NONE), + (HVACMode.HEAT, MAX_TEMP, PRESET_BOOST), + (HVACMode.AUTO, 17.0, PRESET_ECO), + ], +) +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + expected_temperature: float, + expected_preset: str, +) -> None: + """Test setting HVAC modes.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == expected_temperature + assert state.attributes[ATTR_PRESET_MODE] == expected_preset + assert state.state == hvac_mode + + +@pytest.mark.parametrize( + ("service", "expected_temperature"), + [ + (SERVICE_TURN_OFF, MIN_TEMP), + (SERVICE_TURN_ON, 17.0), + ], +) +async def test_turn_on_turn_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + service: str, + expected_temperature: float, +) -> None: + """Test turn_on and turn_off services.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == 20.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == expected_temperature + + +@pytest.mark.parametrize( + ("raise_exception", "raised_exception"), + [ + (TimeoutError, HomeAssistantError), + (ValueError, ServiceValidationError), + ], +) +async def test_set_temperature_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + raise_exception: type[Exception], + raised_exception: type[Exception], +) -> None: + """Test setting target temperature.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # raise exceptions to test error handling + with ( + pytest.raises(raised_exception), + patch( + "homeassistant.components.eurotronic_cometblue.coordinator.AsyncCometBlue.set_temperature_async", + side_effect=raise_exception(), + ), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 21.0}, + blocking=True, + ) + + +async def test_update_data_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that update data errors are handled and retried.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == 20.0 + + # Fail with TimeoutError (expected) and raise UpdateFailed after 3 retries + with patch.object( + mock_config_entry.runtime_data.device, + "get_temperature_async", + side_effect=TimeoutError(), + ) as mock_get_temperature: + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + assert mock_get_temperature.call_count == 3 + assert mock_config_entry.runtime_data.last_update_success is False + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == 20.0 + + # Fail with OSError (unexpected) and raise UpdateFailed directly + with patch.object( + mock_config_entry.runtime_data.device, + "get_temperature_async", + side_effect=OSError(), + ) as mock_get_temperature: + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + assert mock_get_temperature.call_count == 1 + assert mock_config_entry.runtime_data.last_update_success is False + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == 20.0 + + # Fail once with TimeoutError and then succeed, verify that data is updated + updated_temperatures = dict(mock_config_entry.runtime_data.data.temperatures) + updated_temperatures["manualTemp"] = 27.0 + + with patch.object( + mock_config_entry.runtime_data.device, + "get_temperature_async", + side_effect=[TimeoutError(), updated_temperatures], + ) as mock_get_temperature: + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + assert mock_get_temperature.call_count == 2 + assert mock_config_entry.runtime_data.last_update_success is True + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_TEMPERATURE] == 27.0 diff --git a/tests/components/eurotronic_cometblue/test_config_flow.py b/tests/components/eurotronic_cometblue/test_config_flow.py index e69b623d50ac29..117b629f855c3e 100644 --- a/tests/components/eurotronic_cometblue/test_config_flow.py +++ b/tests/components/eurotronic_cometblue/test_config_flow.py @@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import FIXTURE_DEVICE_NAME, FIXTURE_MAC, FIXTURE_USER_INPUT from .conftest import FAKE_SERVICE_INFO -from .const import FIXTURE_DEVICE_NAME, FIXTURE_MAC, FIXTURE_USER_INPUT from tests.common import MockConfigEntry From 81f8319af4adcede9524b91bb219270cf946aeff Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 13 Apr 2026 10:33:37 +0300 Subject: [PATCH 0819/1707] Fix llm tool results mutation (#167485) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/intent.py | 8 +-- tests/components/conversation/test_util.py | 4 +- tests/helpers/test_intent.py | 74 ++++++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 223b26bc71e14e..990fa490c762a3 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1434,16 +1434,16 @@ def async_set_speech_slots(self, speech_slots: dict[str, Any]) -> None: def as_dict(self) -> dict[str, Any]: """Return a dictionary representation of an intent response.""" response_dict: dict[str, Any] = { - "speech": self.speech, - "card": self.card, + "speech": {k: dict(v) for k, v in self.speech.items()}, + "card": {k: dict(v) for k, v in self.card.items()}, "language": self.language, "response_type": self.response_type.value, } if self.reprompt: - response_dict["reprompt"] = self.reprompt + response_dict["reprompt"] = {k: dict(v) for k, v in self.reprompt.items()} if self.speech_slots: - response_dict["speech_slots"] = self.speech_slots + response_dict["speech_slots"] = self.speech_slots.copy() response_data: dict[str, Any] = {} diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py index 196de4ad2fb31f..b57bc5aea02f57 100644 --- a/tests/components/conversation/test_util.py +++ b/tests/components/conversation/test_util.py @@ -11,6 +11,7 @@ async def test_async_get_result_from_chat_log( ) -> None: """Test getting result from chat log.""" intent_response = intent.IntentResponse(language="en") + tool_result = llm.IntentResponseDict(intent_response) with ( chat_session.async_get_chat_session(hass) as session, conversation.async_get_chat_log( @@ -23,7 +24,7 @@ async def test_async_get_result_from_chat_log( agent_id="mock-agent-id", tool_call_id="mock-tool-call-id", tool_name="mock-tool-name", - tool_result=llm.IntentResponseDict(intent_response), + tool_result=tool_result, ), conversation.AssistantContent( agent_id="mock-agent-id", @@ -37,3 +38,4 @@ async def test_async_get_result_from_chat_log( # Original intent response is returned with speech set assert result.response is intent_response assert result.response.speech["plain"]["speech"] == "This is a response." + assert tool_result["speech"] != result.response.speech diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index c592f055111763..5179021a61d010 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -1,6 +1,7 @@ """Tests for the intent helpers.""" import asyncio +from copy import deepcopy from unittest.mock import MagicMock, patch import pytest @@ -983,3 +984,76 @@ async def test_get_all_entity_aliases( state = State("light.test", "on", {"friendly_name": friendly_name}) assert intent.async_get_entity_aliases(hass, entry, state=state) == expected + + +async def test_intent_response_dict() -> None: + """Test that IntentResponse.as_dict() copies mutable objects.""" + response = intent.IntentResponse( + language="en", + intent=None, + ) + # Prepare the intent response initial state + response.async_set_speech( + speech="Hello", speech_type="plain", extra_data={"key": "value"} + ) + response.async_set_reprompt( + speech="Hi", speech_type="plain", extra_data={"key2": "value2"} + ) + response.async_set_card(title="Title", content="Content", card_type="simple") + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.FLOOR, + name="first floor", + id="floor-1", + ) + ], + failed_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name="kitchen light", + id="light.kitchen", + ) + ], + ) + response.async_set_states( + matched_states=[State("light.kitchen", "on")], + unmatched_states=[State("light.bedroom", "off")], + ) + response.async_set_speech_slots({"name": {"value": "kitchen"}}) + + response_dict1 = response.as_dict() + response_dict2 = deepcopy(response_dict1) + + # Mutate the original object + response.async_set_speech( + speech="Changed", speech_type="plain", extra_data={"key": "changed"} + ) + response.async_set_reprompt( + speech="Changed", speech_type="plain", extra_data={"key2": "changed2"} + ) + response.async_set_card(title="Changed", content="Changed", card_type="simple") + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.FLOOR, + name="changed floor", + id="floor-changed", + ) + ], + failed_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name="changed light", + id="light.changed", + ) + ], + ) + response.async_set_states( + matched_states=[State("light.changed", "on")], + unmatched_states=[State("light.changed_bedroom", "off")], + ) + response.async_set_speech_slots({"name": {"value": "changed"}}) + + # The original dict should not be affected by the mutations + assert response_dict1 == response_dict2 From ef589f9b464c1297d38937481bb7db92a90932d8 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:50:39 +0200 Subject: [PATCH 0820/1707] Add unifi_discovery integration, migrate unifiprotect discovery (#168030) Co-authored-by: RaHehl --- CODEOWNERS | 2 + homeassistant/brands/ubiquiti.json | 1 + .../components/unifi_discovery/__init__.py | 18 ++ .../components/unifi_discovery/config_flow.py | 46 ++++ .../components/unifi_discovery/const.py | 12 ++ .../discovery.py | 52 ++--- .../components/unifi_discovery/manifest.json | 63 ++++++ .../components/unifi_discovery/strings.json | 13 ++ .../components/unifiprotect/__init__.py | 27 ++- .../components/unifiprotect/config_flow.py | 25 --- homeassistant/components/unifiprotect/data.py | 1 + .../components/unifiprotect/manifest.json | 56 +---- .../unifiprotect/quality_scale.yaml | 4 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 20 +- homeassistant/generated/ssdp.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/config_flow.py | 2 +- script/hassfest/quality_scale.py | 1 + tests/components/unifi_discovery/__init__.py | 50 +++++ .../unifi_discovery/test_config_flow.py | 98 +++++++++ tests/components/unifi_discovery/test_init.py | 56 +++++ tests/components/unifiprotect/__init__.py | 2 +- tests/components/unifiprotect/conftest.py | 8 +- .../unifiprotect/test_config_flow.py | 196 ++++++------------ tests/components/unifiprotect/test_init.py | 7 +- 27 files changed, 495 insertions(+), 272 deletions(-) create mode 100644 homeassistant/components/unifi_discovery/__init__.py create mode 100644 homeassistant/components/unifi_discovery/config_flow.py create mode 100644 homeassistant/components/unifi_discovery/const.py rename homeassistant/components/{unifiprotect => unifi_discovery}/discovery.py (56%) create mode 100644 homeassistant/components/unifi_discovery/manifest.json create mode 100644 homeassistant/components/unifi_discovery/strings.json create mode 100644 tests/components/unifi_discovery/__init__.py create mode 100644 tests/components/unifi_discovery/test_config_flow.py create mode 100644 tests/components/unifi_discovery/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 9f3637c6c1706e..ffb73467d2d136 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1828,6 +1828,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/unifi_access/ @imhotep @RaHehl /tests/components/unifi_access/ @imhotep @RaHehl /homeassistant/components/unifi_direct/ @tofuSCHNITZEL +/homeassistant/components/unifi_discovery/ @RaHehl +/tests/components/unifi_discovery/ @RaHehl /homeassistant/components/unifiled/ @florisvdk /homeassistant/components/unifiprotect/ @RaHehl /tests/components/unifiprotect/ @RaHehl diff --git a/homeassistant/brands/ubiquiti.json b/homeassistant/brands/ubiquiti.json index bcc6349532420c..47f7bad226185c 100644 --- a/homeassistant/brands/ubiquiti.json +++ b/homeassistant/brands/ubiquiti.json @@ -6,6 +6,7 @@ "unifi", "unifi_access", "unifi_direct", + "unifi_discovery", "unifiled", "unifiprotect" ] diff --git a/homeassistant/components/unifi_discovery/__init__.py b/homeassistant/components/unifi_discovery/__init__.py new file mode 100644 index 00000000000000..d4c8b3cbf5f6a6 --- /dev/null +++ b/homeassistant/components/unifi_discovery/__init__.py @@ -0,0 +1,18 @@ +"""The UniFi Discovery integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .discovery import async_start_discovery + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up UniFi Discovery.""" + async_start_discovery(hass) + return True diff --git a/homeassistant/components/unifi_discovery/config_flow.py b/homeassistant/components/unifi_discovery/config_flow.py new file mode 100644 index 00000000000000..2cf1251f6c6477 --- /dev/null +++ b/homeassistant/components/unifi_discovery/config_flow.py @@ -0,0 +1,46 @@ +"""Config flow for UniFi Discovery.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo + +from .const import DOMAIN +from .discovery import async_start_discovery + +_LOGGER = logging.getLogger(__name__) + + +class UnifiDiscoveryFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for UniFi Discovery.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a user-initiated flow.""" + async_start_discovery(self.hass) + return self.async_abort(reason="discovery_started") + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery via DHCP.""" + _LOGGER.debug("Starting discovery via DHCP: %s", discovery_info) + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + async_start_discovery(self.hass) + return self.async_abort(reason="discovery_started") + + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery via SSDP.""" + _LOGGER.debug("Starting discovery via SSDP: %s", discovery_info) + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + async_start_discovery(self.hass) + return self.async_abort(reason="discovery_started") diff --git a/homeassistant/components/unifi_discovery/const.py b/homeassistant/components/unifi_discovery/const.py new file mode 100644 index 00000000000000..e98925b5f533ed --- /dev/null +++ b/homeassistant/components/unifi_discovery/const.py @@ -0,0 +1,12 @@ +"""Constants for the UniFi Discovery integration.""" + +from unifi_discovery import UnifiService + +DOMAIN = "unifi_discovery" + +# Static mapping of UniFi service types to their Home Assistant integration domains. +# This must be static (not a runtime registry) because consumers may not be loaded +# when initial discovery runs — the same pattern DHCP/SSDP use with manifest matchers. +CONSUMER_MAPPING: dict[UnifiService, str] = { + UnifiService.Protect: "unifiprotect", +} diff --git a/homeassistant/components/unifiprotect/discovery.py b/homeassistant/components/unifi_discovery/discovery.py similarity index 56% rename from homeassistant/components/unifiprotect/discovery.py rename to homeassistant/components/unifi_discovery/discovery.py index 3a7fb7c65e02ee..6bd8a4b24cdfb2 100644 --- a/homeassistant/components/unifiprotect/discovery.py +++ b/homeassistant/components/unifi_discovery/discovery.py @@ -1,13 +1,13 @@ -"""The unifiprotect integration discovery.""" +"""UniFi network device discovery.""" from __future__ import annotations -from dataclasses import asdict, dataclass, field +from dataclasses import asdict from datetime import timedelta import logging from typing import Any -from unifi_discovery import AIOUnifiScanner, UnifiDevice, UnifiService +from unifi_discovery import AIOUnifiScanner, UnifiDevice from homeassistant import config_entries from homeassistant.core import HomeAssistant, callback @@ -15,32 +15,21 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import CONSUMER_MAPPING, DOMAIN _LOGGER = logging.getLogger(__name__) - -@dataclass -class UniFiProtectRuntimeData: - """Runtime data stored in hass.data[DOMAIN].""" - - auth_retries: dict[str, int] = field(default_factory=dict) - discovery_started: bool = False - - -# Typed key for hass.data access at DOMAIN level -DATA_UNIFIPROTECT: HassKey[UniFiProtectRuntimeData] = HassKey(DOMAIN) - DISCOVERY_INTERVAL = timedelta(minutes=60) +DATA_DISCOVERY_STARTED: HassKey[bool] = HassKey(DOMAIN) + @callback def async_start_discovery(hass: HomeAssistant) -> None: - """Start discovery.""" - domain_data = hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData()) - if domain_data.discovery_started: + """Start discovery of UniFi devices.""" + if hass.data.get(DATA_DISCOVERY_STARTED): return - domain_data.discovery_started = True + hass.data[DATA_DISCOVERY_STARTED] = True async def _async_discovery() -> None: async_trigger_discovery(hass, await async_discover_devices()) @@ -48,7 +37,9 @@ async def _async_discovery() -> None: @callback def _async_start_background_discovery(*_: Any) -> None: """Run discovery in the background.""" - hass.async_create_background_task(_async_discovery(), "unifiprotect-discovery") + hass.async_create_background_task( + _async_discovery(), "unifi_discovery-discovery" + ) # Do not block startup since discovery takes 31s or more _async_start_background_discovery() @@ -61,7 +52,7 @@ def _async_start_background_discovery(*_: Any) -> None: async def async_discover_devices() -> list[UnifiDevice]: - """Discover devices.""" + """Discover UniFi devices on the network.""" scanner = AIOUnifiScanner() devices = await scanner.async_scan() _LOGGER.debug("Found devices: %s", devices) @@ -75,10 +66,13 @@ def async_trigger_discovery( ) -> None: """Trigger config flows for discovered devices.""" for device in discovered_devices: - if device.services[UnifiService.Protect] and device.hw_addr: - discovery_flow.async_create_flow( - hass, - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=asdict(device), - ) + if not device.hw_addr: + continue + for service, domain in CONSUMER_MAPPING.items(): + if device.services.get(service): + discovery_flow.async_create_flow( + hass, + domain, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=asdict(device), + ) diff --git a/homeassistant/components/unifi_discovery/manifest.json b/homeassistant/components/unifi_discovery/manifest.json new file mode 100644 index 00000000000000..84cbcfec26b2e7 --- /dev/null +++ b/homeassistant/components/unifi_discovery/manifest.json @@ -0,0 +1,63 @@ +{ + "domain": "unifi_discovery", + "name": "UniFi Discovery", + "codeowners": ["@RaHehl"], + "config_flow": true, + "dhcp": [ + { + "macaddress": "B4FBE4*" + }, + { + "macaddress": "802AA8*" + }, + { + "macaddress": "F09FC2*" + }, + { + "macaddress": "68D79A*" + }, + { + "macaddress": "18E829*" + }, + { + "macaddress": "245A4C*" + }, + { + "macaddress": "784558*" + }, + { + "macaddress": "E063DA*" + }, + { + "macaddress": "265A4C*" + }, + { + "macaddress": "74ACB9*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/unifi_discovery", + "integration_type": "system", + "iot_class": "local_polling", + "loggers": ["unifi_discovery"], + "quality_scale": "internal", + "requirements": ["unifi-discovery==1.4.0"], + "single_config_entry": true, + "ssdp": [ + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine" + }, + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine Pro" + }, + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine SE" + }, + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine Pro Max" + } + ] +} diff --git a/homeassistant/components/unifi_discovery/strings.json b/homeassistant/components/unifi_discovery/strings.json new file mode 100644 index 00000000000000..0f2759c86d7566 --- /dev/null +++ b/homeassistant/components/unifi_discovery/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "discovery_started": "Discovery started" + }, + "step": { + "user": { + "description": "UniFi Discovery is set up automatically." + } + } + } +} diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 9e359de481a084..2d6273dc551d3e 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -40,7 +40,6 @@ PLATFORMS, ) from .data import ProtectData, UFPConfigEntry -from .discovery import DATA_UNIFIPROTECT, UniFiProtectRuntimeData, async_start_discovery from .migrate import async_migrate_data from .services import async_setup_services from .utils import ( @@ -64,11 +63,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the UniFi Protect.""" - # Initialize domain data structure (setdefault in case discovery already started) - hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData()) - # Only start discovery once regardless of how many entries they have async_setup_services(hass) - async_start_discovery(hass) return True @@ -78,20 +73,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: protect = async_create_api_client(hass, entry) _LOGGER.debug("Connect to UniFi Protect") + # Reuse ProtectData from previous retry or create new + if hasattr(entry, "runtime_data"): + data_service = entry.runtime_data + data_service.api = protect + else: + data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) + entry.runtime_data = data_service + try: await protect.update() except NotAuthorized as err: - domain_data = hass.data.setdefault(DATA_UNIFIPROTECT, UniFiProtectRuntimeData()) - retries = domain_data.auth_retries.get(entry.entry_id, 0) - if retries < AUTH_RETRIES: - retries += 1 - domain_data.auth_retries[entry.entry_id] = retries - raise ConfigEntryNotReady from err - raise ConfigEntryAuthFailed(err) from err + data_service.auth_retries += 1 + if data_service.auth_retries > AUTH_RETRIES: + raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryNotReady from err except (TimeoutError, ClientError, ServerDisconnectedError) as err: raise ConfigEntryNotReady from err - - data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) bootstrap = protect.bootstrap nvr_info = bootstrap.nvr auth_user = bootstrap.users.get(bootstrap.auth_user_id) @@ -142,7 +140,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) - entry.runtime_data = data_service entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop) ) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 605c127d8c3f1e..5ac557ea856836 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -36,8 +36,6 @@ async_create_clientsession, async_get_clientsession, ) -from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_integration @@ -56,7 +54,6 @@ OUTDATED_LOG_MESSAGE, ) from .data import UFPConfigEntry, async_last_update_was_successful -from .discovery import async_start_discovery from .utils import ( _async_resolve, _async_short_mac, @@ -205,28 +202,6 @@ def __init__(self) -> None: super().__init__() self._discovered_device: dict[str, str] = {} - async def async_step_dhcp( - self, discovery_info: DhcpServiceInfo - ) -> ConfigFlowResult: - """Handle discovery via dhcp.""" - _LOGGER.debug("Starting discovery via: %s", discovery_info) - return await self._async_discovery_handoff() - - async def async_step_ssdp( - self, discovery_info: SsdpServiceInfo - ) -> ConfigFlowResult: - """Handle a discovered UniFi device.""" - _LOGGER.debug("Starting discovery via: %s", discovery_info) - return await self._async_discovery_handoff() - - async def _async_discovery_handoff(self) -> ConfigFlowResult: - """Ensure discovery is active.""" - # Discovery requires an additional check so we use - # SSDP and DHCP to tell us to start it so it only - # runs on networks where unifi devices are present. - async_start_discovery(self.hass) - return self.async_abort(reason="discovery_started") - async def async_step_integration_discovery( self, discovery_info: DiscoveryInfoType ) -> ConfigFlowResult: diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 1cb56b7311f5f1..8d3abdc768c3df 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -86,6 +86,7 @@ def __init__( self._pending_camera_ids: set[str] = set() self._unsubs: list[CALLBACK_TYPE] = [] self._auth_failures = 0 + self.auth_retries = 0 self.last_update_success = False self.api = protect self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 23407b2787edef..e813e5669c61ca 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,61 +3,11 @@ "name": "UniFi Protect", "codeowners": ["@RaHehl"], "config_flow": true, - "dependencies": ["http", "repairs"], - "dhcp": [ - { - "macaddress": "B4FBE4*" - }, - { - "macaddress": "802AA8*" - }, - { - "macaddress": "F09FC2*" - }, - { - "macaddress": "68D79A*" - }, - { - "macaddress": "18E829*" - }, - { - "macaddress": "245A4C*" - }, - { - "macaddress": "784558*" - }, - { - "macaddress": "E063DA*" - }, - { - "macaddress": "265A4C*" - }, - { - "macaddress": "74ACB9*" - } - ], + "dependencies": ["http", "repairs", "unifi_discovery"], "documentation": "https://www.home-assistant.io/integrations/unifiprotect", "integration_type": "hub", "iot_class": "local_push", - "loggers": ["uiprotect", "unifi_discovery"], + "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==10.2.6", "unifi-discovery==1.4.0"], - "ssdp": [ - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine SE" - }, - { - "manufacturer": "Ubiquiti Networks", - "modelDescription": "UniFi Dream Machine Pro Max" - } - ] + "requirements": ["uiprotect==10.2.6"] } diff --git a/homeassistant/components/unifiprotect/quality_scale.yaml b/homeassistant/components/unifiprotect/quality_scale.yaml index 01d7a68afc397c..edd61c9d060bfa 100644 --- a/homeassistant/components/unifiprotect/quality_scale.yaml +++ b/homeassistant/components/unifiprotect/quality_scale.yaml @@ -39,7 +39,9 @@ rules: devices: done diagnostics: done discovery-update-info: done - discovery: done + discovery: + status: exempt + comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY. docs-data-update: done docs-examples: done docs-known-limitations: done diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f2d084e6a60ab0..13a421d03185d7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -766,6 +766,7 @@ "ukraine_alarm", "unifi", "unifi_access", + "unifi_discovery", "unifiprotect", "upb", "upcloud", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 75b08711f1f433..97625caa89bed8 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1331,43 +1331,43 @@ "hostname": "twinkly-*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "B4FBE4*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "802AA8*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "F09FC2*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "68D79A*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "18E829*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "245A4C*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "784558*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "E063DA*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "265A4C*", }, { - "domain": "unifiprotect", + "domain": "unifi_discovery", "macaddress": "74ACB9*", }, { diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index a1ab04fdb3ad5b..9915ac22019970 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -359,7 +359,7 @@ "modelDescription": "UniFi Dream Machine Pro Max", }, ], - "unifiprotect": [ + "unifi_discovery": [ { "manufacturer": "Ubiquiti Networks", "modelDescription": "UniFi Dream Machine", diff --git a/requirements_all.txt b/requirements_all.txt index 21f262b57e969f..62ec504a1f59da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3186,7 +3186,7 @@ uiprotect==10.2.6 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 -# homeassistant.components.unifiprotect +# homeassistant.components.unifi_discovery unifi-discovery==1.4.0 # homeassistant.components.unifi_direct diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0d76882b6bbcb..57615b35377f0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2698,7 +2698,7 @@ uiprotect==10.2.6 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 -# homeassistant.components.unifiprotect +# homeassistant.components.unifi_discovery unifi-discovery==1.4.0 # homeassistant.components.homeassistant_hardware diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 54ca4230001e68..d21773ce5d4c7c 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -9,7 +9,7 @@ from .model import Brand, Config, Integration, IntegrationType from .serializer import format_python_namespace -UNIQUE_ID_IGNORE = {"huawei_lte", "mqtt", "adguard"} +UNIQUE_ID_IGNORE = {"huawei_lte", "mqtt", "adguard", "unifi_discovery"} def _validate_integration(config: Config, integration: Integration) -> None: diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 7ce31295a5af19..9ececa25f26812 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2151,6 +2151,7 @@ class Rule: "search", "system_health", "system_log", + "unifi_discovery", "tag", "temperature", "timer", diff --git a/tests/components/unifi_discovery/__init__.py b/tests/components/unifi_discovery/__init__.py new file mode 100644 index 00000000000000..c068a3ba1e0e38 --- /dev/null +++ b/tests/components/unifi_discovery/__init__.py @@ -0,0 +1,50 @@ +"""Tests for the UniFi Discovery integration.""" + +from collections.abc import Generator +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from unifi_discovery import AIOUnifiScanner, UnifiDevice, UnifiService + +DEVICE_HOSTNAME = "unvr" +DEVICE_IP_ADDRESS = "127.0.0.1" +DEVICE_MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +DIRECT_CONNECT_DOMAIN = "x.ui.direct" + + +UNIFI_DISCOVERY_PROTECT = UnifiDevice( + source_ip=DEVICE_IP_ADDRESS, + hw_addr=DEVICE_MAC_ADDRESS, + platform=DEVICE_HOSTNAME, + hostname=DEVICE_HOSTNAME, + services={UnifiService.Protect: True}, + direct_connect_domain=DIRECT_CONNECT_DOMAIN, +) + +UNIFI_DISCOVERY_NO_MAC = UnifiDevice( + source_ip=DEVICE_IP_ADDRESS, + hw_addr=None, + platform=DEVICE_HOSTNAME, + hostname=DEVICE_HOSTNAME, + services={UnifiService.Protect: True}, + direct_connect_domain=DIRECT_CONNECT_DOMAIN, +) + + +def _patch_discovery( + device: UnifiDevice | None = None, no_device: bool = False +) -> Generator[MagicMock]: + mock_aio_discovery = MagicMock(spec=AIOUnifiScanner) + scanner_return = [] if no_device else [device or UNIFI_DISCOVERY_PROTECT] + mock_aio_discovery.async_scan = AsyncMock(return_value=scanner_return) + mock_aio_discovery.found_devices = scanner_return + + @contextmanager + def _patcher(): + with patch( + "homeassistant.components.unifi_discovery.discovery.AIOUnifiScanner", + return_value=mock_aio_discovery, + ): + yield mock_aio_discovery + + return _patcher() diff --git a/tests/components/unifi_discovery/test_config_flow.py b/tests/components/unifi_discovery/test_config_flow.py new file mode 100644 index 00000000000000..112c35d69e59d6 --- /dev/null +++ b/tests/components/unifi_discovery/test_config_flow.py @@ -0,0 +1,98 @@ +"""Test the UniFi Discovery config flow.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.unifi_discovery.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo + +from . import DEVICE_HOSTNAME, DEVICE_IP_ADDRESS, DEVICE_MAC_ADDRESS, _patch_discovery + +DHCP_DISCOVERY = DhcpServiceInfo( + hostname=DEVICE_HOSTNAME, + ip=DEVICE_IP_ADDRESS, + macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), +) + +SSDP_DISCOVERY = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + upnp={ + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine", + }, +) + + +@pytest.mark.parametrize( + ("source", "data"), + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_SSDP, SSDP_DISCOVERY), + ], +) +async def test_dhcp_ssdp_abort_with_discovery_started( + hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo +) -> None: + """Test DHCP and SSDP discovery triggers scanner and aborts.""" + with _patch_discovery() as mock_scanner: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data=data, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_started" + assert mock_scanner.async_scan.call_count == 1 + + +@pytest.mark.parametrize( + ("source", "data"), + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_SSDP, SSDP_DISCOVERY), + ], +) +async def test_dhcp_ssdp_abort_already_in_progress( + hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo +) -> None: + """Test DHCP and SSDP abort when another flow is already in progress.""" + with ( + _patch_discovery(), + patch( + "homeassistant.components.unifi_discovery.config_flow.UnifiDiscoveryFlowHandler._async_in_progress", + return_value=[{"flow_id": "mock_flow"}], + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data=data, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_user_flow_aborts(hass: HomeAssistant) -> None: + """Test user-initiated flow aborts.""" + with _patch_discovery() as mock_scanner: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_started" + assert mock_scanner.async_scan.call_count == 1 diff --git a/tests/components/unifi_discovery/test_init.py b/tests/components/unifi_discovery/test_init.py new file mode 100644 index 00000000000000..e76511562c31d6 --- /dev/null +++ b/tests/components/unifi_discovery/test_init.py @@ -0,0 +1,56 @@ +"""Test the UniFi Discovery init.""" + +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.components.unifi_discovery.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import UNIFI_DISCOVERY_NO_MAC, _patch_discovery + + +async def test_setup_starts_discovery(hass: HomeAssistant) -> None: + """Test that async_setup starts discovery and dispatches flows.""" + with _patch_discovery(): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done(wait_background_tasks=True) + + # The scanner should have dispatched a discovery flow for the Protect consumer + flows = hass.config_entries.flow.async_progress_by_handler("unifiprotect") + assert len(flows) == 1 + assert flows[0]["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + + +async def test_setup_no_devices(hass: HomeAssistant) -> None: + """Test setup with no devices found.""" + with _patch_discovery(no_device=True): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done(wait_background_tasks=True) + + flows = hass.config_entries.flow.async_progress_by_handler("unifiprotect") + assert len(flows) == 0 + + +async def test_setup_device_without_mac(hass: HomeAssistant) -> None: + """Test that devices without hw_addr are skipped.""" + with _patch_discovery(device=UNIFI_DISCOVERY_NO_MAC): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done(wait_background_tasks=True) + + flows = hass.config_entries.flow.async_progress_by_handler("unifiprotect") + assert len(flows) == 0 + + +async def test_dependency_loads_discovery( + hass: HomeAssistant, +) -> None: + """Test that loading unifiprotect triggers unifi_discovery as dependency.""" + with _patch_discovery(): + assert await async_setup_component(hass, "unifiprotect", {}) + await hass.async_block_till_done(wait_background_tasks=True) + + # unifi_discovery should have been loaded as a dependency and started scanning + flows = hass.config_entries.flow.async_progress_by_handler("unifiprotect") + assert len(flows) == 1 + assert flows[0]["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY diff --git a/tests/components/unifiprotect/__init__.py b/tests/components/unifiprotect/__init__.py index 51a7e9af177859..0ce0cf0e5821ad 100644 --- a/tests/components/unifiprotect/__init__.py +++ b/tests/components/unifiprotect/__init__.py @@ -43,7 +43,7 @@ def _patch_discovery(device=None, no_device=False): @contextmanager def _patcher(): with patch( - "homeassistant.components.unifiprotect.discovery.AIOUnifiScanner", + "homeassistant.components.unifi_discovery.discovery.AIOUnifiScanner", return_value=mock_aio_discovery, ): yield diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index d2f54cae580ee8..e99989d4abb85b 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -60,6 +60,13 @@ DEFAULT_API_KEY = "test-api-key" +@pytest.fixture(autouse=True) +def mock_discovery(): + """Prevent real network scanning in all unifiprotect tests.""" + with _patch_discovery(no_device=True): + yield + + @pytest.fixture(name="nvr") def mock_nvr(): """Mock UniFi Protect Camera device.""" @@ -158,7 +165,6 @@ def mock_entry( """Mock ProtectApiClient for testing.""" with ( - _patch_discovery(no_device=True), patch( "homeassistant.components.unifiprotect.utils.ProtectApiClient" ) as mock_api, diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 27e382c6f936e2..55d1d73f2d1a7f 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -30,8 +30,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from . import ( DEVICE_HOSTNAME, @@ -40,7 +38,6 @@ DIRECT_CONNECT_DOMAIN, UNIFI_DISCOVERY, UNIFI_DISCOVERY_PARTIAL, - _patch_discovery, ) from .conftest import ( DEFAULT_API_KEY, @@ -54,24 +51,6 @@ from tests.common import MockConfigEntry -DHCP_DISCOVERY = DhcpServiceInfo( - hostname=DEVICE_HOSTNAME, - ip=DEVICE_IP_ADDRESS, - macaddress=DEVICE_MAC_ADDRESS.lower().replace(":", ""), -) -SSDP_DISCOVERY = ( - SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location=f"http://{DEVICE_IP_ADDRESS}:41417/rootDesc.xml", - upnp={ - "friendlyName": "UniFi Dream Machine", - "modelDescription": "UniFi Dream Machine Pro", - "serialNumber": DEVICE_MAC_ADDRESS, - }, - ), -) - # Base user input without credentials (for tests that override them) BASE_USER_INPUT = { CONF_HOST: DEFAULT_HOST, @@ -563,8 +542,6 @@ async def test_form_options( ufp_config_entry.add_to_hass(hass) with ( - _patch_discovery(), - patch("homeassistant.components.unifiprotect.async_start_discovery"), patch( "homeassistant.components.unifiprotect.utils.ProtectApiClient" ) as mock_api, @@ -600,42 +577,17 @@ async def test_form_options( await hass.config_entries.async_unload(ufp_config_entry.entry_id) -@pytest.mark.parametrize( - ("source", "data"), - [ - (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), - (config_entries.SOURCE_SSDP, SSDP_DISCOVERY), - ], -) -async def test_discovered_by_ssdp_or_dhcp( - hass: HomeAssistant, source: str, data: DhcpServiceInfo | SsdpServiceInfo -) -> None: - """Test we handoff to unifi-discovery when discovered via ssdp or dhcp.""" - - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": source}, - data=data, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "discovery_started" - - async def test_discovered_by_unifi_discovery_direct_connect( hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR ) -> None: """Test a discovery from unifi-discovery.""" - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" @@ -714,13 +666,12 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( ) mock_config.add_to_hass(hass) - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -747,7 +698,6 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin mock_config.add_to_hass(hass) with ( - _patch_discovery(), patch( "homeassistant.components.unifiprotect.config_flow.async_console_is_alive", return_value=False, @@ -785,7 +735,6 @@ async def test_discovered_by_unifi_discovery_does_not_update_ip_when_console_is_ mock_config.add_to_hass(hass) with ( - _patch_discovery(), patch( "homeassistant.components.unifiprotect.config_flow.async_console_is_alive", return_value=True, @@ -821,13 +770,12 @@ async def test_discovered_host_not_updated_if_existing_is_a_hostname( ) mock_config.add_to_hass(hass) - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -839,13 +787,12 @@ async def test_discovered_by_unifi_discovery( ) -> None: """Test a discovery from unifi-discovery.""" - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" @@ -909,13 +856,12 @@ async def test_discovered_by_unifi_discovery_partial( ) -> None: """Test a discovery from unifi-discovery partial.""" - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT_PARTIAL, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT_PARTIAL, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" @@ -993,13 +939,12 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa ) mock_config.add_to_hass(hass) - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -1024,13 +969,12 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa ) mock_config.add_to_hass(hass) - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -1060,7 +1004,6 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa other_ip_dict["direct_connect_domain"] = "nomatchsameip.ui.direct" with ( - _patch_discovery(), patch.object( hass.loop, "getaddrinfo", @@ -1103,7 +1046,6 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa other_ip_dict["direct_connect_domain"] = "nomatchsameip.ui.direct" with ( - _patch_discovery(), patch.object(hass.loop, "getaddrinfo", side_effect=OSError), ): result = await hass.config_entries.flow.async_init( @@ -1193,7 +1135,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa other_ip_dict["source_ip"] = "127.0.0.2" other_ip_dict["direct_connect_domain"] = "y.ui.direct" - with _patch_discovery(), patch.object(hass.loop, "getaddrinfo", return_value=[]): + with patch.object(hass.loop, "getaddrinfo", return_value=[]): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, @@ -1214,13 +1156,12 @@ async def test_discovery_can_be_ignored(hass: HomeAssistant) -> None: source=config_entries.SOURCE_IGNORE, ) mock_config.add_to_hass(hass) - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -1256,13 +1197,12 @@ async def test_discovery_with_both_ignored_and_normal_entry( # Discovery should: # 1. Skip all ignored entries with different MAC (line 182 - continue) # 2. Continue to discovery flow since no matching entries - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() # Flow continues to discovery step since no match found assert result["type"] is FlowResultType.FORM @@ -1312,13 +1252,12 @@ async def test_discovery_confirm_fallback_to_ip( mock_api_meta_info: Mock, ) -> None: """Test discovery confirm falls back to IP when direct connect fails.""" - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" @@ -1363,13 +1302,12 @@ async def test_discovery_confirm_with_api_key_error( mock_api_meta_info: Mock, ) -> None: """Test discovery confirm preserves API key in form data on error.""" - with _patch_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data=UNIFI_DISCOVERY_DICT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index c048a01a84f574..4646a1479d04fd 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -328,7 +328,7 @@ async def test_setup_failed_auth(hass: HomeAssistant, ufp: MockUFPFixture) -> No async def test_setup_starts_discovery( hass: HomeAssistant, ufp_config_entry: ConfigEntry, ufp_client: ProtectApiClient ) -> None: - """Test setting up will start discovery.""" + """Test setting up will start discovery via unifi_discovery dependency.""" with ( _patch_discovery(), patch( @@ -340,9 +340,9 @@ async def test_setup_starts_discovery( ufp = MockUFPFixture(ufp_config_entry, ufp_client) await hass.config_entries.async_setup(ufp.entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ufp.entry.state is ConfigEntryState.LOADED - await hass.async_block_till_done() + # Discovery is now handled by unifi_discovery dependency assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 @@ -586,7 +586,6 @@ async def test_migrate_entry_version_2(hass: HomeAssistant) -> None: patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True ), - patch("homeassistant.components.unifiprotect.async_start_discovery"), ): entry = MockConfigEntry( domain=DOMAIN, From 982a2b8af76c91408bab0a5ec77063cfffb047ed Mon Sep 17 00:00:00 2001 From: Niracler Date: Mon, 13 Apr 2026 16:28:14 +0800 Subject: [PATCH 0821/1707] Bump PySrDaliGateway to 0.20.4 (#168078) --- homeassistant/components/sunricher_dali/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sunricher_dali/manifest.json b/homeassistant/components/sunricher_dali/manifest.json index d5a76d0d0d8bad..4332a0e2644730 100644 --- a/homeassistant/components/sunricher_dali/manifest.json +++ b/homeassistant/components/sunricher_dali/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["PySrDaliGateway==0.19.3"] + "requirements": ["PySrDaliGateway==0.20.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 62ec504a1f59da..b6138cbeacadba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -80,7 +80,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.sunricher_dali -PySrDaliGateway==0.19.3 +PySrDaliGateway==0.20.4 # homeassistant.components.switchbot PySwitchbot==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57615b35377f0c..f79e42307378fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -80,7 +80,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.sunricher_dali -PySrDaliGateway==0.19.3 +PySrDaliGateway==0.20.4 # homeassistant.components.switchbot PySwitchbot==2.0.0 From ffd439abc5b820b206dd26d0ffe8416e3f3eba36 Mon Sep 17 00:00:00 2001 From: Fabian Neundorf Date: Mon, 13 Apr 2026 10:30:33 +0200 Subject: [PATCH 0822/1707] Add support for KM7576 in Miele integration (#168069) --- homeassistant/components/miele/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index a723763ea35669..78782fd5de6a2d 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -59,6 +59,7 @@ PLATE_COUNT = { "KM7575": 6, + "KM7576": 6, "KM7678": 6, "KM7697": 6, "KM7699": 5, From e9a79ee0e503b950d4c93d1e46ae8196ca7951c6 Mon Sep 17 00:00:00 2001 From: Giga77 Date: Mon, 13 Apr 2026 11:06:40 +0200 Subject: [PATCH 0823/1707] Replace hacf-fr by hacf-fr reviewers team (#168056) --- CODEOWNERS | 4 ++-- homeassistant/components/meteo_france/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index ffb73467d2d136..f362afeccb724d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1057,8 +1057,8 @@ CLAUDE.md @home-assistant/core /tests/components/met/ @danielhiversen /homeassistant/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore -/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame -/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame +/homeassistant/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame +/tests/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame /homeassistant/components/meteo_lt/ @xE1H /tests/components/meteo_lt/ @xE1H /homeassistant/components/meteoalarm/ @rolfberkenbosch diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 208cd5683500ae..226e99fdd5c2c4 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -1,7 +1,7 @@ { "domain": "meteo_france", "name": "M\u00e9t\u00e9o-France", - "codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"], + "codeowners": ["@hacf-fr/reviewers", "@oncleben31", "@Quentame"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/meteo_france", "integration_type": "service", From 81a657ab2c32626d2a935015caae1cab12ae33f3 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 13 Apr 2026 10:11:30 +0100 Subject: [PATCH 0824/1707] Bump mastodon.py to 2.2.1 (#168084) --- homeassistant/components/mastodon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../mastodon/snapshots/test_diagnostics.ambr | 12 ++++++++++++ .../components/mastodon/snapshots/test_services.ambr | 5 +++++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 2de970e263caa0..c34dc93b9885e2 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["mastodon"], "quality_scale": "gold", - "requirements": ["Mastodon.py==2.1.2"] + "requirements": ["Mastodon.py==2.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b6138cbeacadba..ee4e73a9dbae01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -25,7 +25,7 @@ HATasmota==0.10.1 HueBLE==2.1.0 # homeassistant.components.mastodon -Mastodon.py==2.1.2 +Mastodon.py==2.2.1 # homeassistant.components.playstation_network PSNAWP==3.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f79e42307378fa..80a25fdaa8f3d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ HATasmota==0.10.1 HueBLE==2.1.0 # homeassistant.components.mastodon -Mastodon.py==2.1.2 +Mastodon.py==2.2.1 # homeassistant.components.playstation_network PSNAWP==3.0.3 diff --git a/tests/components/mastodon/snapshots/test_diagnostics.ambr b/tests/components/mastodon/snapshots/test_diagnostics.ambr index 81abc77e21f205..6ba79fd3e3bb91 100644 --- a/tests/components/mastodon/snapshots/test_diagnostics.ambr +++ b/tests/components/mastodon/snapshots/test_diagnostics.ambr @@ -4,6 +4,7 @@ 'account': dict({ 'acct': 'trwnh', 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'avatar_description': None, 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', 'bot': True, 'created_at': '2016-11-24T00:00:00+00:00', @@ -37,6 +38,7 @@ 'following_count': 328, 'group': False, 'header': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', + 'header_description': None, 'header_static': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', 'hide_collections': True, 'id': '14715', @@ -53,6 +55,9 @@ 'role': None, 'roles': list([ ]), + 'show_featured': None, + 'show_media': None, + 'show_media_replies': None, 'source': None, 'statuses_count': 69523, 'suspended': None, @@ -80,6 +85,7 @@ }), }), 'version': '4.4.0-nightly.2025-02-07', + 'wrapstodon': None, }), }) # --- @@ -88,6 +94,7 @@ 'account': dict({ 'acct': 'trwnh', 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'avatar_description': None, 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', 'bot': True, 'created_at': '2016-11-24T00:00:00+00:00', @@ -121,6 +128,7 @@ 'following_count': 328, 'group': False, 'header': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', + 'header_description': None, 'header_static': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', 'hide_collections': True, 'id': '14715', @@ -137,6 +145,9 @@ 'role': None, 'roles': list([ ]), + 'show_featured': None, + 'show_media': None, + 'show_media_replies': None, 'source': None, 'statuses_count': 69523, 'suspended': None, @@ -164,6 +175,7 @@ }), }), 'version': '4.4.0-nightly.2025-02-07', + 'wrapstodon': None, }), }) # --- diff --git a/tests/components/mastodon/snapshots/test_services.ambr b/tests/components/mastodon/snapshots/test_services.ambr index 21c539dd597e7c..7097eca9a3e00d 100644 --- a/tests/components/mastodon/snapshots/test_services.ambr +++ b/tests/components/mastodon/snapshots/test_services.ambr @@ -58,6 +58,11 @@ 'indexable': False, 'hide_collections': True, 'memorial': None, + 'avatar_description': None, + 'header_description': None, + 'show_media': None, + 'show_media_replies': None, + 'show_featured': None, 'moved_to_account': None, }), }) From 4a511a3e53df47c407661840b92f8a29e183b52d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 13 Apr 2026 11:27:12 +0200 Subject: [PATCH 0825/1707] Bump aioamazondevices to 13.4.0 (#167984) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/alexa_devices/test_sensor.py | 2 +- tests/components/alexa_devices/test_switch.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 0dcb3dd3415ac1..eccb7a524504e9 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.3.2"] + "requirements": ["aioamazondevices==13.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee4e73a9dbae01..e1a8b947e7729b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.2 +aioamazondevices==13.4.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80a25fdaa8f3d7..4fef732fde5484 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.3.2 +aioamazondevices==13.4.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/alexa_devices/test_sensor.py b/tests/components/alexa_devices/test_sensor.py index 00a973ae151b31..58930f78b3fe87 100644 --- a/tests/components/alexa_devices/test_sensor.py +++ b/tests/components/alexa_devices/test_sensor.py @@ -3,12 +3,12 @@ from typing import Any from unittest.mock import AsyncMock, patch -from aioamazondevices.api import AmazonDeviceSensor from aioamazondevices.exceptions import ( CannotAuthenticate, CannotConnect, CannotRetrieveData, ) +from aioamazondevices.structures import AmazonDeviceSensor from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py index 06cc36c744cfac..4c454b5b2a9b78 100644 --- a/tests/components/alexa_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, patch -from aioamazondevices.api import AmazonDeviceSensor +from aioamazondevices.structures import AmazonDeviceSensor from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion From 5abaa2ae72f5106b97a446c92268da07783acc00 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:34:05 +0200 Subject: [PATCH 0826/1707] Bump python-melcloud to 0.1.3 (#168086) --- homeassistant/components/melcloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index b683ee6671a763..cd19d93145d788 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["melcloud"], - "requirements": ["python-melcloud==0.1.2"] + "requirements": ["python-melcloud==0.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index e1a8b947e7729b..a27d6e9978689e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2617,7 +2617,7 @@ python-kasa[speedups]==0.10.2 python-linkplay==0.2.12 # homeassistant.components.melcloud -python-melcloud==0.1.2 +python-melcloud==0.1.3 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fef732fde5484..014f00c07dc453 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2219,7 +2219,7 @@ python-kasa[speedups]==0.10.2 python-linkplay==0.2.12 # homeassistant.components.melcloud -python-melcloud==0.1.2 +python-melcloud==0.1.3 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From b8cdd8dccca31899687abe2ffb966cf310be98ae Mon Sep 17 00:00:00 2001 From: Giga77 Date: Mon, 13 Apr 2026 11:53:43 +0200 Subject: [PATCH 0827/1707] Remove hacf-fr (#168054) --- CODEOWNERS | 4 ++-- homeassistant/components/netgear/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f362afeccb724d..3034187e4c6e72 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1150,8 +1150,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/netatmo/ @cgtobi /tests/components/netatmo/ @cgtobi /homeassistant/components/netdata/ @fabaff -/homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG -/tests/components/netgear/ @hacf-fr @Quentame @starkillerOG +/homeassistant/components/netgear/ @Quentame @starkillerOG +/tests/components/netgear/ @Quentame @starkillerOG /homeassistant/components/netgear_lte/ @tkdrob /tests/components/netgear_lte/ @tkdrob /homeassistant/components/network/ @home-assistant/core diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index aa7664a77a8ae5..3b07dc237b3730 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -1,7 +1,7 @@ { "domain": "netgear", "name": "NETGEAR", - "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"], + "codeowners": ["@Quentame", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/netgear", "integration_type": "hub", From 274146cbb28a5884c79bd262c03eb5c61a2a1b5e Mon Sep 17 00:00:00 2001 From: Giga77 Date: Mon, 13 Apr 2026 11:55:10 +0200 Subject: [PATCH 0828/1707] Remove hacf-fr from Synology DSM (#168039) --- CODEOWNERS | 4 ++-- homeassistant/components/synology_dsm/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3034187e4c6e72..4b242bb724ad93 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1694,8 +1694,8 @@ CLAUDE.md @home-assistant/core /tests/components/syncthing/ @zhulik /homeassistant/components/syncthru/ @nielstron /tests/components/syncthru/ @nielstron -/homeassistant/components/synology_dsm/ @hacf-fr @Quentame @mib1185 -/tests/components/synology_dsm/ @hacf-fr @Quentame @mib1185 +/homeassistant/components/synology_dsm/ @Quentame @mib1185 +/tests/components/synology_dsm/ @Quentame @mib1185 /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 4d57beac4e424d..cec61912b82a78 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -1,7 +1,7 @@ { "domain": "synology_dsm", "name": "Synology DSM", - "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], + "codeowners": ["@Quentame", "@mib1185"], "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/synology_dsm", From cdcf81050656387f6d5476d79aebd2877ef32e64 Mon Sep 17 00:00:00 2001 From: Giga77 Date: Mon, 13 Apr 2026 12:02:47 +0200 Subject: [PATCH 0829/1707] Remove hacf-fr from Epic Games Store (#168038) --- CODEOWNERS | 4 ++-- homeassistant/components/epic_games_store/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4b242bb724ad93..8e13af3f2be3ed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -489,8 +489,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/ephember/ @ttroy50 @roberty99 -/homeassistant/components/epic_games_store/ @hacf-fr @Quentame -/tests/components/epic_games_store/ @hacf-fr @Quentame +/homeassistant/components/epic_games_store/ @Quentame +/tests/components/epic_games_store/ @Quentame /homeassistant/components/epion/ @lhgravendeel /tests/components/epion/ @lhgravendeel /homeassistant/components/epson/ @pszafer diff --git a/homeassistant/components/epic_games_store/manifest.json b/homeassistant/components/epic_games_store/manifest.json index 665eaec6668b92..ea4e0c2f928023 100644 --- a/homeassistant/components/epic_games_store/manifest.json +++ b/homeassistant/components/epic_games_store/manifest.json @@ -1,7 +1,7 @@ { "domain": "epic_games_store", "name": "Epic Games Store", - "codeowners": ["@hacf-fr", "@Quentame"], + "codeowners": ["@Quentame"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/epic_games_store", "integration_type": "service", From d901541f48f2a72ed8fd191ce9ab490cab3d165a Mon Sep 17 00:00:00 2001 From: Giga77 Date: Mon, 13 Apr 2026 12:13:14 +0200 Subject: [PATCH 0830/1707] Add hacf/reviewers as codeowners to Freebox (#168050) --- CODEOWNERS | 4 ++-- homeassistant/components/freebox/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8e13af3f2be3ed..a3853567fdc7ad 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -566,8 +566,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/fortios/ @kimfrellsen /homeassistant/components/foscam/ @Foscam-wangzhengyu /tests/components/foscam/ @Foscam-wangzhengyu -/homeassistant/components/freebox/ @hacf-fr @Quentame -/tests/components/freebox/ @hacf-fr @Quentame +/homeassistant/components/freebox/ @hacf-fr/reviewers @Quentame +/tests/components/freebox/ @hacf-fr/reviewers @Quentame /homeassistant/components/freedompro/ @stefano055415 /tests/components/freedompro/ @stefano055415 /homeassistant/components/freshr/ @SierraNL diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 50c1ea96d9a6c3..74545c10a81b28 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -1,7 +1,7 @@ { "domain": "freebox", "name": "Freebox", - "codeowners": ["@hacf-fr", "@Quentame"], + "codeowners": ["@hacf-fr/reviewers", "@Quentame"], "config_flow": true, "dependencies": ["ffmpeg"], "documentation": "https://www.home-assistant.io/integrations/freebox", From f53b629dfdc00b255080e280a78244d041f21f72 Mon Sep 17 00:00:00 2001 From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:41:56 +0200 Subject: [PATCH 0831/1707] Bump satel-integra to 1.1.1 (#168091) --- homeassistant/components/satel_integra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index d1f707cf6ff968..f5f8cda50ec37c 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["satel_integra"], "quality_scale": "bronze", - "requirements": ["satel-integra==1.1.0"] + "requirements": ["satel-integra==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a27d6e9978689e..f476adad3a00d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2886,7 +2886,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.satel_integra -satel-integra==1.1.0 +satel-integra==1.1.1 # homeassistant.components.screenlogic screenlogicpy==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 014f00c07dc453..a2aaa61d2041ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2449,7 +2449,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.satel_integra -satel-integra==1.1.0 +satel-integra==1.1.1 # homeassistant.components.screenlogic screenlogicpy==0.10.2 From 95c3624b01235ad1923a34a62a71b2139c209057 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:43:14 +0800 Subject: [PATCH 0832/1707] Bump PySwitchbot to 2.0.1 (#168090) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 122a46e2a3d683..b26fc77c6af7e7 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -42,5 +42,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==2.0.0"] + "requirements": ["PySwitchbot==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f476adad3a00d5..068fbdc6eed81f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -83,7 +83,7 @@ PyRMVtransport==0.3.3 PySrDaliGateway==0.20.4 # homeassistant.components.switchbot -PySwitchbot==2.0.0 +PySwitchbot==2.0.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2aaa61d2041ed..06602034de967c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -83,7 +83,7 @@ PyRMVtransport==0.3.3 PySrDaliGateway==0.20.4 # homeassistant.components.switchbot -PySwitchbot==2.0.0 +PySwitchbot==2.0.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From 6a3051718aa0aee7b91fb4d6ccc5aa5422b46f4d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Apr 2026 13:06:33 +0200 Subject: [PATCH 0833/1707] Add reconfiguration flow to Elgato (#168036) --- .../components/elgato/config_flow.py | 37 +++++++++ .../components/elgato/quality_scale.yaml | 2 +- homeassistant/components/elgato/strings.json | 12 ++- tests/components/elgato/test_config_flow.py | 75 ++++++++++++++++++- 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index e2832554a22108..eaaa127c47159e 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -71,6 +71,43 @@ async def async_step_zeroconf_confirm( """Handle a flow initiated by zeroconf.""" return self._async_create_entry() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing Elgato device.""" + errors: dict[str, str] = {} + + if user_input is not None: + elgato = Elgato( + host=user_input[CONF_HOST], + session=async_get_clientsession(self.hass), + ) + + try: + info = await elgato.info() + except ElgatoError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_mismatch(reason="different_device") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_HOST: user_input[CONF_HOST]}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, + default=self._get_reconfigure_entry().data[CONF_HOST], + ): str, + } + ), + errors=errors, + ) + async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml index 4649f4ad134ed0..4bfd143989fb41 100644 --- a/homeassistant/components/elgato/quality_scale.yaml +++ b/homeassistant/components/elgato/quality_scale.yaml @@ -62,7 +62,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index ae8d5abf962312..dcfeb23d9acc9f 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -3,13 +3,23 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "different_device": "The configured Elgato device is not the same as the one at this address.", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "flow_title": "{serial_number}", "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::elgato::config::step::user::data_description::host%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index e3ed2c10818adc..6f05df22ae563c 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -3,7 +3,7 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock -from elgato import ElgatoConnectionError +from elgato import ElgatoConnectionError, ElgatoError import pytest from homeassistant.components.elgato.const import DOMAIN @@ -331,3 +331,76 @@ async def test_dhcp_discovery_no_match( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +@pytest.mark.usefixtures("mock_elgato") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring an existing Elgato device.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.42"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.42" + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow recovers from a connection error.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_elgato.info.side_effect = ElgatoError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.42"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + + mock_elgato.info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.42"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_different_device( + hass: HomeAssistant, + mock_elgato: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure aborts when the device at the new host has a different serial.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_elgato.info.return_value.serial_number = "DIFFERENT_SERIAL" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "127.0.0.42"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "different_device" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" From 431736c5d8bb1931d1853d95fa6a0c2bfb0dc5a4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Apr 2026 13:28:04 +0200 Subject: [PATCH 0834/1707] Set parallel updates to 0 for Met.no (#168094) --- homeassistant/components/met/weather.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 8d8317607be8f7..524d663bf65b53 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -48,6 +48,8 @@ ) from .coordinator import MetDataUpdateCoordinator, MetWeatherConfigEntry +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "Met.no" From 10780adb6e32bfadf61211e8f64dc0f56dde5a77 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:41:23 +0200 Subject: [PATCH 0835/1707] Update mypy to 1.20.1 (#168100) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 35ce1d9b1998f1..701e4f16058608 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ freezegun==1.5.2 librt==0.8.1 license-expression==30.4.3 mock-open==1.4.0 -mypy==1.20.0 +mypy==1.20.1 prek==0.2.28 pydantic==2.12.2 pylint==4.0.5 From 667002ddfa69d2544d2bb995cbabd8ef403c87e2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:53:27 +0200 Subject: [PATCH 0836/1707] Update pydantic pin to 2.13.0 (#168103) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 10 ---------- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7c1a87048a5852..e07f3b1fe68df4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -134,7 +134,7 @@ backoff>=2.0 Brotli>=1.2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.12.2 +pydantic==2.13.0 # Required for Python 3.14.0 compatibility (#119223). mashumaro>=3.17.0 diff --git a/pyproject.toml b/pyproject.toml index 3d100ae5e1c64f..4bc7dd13ee96ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -584,16 +584,6 @@ filterwarnings = [ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", - # -- Pydantic V1 models - # https://github.com/madpilot/amberelectric.py - v2.0.12 - 2024-11-19 - "ignore:Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater:UserWarning:amberelectric.api.amber_api", - # https://github.com/elevenlabs/elevenlabs-python - v2.18.0 - 2024-10-14 - "ignore:Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater:UserWarning:elevenlabs.core.pydantic_utilities", - # https://github.com/Lektrico/lektricowifi - v0.1 - 2025-05-19 - "ignore:Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater:UserWarning:lektricowifi.models", - # https://github.com/sstallion/sensorpush-api - v2.1.3 - 2025-06-10 - "ignore:Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater:UserWarning:sensorpush_api.api.api_api", - # -- New in Python 3.14 # https://github.com/litl/backoff/pull/220 - v2.2.1 - 2022-10-05 (archived) "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning:(backoff._decorator|backoff._async)", diff --git a/requirements_test.txt b/requirements_test.txt index 701e4f16058608..4d9999b42023ba 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ license-expression==30.4.3 mock-open==1.4.0 mypy==1.20.1 prek==0.2.28 -pydantic==2.12.2 +pydantic==2.13.0 pylint==4.0.5 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f54535e9278c24..e5fdab736c320c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -124,7 +124,7 @@ Brotli>=1.2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.12.2 +pydantic==2.13.0 # Required for Python 3.14.0 compatibility (#119223). mashumaro>=3.17.0 From d1fcc7564e44685ead2a86a3749646d34b718f9e Mon Sep 17 00:00:00 2001 From: gerculanum Date: Tue, 14 Apr 2026 00:04:03 +1000 Subject: [PATCH 0837/1707] Fix missing kWh unit for dlq ADD_ELE energy sensor (#168026) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tuya/sensor.py | 1 + .../tuya/snapshots/test_sensor.ambr | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 59307204567069..7019fe098704c6 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -379,6 +379,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): translation_key="total_energy", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), TuyaSensorEntityDescription( key=DPCode.FORWARD_ENERGY_TOTAL, diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 3201bb125edc88..07e34176e7533d 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -6549,8 +6549,11 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -6559,15 +6562,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldadd_ele', - 'unit_of_measurement': '', + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.eau_chaude_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Eau Chaude Total energy', 'state_class': , - 'unit_of_measurement': '', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.eau_chaude_total_energy', @@ -25764,8 +25768,11 @@ 'name': None, 'object_id_base': 'Total energy', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total energy', 'platform': 'tuya', @@ -25774,14 +25781,16 @@ 'supported_features': 0, 'translation_key': 'total_energy', 'unique_id': 'tuya.fcdadqsiax2gvnt0qldadd_ele', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': '一路带计量磁保持通断器 Total energy', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_total_energy', From f8e5165ec7fd749bb3fffafacb662ea9d4ba96a8 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 13 Apr 2026 16:18:34 +0100 Subject: [PATCH 0838/1707] Add quote approval policy to Mastodon post service (#168092) Co-authored-by: Erwin Douna Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> --- homeassistant/components/mastodon/const.py | 1 + homeassistant/components/mastodon/services.py | 18 +++++++++++++++ .../components/mastodon/services.yaml | 8 +++++++ .../components/mastodon/strings.json | 11 ++++++++++ tests/components/mastodon/test_services.py | 22 +++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 63d2ef7c66eb33..805c01ad7627e4 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -15,6 +15,7 @@ ATTR_ACCOUNT_NAME = "account_name" ATTR_STATUS = "status" ATTR_VISIBILITY = "visibility" +ATTR_QUOTE_APPROVAL_POLICY = "quote_approval_policy" ATTR_IDEMPOTENCY_KEY = "idempotency_key" ATTR_CONTENT_WARNING = "content_warning" ATTR_MEDIA_WARNING = "media_warning" diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 5e93447fba5dfd..018b91d80d3b42 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -52,6 +52,7 @@ ATTR_MEDIA_DESCRIPTION, ATTR_MEDIA_WARNING, ATTR_NOTE, + ATTR_QUOTE_APPROVAL_POLICY, ATTR_STATUS, ATTR_VALUE, ATTR_VISIBILITY, @@ -73,6 +74,14 @@ class StatusVisibility(StrEnum): DIRECT = "direct" +class QuoteApprovalPolicy(StrEnum): + """QuoteApprovalPolicy model.""" + + PUBLIC = "public" + FOLLOWERS = "followers" + NOBODY = "nobody" + + SERVICE_GET_ACCOUNT = "get_account" SERVICE_GET_ACCOUNT_SCHEMA = vol.Schema( { @@ -107,6 +116,9 @@ class StatusVisibility(StrEnum): vol.Required(ATTR_CONFIG_ENTRY_ID): str, vol.Required(ATTR_STATUS): str, vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]), + vol.Optional(ATTR_QUOTE_APPROVAL_POLICY): vol.In( + [x.lower() for x in QuoteApprovalPolicy] + ), vol.Optional(ATTR_IDEMPOTENCY_KEY): str, vol.Optional(ATTR_CONTENT_WARNING): str, vol.Optional(ATTR_LANGUAGE): str, @@ -287,6 +299,11 @@ async def _async_post(call: ServiceCall) -> ServiceResponse: if ATTR_VISIBILITY in call.data else None ) + quote_approval_policy: str | None = ( + QuoteApprovalPolicy(call.data[ATTR_QUOTE_APPROVAL_POLICY]) + if ATTR_QUOTE_APPROVAL_POLICY in call.data + else None + ) idempotency_key: str | None = call.data.get(ATTR_IDEMPOTENCY_KEY) spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING) language: str | None = call.data.get(ATTR_LANGUAGE) @@ -307,6 +324,7 @@ async def _async_post(call: ServiceCall) -> ServiceResponse: client=client, status=status, visibility=visibility, + quote_approval_policy=quote_approval_policy, idempotency_key=idempotency_key, spoiler_text=spoiler_text, language=language, diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index ec637315c821ab..0fce29eff419b7 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -50,6 +50,14 @@ post: - private - direct translation_key: post_visibility + quote_approval_policy: + selector: + select: + options: + - public + - followers + - nobody + translation_key: quote_approval_policy idempotency_key: selector: text: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index c63f9168627dae..720c0c71851788 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -152,6 +152,13 @@ "public": "Public - Visible to everyone", "unlisted": "Unlisted - Public but not shown in public timelines" } + }, + "quote_approval_policy": { + "options": { + "followers": "Followers - Only accounts that follow you can quote this post", + "nobody": "Nobody - No one but you can quote this post", + "public": "Public - Anyone can quote this post" + } } }, "services": { @@ -222,6 +229,10 @@ "description": "If an image or video is attached, will mark the media as sensitive (default: no media warning).", "name": "Media warning" }, + "quote_approval_policy": { + "description": "Who can quote this post (default: account setting).\nIgnored if visibility is private or direct.", + "name": "Who can quote" + }, "status": { "description": "The status to post.", "name": "Status" diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index 28e688660f0a95..0aee7768fca214 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -34,6 +34,7 @@ ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, ATTR_NOTE, + ATTR_QUOTE_APPROVAL_POLICY, ATTR_STATUS, ATTR_VISIBILITY, DOMAIN, @@ -378,6 +379,7 @@ async def test_unmute_account_failure_api_error( "status": "test toot", "spoiler_text": None, "visibility": None, + "quote_approval_policy": None, "idempotency_key": None, "language": None, "media_ids": None, @@ -390,6 +392,7 @@ async def test_unmute_account_failure_api_error( "status": "test toot", "spoiler_text": None, "visibility": "private", + "quote_approval_policy": None, "idempotency_key": None, "language": None, "media_ids": None, @@ -406,6 +409,7 @@ async def test_unmute_account_failure_api_error( "status": "test toot", "spoiler_text": "Spoiler", "visibility": "private", + "quote_approval_policy": None, "idempotency_key": None, "language": None, "media_ids": None, @@ -423,6 +427,7 @@ async def test_unmute_account_failure_api_error( "status": "test toot", "spoiler_text": "Spoiler", "visibility": None, + "quote_approval_policy": None, "idempotency_key": None, "language": "nl", "media_ids": "1", @@ -441,6 +446,7 @@ async def test_unmute_account_failure_api_error( "status": "test toot", "spoiler_text": "Spoiler", "visibility": None, + "quote_approval_policy": None, "idempotency_key": None, "language": "en", "media_ids": "1", @@ -454,6 +460,7 @@ async def test_unmute_account_failure_api_error( "language": "invalid-lang", "spoiler_text": None, "visibility": None, + "quote_approval_policy": None, "idempotency_key": None, "media_ids": None, "sensitive": None, @@ -470,6 +477,20 @@ async def test_unmute_account_failure_api_error( "language": None, "spoiler_text": None, "visibility": None, + "quote_approval_policy": None, + "media_ids": None, + "sensitive": None, + }, + ), + ( + {ATTR_STATUS: "test toot", ATTR_QUOTE_APPROVAL_POLICY: "followers"}, + { + "status": "test toot", + "spoiler_text": None, + "visibility": None, + "quote_approval_policy": "followers", + "idempotency_key": None, + "language": None, "media_ids": None, "sensitive": None, }, @@ -528,6 +549,7 @@ async def test_service_post( "status": "test toot", "spoiler_text": "Spoiler", "visibility": None, + "quote_approval_policy": None, "idempotency_key": None, "media_ids": "1", "media_description": None, From 1fbf437c494156a07fb8d44c07225067a5fe9f14 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Apr 2026 17:22:48 +0200 Subject: [PATCH 0839/1707] Adjust logbook timestamp handling (#168079) --- homeassistant/components/logbook/processor.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 1a139bb379e8e8..c2eadfd1bca9a1 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -282,12 +282,16 @@ def _humanify( else: continue - time_fired_ts = row[TIME_FIRED_TS_POS] + row_time_fired_ts = row[TIME_FIRED_TS_POS] + # Explicit None check: 0.0 is a valid epoch. + time_fired_ts: float = ( + row_time_fired_ts if row_time_fired_ts is not None else time.time() + ) if timestamp: - when = time_fired_ts or time.time() + when: str | float = time_fired_ts else: when = process_timestamp_to_utc_isoformat( - dt_util.utc_from_timestamp(time_fired_ts) or dt_util.utcnow() + dt_util.utc_from_timestamp(time_fired_ts) ) data[LOGBOOK_ENTRY_WHEN] = when From 8789afe21fce4bc121434a213cf10c1fb8aef44f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Apr 2026 20:13:12 +0200 Subject: [PATCH 0840/1707] Translate coordinator exceptions for Sensor.Community (#168048) --- .../components/luftdaten/coordinator.py | 17 ++++++++++++++--- .../components/luftdaten/strings.json | 11 +++++++++++ tests/components/luftdaten/test_init.py | 19 ++++++++++--------- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/luftdaten/coordinator.py b/homeassistant/components/luftdaten/coordinator.py index 2c311bb6409080..3a608dcc9ce015 100644 --- a/homeassistant/components/luftdaten/coordinator.py +++ b/homeassistant/components/luftdaten/coordinator.py @@ -9,7 +9,7 @@ import logging from luftdaten import Luftdaten -from luftdaten.exceptions import LuftdatenError +from luftdaten.exceptions import LuftdatenConnectionError, LuftdatenError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -47,11 +47,22 @@ async def _async_update_data(self) -> dict[str, float | int]: """Update sensor/binary sensor data.""" try: await self._sensor_community.get_data() + except LuftdatenConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err except LuftdatenError as err: - raise UpdateFailed("Unable to retrieve data from Sensor.Community") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err if not self._sensor_community.values: - raise UpdateFailed("Did not receive sensor data from Sensor.Community") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_data_received", + ) data: dict[str, float | int] = self._sensor_community.values data.update(self._sensor_community.meta) diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json index 412d665e0dd1c8..d40f74e32e707b 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -21,5 +21,16 @@ "sensor": { "pressure_at_sealevel": { "name": "Pressure at sea level" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Sensor.Community service." + }, + "no_data_received": { + "message": "Did not receive sensor data from the Sensor.Community service." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the Sensor.Community service." + } } } diff --git a/tests/components/luftdaten/test_init.py b/tests/components/luftdaten/test_init.py index dda7c147672b4b..4356889778b53d 100644 --- a/tests/components/luftdaten/test_init.py +++ b/tests/components/luftdaten/test_init.py @@ -1,8 +1,9 @@ """Tests for the Luftdaten integration.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock -from luftdaten.exceptions import LuftdatenError +from luftdaten.exceptions import LuftdatenConnectionError, LuftdatenError +import pytest from homeassistant.components.luftdaten.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -14,7 +15,7 @@ async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_luftdaten: AsyncMock, + mock_luftdaten: MagicMock, ) -> None: """Test the Luftdaten configuration entry loading/unloading.""" mock_config_entry.add_to_hass(hass) @@ -30,21 +31,21 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -@patch( - "homeassistant.components.luftdaten.Luftdaten.get_data", - side_effect=LuftdatenError, -) +@pytest.mark.parametrize("side_effect", [LuftdatenConnectionError, LuftdatenError]) async def test_config_entry_not_ready( - mock_get_data: MagicMock, hass: HomeAssistant, + mock_luftdaten: MagicMock, mock_config_entry: MockConfigEntry, + side_effect: type[Exception], ) -> None: """Test the Luftdaten configuration entry not ready.""" + mock_luftdaten.get_data.side_effect = side_effect + mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_get_data.call_count == 1 + assert mock_luftdaten.get_data.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From e6d9f80c9e2ba3b6b9f03c29a8e873b3e6661fd5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Apr 2026 20:13:25 +0200 Subject: [PATCH 0841/1707] Translate coordinator exceptions for RDW (#168044) --- homeassistant/components/rdw/coordinator.py | 17 ++++++++++++++--- homeassistant/components/rdw/strings.json | 8 ++++++++ tests/components/rdw/test_init.py | 19 +++++++++++-------- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/rdw/coordinator.py b/homeassistant/components/rdw/coordinator.py index aecd3116d426cf..6a2b7893ebc216 100644 --- a/homeassistant/components/rdw/coordinator.py +++ b/homeassistant/components/rdw/coordinator.py @@ -2,12 +2,12 @@ from __future__ import annotations -from vehicle import RDW, Vehicle +from vehicle import RDW, RDWConnectionError, RDWError, Vehicle from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LICENSE_PLATE, DOMAIN, LOGGER, SCAN_INTERVAL @@ -35,4 +35,15 @@ def __init__(self, hass: HomeAssistant, config_entry: RDWConfigEntry) -> None: async def _async_update_data(self) -> Vehicle: """Fetch data from RDW.""" - return await self._rdw.vehicle() + try: + return await self._rdw.vehicle() + except RDWConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + except RDWError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unknown_error", + ) from err diff --git a/homeassistant/components/rdw/strings.json b/homeassistant/components/rdw/strings.json index 5a2683588a42f9..16480fe4e0d8c6 100644 --- a/homeassistant/components/rdw/strings.json +++ b/homeassistant/components/rdw/strings.json @@ -35,5 +35,13 @@ "name": "Ascription date" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the RDW service." + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the RDW service." + } } } diff --git a/tests/components/rdw/test_init.py b/tests/components/rdw/test_init.py index 121de64f91e362..4dcbb2c68e0762 100644 --- a/tests/components/rdw/test_init.py +++ b/tests/components/rdw/test_init.py @@ -1,6 +1,9 @@ """Tests for the RDW integration.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock + +import pytest +from vehicle import RDWConnectionError, RDWError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -11,7 +14,7 @@ async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_rdw: AsyncMock, + mock_rdw: MagicMock, ) -> None: """Test the RDW configuration entry loading/unloading.""" mock_config_entry.add_to_hass(hass) @@ -26,19 +29,19 @@ async def test_load_unload_config_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -@patch( - "homeassistant.components.rdw.coordinator.RDW.vehicle", - side_effect=RuntimeError, -) +@pytest.mark.parametrize("side_effect", [RDWConnectionError, RDWError]) async def test_config_entry_not_ready( - mock_request: MagicMock, hass: HomeAssistant, + mock_rdw: MagicMock, mock_config_entry: MockConfigEntry, + side_effect: type[Exception], ) -> None: """Test the RDW configuration entry not ready.""" + mock_rdw.vehicle.side_effect = side_effect + mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_request.call_count == 1 + assert mock_rdw.vehicle.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From a74c3d41b9571f841ad9dc0967acc7a5e72bc131 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 13 Apr 2026 20:41:49 +0200 Subject: [PATCH 0842/1707] Bump aioamazondevices to 13.4.1 (#168121) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index eccb7a524504e9..ed17385e825e5c 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.4.0"] + "requirements": ["aioamazondevices==13.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 068fbdc6eed81f..ca17324d56e301 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.4.0 +aioamazondevices==13.4.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06602034de967c..d0a0ed77922a8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.4.0 +aioamazondevices==13.4.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 308aa7b868b64dfb775c9d2c4d17c05154be4338 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Apr 2026 20:53:06 +0200 Subject: [PATCH 0843/1707] Add missing return after reloading in telegram_bot (#168114) --- homeassistant/components/telegram_bot/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 2ef42e7d9586e0..66b876c1d8af80 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -916,6 +916,7 @@ async def update_listener(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> if entry.runtime_data.old_config_data != entry.data: # Reload if config data has changed hass.config_entries.async_schedule_reload(entry.entry_id) + return # reload entities await hass.config_entries.async_unload_platforms(entry, PLATFORMS) From 6396744f19905c562b4c71d1e91765a1e71f64cb Mon Sep 17 00:00:00 2001 From: potelux Date: Mon, 13 Apr 2026 13:56:47 -0500 Subject: [PATCH 0844/1707] Remove redundant _attr_media_image_remotely_accessible from Jellyfin (#168112) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- homeassistant/components/jellyfin/media_player.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 893d35677d1c38..f8bd7667b7360f 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -57,8 +57,6 @@ def handle_coordinator_update() -> None: class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): """Represents a Jellyfin Player device.""" - _attr_media_image_remotely_accessible = False - def __init__( self, coordinator: JellyfinDataUpdateCoordinator, From 888ec5e9658888cb847afcae2fad9af414a76055 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Apr 2026 21:05:29 +0200 Subject: [PATCH 0845/1707] Set parallel updates to 0 for Apple TV binary sensor (#168116) --- homeassistant/components/apple_tv/binary_sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/apple_tv/binary_sensor.py b/homeassistant/components/apple_tv/binary_sensor.py index 3bbd46083fc3bf..84560111006166 100644 --- a/homeassistant/components/apple_tv/binary_sensor.py +++ b/homeassistant/components/apple_tv/binary_sensor.py @@ -14,6 +14,8 @@ from . import SIGNAL_CONNECTED, AppleTvConfigEntry from .entity import AppleTVEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From 30a554a242e16ac1d5a3a357aee7e1ce6453176c Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:11:20 +0200 Subject: [PATCH 0846/1707] Add button platform to eurotronic_cometblue (#168120) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../eurotronic_cometblue/__init__.py | 1 + .../components/eurotronic_cometblue/button.py | 61 +++++++++++++++++++ .../eurotronic_cometblue/icons.json | 9 +++ .../eurotronic_cometblue/strings.json | 7 +++ .../eurotronic_cometblue/test_button.py | 37 +++++++++++ 5 files changed, 115 insertions(+) create mode 100644 homeassistant/components/eurotronic_cometblue/button.py create mode 100644 homeassistant/components/eurotronic_cometblue/icons.json create mode 100644 tests/components/eurotronic_cometblue/test_button.py diff --git a/homeassistant/components/eurotronic_cometblue/__init__.py b/homeassistant/components/eurotronic_cometblue/__init__.py index 93052c50218e0c..9036693b8d969c 100644 --- a/homeassistant/components/eurotronic_cometblue/__init__.py +++ b/homeassistant/components/eurotronic_cometblue/__init__.py @@ -16,6 +16,7 @@ from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.CLIMATE, ] diff --git a/homeassistant/components/eurotronic_cometblue/button.py b/homeassistant/components/eurotronic_cometblue/button.py new file mode 100644 index 00000000000000..7218251e85d93a --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/button.py @@ -0,0 +1,61 @@ +"""Comet Blue button platform.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator +from .entity import CometBlueBluetoothEntity + +PARALLEL_UPDATES = 1 + +DESCRIPTIONS = [ + ButtonEntityDescription( + key="sync_time", + translation_key="sync_time", + entity_category=EntityCategory.CONFIG, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CometBlueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the client entities.""" + + coordinator = entry.runtime_data + + async_add_entities( + [ + CometBlueButtonEntity(coordinator, description) + for description in DESCRIPTIONS + ] + ) + + +class CometBlueButtonEntity(CometBlueBluetoothEntity, ButtonEntity): + """Representation of a button.""" + + def __init__( + self, + coordinator: CometBlueDataUpdateCoordinator, + description: ButtonEntityDescription, + ) -> None: + """Initialize CometBlueButtonEntity.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.address}-{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + if self.entity_description.key == "sync_time": + await self.coordinator.send_command( + self.coordinator.device.set_datetime_async, {"date": dt_util.now()} + ) diff --git a/homeassistant/components/eurotronic_cometblue/icons.json b/homeassistant/components/eurotronic_cometblue/icons.json new file mode 100644 index 00000000000000..ce5f503321444f --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "sync_time": { + "default": "mdi:calendar-clock" + } + } + } +} diff --git a/homeassistant/components/eurotronic_cometblue/strings.json b/homeassistant/components/eurotronic_cometblue/strings.json index 4646c337675897..e69f84a6776809 100644 --- a/homeassistant/components/eurotronic_cometblue/strings.json +++ b/homeassistant/components/eurotronic_cometblue/strings.json @@ -29,5 +29,12 @@ } } } + }, + "entity": { + "button": { + "sync_time": { + "name": "Sync time" + } + } } } diff --git a/tests/components/eurotronic_cometblue/test_button.py b/tests/components/eurotronic_cometblue/test_button.py new file mode 100644 index 00000000000000..89d9cd0f4491aa --- /dev/null +++ b/tests/components/eurotronic_cometblue/test_button.py @@ -0,0 +1,37 @@ +"""Test the eurotronic_cometblue button platform.""" + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .conftest import setup_with_selected_platforms + +from tests.common import MockConfigEntry + + +async def test_button_press_sync_time( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that pressing the sync-time button sets the device datetime.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.BUTTON]) + + # Check if button is called correctly + with patch.object( + mock_config_entry.runtime_data.device, + "set_datetime_async", + ) as mock_set_datetime: + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: "button.comet_blue_aa_bb_cc_dd_ee_ff_sync_time"}, + blocking=True, + ) + + mock_set_datetime.assert_called_once_with(date=dt_util.now()) From 6d2a5675727dcf3834f94c732606c7d98b9d608b Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Mon, 13 Apr 2026 21:33:01 +0200 Subject: [PATCH 0847/1707] Update Z-Wave cover moving state based on current position and cover capabilities (#168096) --- homeassistant/components/zwave_js/cover.py | 28 +- tests/components/zwave_js/test_cover.py | 326 +++++++++++++++++++++ 2 files changed, 352 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 4f5379684226b0..cf79e9b2ace068 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -495,18 +495,42 @@ def _tilt_range(self) -> int: async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" + # Check before issuing the command in case targetValue report arrives early. + already_open = ( + (cv := self._current_position_value) is not None + and cv.value is not None + and (tpv := self._target_position_value) is not None + and tpv.value == cv.value == self._fully_open_position + ) result = await self._async_set_value(self._up_value, True) # StartLevelChange: SUCCESS means the device started moving in the desired direction - if result is not None and result.status in SET_VALUE_SUCCESS: + if ( + result is not None + and result.status in SET_VALUE_SUCCESS + and self.supported_features & CoverEntityFeature.SET_POSITION + and not already_open + ): self._attr_is_opening = True self._attr_is_closing = False self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" + # Check before issuing the command in case targetValue report arrives early. + already_closed = ( + (cv := self._current_position_value) is not None + and cv.value is not None + and (tpv := self._target_position_value) is not None + and tpv.value == cv.value == self._fully_closed_position + ) result = await self._async_set_value(self._down_value, True) # StartLevelChange: SUCCESS means the device started moving in the desired direction - if result is not None and result.status in SET_VALUE_SUCCESS: + if ( + result is not None + and result.status in SET_VALUE_SUCCESS + and self.supported_features & CoverEntityFeature.SET_POSITION + and not already_closed + ): self._attr_is_opening = False self._attr_is_closing = True self.async_write_ha_state() diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index ef778be50fef71..33300c62c4d4dd 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -67,6 +67,27 @@ def platforms() -> list[str]: return [Platform.COVER] +@pytest.fixture(name="window_covering_outbound_bottom_no_position") +def window_covering_outbound_bottom_no_position_fixture( + client: MagicMock, + window_covering_outbound_bottom_state: dict[str, Any], +) -> Node: + """Load a Window Covering node that does not support setting a position.""" + node_state = copy.deepcopy(window_covering_outbound_bottom_state) + for value in node_state["values"]: + if value.get("commandClass") != CommandClass.WINDOW_COVERING: + continue + if value.get("propertyKey") != 13: + continue + value["propertyKey"] = 12 + value["propertyKeyName"] = "Outbound Bottom (no position)" + if "metadata" in value and "ccSpecific" in value["metadata"]: + value["metadata"]["ccSpecific"]["parameter"] = 12 + node = Node(client, node_state) + client.driver.controller.nodes[node.node_id] = node + return node + + async def test_window_cover( hass: HomeAssistant, client: MagicMock, @@ -1896,3 +1917,308 @@ async def test_multilevel_switch_cover_v3_no_moving_state_unsupervised( ) state = hass.states.get(WINDOW_COVER_ENTITY) assert state.state == CoverState.CLOSED + + +async def test_window_covering_cover_moving_state_position_support( + hass: HomeAssistant, + client: MagicMock, + window_covering_outbound_bottom: Node, + integration: MockConfigEntry, +) -> None: + """Test moving state is only set when not already at the target endpoint.""" + node = window_covering_outbound_bottom + entity_id = "cover.node_2_outbound_bottom" + + # Initial currentValue is 52 (mid-position). open_cover SHOULD set OPENING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPENING + + # Clear moving state before next scenario. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Simulate device reaching fully open (raw Z-Wave value 99 → HA position 100%). + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "targetValue", + "propertyKey": 13, + "newValue": 99, + "prevValue": 52, + "propertyName": "targetValue", + }, + }, + ) + ) + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "currentValue", + "propertyKey": 13, + "newValue": 99, + "prevValue": 52, + "propertyName": "currentValue", + }, + }, + ) + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + + # Already fully open — open_cover must NOT set OPENING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state not in (CoverState.OPENING, CoverState.CLOSING) + + # Fully open but not fully closed — close_cover SHOULD set CLOSING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSING + + # Clear moving state before next scenario. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Simulate device reaching fully closed (raw Z-Wave value 0 → HA position 0%). + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "targetValue", + "propertyKey": 13, + "newValue": 0, + "prevValue": 99, + "propertyName": "targetValue", + }, + }, + ) + ) + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "currentValue", + "propertyKey": 13, + "newValue": 0, + "prevValue": 99, + "propertyName": "currentValue", + }, + }, + ) + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + + # Already fully closed — close_cover must NOT set CLOSING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state not in (CoverState.OPENING, CoverState.CLOSING) + + # From fully closed, open_cover SHOULD set OPENING (not at fully open endpoint). + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPENING + + # Simulate the device moving: targetValue arrives first (early report), then + # currentValue catches up to halfway. Moving state must stay OPENING throughout. + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "targetValue", + "propertyKey": 13, + "newValue": 99, + "prevValue": 0, + "propertyName": "targetValue", + }, + }, + ) + ) + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "currentValue", + "propertyKey": 13, + "newValue": 52, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPENING + + # Reverse halfway: close_cover while mid-travel MUST set CLOSING (not at endpoint). + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSING + + # Simulate the device moving back down: targetValue=0 arrives first (early report), + # then currentValue reaches halfway. Moving state must stay CLOSING throughout. + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "targetValue", + "propertyKey": 13, + "newValue": 0, + "prevValue": 99, + "propertyName": "targetValue", + }, + }, + ) + ) + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "currentValue", + "propertyKey": 13, + "newValue": 52, + "prevValue": 99, + "propertyName": "currentValue", + }, + }, + ) + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSING + + # Reverse halfway: open_cover while mid-travel MUST set OPENING (not at endpoint). + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPENING + + +async def test_window_covering_cover_moving_state_no_position( + hass: HomeAssistant, + client: MagicMock, + window_covering_outbound_bottom_no_position: Node, + integration: MockConfigEntry, +) -> None: + """Test that moving state is never set for Window Covering without position support.""" + entity_id = "cover.node_2_outbound_bottom" + + # No SET_POSITION feature — open_cover must NOT set OPENING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state not in (CoverState.OPENING, CoverState.CLOSING) + + # No SET_POSITION feature — close_cover must NOT set CLOSING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state not in (CoverState.OPENING, CoverState.CLOSING) From 6a66d0a9a263d9515ea06f6bf26bf7ffb0b6e83e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:52:31 +0200 Subject: [PATCH 0848/1707] Update pytest to 9.0.3 (#168132) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4d9999b42023ba..3808c9da509a17 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -31,7 +31,7 @@ pytest-timeout==2.4.0 pytest-unordered==0.7.0 pytest-picked==0.5.1 pytest-xdist==3.8.0 -pytest==9.0.0 +pytest==9.0.3 requests-mock==1.12.1 respx==0.22.0 syrupy==5.0.0 From ed9c2616bb00a3019cfaab48b1537eb5d2875357 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Apr 2026 23:00:55 +0200 Subject: [PATCH 0849/1707] Bump pynordpool to 0.4.0 (#168130) --- .../components/nordpool/coordinator.py | 21 +++++++------------ .../components/nordpool/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index f2f41322aff225..3fc346ff7547a7 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -108,11 +108,11 @@ async def handle_data(self, initial: bool = False) -> DeliveryPeriodsData: """Fetch data from Nord Pool.""" data = await self.api_call() if data and data.entries: - current_day = dt_util.utcnow().strftime("%Y-%m-%d") - for entry in data.entries: - if entry.requested_date == current_day: - LOGGER.debug("Data for current day found") - return data + current_day = dt_util.now().date() + if current_day in data.entries: + LOGGER.debug("Data for current day found") + return data + if data and not data.entries and not initial: # Empty response, use cache LOGGER.debug("No data entries received") @@ -158,16 +158,11 @@ async def api_call(self, retry: int = 3) -> DeliveryPeriodsData | None: def merge_price_entries(self) -> list[DeliveryPeriodEntry]: """Return the merged price entries.""" merged_entries: list[DeliveryPeriodEntry] = [] - for del_period in self.data.entries: + for del_period in self.data.entries.values(): merged_entries.extend(del_period.entries) return merged_entries def get_data_current_day(self) -> DeliveryPeriodData: """Return the current day data.""" - current_day = dt_util.utcnow().strftime("%Y-%m-%d") - delivery_period: DeliveryPeriodData = self.data.entries[0] - for del_period in self.data.entries: - if del_period.requested_date == current_day: - delivery_period = del_period - break - return delivery_period + current_day = dt_util.now().date() + return self.data.entries[current_day] diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index 1ac32f28763b38..85e43a3545c442 100644 --- a/homeassistant/components/nordpool/manifest.json +++ b/homeassistant/components/nordpool/manifest.json @@ -8,6 +8,6 @@ "iot_class": "cloud_polling", "loggers": ["pynordpool"], "quality_scale": "platinum", - "requirements": ["pynordpool==0.3.2"], + "requirements": ["pynordpool==0.4.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index ca17324d56e301..b988c1968a0b51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2326,7 +2326,7 @@ pynintendoparental==2.3.4 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.3.2 +pynordpool==0.4.0 # homeassistant.components.nuki pynuki==1.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0a0ed77922a8c..91c630d49cbaec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1991,7 +1991,7 @@ pynintendoparental==2.3.4 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.3.2 +pynordpool==0.4.0 # homeassistant.components.nuki pynuki==1.6.3 From f3eb9f1bbc106d6484ddfdf7d029de73445afded Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:39:36 +0200 Subject: [PATCH 0850/1707] Update asyncinotify to 4.4.4 (#168141) --- homeassistant/components/keyboard_remote/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index 2159dd9d90eab6..76197d32fe572b 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aionotify", "evdev"], "quality_scale": "legacy", - "requirements": ["evdev==1.9.3", "asyncinotify==4.4.0"] + "requirements": ["evdev==1.9.3", "asyncinotify==4.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b988c1968a0b51..6232ed265cb9a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -559,7 +559,7 @@ async-upnp-client==0.46.2 asyncarve==0.1.1 # homeassistant.components.keyboard_remote -asyncinotify==4.4.0 +asyncinotify==4.4.4 # homeassistant.components.supla asyncpysupla==0.0.5 From aa70023d897da57f0aafc94ac2f7b10ffa1aa041 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Tue, 14 Apr 2026 02:57:16 +0300 Subject: [PATCH 0851/1707] Bump pyseventeentrack to 1.1.3 (#168135) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/seventeentrack/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index 1064296fa61dca..e4080c43a5e70e 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyseventeentrack"], - "requirements": ["pyseventeentrack==1.1.2"] + "requirements": ["pyseventeentrack==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6232ed265cb9a9..25d504ea371717 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2476,7 +2476,7 @@ pyserial==3.5 pysesame2==1.0.1 # homeassistant.components.seventeentrack -pyseventeentrack==1.1.2 +pyseventeentrack==1.1.3 # homeassistant.components.sia pysiaalarm==3.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91c630d49cbaec..f5f2a1e9f38e8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2114,7 +2114,7 @@ pysenz==1.0.2 pyserial==3.5 # homeassistant.components.seventeentrack -pyseventeentrack==1.1.2 +pyseventeentrack==1.1.3 # homeassistant.components.sia pysiaalarm==3.2.2 From eae9db4aacf02e8d724e2121dae813629ce2e84d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 13 Apr 2026 18:58:10 -0600 Subject: [PATCH 0852/1707] Bump pylitterbot to 2025.3.2 (#168146) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index f217da5b801a95..8518d9781d1c56 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -16,5 +16,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "platinum", - "requirements": ["pylitterbot==2025.2.1"] + "requirements": ["pylitterbot==2025.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 25d504ea371717..52e48608f385d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2260,7 +2260,7 @@ pyliebherrhomeapi==0.4.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2025.2.1 +pylitterbot==2025.3.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.28.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5f2a1e9f38e8c..f3b3fdb969aea1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1937,7 +1937,7 @@ pyliebherrhomeapi==0.4.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2025.2.1 +pylitterbot==2025.3.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.28.0 From 27d8c7b93e6ae7c3232dadc1ff2d49a6add1b52e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Apr 2026 07:48:33 +0200 Subject: [PATCH 0853/1707] Improve logbook parent context handling (#167036) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/logbook/helpers.py | 29 +- homeassistant/components/logbook/models.py | 5 +- homeassistant/components/logbook/processor.py | 121 ++++- .../components/logbook/queries/common.py | 24 +- .../components/logbook/websocket_api.py | 16 +- tests/components/logbook/common.py | 103 ++++- tests/components/logbook/test_init.py | 354 ++++++++++++++- .../components/logbook/test_websocket_api.py | 425 ++++++++++++++++++ 8 files changed, 1059 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 238e6a0dda8a27..0ac4efd6026098 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -11,7 +11,9 @@ ATTR_DEVICE_ID, ATTR_DOMAIN, ATTR_ENTITY_ID, + ATTR_SERVICE_DATA, ATTR_UNIT_OF_MEASUREMENT, + EVENT_CALL_SERVICE, EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, ) @@ -104,10 +106,22 @@ def async_determine_event_types( @callback -def extract_attr(source: Mapping[str, Any], attr: str) -> list[str]: - """Extract an attribute as a list or string.""" +def extract_attr( + event_type: EventType[Any] | str, source: Mapping[str, Any], attr: str +) -> list[str]: + """Extract an attribute as a list or string. + + For EVENT_CALL_SERVICE events, the entity_id is inside service_data, + not at the top level. Check service_data as a fallback. + """ if (value := source.get(attr)) is None: - return [] + # Early return to avoid unnecessary dict lookups for non-service events + if event_type != EVENT_CALL_SERVICE: + return [] + if service_data := source.get(ATTR_SERVICE_DATA): + value = service_data.get(attr) + if value is None: + return [] if isinstance(value, list): return value return str(value).split(",") @@ -135,7 +149,7 @@ def event_forwarder_filtered( def _forward_events_filtered_by_entities_filter(event: Event) -> None: assert entities_filter is not None event_data = event.data - entity_ids = extract_attr(event_data, ATTR_ENTITY_ID) + entity_ids = extract_attr(event.event_type, event_data, ATTR_ENTITY_ID) if entity_ids and not any( entities_filter(entity_id) for entity_id in entity_ids ): @@ -157,9 +171,12 @@ def _forward_events_filtered_by_entities_filter(event: Event) -> None: @callback def _forward_events_filtered_by_device_entity_ids(event: Event) -> None: event_data = event.data + event_type = event.event_type if entity_ids_set.intersection( - extract_attr(event_data, ATTR_ENTITY_ID) - ) or device_ids_set.intersection(extract_attr(event_data, ATTR_DEVICE_ID)): + extract_attr(event_type, event_data, ATTR_ENTITY_ID) + ) or device_ids_set.intersection( + extract_attr(event_type, event_data, ATTR_DEVICE_ID) + ): target(event) return _forward_events_filtered_by_device_entity_ids diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index f27a470a23dae7..d8e4d6f6815627 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -162,7 +162,10 @@ def async_event_to_row(event: Event) -> EventAsRow: # that are missing new_state or old_state # since the logbook does not show these new_state: State = event.data["new_state"] - context = new_state.context + # Use the event's context rather than the state's context because + # State.expire() replaces the context with a copy that loses + # origin_event, which is needed for context augmentation. + context = event.context return EventAsRow( row_id=hash(event), event_type=None, diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index c2eadfd1bca9a1..7128d45e61c682 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -3,14 +3,16 @@ from __future__ import annotations from collections.abc import Callable, Generator, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime as dt import logging import time from typing import TYPE_CHECKING, Any +from lru import LRU from sqlalchemy.engine import Result from sqlalchemy.engine.row import Row +from sqlalchemy.orm import Session from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.filters import Filters @@ -37,6 +39,7 @@ from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all from homeassistant.util.event_type import EventType from .const import ( @@ -80,10 +83,18 @@ async_event_to_row, ) from .queries import statement_for_request -from .queries.common import PSEUDO_EVENT_STATE_CHANGED +from .queries.common import ( + PSEUDO_EVENT_STATE_CHANGED, + select_context_user_ids_for_context_ids, +) _LOGGER = logging.getLogger(__name__) +# Bound for the parent-context user-id cache — only needs to bridge the +# historical→live handoff, so the in-flight set is realistically ~tens with +# peak bursts of ~100. Ceiling bounds memory in pathological cases. +MAX_CONTEXT_USER_IDS_CACHE = 256 + @dataclass(slots=True) class LogbookRun: @@ -99,6 +110,14 @@ class LogbookRun: include_entity_name: bool timestamp: bool memoize_new_contexts: bool = True + # True when this run will switch to a live stream; gates population of + # context_user_ids (wasted work for one-shot REST/get_events callers). + for_live_stream: bool = False + # context_id -> user_id for parent context attribution; persisted across + # batches so child rows can inherit user_id from a parent seen earlier. + context_user_ids: LRU[bytes, bytes] = field( + default_factory=lambda: LRU(MAX_CONTEXT_USER_IDS_CACHE) + ) class EventProcessor: @@ -113,6 +132,7 @@ def __init__( context_id: str | None = None, timestamp: bool = False, include_entity_name: bool = True, + for_live_stream: bool = False, ) -> None: """Init the event stream.""" assert not (context_id and (entity_ids or device_ids)), ( @@ -133,6 +153,7 @@ def __init__( entity_name_cache=EntityNameCache(self.hass), include_entity_name=include_entity_name, timestamp=timestamp, + for_live_stream=for_live_stream, ) self.context_augmenter = ContextAugmenter(self.logbook_run) @@ -180,13 +201,67 @@ def get_events( self.filters, self.context_id, ) - return self.humanify( - execute_stmt_lambda_element(session, stmt, orm_rows=False) + rows = execute_stmt_lambda_element(session, stmt, orm_rows=False) + query_parent_user_ids: dict[bytes, bytes] | None = None + if self.entity_ids or self.device_ids: + # Filtered queries exclude parent call_service rows for + # unrelated targets, so child contexts lose user attribution + # without a pre-pass. all_stmt already includes them. + rows = list(rows) + query_parent_user_ids = self._fetch_parent_user_ids( + session, rows, instance.max_bind_vars + ) + return self.humanify(rows, query_parent_user_ids) + + def _fetch_parent_user_ids( + self, + session: Session, + rows: list[Row], + max_bind_vars: int, + ) -> dict[bytes, bytes] | None: + """Resolve parent-context user_ids for rows in a filtered query. + + Done in Python rather than as a SQL union branch because the + context_parent_id_bin column is sparsely populated — scanning the + States table for non-null parents costs ~40% of the overall query + on real datasets. Here we collect only the parent ids we actually + need and fetch them via an indexed point-lookup on context_id_bin. + """ + cache = self.logbook_run.context_user_ids + pending: set[bytes] = { + parent_id + for row in rows + if (parent_id := row[CONTEXT_PARENT_ID_BIN_POS]) and parent_id not in cache + } + if not pending: + return None + query_parent_user_ids: dict[bytes, bytes] = {} + # The lambda statement unions events and states, so each id appears + # in two IN clauses — halve the chunk size to stay under the + # database's max bind variable count. + for pending_chunk in chunked_or_all(pending, max_bind_vars // 2): + # Schema allows NULL but the query's WHERE clauses exclude it; + # explicit checks satisfy the type checker. + query_parent_user_ids.update( + { + parent_id: user_id + for parent_id, user_id in execute_stmt_lambda_element( + session, + select_context_user_ids_for_context_ids(pending_chunk), + orm_rows=False, + ) + if parent_id is not None and user_id is not None + } ) + if self.logbook_run.for_live_stream: + cache.update(query_parent_user_ids) + return query_parent_user_ids def humanify( - self, rows: Generator[EventAsRow] | Sequence[Row] | Result - ) -> list[dict[str, str]]: + self, + rows: Generator[EventAsRow] | Sequence[Row] | Result, + query_parent_user_ids: dict[bytes, bytes] | None = None, + ) -> list[dict[str, Any]]: """Humanify rows.""" return list( _humanify( @@ -195,6 +270,7 @@ def humanify( self.ent_reg, self.logbook_run, self.context_augmenter, + query_parent_user_ids, ) ) @@ -205,6 +281,7 @@ def _humanify( ent_reg: er.EntityRegistry, logbook_run: LogbookRun, context_augmenter: ContextAugmenter, + query_parent_user_ids: dict[bytes, bytes] | None, ) -> Generator[dict[str, Any]]: """Generate a converted list of events into entries.""" # Continuous sensors, will be excluded from the logbook @@ -220,11 +297,21 @@ def _humanify( context_id_bin: bytes data: dict[str, Any] + context_user_ids = logbook_run.context_user_ids + # Skip the LRU write on one-shot runs — the LogbookRun is discarded. + populate_context_user_ids = logbook_run.for_live_stream + # Process rows for row in rows: context_id_bin = row[CONTEXT_ID_BIN_POS] if memoize_new_contexts and context_id_bin not in context_lookup: context_lookup[context_id_bin] = row + if ( + populate_context_user_ids + and (context_user_id_bin := row[CONTEXT_USER_ID_BIN_POS]) + and context_id_bin not in context_user_ids + ): + context_user_ids[context_id_bin] = context_user_id_bin if row[CONTEXT_ONLY_POS]: continue event_type = row[EVENT_TYPE_POS] @@ -311,6 +398,28 @@ def _humanify( ): context_augmenter.augment(data, context_row) + # Fall back to the parent context for child contexts that inherit + # user attribution (e.g., generic_thermostat -> switch turn_on). + # Read from context_lookup directly instead of get_context() to + # avoid the origin_event fallback which would return the *child* + # row's origin event, not the parent's. + if CONTEXT_USER_ID not in data and ( + context_parent_id_bin := row[CONTEXT_PARENT_ID_BIN_POS] + ): + parent_user_id_bin: bytes | None = context_user_ids.get( + context_parent_id_bin + ) + if parent_user_id_bin is None and query_parent_user_ids is not None: + parent_user_id_bin = query_parent_user_ids.get(context_parent_id_bin) + if ( + parent_user_id_bin is None + and (parent_row := context_lookup.get(context_parent_id_bin)) + is not None + ): + parent_user_id_bin = parent_row[CONTEXT_USER_ID_BIN_POS] + if parent_user_id_bin: + data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(parent_user_id_bin) + yield data diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 8f9ab8a80cd08a..cc67786e58f5ee 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -2,12 +2,14 @@ from __future__ import annotations +from collections.abc import Collection from typing import Final import sqlalchemy -from sqlalchemy import select +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.sql.elements import BooleanClauseList, ColumnElement from sqlalchemy.sql.expression import literal +from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select from homeassistant.components.recorder.db_schema import ( @@ -122,6 +124,26 @@ def select_events_context_id_subquery( ) +def select_context_user_ids_for_context_ids( + context_ids: Collection[bytes], +) -> StatementLambdaElement: + """Select (context_id_bin, context_user_id_bin) for the given context ids. + + Union of events and states since a parent context can originate from + either table (e.g., a state set directly via the API). + """ + return lambda_stmt( + lambda: union_all( + select(Events.context_id_bin, Events.context_user_id_bin) + .where(Events.context_id_bin.in_(context_ids)) + .where(Events.context_user_id_bin.is_not(None)), + select(States.context_id_bin, States.context_user_id_bin) + .where(States.context_id_bin.in_(context_ids)) + .where(States.context_user_id_bin.is_not(None)), + ) + ) + + def select_events_context_only() -> Select: """Generate an events query that mark them as for context_only. diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 4b767f66d699b0..e0d8989e633e1c 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -14,11 +14,13 @@ from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance from homeassistant.components.websocket_api import ActiveConnection, messages +from homeassistant.const import EVENT_CALL_SERVICE from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task +from homeassistant.util.event_type import EventType from .const import DOMAIN from .helpers import ( @@ -289,6 +291,8 @@ async def ws_event_stream( return event_types = async_determine_event_types(hass, entity_ids, device_ids) + # A past end_time makes this a one-shot fetch that never goes live. + will_go_live = not (end_time and end_time <= utc_now) event_processor = EventProcessor( hass, event_types, @@ -297,6 +301,7 @@ async def ws_event_stream( None, timestamp=True, include_entity_name=False, + for_live_stream=will_go_live, ) if end_time and end_time <= utc_now: @@ -357,11 +362,20 @@ def _queue_or_cancel(event: Event) -> None: logbook_config: LogbookConfig = hass.data[DOMAIN] entities_filter = logbook_config.entity_filter + # Live subscription needs call_service events so the live consumer can + # cache parent user_ids as they fire. Historical queries don't — the + # context_only join fetches them by context_id regardless of type. + # Unfiltered streams already include it via BUILT_IN_EVENTS. + live_event_types: tuple[EventType[Any] | str, ...] = ( + event_types + if EVENT_CALL_SERVICE in event_types + else (*event_types, EVENT_CALL_SERVICE) + ) async_subscribe_events( hass, subscriptions, _queue_or_cancel, - event_types, + live_event_types, entities_filter, entity_ids, device_ids, diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index b303a34e1517b2..ec709619cc003b 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -13,7 +13,16 @@ ulid_to_bytes_or_none, uuid_hex_to_bytes_or_none, ) -from homeassistant.core import Context +from homeassistant.const import ( + ATTR_DOMAIN, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SERVICE, + EVENT_CALL_SERVICE, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder from homeassistant.util import dt as dt_util @@ -65,6 +74,97 @@ def time_fired_isoformat(self): return process_timestamp_to_utc_isoformat(self.time_fired) +def setup_thermostat_context_test_entities(hass_: HomeAssistant) -> None: + """Set up initial states for the thermostat context chain test entities.""" + hass_.states.async_set( + "climate.living_room", + "off", + {ATTR_FRIENDLY_NAME: "Living Room Thermostat"}, + ) + hass_.states.async_set("switch.heater", STATE_OFF) + + +def simulate_thermostat_context_chain( + hass_: HomeAssistant, + user_id: str = "b400facee45711eaa9308bfd3d19e474", +) -> tuple[Context, Context]: + """Simulate the generic_thermostat context chain. + + Fires events in the realistic order: + 1. EVENT_CALL_SERVICE for set_hvac_mode (parent context) + 2. EVENT_CALL_SERVICE for homeassistant.turn_on (child context) + 3. Climate state changes off → heat (parent context) + 4. Switch state changes off → on (child context) + + Returns the (parent_context, child_context) tuple. + """ + parent_context = Context( + id="01GTDGKBCH00GW0X476W5TVAAA", + user_id=user_id, + ) + child_context = Context( + id="01GTDGKBCH00GW0X476W5TVDDD", + parent_id=parent_context.id, + ) + + hass_.bus.async_fire( + EVENT_CALL_SERVICE, + { + ATTR_DOMAIN: "climate", + ATTR_SERVICE: "set_hvac_mode", + "service_data": {ATTR_ENTITY_ID: "climate.living_room"}, + }, + context=parent_context, + ) + hass_.bus.async_fire( + EVENT_CALL_SERVICE, + { + ATTR_DOMAIN: "homeassistant", + ATTR_SERVICE: "turn_on", + "service_data": {ATTR_ENTITY_ID: "switch.heater"}, + }, + context=child_context, + ) + hass_.states.async_set( + "climate.living_room", + "heat", + {ATTR_FRIENDLY_NAME: "Living Room Thermostat"}, + context=parent_context, + ) + hass_.states.async_set( + "switch.heater", + STATE_ON, + {ATTR_FRIENDLY_NAME: "Heater"}, + context=child_context, + ) + return parent_context, child_context + + +def assert_thermostat_context_chain_events( + events: list[dict[str, Any]], parent_context: Context +) -> None: + """Assert the logbook events for a thermostat context chain. + + Verifies that climate and switch state changes have correct + state, user attribution, and service call context. + """ + climate_entries = [e for e in events if e.get("entity_id") == "climate.living_room"] + assert len(climate_entries) == 1 + assert climate_entries[0]["state"] == "heat" + assert climate_entries[0]["context_user_id"] == parent_context.user_id + assert climate_entries[0]["context_event_type"] == EVENT_CALL_SERVICE + assert climate_entries[0]["context_domain"] == "climate" + assert climate_entries[0]["context_service"] == "set_hvac_mode" + + heater_entries = [e for e in events if e.get("entity_id") == "switch.heater"] + assert len(heater_entries) == 1 + assert heater_entries[0]["state"] == "on" + assert heater_entries[0]["context_user_id"] == parent_context.user_id + assert heater_entries[0]["context_event_type"] == EVENT_CALL_SERVICE + assert heater_entries[0]["context_domain"] == "homeassistant" + assert heater_entries[0]["context_service"] == "turn_on" + + def mock_humanify(hass_, rows): """Wrap humanify with mocked logbook objects.""" entity_name_cache = processor.EntityNameCache(hass_) @@ -89,5 +189,6 @@ def mock_humanify(hass_, rows): ent_reg, logbook_run, context_augmenter, + None, ), ) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index c62bdcaa824acb..63f6a5cf4de1bf 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -20,6 +20,7 @@ from homeassistant.components.logbook.processor import EventProcessor from homeassistant.components.logbook.queries.common import PSEUDO_EVENT_STATE_CHANGED from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.models import ulid_to_bytes_or_none from homeassistant.components.script import EVENT_SCRIPT_STARTED from homeassistant.components.sensor import SensorStateClass from homeassistant.const import ( @@ -41,13 +42,19 @@ STATE_OFF, STATE_ON, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Context, Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .common import MockRow, mock_humanify +from .common import ( + MockRow, + assert_thermostat_context_chain_events, + mock_humanify, + setup_thermostat_context_test_entities, + simulate_thermostat_context_chain, +) from tests.common import MockConfigEntry, async_capture_events, mock_platform from tests.components.recorder.common import ( @@ -3002,3 +3009,346 @@ async def test_logbook_with_non_iterable_entity_filter(hass: HomeAssistant) -> N }, ) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("recorder_mock") +async def test_logbook_user_id_from_parent_context( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test user attribution is inherited through the full context chain. + + Simulates the generic_thermostat pattern: + 1. User calls set_hvac_mode → parent context (has user_id) + - Climate state changes off → heat (parent context) + 2. Thermostat calls homeassistant.turn_on → child context (no user_id) + - SERVICE_CALL event fired (child context) + 3. Switch state changes off → on (child context) + + All entries should have user_id attributed, either directly (step 1) + or inherited from the parent context (steps 2-3). + """ + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + + await async_recorder_block_till_done(hass) + + setup_thermostat_context_test_entities(hass) + await hass.async_block_till_done() + + parent_context, _ = simulate_thermostat_context_chain(hass) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + client = await hass_client() + + start = dt_util.utcnow().date() + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) + end_time = start_date + timedelta(hours=24) + + response = await client.get( + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, + ) + assert response.status == HTTPStatus.OK + json_dict = await response.json() + + assert_thermostat_context_chain_events(json_dict, parent_context) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_logbook_user_id_from_parent_context_state_changes_only( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test user attribution is inherited when only state changes are present. + + Same chain as the full test but without the EVENT_CALL_SERVICE event. + This exercises the code path where context_lookup resolves the child + context to the state change row itself, and augment walks up to the + parent state change. + """ + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + + await async_recorder_block_till_done(hass) + + # Set initial states so that subsequent changes are real state transitions + hass.states.async_set( + "climate.living_room", + "off", + {ATTR_FRIENDLY_NAME: "Living Room Thermostat"}, + ) + hass.states.async_set("switch.heater", STATE_OFF) + await hass.async_block_till_done() + + # Parent context with user_id + parent_context = ha.Context( + id="01GTDGKBCH00GW0X476W5TVAAA", + user_id="b400facee45711eaa9308bfd3d19e474", + ) + + # Climate state change with the parent context + hass.states.async_set( + "climate.living_room", + "heat", + {ATTR_FRIENDLY_NAME: "Living Room Thermostat"}, + context=parent_context, + ) + await hass.async_block_till_done() + + # Child context WITHOUT user_id, no service call event + child_context = ha.Context( + id="01GTDGKBCH00GW0X476W5TVDDD", + parent_id="01GTDGKBCH00GW0X476W5TVAAA", + ) + + # Switch state change with the child context + hass.states.async_set( + "switch.heater", + STATE_ON, + {ATTR_FRIENDLY_NAME: "Heater"}, + context=child_context, + ) + await hass.async_block_till_done() + + # Climate updates again in response to switch state change + hass.states.async_set( + "climate.living_room", + "heat", + {ATTR_FRIENDLY_NAME: "Living Room Thermostat"}, + context=child_context, + ) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + client = await hass_client() + + start = dt_util.utcnow().date() + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) + end_time = start_date + timedelta(hours=24) + + response = await client.get( + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, + ) + assert response.status == HTTPStatus.OK + json_dict = await response.json() + + # Switch state change should be attributed to the climate entity + # and inherit user_id from the parent context + heater_entries = [ + entry for entry in json_dict if entry.get("entity_id") == "switch.heater" + ] + assert len(heater_entries) == 1 + + heater_entry = heater_entries[0] + assert heater_entry["context_entity_id"] == "climate.living_room" + assert heater_entry["context_entity_id_name"] == "Living Room Thermostat" + assert heater_entry["context_state"] == "heat" + assert heater_entry["context_user_id"] == "b400facee45711eaa9308bfd3d19e474" + + +async def test_context_user_ids_lru_eviction( + hass: HomeAssistant, +) -> None: + """Test that the parent context user-id cache is bounded by LRU eviction. + + The cache must keep memory bounded under sustained load. New entries + arriving after the cap evict the least recently used entries. An + early parent context whose entry has been evicted should no longer + contribute its user_id to a later child state change. + """ + user_id = "b400facee45711eaa9308bfd3d19e474" + early_parent_context = ha.Context( + id="01GTDGKBCH00GW0X476W5TVAAA", + user_id=user_id, + ) + child_context = ha.Context( + id="01GTDGKBCH00GW0X476W5TVDDD", + parent_id=early_parent_context.id, + ) + + logbook_run = logbook.processor.LogbookRun( + context_lookup={None: None}, + external_events={}, + event_cache=logbook.processor.EventCache({}), + entity_name_cache=logbook.processor.EntityNameCache(hass), + include_entity_name=True, + timestamp=False, + memoize_new_contexts=False, + for_live_stream=True, + ) + context_augmenter = logbook.processor.ContextAugmenter(logbook_run) + ent_reg = er.async_get(hass) + + processor = logbook.processor.EventProcessor.__new__( + logbook.processor.EventProcessor + ) + processor.hass = hass + processor.ent_reg = ent_reg + processor.logbook_run = logbook_run + processor.context_augmenter = context_augmenter + + hass.states.async_set("switch.heater", STATE_OFF) + await hass.async_block_till_done() + + # Seed: the early parent SERVICE_CALL event populates the cache. + parent_row = MockRow( + EVENT_CALL_SERVICE, + { + ATTR_DOMAIN: "climate", + ATTR_SERVICE: "set_hvac_mode", + "service_data": {ATTR_ENTITY_ID: "climate.living_room"}, + }, + context=early_parent_context, + ) + parent_row.context_only = True + parent_row.icon = None + processor.humanify([parent_row]) + assert ( + ulid_to_bytes_or_none(early_parent_context.id) in logbook_run.context_user_ids + ) + + # Flood the cache with MAX+1 unrelated parent contexts so the early + # parent is evicted from the front of the LRU. + filler_rows = [] + for index in range(logbook.processor.MAX_CONTEXT_USER_IDS_CACHE + 1): + filler_context = ha.Context( + user_id=f"ffffffff{index:024x}"[:32], + ) + filler_row = MockRow( + EVENT_CALL_SERVICE, + { + ATTR_DOMAIN: "test", + ATTR_SERVICE: "noop", + "service_data": {}, + }, + context=filler_context, + ) + filler_row.context_only = True + filler_row.icon = None + filler_rows.append(filler_row) + processor.humanify(filler_rows) + + assert ( + len(logbook_run.context_user_ids) + == logbook.processor.MAX_CONTEXT_USER_IDS_CACHE + ) + assert ( + ulid_to_bytes_or_none(early_parent_context.id) + not in logbook_run.context_user_ids + ) + + # The child state change can no longer inherit the early parent's user_id + # because that entry was evicted. + child_row = MockRow( + PSEUDO_EVENT_STATE_CHANGED, + context=child_context, + ) + child_row.state = STATE_ON + child_row.entity_id = "switch.heater" + child_row.icon = None + results = processor.humanify([child_row]) + + heater_entries = [e for e in results if e.get("entity_id") == "switch.heater"] + assert len(heater_entries) == 1 + assert "context_user_id" not in heater_entries[0] + + +async def test_parent_user_attribution_does_not_use_origin_event_fallback( + hass: HomeAssistant, +) -> None: + """Test that parent context lookup doesn't fall back to origin_event. + + ContextAugmenter.get_context() has a fallback: when a context_id isn't in + context_lookup, it returns async_event_to_row(row.context.origin_event). + This fallback uses the *child row's* origin event, not the parent's, + so it can attribute the wrong user_id to a child context. + + In practice this scenario is unlikely — child contexts don't carry a + user_id, so the origin_event fallback would return None for user_id + anyway. We guard against it nevertheless to ensure the lookup is + semantically correct: the parent context should only be resolved via + context_lookup, never via an unrelated fallback path. + + Scenario: + - A user_id is set directly on child_context (not realistic, but + exercises the fallback path). + - Creating an Event with that context sets context.origin_event, + which carries the user_id. + - A state change for switch.heater uses that same child_context. + - The parent context is NOT in context_lookup (simulating live stream). + - The parent user_id should NOT be resolved via the origin_event fallback. + """ + wrong_user_id = "aaaaaaaaaaa711eaa9308bfd3d19e474" + parent_context = Context(id="01GTDGKBCH00GW0X476W5TVAAA") + + # Child context whose origin_event will carry wrong_user_id + child_context = Context( + id="01GTDGKBCH00GW0X476W5TVDDD", + parent_id=parent_context.id, + user_id=wrong_user_id, + ) + # Creating an Event sets context.origin_event = self, which carries + # wrong_user_id via child_context.user_id. + Event(EVENT_CALL_SERVICE, {}, context=child_context) + assert child_context.origin_event is not None + + hass.states.async_set("switch.heater", STATE_OFF) + await hass.async_block_till_done() + + logbook_run = logbook.processor.LogbookRun( + context_lookup={None: None}, + external_events={}, + event_cache=logbook.processor.EventCache({}), + entity_name_cache=logbook.processor.EntityNameCache(hass), + include_entity_name=True, + timestamp=False, + memoize_new_contexts=False, + ) + context_augmenter = logbook.processor.ContextAugmenter(logbook_run) + ent_reg = er.async_get(hass) + + processor = logbook.processor.EventProcessor.__new__( + logbook.processor.EventProcessor + ) + processor.hass = hass + processor.ent_reg = ent_reg + processor.logbook_run = logbook_run + processor.context_augmenter = context_augmenter + + # Build a child state-change EventAsRow with the child_context. + # The row itself has no user_id (context_user_id_bin=None) but + # the child_context.origin_event carries wrong_user_id. + child_row = EventAsRow( + row_id=1, + event_type=PSEUDO_EVENT_STATE_CHANGED, + event_data=None, + time_fired_ts=dt_util.utcnow().timestamp(), + context_id_bin=ulid_to_bytes_or_none(child_context.id), + context_user_id_bin=None, + context_parent_id_bin=ulid_to_bytes_or_none(child_context.parent_id), + state=STATE_ON, + entity_id="switch.heater", + icon=None, + context_only=False, + data={}, + context=child_context, + ) + + results = processor.humanify([child_row]) + + heater_entries = [e for e in results if e.get("entity_id") == "switch.heater"] + assert len(heater_entries) == 1 + # The parent context is unknown — no user should be attributed. + # If get_context's origin_event fallback is used, wrong_user_id leaks in. + assert "context_user_id" not in heater_entries[0] diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 4c88a5874a36e8..ab12f84b5108ea 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -24,11 +24,13 @@ ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_NAME, + ATTR_SERVICE, ATTR_UNIT_OF_MEASUREMENT, CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, + EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_FINAL_WRITE, EVENT_HOMEASSISTANT_START, STATE_OFF, @@ -41,6 +43,12 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from .common import ( + assert_thermostat_context_chain_events, + setup_thermostat_context_test_entities, + simulate_thermostat_context_chain, +) + from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.recorder.common import ( async_block_recorder, @@ -3202,3 +3210,420 @@ async def test_consistent_stream_and_recorder_filtering( results = response["result"] assert len(results) == result_count + + +@pytest.mark.usefixtures("recorder_mock") +async def test_logbook_stream_user_id_from_parent_context( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test user attribution from parent context in live event stream. + + Simulates the generic_thermostat pattern where a child context + (no user_id) is created for the heater service call, while the + parent context (from the user's set_hvac_mode call) has the user_id. + + The live stream uses memoize_new_contexts=False, so context_lookup + is empty. User_id must be resolved via the context_user_ids map. + """ + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + await hass.async_block_till_done() + + setup_thermostat_context_test_entities(hass) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + now = dt_util.utcnow() + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Receive historical events (partial) and sync message + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["event"]["partial"] is True + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["event"]["events"] == [] + + # Simulate the full generic_thermostat chain as live events + parent_context, _ = simulate_thermostat_context_chain(hass) + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + + assert_thermostat_context_chain_events(msg["event"]["events"], parent_context) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_logbook_stream_user_id_from_parent_context_filtered( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test user attribution from parent context in filtered live event stream. + + Same scenario as test_logbook_stream_user_id_from_parent_context but + with entity_ids in the subscription, matching what the frontend does. + This exercises the filtered event subscription path where + EVENT_CALL_SERVICE must be explicitly included and matched via + service_data. + """ + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + await hass.async_block_till_done() + + setup_thermostat_context_test_entities(hass) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + now = dt_util.utcnow() + websocket_client = await hass_ws_client() + # Subscribe with entity_ids, matching what the frontend logbook card does + end_time = now + timedelta(hours=3) + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "end_time": end_time.isoformat(), + "entity_ids": ["climate.living_room", "switch.heater"], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Receive historical events (partial) and sync message + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["event"]["partial"] is True + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["event"]["events"] == [] + + # Simulate the full chain as live events + parent_context, _ = simulate_thermostat_context_chain(hass) + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + + assert_thermostat_context_chain_events(msg["event"]["events"], parent_context) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_logbook_stream_parent_context_bridges_historical_to_live( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test parent-context user attribution bridges the historical→live switch. + + Scenario: a user fires a service call (parent context) that triggers a + child state change BEFORE the websocket subscription is opened. The + parent's call_service event lives only in the historical window. After + the historical backfill completes and the stream switches to live, a + NEW state change reusing the same child context (whose parent_id points + back at the historical parent) fires. The live event must inherit the + user_id from the historical parent — which can only happen if the + historical pre-pass populated the persistent LRU cache so the live + consumer can find it. + """ + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + await hass.async_block_till_done() + + setup_thermostat_context_test_entities(hass) + await hass.async_block_till_done() + + user_id = "b400facee45711eaa9308bfd3d19e474" + parent_context = core.Context( + id="01GTDGKBCH00GW0X476W5TVAAA", + user_id=user_id, + ) + child_context = core.Context( + id="01GTDGKBCH00GW0X476W5TVDDD", + parent_id=parent_context.id, + ) + + # Fire the parent service call and the first child state change BEFORE + # the websocket subscription. These will live in the historical window. + start_time = dt_util.utcnow() + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + ATTR_DOMAIN: "climate", + ATTR_SERVICE: "set_hvac_mode", + "service_data": {ATTR_ENTITY_ID: "climate.living_room"}, + }, + context=parent_context, + ) + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + ATTR_DOMAIN: "homeassistant", + ATTR_SERVICE: "turn_on", + "service_data": {ATTR_ENTITY_ID: "switch.heater"}, + }, + context=child_context, + ) + hass.states.async_set( + "switch.heater", + STATE_ON, + {ATTR_FRIENDLY_NAME: "Heater"}, + context=child_context, + ) + await async_wait_recording_done(hass) + + # Open a filtered subscription. The filtered query path excludes the + # parent's set_hvac_mode call_service from the historical row stream + # because its event_data references climate.living_room, not + # switch.heater. The pre-pass must fetch the parent and populate the + # persistent LRU so the upcoming live event can resolve attribution. + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": start_time.isoformat(), + "entity_ids": ["switch.heater"], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Drain the historical backfill messages. + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["event"]["partial"] is True + historical_events = msg["event"]["events"] + historical_heater = [ + e for e in historical_events if e.get("entity_id") == "switch.heater" + ] + assert len(historical_heater) == 1 + assert historical_heater[0]["context_user_id"] == user_id + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["event"]["events"] == [] + + # Stream is now live. Fire a NEW switch.heater state change reusing + # child_context — its parent_id still points at the historical parent. + # The live consumer must resolve the user_id via the persistent LRU + # populated during the historical pre-pass. + hass.states.async_set( + "switch.heater", + STATE_OFF, + {ATTR_FRIENDLY_NAME: "Heater"}, + context=child_context, + ) + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + live_heater = [ + e for e in msg["event"]["events"] if e.get("entity_id") == "switch.heater" + ] + assert len(live_heater) == 1 + assert live_heater[0]["state"] == "off" + assert live_heater[0]["context_user_id"] == user_id + + +@pytest.mark.usefixtures("recorder_mock") +async def test_logbook_get_events_user_id_from_parent_context( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test user attribution from parent context in unfiltered historical logbook. + + Uses logbook/get_events without entity_ids, which triggers the + unfiltered SQL query path. + """ + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + await hass.async_block_till_done() + + setup_thermostat_context_test_entities(hass) + await hass.async_block_till_done() + + now = dt_util.utcnow() + + parent_context, _ = simulate_thermostat_context_chain(hass) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 1, + "type": "logbook/get_events", + "start_time": now.isoformat(), + } + ) + response = await websocket_client.receive_json() + assert response["success"] + + assert_thermostat_context_chain_events(response["result"], parent_context) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_logbook_get_events_user_id_from_parent_context_filtered( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test user attribution from parent context in historical logbook with entity filter. + + Uses logbook/get_events with entity_ids, which triggers the filtered + SQL query path. The query must also fetch parent context rows so that + user_id can be inherited from the parent context. + """ + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + await hass.async_block_till_done() + + setup_thermostat_context_test_entities(hass) + await hass.async_block_till_done() + + now = dt_util.utcnow() + + parent_context, _ = simulate_thermostat_context_chain(hass) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 1, + "type": "logbook/get_events", + "start_time": now.isoformat(), + "entity_ids": ["climate.living_room", "switch.heater"], + } + ) + response = await websocket_client.receive_json() + assert response["success"] + + assert_thermostat_context_chain_events(response["result"], parent_context) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_logbook_stream_live_parent_service_call_only( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test user attribution when parent context only appears on a service call. + + In the thermostat pattern, the parent context also appears on a state + change for climate.living_room. This test covers the case where the + parent context ONLY fires a call_service event (no state change with + the parent context for any subscribed entity). The live consumer must + still resolve the child's user_id from the parent's call_service event. + + This fails if EVENT_CALL_SERVICE is not subscribed to in the live stream. + """ + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook") + ] + ) + await hass.async_block_till_done() + + hass.states.async_set("switch.heater", STATE_OFF) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + now = dt_util.utcnow() + websocket_client = await hass_ws_client() + end_time = now + timedelta(hours=3) + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "end_time": end_time.isoformat(), + "entity_ids": ["switch.heater"], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Drain historical backfill + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["event"]["partial"] is True + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["event"]["events"] == [] + + # Stream is now live. Fire a parent service call (no state change with + # the parent context) followed by a child state change. + user_id = "b400facee45711eaa9308bfd3d19e474" + parent_context = core.Context( + id="01GTDGKBCH00GW0X476W5TVAAA", + user_id=user_id, + ) + child_context = core.Context( + id="01GTDGKBCH00GW0X476W5TVDDD", + parent_id=parent_context.id, + ) + + # Only the service call carries the parent context — no state change + # with parent_context for any subscribed entity. + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + ATTR_DOMAIN: "homeassistant", + ATTR_SERVICE: "turn_on", + "service_data": {ATTR_ENTITY_ID: "switch.heater"}, + }, + context=parent_context, + ) + + # Child state change with no user_id on its context + hass.states.async_set( + "switch.heater", + STATE_ON, + {ATTR_FRIENDLY_NAME: "Heater"}, + context=child_context, + ) + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + + heater_entries = [ + e for e in msg["event"]["events"] if e.get("entity_id") == "switch.heater" + ] + assert len(heater_entries) == 1 + assert heater_entries[0]["state"] == "on" + assert heater_entries[0]["context_user_id"] == user_id From c65dc842a56016058e4673cb129480c0bfca4a7f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Apr 2026 08:03:56 +0200 Subject: [PATCH 0854/1707] Fix generic_thermostat context handling (#168080) --- .../components/generic_thermostat/climate.py | 43 +++-- .../generic_thermostat/test_climate.py | 177 ++++++++++++++++++ 2 files changed, 208 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 10b24ec17cab46..53cdd3a237b0e8 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -8,6 +8,7 @@ from functools import partial import logging import math +import time from typing import Any import voluptuous as vol @@ -51,6 +52,7 @@ ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_entity_id_to_device +from homeassistant.helpers.entity import CONTEXT_RECENT_TIME_SECONDS from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -478,6 +480,7 @@ async def _async_sensor_changed(self, event: Event[EventStateChangedData]) -> No if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return + self.async_set_context(event.context) self._async_update_temp(new_state) await self._async_control_heating() self.async_write_ha_state() @@ -531,9 +534,11 @@ def _async_update_temp(self, state: State) -> None: _LOGGER.error("Unable to update from sensor: %s", ex) async def _async_control_heating( - self, time: datetime | None = None, force: bool = False + self, _time: datetime | None = None, force: bool = False ) -> None: """Check if we need to turn heating on or off.""" + called_by_timer = _time is not None + async with self._temp_lock: if not self._active and None not in ( self._cur_temp, @@ -552,7 +557,7 @@ async def _async_control_heating( if not self._active or self._hvac_mode == HVACMode.OFF: return - if force and time is not None and self.max_cycle_duration: + if force and called_by_timer and self.max_cycle_duration: # We were invoked due to `max_cycle_duration`, so turn off _LOGGER.debug( "Turning off heater %s due to max cycle time of %s", @@ -587,7 +592,7 @@ async def _async_control_heating( now - self._last_toggled_time + self.min_cycle_duration, self._async_timer_control_heating, ) - elif time is not None: + elif called_by_timer: # This is a keep-alive call, so ensure it's on _LOGGER.debug( "Keep-alive - Turning on heater %s", @@ -609,7 +614,7 @@ async def _async_control_heating( now - self._last_toggled_time + self.cycle_cooldown, self._async_timer_control_heating, ) - elif time is not None: + elif called_by_timer: # This is a keep-alive call, so ensure it's off _LOGGER.debug( "Keep-alive - Turning off heater %s", self.heater_entity_id @@ -624,13 +629,25 @@ def _is_device_active(self) -> bool | None: return self.hass.states.is_state(self.heater_entity_id, STATE_ON) + def _get_current_context(self) -> Context | None: + """Return the current context if it is still recent, or None.""" + if ( + self._context_set is not None + and time.time() - self._context_set > CONTEXT_RECENT_TIME_SECONDS + ): + self._context = None + self._context_set = None + return self._context + async def _async_heater_turn_on(self, keepalive: bool = False) -> None: """Turn heater toggleable device on.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} - # Create a new context for this service call so we can identify - # the resulting state change event as originating from us - new_context = Context(parent_id=self._context.id if self._context else None) - self.async_set_context(new_context) + # Create a child context for the switch service call so we can + # identify the resulting state change event as originating from us. + # Don't set it as our own context — the climate entity's state changes + # should remain attributed to the parent context (e.g., set_hvac_mode). + current_context = self._get_current_context() + new_context = Context(parent_id=current_context.id if current_context else None) self._last_context_id = new_context.id await self.hass.services.async_call( HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context @@ -654,10 +671,12 @@ async def _async_heater_turn_on(self, keepalive: bool = False) -> None: async def _async_heater_turn_off(self, keepalive: bool = False) -> None: """Turn heater toggleable device off.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} - # Create a new context for this service call so we can identify - # the resulting state change event as originating from us - new_context = Context(parent_id=self._context.id if self._context else None) - self.async_set_context(new_context) + # Create a child context for the switch service call so we can + # identify the resulting state change event as originating from us. + # Don't set it as our own context — the climate entity's state changes + # should remain attributed to the parent context (e.g., set_hvac_mode). + current_context = self._get_current_context() + new_context = Context(parent_id=current_context.id if current_context else None) self._last_context_id = new_context.id await self.hass.services.async_call( HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index b7cb19233a141a..e1f026c160801f 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -36,6 +36,7 @@ ) from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, + Context, CoreState, HomeAssistant, ServiceCall, @@ -55,6 +56,7 @@ from tests.common import ( MockConfigEntry, + MockUser, assert_setup_component, async_fire_time_changed, async_mock_service, @@ -1784,3 +1786,178 @@ async def test_device_id( helper_entity = entity_registry.async_get("climate.test") assert helper_entity is not None assert helper_entity.device_id == source_entity.device_id + + +@pytest.mark.usefixtures("setup_comp_1") +async def test_hvac_mode_change_user_context( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: + """Test user context is preserved through the full chain. + + Full chain: + 1. User calls set_hvac_mode → parent context (has user_id) + 2. Generic thermostat calls homeassistant.turn_on → child context (no user_id) + 3. Switch state changes → child context + 4. Climate state updates in response → child context + """ + heater_switch = "input_boolean.test" + assert await async_setup_component( + hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} + ) + + assert await async_setup_component( + hass, + CLIMATE_DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test", + "heater": heater_switch, + "target_sensor": ENT_SENSOR, + "initial_hvac_mode": HVACMode.OFF, + "cold_tolerance": 2, + "hot_tolerance": 4, + } + }, + ) + await hass.async_block_till_done() + + # Set sensor below target so heating triggers on mode change + _setup_sensor(hass, 18) + await hass.async_block_till_done() + await common.async_set_temperature(hass, 23) + await hass.async_block_till_done() + + assert hass.states.get(heater_switch).state == STATE_OFF + + # Change HVAC mode with a user context + user_context = Context(user_id=hass_admin_user.id) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_hvac_mode", + {"entity_id": ENTITY, "hvac_mode": HVACMode.HEAT}, + blocking=True, + context=user_context, + ) + await hass.async_block_till_done() + + # Step 2: The heater should have been turned on + assert hass.states.get(heater_switch).state == STATE_ON + + # The switch state change should have a child context with the + # user context as parent + switch_state = hass.states.get(heater_switch) + child_context = switch_state.context + assert child_context.id != user_context.id + assert child_context.parent_id == user_context.id + + # Step 4: The climate entity should keep the parent (user) context, + # not the child context created for the switch service call + climate_state = hass.states.get(ENTITY) + assert climate_state.context.id == user_context.id + assert climate_state.context.user_id == hass_admin_user.id + + +@pytest.mark.usefixtures("setup_comp_1") +async def test_sensor_change_inherits_context(hass: HomeAssistant) -> None: + """Test that sensor changes set the sensor's context on the thermostat. + + When the sensor updates, the thermostat should inherit the sensor's + context so the resulting switch toggle has the sensor context as parent. + """ + heater_switch = "input_boolean.test" + assert await async_setup_component( + hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} + ) + + assert await async_setup_component( + hass, + CLIMATE_DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test", + "heater": heater_switch, + "target_sensor": ENT_SENSOR, + "initial_hvac_mode": HVACMode.HEAT, + "cold_tolerance": 2, + "hot_tolerance": 4, + } + }, + ) + await hass.async_block_till_done() + + await common.async_set_temperature(hass, 30) + await hass.async_block_till_done() + + assert hass.states.get(heater_switch).state == STATE_OFF + + # Set sensor below target with a specific context + sensor_context = Context() + hass.states.async_set(ENT_SENSOR, "18", context=sensor_context) + await hass.async_block_till_done() + + # The heater should have turned on + assert hass.states.get(heater_switch).state == STATE_ON + + # The switch state change should have a child context with the + # sensor context as parent + switch_state = hass.states.get(heater_switch) + assert switch_state.context.parent_id == sensor_context.id + assert switch_state.context.id != sensor_context.id + + +@pytest.mark.usefixtures("setup_comp_1") +async def test_stale_context_not_used_as_parent( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test that an expired context is not used as parent for the switch call. + + When a keepalive timer fires long after the last user interaction, + the thermostat should not link the switch service call to the old + user context. + """ + heater_switch = "input_boolean.test" + assert await async_setup_component( + hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} + ) + + assert await async_setup_component( + hass, + CLIMATE_DOMAIN, + { + "climate": { + "platform": "generic_thermostat", + "name": "test", + "heater": heater_switch, + "target_sensor": ENT_SENSOR, + "initial_hvac_mode": HVACMode.HEAT, + "cold_tolerance": 2, + "hot_tolerance": 4, + "keep_alive": datetime.timedelta(minutes=10), + } + }, + ) + await hass.async_block_till_done() + + # Set temperature and sensor so heater is on + await common.async_set_temperature(hass, 30) + _setup_sensor(hass, 18) + await hass.async_block_till_done() + assert hass.states.get(heater_switch).state == STATE_ON + + # Capture service calls to check the keepalive context + calls = async_mock_service(hass, ha.DOMAIN, SERVICE_TURN_ON) + + # Advance time past keepalive interval (10 min) and context expiry (5s) + freezer.tick(datetime.timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # The keepalive should have sent a turn_on call + assert len(calls) == 1 + assert calls[0].data["entity_id"] == heater_switch + + # The keepalive call's context should have no parent, + # because the original context expired long ago + assert calls[0].context.parent_id is None From 20ea635e392509e9bdb596d650daf66b3b731519 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Apr 2026 10:11:17 +0200 Subject: [PATCH 0855/1707] Deduplicate installation method repair tests (#168157) --- tests/components/homeassistant/test_init.py | 60 +++++---------------- 1 file changed, 12 insertions(+), 48 deletions(-) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 80211c48eed9d1..fff855624a3e87 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -642,17 +642,21 @@ async def test_reload_all( @pytest.mark.parametrize( - "arch", + ("arch", "bit_32", "expected_issue"), [ - "i386", - "armhf", - "armv7", + ("i386", True, "deprecated_method_architecture"), + ("armhf", True, "deprecated_method_architecture"), + ("armv7", True, "deprecated_method_architecture"), + ("aarch64", False, "deprecated_method"), + ("generic-x86-64", False, "deprecated_method"), ], ) -async def test_deprecated_installation_issue_32bit_core( +async def test_deprecated_installation_issue_core( hass: HomeAssistant, issue_registry: ir.IssueRegistry, arch: str, + bit_32: bool, + expected_issue: str, ) -> None: """Test deprecated installation issue.""" with ( @@ -665,47 +669,7 @@ async def test_deprecated_installation_issue_32bit_core( ), patch( "homeassistant.components.homeassistant._is_32_bit", - return_value=True, - ), - ): - assert await async_setup_component(hass, DOMAIN, {}) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_method_architecture") - assert issue.domain == DOMAIN - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_placeholders == { - "installation_type": "Core", - "arch": arch, - } - - -@pytest.mark.parametrize( - "arch", - [ - "aarch64", - "generic-x86-64", - ], -) -async def test_deprecated_installation_issue_64bit_core( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - arch: str, -) -> None: - """Test deprecated installation issue.""" - with ( - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Core", - "arch": arch, - }, - ), - patch( - "homeassistant.components.homeassistant._is_32_bit", - return_value=False, + return_value=bit_32, ), ): assert await async_setup_component(hass, DOMAIN, {}) @@ -713,7 +677,7 @@ async def test_deprecated_installation_issue_64bit_core( await hass.async_block_till_done() assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_method") + issue = issue_registry.async_get_issue(DOMAIN, expected_issue) assert issue.domain == DOMAIN assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_placeholders == { @@ -730,7 +694,7 @@ async def test_deprecated_installation_issue_64bit_core( "armhf", ], ) -async def test_deprecated_installation_issue_32bit( +async def test_deprecated_installation_issue_container_32bit( hass: HomeAssistant, issue_registry: ir.IssueRegistry, arch: str, From 68d6a3e6bd9443214c4bd871b3f1bf4bd8d44d47 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 14 Apr 2026 10:12:26 +0200 Subject: [PATCH 0856/1707] Move template state infrastructure into a dedicated states module (#167996) --- homeassistant/helpers/template/__init__.py | 520 +-------------------- homeassistant/helpers/template/states.py | 513 ++++++++++++++++++++ tests/helpers/template/test_init.py | 67 +-- tests/helpers/template/test_states.py | 80 ++++ 4 files changed, 614 insertions(+), 566 deletions(-) create mode 100644 homeassistant/helpers/template/states.py create mode 100644 tests/helpers/template/test_states.py diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 7d585bfafeba89..8cb247c6917475 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -5,10 +5,9 @@ from ast import literal_eval import asyncio import collections.abc -from collections.abc import Callable, Generator, Iterable -from datetime import datetime, timedelta -from enum import Enum -from functools import cache, lru_cache, partial, wraps +from collections.abc import Callable, Iterable +from datetime import timedelta +from functools import lru_cache, partial, wraps import logging import pathlib import re @@ -22,42 +21,27 @@ from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace -from lru import LRU -from propcache.api import under_cached_property from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_PERSONS, - ATTR_UNIT_OF_MEASUREMENT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, ) -from homeassistant.core import ( - Context, - HomeAssistant, - State, - callback, - valid_domain, - valid_entity_id, -) +from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import TemplateError -from homeassistant.helpers import entity_registry as er, location as loc_helper +from homeassistant.helpers import location as loc_helper from homeassistant.helpers.singleton import singleton -from homeassistant.helpers.translation import ( - async_translate_state, - async_translate_state_attr, -) from homeassistant.helpers.typing import TemplateVarsType from homeassistant.util import convert, location as location_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads -from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException from .context import ( @@ -68,6 +52,20 @@ ) from .helpers import result_as_boolean as result_as_boolean from .render_info import RenderInfo, render_info_cv +from .states import ( + CACHED_TEMPLATE_LRU, + CACHED_TEMPLATE_NO_COLLECT_LRU, + ENTITY_COUNT_GROWTH_FACTOR, + AllStates, + DomainStates, + StateAttrTranslated, + StateTranslated, + TemplateState as TemplateState, + TemplateStateFromEntityId as TemplateStateFromEntityId, + _collect_state, + _get_state, + _resolve_state, +) if TYPE_CHECKING: from _typeshed import OptExcInfo @@ -90,68 +88,11 @@ # Match "simple" ints and floats. -1.0, 1, +5, 5.0 _IS_NUMERIC = re.compile(r"^[+-]?(?!0\d)\d*(?:\.\d*)?$") -_RESERVED_NAMES = { - "contextfunction", - "evalcontextfunction", - "environmentfunction", - "jinja_pass_arg", -} - -_COLLECTABLE_STATE_ATTRIBUTES = { - "state", - "attributes", - "last_changed", - "last_updated", - "context", - "domain", - "object_id", - "name", -} - - -# -# CACHED_TEMPLATE_STATES is a rough estimate of the number of entities -# on a typical system. It is used as the initial size of the LRU cache -# for TemplateState objects. -# -# If the cache is too small we will end up creating and destroying -# TemplateState objects too often which will cause a lot of GC activity -# and slow down the system. For systems with a lot of entities and -# templates, this can reach 100000s of object creations and destructions -# per minute. -# -# Since entity counts may grow over time, we will increase -# the size if the number of entities grows via _async_adjust_lru_sizes -# at the start of the system and every 10 minutes if needed. -# -CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 MAX_TEMPLATE_OUTPUT = 256 * 1024 # 256KiB -CACHED_TEMPLATE_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) -CACHED_TEMPLATE_NO_COLLECT_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) -ENTITY_COUNT_GROWTH_FACTOR = 1.2 - - -def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: - """Return a TemplateState for a state without collecting.""" - if template_state := CACHED_TEMPLATE_NO_COLLECT_LRU.get(state): - return template_state - template_state = _create_template_state_no_collect(hass, state) - CACHED_TEMPLATE_NO_COLLECT_LRU[state] = template_state - return template_state - - -def _template_state(hass: HomeAssistant, state: State) -> TemplateState: - """Return a TemplateState for a state that collects.""" - if template_state := CACHED_TEMPLATE_LRU.get(state): - return template_state - template_state = TemplateState(hass, state) - CACHED_TEMPLATE_LRU[state] = template_state - return template_state - def async_setup(hass: HomeAssistant) -> bool: """Set up tracking the template LRUs.""" @@ -690,429 +631,6 @@ def __repr__(self) -> str: return f"Template" -@cache -def _domain_states(hass: HomeAssistant, name: str) -> DomainStates: - return DomainStates(hass, name) - - -def _readonly(*args: Any, **kwargs: Any) -> Any: - """Raise an exception when a states object is modified.""" - raise RuntimeError(f"Cannot modify template States object: {args} {kwargs}") - - -class AllStates: - """Class to expose all HA states as attributes.""" - - __setitem__ = _readonly - __delitem__ = _readonly - __slots__ = ("_hass",) - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize all states.""" - self._hass = hass - - def __getattr__(self, name): - """Return the domain state.""" - if "." in name: - return _get_state_if_valid(self._hass, name) - - if name in _RESERVED_NAMES: - return None - - if not valid_domain(name): - raise TemplateError(f"Invalid domain name '{name}'") - - return _domain_states(self._hass, name) - - # Jinja will try __getitem__ first and it avoids the need - # to call is_safe_attribute - __getitem__ = __getattr__ - - def _collect_all(self) -> None: - if (render_info := render_info_cv.get()) is not None: - render_info.all_states = True - - def _collect_all_lifecycle(self) -> None: - if (render_info := render_info_cv.get()) is not None: - render_info.all_states_lifecycle = True - - def __iter__(self) -> Generator[TemplateState]: - """Return all states.""" - self._collect_all() - return _state_generator(self._hass, None) - - def __len__(self) -> int: - """Return number of states.""" - self._collect_all_lifecycle() - return self._hass.states.async_entity_ids_count() - - def __call__( - self, - entity_id: str, - rounded: bool | object = _SENTINEL, - with_unit: bool = False, - ) -> str: - """Return the states.""" - state = _get_state(self._hass, entity_id) - if state is None: - return STATE_UNKNOWN - if rounded is _SENTINEL: - rounded = with_unit - if rounded or with_unit: - return state.format_state(rounded, with_unit) # type: ignore[arg-type] - return state.state - - def __repr__(self) -> str: - """Representation of All States.""" - return "