From e7869393425ff9a0b62ad26847452078cd10f04b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Sep 2026 11:26:56 +0200 Subject: [PATCH 01/25] Improve search test coverage of synthetic device handling (#182166) --- tests/components/search/test_init.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index fd71359d15fd59..883f9a766c6090 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -1123,9 +1123,11 @@ async def test_search_pre_migration_composite_device( device records the id of the pre-migration composite. Automations and scripts created before the split still reference the composite id, so: - searching a split device must return them, but not automations or scripts - referencing only a sibling split, and + referencing only a sibling split, - searching such an automation or script must return the live split devices, not - the virtual composite id. + the virtual composite id, and + - searching the composite id itself must return the union of the splits' config + entries and integrations, since the restored composite spans them all. """ assert await async_setup_component(hass, DOMAIN, {}) @@ -1220,6 +1222,16 @@ def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: assert search(ItemType.AUTOMATION, "automation.composite") == expected_reverse assert search(ItemType.SCRIPT, "script.composite") == expected_reverse + # Searching the composite id itself resolves it to the read-only composite device, + # which belongs to every split's config entry, not just the base split's. Only + # references to the composite id match; the splits' own references do not. + assert search(ItemType.DEVICE, composite_device_id) == { + ItemType.AUTOMATION: {"automation.composite"}, + ItemType.SCRIPT: {"script.composite"}, + ItemType.CONFIG_ENTRY: {entry_1.entry_id, entry_2.entry_id}, + ItemType.INTEGRATION: {"test1", "test2"}, + } + async def test_search_label_on_child_device( hass: HomeAssistant, From 4c8c05b5d4d15a0885fedc5b09f62d70d6d85335 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Sep 2026 11:28:45 +0200 Subject: [PATCH 02/25] Improve logbook test coverage of synthetic device handling (#182165) --- .../components/logbook/test_websocket_api.py | 78 ++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 4b9ba1239a9b47..ddee98b31dfa7e 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import ANY, patch +import attr from freezegun import freeze_time import pytest @@ -93,7 +94,9 @@ def async_describe_test_event(event: Event) -> dict[str, str]: logbook._process_logbook_platform(hass, "test", MockLogbookPlatform) -async def _async_mock_logbook_platform(hass: HomeAssistant) -> None: +async def _async_mock_logbook_platform( + hass: HomeAssistant, domain: str = "test", event_name: str = "mock_event" +) -> None: class MockLogbookPlatform: """Mock a logbook platform.""" @@ -114,9 +117,9 @@ def async_describe_test_event(event: Event) -> dict[str, str]: "message": event.data.get("message", "is on fire"), } - async_describe_event("test", "mock_event", async_describe_test_event) + async_describe_event(domain, event_name, async_describe_test_event) - logbook._process_logbook_platform(hass, "test", MockLogbookPlatform) + logbook._process_logbook_platform(hass, domain, MockLogbookPlatform) async def _async_mock_entity_with_broken_logbook_platform( @@ -581,6 +584,75 @@ async def test_get_events_with_device_ids( assert isinstance(results[4]["when"], float) +async def test_get_events_with_composite_device_id( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test logbook get_events for a pre-migration composite device id. + + The composite device spans two config entries with different domains, so the + external logbook events of both domains must be returned. A composite reports + only one of the two as its config_entry_id, so the union of its config entries + is what makes both domains interesting. + """ + composite_id = "composite00000000000000000000ab" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, domain, {}) + for domain in ("homeassistant", "logbook") + ] + ) + + entry_a = MockConfigEntry(domain="test_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="test_b") + entry_b.add_to_hass(hass) + await _async_mock_logbook_platform(hass, "test_a", "mock_event_a") + await _async_mock_logbook_platform(hass, "test_b", "mock_event_b") + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test_a", "0123")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test_b", "0123")} + ) + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry._devices[device_a.id] = attr.evolve( + device_a, composite_device_id=composite_id + ) + device_registry._devices[device_b.id] = attr.evolve( + device_b, composite_device_id=composite_id + ) + assert device_registry.async_get(composite_id).is_composite_device is True + + # No entity_ids are requested, so the composite device is the only route by which + # either domain can become interesting + hass.bus.async_fire("mock_event_a", {"device_id": composite_id}) + hass.bus.async_fire("mock_event_b", {"device_id": composite_id}) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "logbook/get_events", + "start_time": now.isoformat(), + "device_ids": [composite_id], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 1 + + results = response["result"] + assert len(results) == 2 + assert {result["domain"] for result in results} == {"test_a", "test_b"} + assert {result["message"] for result in results} == {"is on fire"} + + @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator From 432e9d4ec3f8e73fe6ff65173803aee233e5ae1d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Sep 2026 11:29:12 +0200 Subject: [PATCH 03/25] Improve hue test coverage of synthetic device handling (#182164) --- tests/components/hue/test_device_trigger_v2.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index 04dbdca6160985..88d25fe2c02ba6 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -181,13 +181,22 @@ async def test_get_triggers_for_composite_device_id( config_entry_id=other_entry.entry_id, identifiers={("other", "1")} ) composite_id = "composite00000000000000000000ab" - # Simulate a migration split: both devices carry the pre-migration composite id + # Simulate a migration split: both devices carry the pre-migration composite id, + # with the non-hue config entry as the composite's former primary device_registry._devices[hue_wall_switch_device.id] = attr.evolve( - hue_wall_switch_device, composite_device_id=composite_id + hue_wall_switch_device, + composite_device_id=composite_id, + composite_primary_config_entry=other_entry.entry_id, ) device_registry._devices[other_device.id] = attr.evolve( - other_device, composite_device_id=composite_id + other_device, + composite_device_id=composite_id, + composite_primary_config_entry=other_entry.entry_id, ) + # The restored composite is based on the non-hue split, so the hue config entry is + # only reachable through the composite's merged config entries + composite_device = device_registry.async_get(composite_id) + assert composite_device.config_entry_id == other_entry.entry_id triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, composite_id From b94ec35d31c77cd2d1ceae2c6b6394d4859e23c4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:30:41 +0200 Subject: [PATCH 04/25] LibreNMS reached silver on the IQS (#182098) --- homeassistant/components/librenms/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/librenms/manifest.json b/homeassistant/components/librenms/manifest.json index 5e5ece72e8a457..ed16ba266f87fc 100644 --- a/homeassistant/components/librenms/manifest.json +++ b/homeassistant/components/librenms/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["aiolibrenms"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["aiolibrenms==0.0.4"] } From c8035dc829a6fa2b9469a2a9074721ac2376ddca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:36:02 +0200 Subject: [PATCH 05/25] Move service registration to async_setup in lifx (#181196) --- homeassistant/components/lifx/__init__.py | 10 +- homeassistant/components/lifx/const.py | 23 ++ homeassistant/components/lifx/light.py | 8 +- homeassistant/components/lifx/manager.py | 255 ++------------------ homeassistant/components/lifx/services.py | 262 +++++++++++++++++++++ homeassistant/components/lifx/strings.json | 5 + tests/components/lifx/test_light.py | 6 +- tests/components/lifx/test_services.py | 86 +++++++ 8 files changed, 412 insertions(+), 243 deletions(-) create mode 100644 homeassistant/components/lifx/services.py create mode 100644 tests/components/lifx/test_services.py diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 983f8142e2688b..fd51df1006159b 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -28,6 +28,7 @@ from .discovery import async_discover_devices, async_trigger_discovery from .manager import LIFXManager from .migration import async_migrate_entities_devices, async_migrate_legacy_entries +from .services import async_setup_services from .util import async_entry_is_legacy, async_get_legacy_entry, formatted_serial CONF_SERVER = "server" @@ -156,6 +157,8 @@ async def async_discovery(self, *_: Any) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIFX component.""" + async_setup_services(hass) + migrating = bool(async_get_legacy_entry(hass)) discovery_manager = LIFXDiscoveryManager(hass, migrating) @@ -197,9 +200,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool assert entry.unique_id is not None if DATA_LIFX_MANAGER not in hass.data: - manager = LIFXManager(hass) - hass.data[DATA_LIFX_MANAGER] = manager - manager.async_setup() + hass.data[DATA_LIFX_MANAGER] = LIFXManager(hass) host = entry.data[CONF_HOST] connection = LIFXConnection(host, TARGET_ANY) @@ -240,6 +241,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> boo entry.runtime_data.connection.async_stop() # Only the DATA_LIFX_MANAGER left, remove it. if len(hass.config_entries.async_loaded_entries(DOMAIN)) == 0: - manager = hass.data.pop(DATA_LIFX_MANAGER) - manager.async_unload() + hass.data.pop(DATA_LIFX_MANAGER) return unload_ok diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 1c756a0d51a4b7..e29bca60053ff1 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -58,6 +58,29 @@ ATTR_THEME = "theme" +ATTR_CHANGE = "change" +ATTR_CLOUD_SATURATION_MAX = "cloud_saturation_max" +ATTR_CLOUD_SATURATION_MIN = "cloud_saturation_min" +ATTR_CYCLES = "cycles" +ATTR_DIRECTION = "direction" +ATTR_PALETTE = "palette" +ATTR_PERIOD = "period" +ATTR_POWER_ON = "power_on" +ATTR_SATURATION_MAX = "saturation_max" +ATTR_SATURATION_MIN = "saturation_min" +ATTR_SKY_TYPE = "sky_type" +ATTR_SPEED = "speed" +ATTR_SPREAD = "spread" + +SERVICE_EFFECT_COLORLOOP = "effect_colorloop" +SERVICE_EFFECT_FLAME = "effect_flame" +SERVICE_EFFECT_MORPH = "effect_morph" +SERVICE_EFFECT_MOVE = "effect_move" +SERVICE_EFFECT_PULSE = "effect_pulse" +SERVICE_EFFECT_SKY = "effect_sky" +SERVICE_EFFECT_STOP = "effect_stop" +SERVICE_PAINT_THEME = "paint_theme" + HEV_CYCLE_STATE = "hev_cycle_state" INFRARED_BRIGHTNESS = "infrared_brightness" INFRARED_BRIGHTNESS_VALUES_MAP = { diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 23251e58a47106..28e6a18ff7b8c0 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -36,10 +36,6 @@ INFRARED_BRIGHTNESS, LIFX_CEILING_PRODUCT_IDS, LOGGER, -) -from .coordinator import FirmwareEffect, LIFXConfigEntry, LIFXUpdateCoordinator -from .entity import LIFXEntity -from .manager import ( SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_FLAME, SERVICE_EFFECT_MORPH, @@ -47,8 +43,10 @@ SERVICE_EFFECT_PULSE, SERVICE_EFFECT_SKY, SERVICE_EFFECT_STOP, - LIFXManager, ) +from .coordinator import FirmwareEffect, LIFXConfigEntry, LIFXUpdateCoordinator +from .entity import LIFXEntity +from .manager import LIFXManager from .util import convert_8_to_16, convert_16_to_8, find_hsbk, lifx_features, merge_hsbk LIFX_STATE_SETTLE_DELAY = 0.3 diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index c621fefe2e7955..6d8fbe01590ba8 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -8,30 +8,39 @@ import aiolifx_effects from aiolifx_themes.painter import ThemePainter from aiolifx_themes.themes import Theme, ThemeLibrary -import probatio from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_NAME, - ATTR_COLOR_TEMP_KELVIN, - ATTR_HS_COLOR, - ATTR_RGB_COLOR, ATTR_TRANSITION, - ATTR_XY_COLOR, - COLOR_GROUP, - VALID_BRIGHTNESS, - VALID_BRIGHTNESS_PCT, ) from homeassistant.const import ATTR_MODE -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.target import ( - TargetSelection, - async_extract_referenced_entity_ids, +from homeassistant.core import HomeAssistant, callback + +from .const import ( + ATTR_CHANGE, + ATTR_CLOUD_SATURATION_MAX, + ATTR_CLOUD_SATURATION_MIN, + ATTR_CYCLES, + ATTR_DIRECTION, + ATTR_PALETTE, + ATTR_PERIOD, + ATTR_POWER_ON, + ATTR_SATURATION_MAX, + ATTR_SATURATION_MIN, + ATTR_SKY_TYPE, + ATTR_SPEED, + ATTR_SPREAD, + ATTR_THEME, + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, ) - -from .const import ATTR_THEME, DOMAIN from .coordinator import LIFXUpdateCoordinator from .util import convert_8_to_16, find_hsbk @@ -40,30 +49,6 @@ SCAN_INTERVAL = timedelta(seconds=10) -SERVICE_EFFECT_COLORLOOP = "effect_colorloop" -SERVICE_EFFECT_FLAME = "effect_flame" -SERVICE_EFFECT_MORPH = "effect_morph" -SERVICE_EFFECT_MOVE = "effect_move" -SERVICE_EFFECT_PULSE = "effect_pulse" -SERVICE_EFFECT_SKY = "effect_sky" -SERVICE_EFFECT_STOP = "effect_stop" -SERVICE_PAINT_THEME = "paint_theme" - -ATTR_CHANGE = "change" -ATTR_CLOUD_SATURATION_MIN = "cloud_saturation_min" -ATTR_CLOUD_SATURATION_MAX = "cloud_saturation_max" -ATTR_CYCLES = "cycles" -ATTR_DIRECTION = "direction" -ATTR_PALETTE = "palette" -ATTR_PERIOD = "period" -ATTR_POWER_OFF = "power_off" -ATTR_POWER_ON = "power_on" -ATTR_SATURATION_MAX = "saturation_max" -ATTR_SATURATION_MIN = "saturation_min" -ATTR_SKY_TYPE = "sky_type" -ATTR_SPEED = "speed" -ATTR_SPREAD = "spread" - EFFECT_FLAME = "FLAME" EFFECT_MORPH = "MORPH" EFFECT_MOVE = "MOVE" @@ -77,179 +62,13 @@ EFFECT_MOVE_DEFAULT_SPEED = 3 EFFECT_MOVE_DEFAULT_DIRECTION = "right" -EFFECT_MOVE_DIRECTION_RIGHT = "right" -EFFECT_MOVE_DIRECTION_LEFT = "left" - -EFFECT_MOVE_DIRECTIONS = [EFFECT_MOVE_DIRECTION_LEFT, EFFECT_MOVE_DIRECTION_RIGHT] - EFFECT_SKY_DEFAULT_SPEED = 50 EFFECT_SKY_DEFAULT_SKY_TYPE = "Clouds" EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN = 50 EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX = 180 -EFFECT_SKY_SKY_TYPES = ["Sunrise", "Sunset", "Clouds"] - PAINT_THEME_DEFAULT_TRANSITION = 1 -PULSE_MODE_BLINK = "blink" -PULSE_MODE_BREATHE = "breathe" -PULSE_MODE_PING = "ping" -PULSE_MODE_SOLID = "solid" -PULSE_MODE_STROBE = "strobe" - -PULSE_MODES = [ - PULSE_MODE_BLINK, - PULSE_MODE_BREATHE, - PULSE_MODE_PING, - PULSE_MODE_STROBE, - PULSE_MODE_SOLID, -] - -LIFX_EFFECT_SCHEMA = { - probatio.Optional(ATTR_POWER_ON, default=True): cv.boolean, -} - -LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, - probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, - probatio.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, - probatio.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), probatio.ExactSequence((cv.byte, cv.byte, cv.byte)) - ), - probatio.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), - probatio.ExactSequence((cv.small_float, cv.small_float)), - ), - probatio.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), - probatio.ExactSequence( - ( - probatio.All( - probatio.Coerce(float), probatio.Range(min=0, max=360) - ), - probatio.All( - probatio.Coerce(float), probatio.Range(min=0, max=100) - ), - ) - ), - ), - probatio.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): probatio.All( - probatio.Coerce(int), probatio.Range(min=1500, max=9000) - ), - ATTR_PERIOD: probatio.All(probatio.Coerce(float), probatio.Range(min=0.05)), - ATTR_CYCLES: probatio.All(probatio.Coerce(float), probatio.Range(min=1)), - ATTR_MODE: probatio.In(PULSE_MODES), - } -) - -LIFX_EFFECT_COLORLOOP_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, - probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, - ATTR_SATURATION_MAX: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=0, max=100) - ), - ATTR_SATURATION_MIN: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=0, max=100) - ), - ATTR_PERIOD: probatio.All(probatio.Coerce(float), probatio.Clamp(min=0.05)), - ATTR_CHANGE: probatio.All( - probatio.Coerce(float), probatio.Clamp(min=0, max=360) - ), - ATTR_SPREAD: probatio.All( - probatio.Coerce(float), probatio.Clamp(min=0, max=360) - ), - ATTR_TRANSITION: cv.positive_float, - } -) - -LIFX_EFFECT_STOP_SCHEMA = cv.make_entity_service_schema({}) - -LIFX_EFFECT_FLAME_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_SPEED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=1, max=25)), - } -) - -HSBK_SCHEMA = probatio.All( - probatio.Coerce(tuple), - probatio.ExactSequence( - ( - probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=360)), - probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=100)), - probatio.All(probatio.Coerce(float), probatio.Clamp(min=0, max=100)), - probatio.All(probatio.Coerce(int), probatio.Clamp(min=1500, max=9000)), - ) - ), -) - -LIFX_EFFECT_MORPH_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_SPEED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=1, max=25)), - probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), - probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( - cv.ensure_list, [HSBK_SCHEMA] - ), - } -) - -LIFX_EFFECT_MOVE_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_SPEED: probatio.All( - probatio.Coerce(float), probatio.Clamp(min=0.1, max=60) - ), - ATTR_DIRECTION: probatio.In(EFFECT_MOVE_DIRECTIONS), - probatio.Optional(ATTR_THEME): probatio.In(ThemeLibrary().themes), - } -) - -LIFX_EFFECT_SKY_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_SPEED: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=1, max=86400) - ), - ATTR_SKY_TYPE: probatio.In(EFFECT_SKY_SKY_TYPES), - ATTR_CLOUD_SATURATION_MIN: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=0, max=255) - ), - ATTR_CLOUD_SATURATION_MAX: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=0, max=255) - ), - ATTR_PALETTE: probatio.All(cv.ensure_list, [HSBK_SCHEMA]), - } -) - -LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_TRANSITION: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=1, max=3600) - ), - probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), - probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( - cv.ensure_list, [HSBK_SCHEMA] - ), - } -) - -SERVICES_SCHEMA = { - SERVICE_EFFECT_COLORLOOP: LIFX_EFFECT_COLORLOOP_SCHEMA, - SERVICE_EFFECT_FLAME: LIFX_EFFECT_FLAME_SCHEMA, - SERVICE_EFFECT_MORPH: LIFX_EFFECT_MORPH_SCHEMA, - SERVICE_EFFECT_MOVE: LIFX_EFFECT_MOVE_SCHEMA, - SERVICE_EFFECT_PULSE: LIFX_EFFECT_PULSE_SCHEMA, - SERVICE_EFFECT_SKY: LIFX_EFFECT_SKY_SCHEMA, - SERVICE_EFFECT_STOP: LIFX_EFFECT_STOP_SCHEMA, - SERVICE_PAINT_THEME: LIFX_PAINT_THEME_SCHEMA, -} - class LIFXManager: """Representation of all known LIFX entities.""" @@ -260,12 +79,6 @@ def __init__(self, hass: HomeAssistant) -> None: self.effects_conductor = aiolifx_effects.Conductor(hass.loop) self.entity_id_to_coordinator: dict[str, LIFXUpdateCoordinator] = {} - @callback - def async_unload(self) -> None: - """Release resources.""" - for service in SERVICES_SCHEMA: - self.hass.services.async_remove(DOMAIN, service) - @callback def async_register_entity( self, entity_id: str, coordinator: LIFXUpdateCoordinator @@ -280,24 +93,6 @@ def unregister_entity() -> None: return unregister_entity - @callback - def async_setup(self) -> None: - """Register the LIFX effects as hass service calls.""" - - async def service_handler(service: ServiceCall) -> None: - """Apply a service, i.e. start an effect.""" - referenced = async_extract_referenced_entity_ids( - self.hass, TargetSelection(service.data) - ) - all_referenced = referenced.referenced | referenced.indirectly_referenced - if all_referenced: - await self.start_effect(all_referenced, service.service, **service.data) - - for service, schema in SERVICES_SCHEMA.items(): - self.hass.services.async_register( - DOMAIN, service, service_handler, schema=schema - ) - @staticmethod def build_theme(theme_name: str = "exciting", palette: list | None = None) -> Theme: """Either return the predefined theme or build one from the palette.""" diff --git a/homeassistant/components/lifx/services.py b/homeassistant/components/lifx/services.py new file mode 100644 index 00000000000000..a170dd54f89a9c --- /dev/null +++ b/homeassistant/components/lifx/services.py @@ -0,0 +1,262 @@ +"""Support for LIFX services.""" + +from typing import TYPE_CHECKING + +from aiolifx_themes.themes import ThemeLibrary +import probatio + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + ATTR_XY_COLOR, + COLOR_GROUP, + VALID_BRIGHTNESS, + VALID_BRIGHTNESS_PCT, +) +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 +from homeassistant.helpers.target import ( + TargetSelection, + async_extract_referenced_entity_ids, +) + +from .const import ( + ATTR_CHANGE, + ATTR_CLOUD_SATURATION_MAX, + ATTR_CLOUD_SATURATION_MIN, + ATTR_CYCLES, + ATTR_DIRECTION, + ATTR_PALETTE, + ATTR_PERIOD, + ATTR_POWER_ON, + ATTR_SATURATION_MAX, + ATTR_SATURATION_MIN, + ATTR_SKY_TYPE, + ATTR_SPEED, + ATTR_SPREAD, + ATTR_THEME, + DATA_LIFX_MANAGER, + DOMAIN, + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, +) +from .util import async_entry_is_legacy + +if TYPE_CHECKING: + from .manager import LIFXManager + +EFFECT_MOVE_DIRECTION_LEFT = "left" +EFFECT_MOVE_DIRECTION_RIGHT = "right" + +EFFECT_MOVE_DIRECTIONS = [EFFECT_MOVE_DIRECTION_LEFT, EFFECT_MOVE_DIRECTION_RIGHT] + +EFFECT_SKY_SKY_TYPES = ["Sunrise", "Sunset", "Clouds"] + +PULSE_MODE_BLINK = "blink" +PULSE_MODE_BREATHE = "breathe" +PULSE_MODE_PING = "ping" +PULSE_MODE_SOLID = "solid" +PULSE_MODE_STROBE = "strobe" + +PULSE_MODES = [ + PULSE_MODE_BLINK, + PULSE_MODE_BREATHE, + PULSE_MODE_PING, + PULSE_MODE_STROBE, + PULSE_MODE_SOLID, +] + +LIFX_EFFECT_SCHEMA = { + probatio.Optional(ATTR_POWER_ON, default=True): cv.boolean, +} + +LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, + probatio.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, + probatio.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), probatio.ExactSequence((cv.byte, cv.byte, cv.byte)) + ), + probatio.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), + probatio.ExactSequence((cv.small_float, cv.small_float)), + ), + probatio.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), + probatio.ExactSequence( + ( + probatio.All( + probatio.Coerce(float), probatio.Range(min=0, max=360) + ), + probatio.All( + probatio.Coerce(float), probatio.Range(min=0, max=100) + ), + ) + ), + ), + probatio.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): probatio.All( + probatio.Coerce(int), probatio.Range(min=1500, max=9000) + ), + ATTR_PERIOD: probatio.All(probatio.Coerce(float), probatio.Range(min=0.05)), + ATTR_CYCLES: probatio.All(probatio.Coerce(float), probatio.Range(min=1)), + ATTR_MODE: probatio.In(PULSE_MODES), + } +) + +LIFX_EFFECT_COLORLOOP_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, + ATTR_SATURATION_MAX: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=0, max=100) + ), + ATTR_SATURATION_MIN: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=0, max=100) + ), + ATTR_PERIOD: probatio.All(probatio.Coerce(float), probatio.Clamp(min=0.05)), + ATTR_CHANGE: probatio.All( + probatio.Coerce(float), probatio.Clamp(min=0, max=360) + ), + ATTR_SPREAD: probatio.All( + probatio.Coerce(float), probatio.Clamp(min=0, max=360) + ), + ATTR_TRANSITION: cv.positive_float, + } +) + +LIFX_EFFECT_STOP_SCHEMA = cv.make_entity_service_schema({}) + +LIFX_EFFECT_FLAME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=1, max=25)), + } +) + +HSBK_SCHEMA = probatio.All( + probatio.Coerce(tuple), + probatio.ExactSequence( + ( + probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=360)), + probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=100)), + probatio.All(probatio.Coerce(float), probatio.Clamp(min=0, max=100)), + probatio.All(probatio.Coerce(int), probatio.Clamp(min=1500, max=9000)), + ) + ), +) + +LIFX_EFFECT_MORPH_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=1, max=25)), + probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), + probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( + cv.ensure_list, [HSBK_SCHEMA] + ), + } +) + +LIFX_EFFECT_MOVE_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: probatio.All( + probatio.Coerce(float), probatio.Clamp(min=0.1, max=60) + ), + ATTR_DIRECTION: probatio.In(EFFECT_MOVE_DIRECTIONS), + probatio.Optional(ATTR_THEME): probatio.In(ThemeLibrary().themes), + } +) + +LIFX_EFFECT_SKY_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=1, max=86400) + ), + ATTR_SKY_TYPE: probatio.In(EFFECT_SKY_SKY_TYPES), + ATTR_CLOUD_SATURATION_MIN: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=0, max=255) + ), + ATTR_CLOUD_SATURATION_MAX: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=0, max=255) + ), + ATTR_PALETTE: probatio.All(cv.ensure_list, [HSBK_SCHEMA]), + } +) + +LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_TRANSITION: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=1, max=3600) + ), + probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), + probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( + cv.ensure_list, [HSBK_SCHEMA] + ), + } +) + +SERVICES_SCHEMA = { + SERVICE_EFFECT_COLORLOOP: LIFX_EFFECT_COLORLOOP_SCHEMA, + SERVICE_EFFECT_FLAME: LIFX_EFFECT_FLAME_SCHEMA, + SERVICE_EFFECT_MORPH: LIFX_EFFECT_MORPH_SCHEMA, + SERVICE_EFFECT_MOVE: LIFX_EFFECT_MOVE_SCHEMA, + SERVICE_EFFECT_PULSE: LIFX_EFFECT_PULSE_SCHEMA, + SERVICE_EFFECT_SKY: LIFX_EFFECT_SKY_SCHEMA, + SERVICE_EFFECT_STOP: LIFX_EFFECT_STOP_SCHEMA, + SERVICE_PAINT_THEME: LIFX_PAINT_THEME_SCHEMA, +} + + +def _get_manager(service: ServiceCall) -> LIFXManager: + """Return the LIFX manager, raising a user-facing error if unavailable.""" + hass = service.hass + # The manager is stored before the connection and first refresh are awaited, + # so its presence alone does not mean a device is usable. + if (manager := hass.data.get(DATA_LIFX_MANAGER)) is None or all( + async_entry_is_legacy(entry) + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + ) + + return manager + + +async def _async_start_effect(service: ServiceCall) -> None: + """Apply a service, i.e. start an effect.""" + manager = _get_manager(service) + referenced = async_extract_referenced_entity_ids( + service.hass, TargetSelection(service.data) + ) + all_referenced = referenced.referenced | referenced.indirectly_referenced + if all_referenced: + await manager.start_effect(all_referenced, service.service, **service.data) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the LIFX effect services.""" + for service, schema in SERVICES_SCHEMA.items(): + hass.services.async_register( + DOMAIN, service, _async_start_effect, schema=schema + ) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index d9f0f7bf02c3af..ffadf54c88077a 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -50,6 +50,11 @@ } } }, + "exceptions": { + "not_loaded": { + "message": "LIFX is not loaded, so this action cannot be performed." + } + }, "services": { "effect_colorloop": { "description": "Runs an effect with looping colors.", diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index fb290e4f82ade0..6fdd886249f92f 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -9,13 +9,12 @@ from homeassistant.components import lifx from homeassistant.components.lifx import DOMAIN -from homeassistant.components.lifx.const import ATTR_POWER -from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES -from homeassistant.components.lifx.manager import ( +from homeassistant.components.lifx.const import ( ATTR_CLOUD_SATURATION_MAX, ATTR_CLOUD_SATURATION_MIN, ATTR_DIRECTION, ATTR_PALETTE, + ATTR_POWER, ATTR_SATURATION_MAX, ATTR_SATURATION_MIN, ATTR_SKY_TYPE, @@ -27,6 +26,7 @@ SERVICE_EFFECT_SKY, SERVICE_PAINT_THEME, ) +from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, diff --git a/tests/components/lifx/test_services.py b/tests/components/lifx/test_services.py new file mode 100644 index 00000000000000..aa6539ad2226e1 --- /dev/null +++ b/tests/components/lifx/test_services.py @@ -0,0 +1,86 @@ +"""Tests for the LIFX services.""" + +import pytest + +from homeassistant.components.lifx import DOMAIN +from homeassistant.components.lifx.const import ( + DATA_LIFX_MANAGER, + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, +) +from homeassistant.components.lifx.manager import LIFXManager +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.setup import async_setup_component + +SERVICES = ( + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, +) + + +@pytest.mark.usefixtures("mock_discovery") +async def test_services_registered_without_entry(hass: HomeAssistant) -> None: + """Test the effect actions are registered during component setup.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + for service in SERVICES: + assert hass.services.has_service(DOMAIN, service) + + +@pytest.mark.parametrize("service", SERVICES) +@pytest.mark.usefixtures("mock_discovery") +async def test_service_without_manager_raises( + hass: HomeAssistant, service: str +) -> None: + """Test the effect actions raise when no config entry is loaded.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: "light.test"}, blocking=True + ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "not_loaded" + assert "LIFX is not loaded" in str(err.value) + + +@pytest.mark.parametrize("service", SERVICES) +@pytest.mark.usefixtures("mock_discovery") +async def test_service_with_manager_but_no_loaded_entry_raises( + hass: HomeAssistant, service: str +) -> None: + """Test the actions raise when a failed setup left a manager behind. + + async_setup_entry stores the manager before awaiting the connection and the + first refresh, so a setup retry leaves an empty manager in hass.data. + """ + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + # Simulate the leftover manager from a setup that raised ConfigEntryNotReady. + hass.data[DATA_LIFX_MANAGER] = LIFXManager(hass) + assert not hass.config_entries.async_loaded_entries(DOMAIN) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: "light.test"}, blocking=True + ) + + assert err.value.translation_key == "not_loaded" From 152ca3f515c266a361e497e7e938b919c8975683 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:44:49 +0200 Subject: [PATCH 06/25] Move service registration to async_setup in zha (#181186) --- homeassistant/components/zha/__init__.py | 5 +- homeassistant/components/zha/const.py | 6 + homeassistant/components/zha/device_action.py | 2 +- homeassistant/components/zha/helpers.py | 25 +- homeassistant/components/zha/services.py | 452 ++++++++++++++++++ homeassistant/components/zha/strings.json | 5 + homeassistant/components/zha/websocket_api.py | 440 +---------------- tests/components/zha/test_services.py | 95 ++++ tests/components/zha/test_websocket_api.py | 14 +- 9 files changed, 604 insertions(+), 440 deletions(-) create mode 100644 homeassistant/components/zha/services.py create mode 100644 tests/components/zha/test_services.py diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 2ebb288472a2b0..64944e9f6ce5c2 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -61,6 +61,7 @@ AlreadyRunningEZSP, warn_on_wrong_silabs_firmware, ) +from .services import async_setup_services DEVICE_CONFIG_SCHEMA_ENTRY = probatio.Schema({probatio.Optional(CONF_TYPE): cv.string}) ZHA_CONFIG_SCHEMA = { @@ -122,6 +123,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_firmware_info_provider(hass, DOMAIN, homeassistant_hardware) + async_setup_services(hass) + return True @@ -299,8 +302,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> for platform in PLATFORMS: del ha_zha_data.platforms[platform] - websocket_api.async_unload_api(hass) - return True diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index f27fc8583c31d8..dd1d6c8ea64d34 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -5,6 +5,10 @@ ) ATTR_ACTIVE_COORDINATOR = "active_coordinator" +ATTR_DURATION = "duration" +ATTR_INSTALL_CODE = "install_code" +ATTR_QR_CODE = "qr_code" +ATTR_SOURCE_IEEE = "source_ieee" ATTR_ATTRIBUTES = "attributes" ATTR_AVAILABLE = "available" ATTR_DEVICE_TYPE = "device_type" @@ -79,3 +83,5 @@ # Dispatcher signal carrying device reconfigure progress events (bind result, # attribute reporting result, configure complete) to the websocket subscriber. SIGNAL_DEVICE_RECONFIGURE_EVENT = "zha_device_reconfigure_event" + +RESPONSE = "response" diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 92f78dbbf91f8a..3e4ad06081af14 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -14,7 +14,7 @@ from .const import DOMAIN from .helpers import async_get_zha_device_proxy, convert_zha_error_to_ha_error -from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN +from .services import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN # mypy: disallow-any-generics diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 59665ff83c9249..dce6f123a3f162 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -60,6 +60,8 @@ QuirksConfiguration, ZHAConfiguration, ZHAData, + convert_install_code, + qr_to_install_code, ) from zha.application.platforms import GroupEntity, PlatformEntity from zha.event import EventBase @@ -118,27 +120,31 @@ ) from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.util.logging import HomeAssistantQueueHandler from .const import ( ATTR_ACTIVE_COORDINATOR, ATTR_AVAILABLE, ATTR_DEVICE_TYPE, + ATTR_DURATION, ATTR_ENDPOINT_NAMES, ATTR_EXPOSES_FEATURES, ATTR_IEEE, + ATTR_INSTALL_CODE, ATTR_LAST_SEEN, ATTR_LQI, ATTR_MANUFACTURER_CODE, ATTR_NEIGHBORS, ATTR_NWK, ATTR_POWER_SOURCE, + ATTR_QR_CODE, ATTR_QUIRK_APPLIED, ATTR_QUIRK_CLASS, ATTR_ROUTES, ATTR_RSSI, ATTR_SIGNATURE, + ATTR_SOURCE_IEEE, CONF_ALARM_ARM_REQUIRES_CODE, CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, @@ -1473,3 +1479,20 @@ def exclude_none_values(obj: Mapping[str, Any]) -> dict[str, Any]: def get_config_entry_unique_id(network_info: NetworkInfo) -> str: """Generate a unique id for a config entry based on the network info.""" return f"epid={network_info.extended_pan_id}".lower() + + +IEEE_SCHEMA = probatio.All(cv.string, EUI64.convert) + +SERVICE_PERMIT_PARAMS: VolDictType = { + probatio.Optional(ATTR_IEEE): IEEE_SCHEMA, + probatio.Optional(ATTR_DURATION, default=60): probatio.All( + probatio.Coerce(int), probatio.Range(0, 254) + ), + probatio.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, + probatio.Inclusive(ATTR_INSTALL_CODE, "install_code"): probatio.All( + cv.string, convert_install_code + ), + probatio.Exclusive(ATTR_QR_CODE, "install_code"): probatio.All( + cv.string, qr_to_install_code + ), +} diff --git a/homeassistant/components/zha/services.py b/homeassistant/components/zha/services.py new file mode 100644 index 00000000000000..a40f2eabffa6bf --- /dev/null +++ b/homeassistant/components/zha/services.py @@ -0,0 +1,452 @@ +"""Support for Zigbee Home Automation services.""" + +import logging +from typing import Any, cast + +import probatio +from zha.application.const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE, + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_COMMAND_TYPE, + ATTR_ENDPOINT_ID, + ATTR_IEEE, + ATTR_LEVEL, + ATTR_MANUFACTURER, + ATTR_PARAMS, + ATTR_VALUE, + ATTR_WARNING_DEVICE_DURATION, + ATTR_WARNING_DEVICE_MODE, + ATTR_WARNING_DEVICE_STROBE, + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + CLUSTER_TYPE_IN, +) +from zha.application.gateway import Gateway +from zha.application.platforms.siren import ( + BaseSiren, + SirenLevel, + SquawkMode, + Strobe, + StrobeLevel, + WarningMode, +) +from zigpy.types.named import EUI64, KeyData +from zigpy.typing import ( + UNDEFINED as ZIGPY_UNDEFINED, + UndefinedType as ZigpyUndefinedType, +) + +from homeassistant.const import ATTR_COMMAND, Platform +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.typing import VolSchemaType + +from .const import ( + ATTR_DURATION, + ATTR_INSTALL_CODE, + ATTR_QR_CODE, + ATTR_SOURCE_IEEE, + DOMAIN, + MFG_CLUSTER_ID_START, + RESPONSE, +) +from .helpers import IEEE_SCHEMA, SERVICE_PERMIT_PARAMS, get_zha_data + +_LOGGER = logging.getLogger(__name__) + +ATTR_GROUP = "group" +ATTR_IEEE_ADDRESS = "ieee_address" + +SERVICE_PERMIT = "permit" +SERVICE_REMOVE = "remove" +SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" +SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" +SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" +SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" +SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" + +IEEE_SERVICE = "ieee_based_service" + + +def _ensure_list_if_present[_T](value: _T | None) -> list[_T] | list[Any] | None: + """Wrap value in list if it is provided and not one.""" + if value is None: + return None + return cast("list[_T]", value) if isinstance(value, list) else [value] + + +SERVICE_SCHEMAS: dict[str, VolSchemaType] = { + SERVICE_PERMIT: probatio.Schema( + probatio.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + SERVICE_PERMIT_PARAMS, + ) + ), + IEEE_SERVICE: probatio.Schema( + probatio.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + {probatio.Required(ATTR_IEEE): IEEE_SCHEMA}, + ) + ), + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: probatio.Schema( + { + probatio.Required(ATTR_IEEE): IEEE_SCHEMA, + probatio.Required(ATTR_ENDPOINT_ID): cv.positive_int, + probatio.Required(ATTR_CLUSTER_ID): cv.positive_int, + probatio.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + probatio.Required(ATTR_ATTRIBUTE): probatio.Any(cv.positive_int, str), + probatio.Required(ATTR_VALUE): probatio.Any(int, cv.boolean, cv.string), + probatio.Optional(ATTR_MANUFACTURER): probatio.All( + probatio.Coerce(int), probatio.Range(min=-1) + ), + } + ), + SERVICE_WARNING_DEVICE_SQUAWK: probatio.Schema( + { + probatio.Required(ATTR_IEEE): IEEE_SCHEMA, + probatio.Optional( + ATTR_WARNING_DEVICE_MODE, default=SquawkMode.Armed + ): cv.positive_int, + probatio.Optional( + ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe + ): cv.positive_int, + probatio.Optional( + ATTR_LEVEL, default=SirenLevel.High_level_sound + ): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_WARN: probatio.Schema( + { + probatio.Required(ATTR_IEEE): IEEE_SCHEMA, + probatio.Optional( + ATTR_WARNING_DEVICE_MODE, default=WarningMode.Emergency + ): cv.positive_int, + probatio.Optional( + ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe + ): cv.positive_int, + probatio.Optional( + ATTR_LEVEL, default=SirenLevel.High_level_sound + ): cv.positive_int, + probatio.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, + probatio.Optional( + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 + ): cv.positive_int, + probatio.Optional( + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + default=StrobeLevel.High_level_strobe, + ): cv.positive_int, + } + ), + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: probatio.All( + probatio.Schema( + { + probatio.Required(ATTR_IEEE): IEEE_SCHEMA, + probatio.Required(ATTR_ENDPOINT_ID): cv.positive_int, + probatio.Required(ATTR_CLUSTER_ID): cv.positive_int, + probatio.Optional( + ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN + ): cv.string, + probatio.Required(ATTR_COMMAND): cv.positive_int, + probatio.Required(ATTR_COMMAND_TYPE): cv.string, + probatio.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, + probatio.Exclusive(ATTR_PARAMS, "attrs_params"): dict, + probatio.Optional(ATTR_MANUFACTURER): probatio.All( + probatio.Coerce(int), probatio.Range(min=-1) + ), + } + ), + cv.deprecated(ATTR_ARGS), + cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), + ), + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: probatio.Schema( + { + probatio.Required(ATTR_GROUP): cv.positive_int, + probatio.Required(ATTR_CLUSTER_ID): cv.positive_int, + probatio.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + probatio.Required(ATTR_COMMAND): cv.positive_int, + probatio.Optional(ATTR_ARGS, default=[]): cv.ensure_list, + probatio.Optional(ATTR_MANUFACTURER): probatio.All( + probatio.Coerce(int), probatio.Range(min=-1) + ), + } + ), +} + + +def _get_gateway(service: ServiceCall) -> Gateway: + """Return the ZHA gateway, raising a user-facing error if it is unavailable.""" + if (gateway_proxy := get_zha_data(service.hass).gateway_proxy) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_gateway", + ) + + return gateway_proxy.gateway + + +async def _permit(service: ServiceCall) -> None: + """Allow devices to join this network.""" + application_controller = _get_gateway(service).application_controller + duration: int = service.data[ATTR_DURATION] + ieee: EUI64 | None = service.data.get(ATTR_IEEE) + src_ieee: EUI64 + link_key: KeyData + if ATTR_SOURCE_IEEE in service.data: + src_ieee = service.data[ATTR_SOURCE_IEEE] + link_key = service.data[ATTR_INSTALL_CODE] + _LOGGER.info("Allowing join for %s device with link key", src_ieee) + await application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key + ) + return + + if ATTR_QR_CODE in service.data: + src_ieee, link_key = service.data[ATTR_QR_CODE] + _LOGGER.info("Allowing join for %s device with link key", src_ieee) + await application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key + ) + return + + if ieee: + _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) + else: + _LOGGER.info("Permitting joins for %ss", duration) + await application_controller.permit(time_s=duration, node=ieee) + + +async def _remove(service: ServiceCall) -> None: + """Remove a node from the network.""" + zha_gateway = _get_gateway(service) + ieee: EUI64 = service.data[ATTR_IEEE] + _LOGGER.info("Removing node %s", ieee) + await zha_gateway.async_remove_device(ieee) + + +async def _set_zigbee_cluster_attributes(service: ServiceCall) -> None: + """Set zigbee attribute for cluster on zha entity.""" + zha_gateway = _get_gateway(service) + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + attribute: int | str = service.data[ATTR_ATTRIBUTE] + value: int | bool | str = service.data[ATTR_VALUE] + manufacturer: int | ZigpyUndefinedType = service.data.get( + ATTR_MANUFACTURER, ZIGPY_UNDEFINED + ) + zha_device = zha_gateway.get_device(ieee) + response = None + if zha_device is not None: + response = await zha_device.write_zigbee_attribute( + endpoint_id, + cluster_id, + attribute, + value, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {ieee!s} not found") + + _LOGGER.debug( + ( + "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" + " [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_VALUE, + value, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + +async def _issue_zigbee_cluster_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on ZHA entity.""" + zha_gateway = _get_gateway(service) + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + command: int = service.data[ATTR_COMMAND] + command_type: str = service.data[ATTR_COMMAND_TYPE] + args: list | None = service.data.get(ATTR_ARGS) + params: dict | None = service.data.get(ATTR_PARAMS) + manufacturer: int | ZigpyUndefinedType = service.data.get( + ATTR_MANUFACTURER, ZIGPY_UNDEFINED + ) + zha_device = zha_gateway.get_device(ieee) + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + + await zha_device.issue_cluster_command( + endpoint_id, + cluster_id, + command, + command_type, + args, + params, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s] %s: [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_COMMAND, + command, + ATTR_COMMAND_TYPE, + command_type, + ATTR_ARGS, + args, + ATTR_PARAMS, + params, + ATTR_MANUFACTURER, + manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {ieee!s} not found") + + +async def _issue_zigbee_group_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on a zigbee group.""" + zha_gateway = _get_gateway(service) + group_id: int = service.data[ATTR_GROUP] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + command: int = service.data[ATTR_COMMAND] + args: list = service.data[ATTR_ARGS] + manufacturer: int | ZigpyUndefinedType = service.data.get( + ATTR_MANUFACTURER, ZIGPY_UNDEFINED + ) + group = zha_gateway.get_group(group_id) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) + response = None + if group is not None: + cluster = group.endpoint[cluster_id] + response = await cluster.command( + command, *args, manufacturer=manufacturer, expect_reply=True + ) + _LOGGER.debug( + "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_COMMAND, + command, + ATTR_ARGS, + args, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + +async def _warning_device_squawk(service: ServiceCall) -> None: + """Issue the squawk command for an IAS warning device.""" + zha_gateway = _get_gateway(service) + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + + device = zha_gateway.get_device(ieee) + siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True) + + await siren.async_squawk(mode=mode, strobe=strobe, squawk_level=level) + + +async def _warning_device_warn(service: ServiceCall) -> None: + """Issue the warning command for an IAS warning device.""" + zha_gateway = _get_gateway(service) + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] + duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] + intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] + + device = zha_gateway.get_device(ieee) + siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True) + + await siren.async_turn_on( + tone=mode, + volume_level=level, + duration=duration, + strobe=strobe, + strobe_duty_cycle=duty_mode, + strobe_intensity=intensity, + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the ZHA services.""" + async_register_admin_service( + hass, + DOMAIN, + SERVICE_PERMIT, + _permit, + schema=SERVICE_SCHEMAS[SERVICE_PERMIT], + ) + async_register_admin_service( + hass, DOMAIN, SERVICE_REMOVE, _remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + _set_zigbee_cluster_attributes, + schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + _issue_zigbee_cluster_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + _issue_zigbee_group_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_SQUAWK, + _warning_device_squawk, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_WARN, + _warning_device_warn, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], + ) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 175e8a2b9cf93d..90049735f9e8b3 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -2291,6 +2291,11 @@ } } }, + "exceptions": { + "no_gateway": { + "message": "ZHA is not loaded, so this action cannot be performed." + } + }, "issues": { "inconsistent_network_settings": { "fix_flow": { diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 56c2c0fe7a3aab..1ee1ff1f259ad5 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -6,24 +6,14 @@ import probatio from zha.application.const import ( - ATTR_ARGS, ATTR_ATTRIBUTE, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, - ATTR_COMMAND_TYPE, ATTR_ENDPOINT_ID, ATTR_IEEE, - ATTR_LEVEL, ATTR_MANUFACTURER, ATTR_MEMBERS, - ATTR_PARAMS, ATTR_TYPE, - ATTR_VALUE, - ATTR_WARNING_DEVICE_DURATION, - ATTR_WARNING_DEVICE_MODE, - ATTR_WARNING_DEVICE_STROBE, - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, - ATTR_WARNING_DEVICE_STROBE_INTENSITY, CLUSTER_COMMAND_SERVER, CLUSTER_COMMANDS_CLIENT, CLUSTER_COMMANDS_SERVER, @@ -32,20 +22,7 @@ ZHA_GW_MSG, ) from zha.application.gateway import Gateway -from zha.application.helpers import ( - async_is_bindable_target, - convert_install_code, - get_matched_clusters, - qr_to_install_code, -) -from zha.application.platforms.siren import ( - BaseSiren, - SirenLevel, - SquawkMode, - Strobe, - StrobeLevel, - WarningMode, -) +from zha.application.helpers import async_is_bindable_target, get_matched_clusters from zha.zigbee.group import GroupMemberReference import zigpy.backups from zigpy.config import CONF_DEVICE @@ -60,12 +37,10 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.const import ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.service import async_register_admin_service -from homeassistant.helpers.typing import VolDictType, VolSchemaType from .api import ( async_change_channel, @@ -73,13 +48,16 @@ async_get_radio_type, ) from .const import ( + ATTR_DURATION, + ATTR_INSTALL_CODE, + ATTR_QR_CODE, + ATTR_SOURCE_IEEE, CUSTOM_CONFIGURATION, - DOMAIN, EZSP_OVERWRITE_EUI64, GROUP_ID, GROUP_IDS, GROUP_NAME, - MFG_CLUSTER_ID_START, + RESPONSE, SIGNAL_DEVICE_RECONFIGURE_EVENT, ZHA_ALARM_OPTIONS, ZHA_OPTIONS, @@ -87,6 +65,8 @@ from .helpers import ( CONF_ZHA_ALARM_SCHEMA, CONF_ZHA_OPTIONS_SCHEMA, + IEEE_SCHEMA, + SERVICE_PERMIT_PARAMS, EntityReference, ZHAGatewayProxy, async_cluster_exists, @@ -104,152 +84,16 @@ TYPE = "type" CLIENT = "client" ID = "id" -RESPONSE = "response" DEVICE_INFO = "device_info" -ATTR_DURATION = "duration" -ATTR_GROUP = "group" -ATTR_IEEE_ADDRESS = "ieee_address" -ATTR_INSTALL_CODE = "install_code" ATTR_NEW_CHANNEL = "new_channel" -ATTR_SOURCE_IEEE = "source_ieee" ATTR_TARGET_IEEE = "target_ieee" -ATTR_QR_CODE = "qr_code" BINDINGS = "bindings" -SERVICE_PERMIT = "permit" -SERVICE_REMOVE = "remove" -SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" -SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" -SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" -SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" -SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" SERVICE_ZIGBEE_BIND = "service_zigbee_bind" -IEEE_SERVICE = "ieee_based_service" - -IEEE_SCHEMA = probatio.All(cv.string, EUI64.convert) - - -def _ensure_list_if_present[_T](value: _T | None) -> list[_T] | list[Any] | None: - """Wrap value in list if it is provided and not one.""" - if value is None: - return None - return cast("list[_T]", value) if isinstance(value, list) else [value] - - -SERVICE_PERMIT_PARAMS: VolDictType = { - probatio.Optional(ATTR_IEEE): IEEE_SCHEMA, - probatio.Optional(ATTR_DURATION, default=60): probatio.All( - probatio.Coerce(int), probatio.Range(0, 254) - ), - probatio.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, - probatio.Inclusive(ATTR_INSTALL_CODE, "install_code"): probatio.All( - cv.string, convert_install_code - ), - probatio.Exclusive(ATTR_QR_CODE, "install_code"): probatio.All( - cv.string, qr_to_install_code - ), -} - -SERVICE_SCHEMAS: dict[str, VolSchemaType] = { - SERVICE_PERMIT: probatio.Schema( - probatio.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - SERVICE_PERMIT_PARAMS, - ) - ), - IEEE_SERVICE: probatio.Schema( - probatio.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - {probatio.Required(ATTR_IEEE): IEEE_SCHEMA}, - ) - ), - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: probatio.Schema( - { - probatio.Required(ATTR_IEEE): IEEE_SCHEMA, - probatio.Required(ATTR_ENDPOINT_ID): cv.positive_int, - probatio.Required(ATTR_CLUSTER_ID): cv.positive_int, - probatio.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - probatio.Required(ATTR_ATTRIBUTE): probatio.Any(cv.positive_int, str), - probatio.Required(ATTR_VALUE): probatio.Any(int, cv.boolean, cv.string), - probatio.Optional(ATTR_MANUFACTURER): probatio.All( - probatio.Coerce(int), probatio.Range(min=-1) - ), - } - ), - SERVICE_WARNING_DEVICE_SQUAWK: probatio.Schema( - { - probatio.Required(ATTR_IEEE): IEEE_SCHEMA, - probatio.Optional( - ATTR_WARNING_DEVICE_MODE, default=SquawkMode.Armed - ): cv.positive_int, - probatio.Optional( - ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe - ): cv.positive_int, - probatio.Optional( - ATTR_LEVEL, default=SirenLevel.High_level_sound - ): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_WARN: probatio.Schema( - { - probatio.Required(ATTR_IEEE): IEEE_SCHEMA, - probatio.Optional( - ATTR_WARNING_DEVICE_MODE, default=WarningMode.Emergency - ): cv.positive_int, - probatio.Optional( - ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe - ): cv.positive_int, - probatio.Optional( - ATTR_LEVEL, default=SirenLevel.High_level_sound - ): cv.positive_int, - probatio.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, - probatio.Optional( - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 - ): cv.positive_int, - probatio.Optional( - ATTR_WARNING_DEVICE_STROBE_INTENSITY, - default=StrobeLevel.High_level_strobe, - ): cv.positive_int, - } - ), - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: probatio.All( - probatio.Schema( - { - probatio.Required(ATTR_IEEE): IEEE_SCHEMA, - probatio.Required(ATTR_ENDPOINT_ID): cv.positive_int, - probatio.Required(ATTR_CLUSTER_ID): cv.positive_int, - probatio.Optional( - ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN - ): cv.string, - probatio.Required(ATTR_COMMAND): cv.positive_int, - probatio.Required(ATTR_COMMAND_TYPE): cv.string, - probatio.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, - probatio.Exclusive(ATTR_PARAMS, "attrs_params"): dict, - probatio.Optional(ATTR_MANUFACTURER): probatio.All( - probatio.Coerce(int), probatio.Range(min=-1) - ), - } - ), - cv.deprecated(ATTR_ARGS), - cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), - ), - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: probatio.Schema( - { - probatio.Required(ATTR_GROUP): cv.positive_int, - probatio.Required(ATTR_CLUSTER_ID): cv.positive_int, - probatio.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - probatio.Required(ATTR_COMMAND): cv.positive_int, - probatio.Optional(ATTR_ARGS, default=[]): cv.ensure_list, - probatio.Optional(ATTR_MANUFACTURER): probatio.All( - probatio.Coerce(int), probatio.Range(min=-1) - ), - } - ), -} ZHA_CONFIG_SCHEMAS = { @@ -1294,258 +1138,6 @@ async def websocket_change_channel( @callback def async_load_api(hass: HomeAssistant) -> None: """Set up the web socket API.""" - zha_gateway = get_zha_gateway(hass) - application_controller = zha_gateway.application_controller - - async def permit(service: ServiceCall) -> None: - """Allow devices to join this network.""" - duration: int = service.data[ATTR_DURATION] - ieee: EUI64 | None = service.data.get(ATTR_IEEE) - src_ieee: EUI64 - link_key: KeyData - if ATTR_SOURCE_IEEE in service.data: - src_ieee = service.data[ATTR_SOURCE_IEEE] - link_key = service.data[ATTR_INSTALL_CODE] - _LOGGER.info("Allowing join for %s device with link key", src_ieee) - await application_controller.permit_with_link_key( - time_s=duration, node=src_ieee, link_key=link_key - ) - return - - if ATTR_QR_CODE in service.data: - src_ieee, link_key = service.data[ATTR_QR_CODE] - _LOGGER.info("Allowing join for %s device with link key", src_ieee) - await application_controller.permit_with_link_key( - time_s=duration, node=src_ieee, link_key=link_key - ) - return - - if ieee: - _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) - else: - _LOGGER.info("Permitting joins for %ss", duration) - await application_controller.permit(time_s=duration, node=ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] - ) - - async def remove(service: ServiceCall) -> None: - """Remove a node from the network.""" - zha_gateway = get_zha_gateway(hass) - ieee: EUI64 = service.data[ATTR_IEEE] - _LOGGER.info("Removing node %s", ieee) - await zha_gateway.async_remove_device(ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] - ) - - async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: - """Set zigbee attribute for cluster on zha entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - attribute: int | str = service.data[ATTR_ATTRIBUTE] - value: int | bool | str = service.data[ATTR_VALUE] - manufacturer: int | ZigpyUndefinedType = service.data.get( - ATTR_MANUFACTURER, ZIGPY_UNDEFINED - ) - zha_device = zha_gateway.get_device(ieee) - response = None - if zha_device is not None: - response = await zha_device.write_zigbee_attribute( - endpoint_id, - cluster_id, - attribute, - value, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - else: - raise ValueError(f"Device with IEEE {ieee!s} not found") - - _LOGGER.debug( - ( - "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" - " [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_VALUE, - value, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, - set_zigbee_cluster_attributes, - schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], - ) - - async def issue_zigbee_cluster_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on ZHA entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - command: int = service.data[ATTR_COMMAND] - command_type: str = service.data[ATTR_COMMAND_TYPE] - args: list | None = service.data.get(ATTR_ARGS) - params: dict | None = service.data.get(ATTR_PARAMS) - manufacturer: int | ZigpyUndefinedType = service.data.get( - ATTR_MANUFACTURER, ZIGPY_UNDEFINED - ) - zha_device = zha_gateway.get_device(ieee) - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - - await zha_device.issue_cluster_command( - endpoint_id, - cluster_id, - command, - command_type, - args, - params, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s] %s: [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_COMMAND, - command, - ATTR_COMMAND_TYPE, - command_type, - ATTR_ARGS, - args, - ATTR_PARAMS, - params, - ATTR_MANUFACTURER, - manufacturer, - ) - else: - raise ValueError(f"Device with IEEE {ieee!s} not found") - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, - issue_zigbee_cluster_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], - ) - - async def issue_zigbee_group_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on a zigbee group.""" - group_id: int = service.data[ATTR_GROUP] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - command: int = service.data[ATTR_COMMAND] - args: list = service.data[ATTR_ARGS] - manufacturer: int | ZigpyUndefinedType = service.data.get( - ATTR_MANUFACTURER, ZIGPY_UNDEFINED - ) - group = zha_gateway.get_group(group_id) - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) - response = None - if group is not None: - cluster = group.endpoint[cluster_id] - response = await cluster.command( - command, *args, manufacturer=manufacturer, expect_reply=True - ) - _LOGGER.debug( - "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_COMMAND, - command, - ATTR_ARGS, - args, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, - issue_zigbee_group_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], - ) - - async def warning_device_squawk(service: ServiceCall) -> None: - """Issue the squawk command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - - device = zha_gateway.get_device(ieee) - siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True) - - await siren.async_squawk(mode=mode, strobe=strobe, squawk_level=level) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_SQUAWK, - warning_device_squawk, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], - ) - - async def warning_device_warn(service: ServiceCall) -> None: - """Issue the warning command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] - duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] - intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] - - device = zha_gateway.get_device(ieee) - siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True) - - await siren.async_turn_on( - tone=mode, - volume_level=level, - duration=duration, - strobe=strobe, - strobe_duty_cycle=duty_mode, - strobe_intensity=intensity, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_WARN, - warning_device_warn, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], - ) - websocket_api.async_register_command(hass, websocket_permit_devices) websocket_api.async_register_command(hass, websocket_get_devices) websocket_api.async_register_command(hass, websocket_get_groupable_devices) @@ -1574,15 +1166,3 @@ async def warning_device_warn(service: ServiceCall) -> None: websocket_api.async_register_command(hass, websocket_create_network_backup) websocket_api.async_register_command(hass, websocket_restore_network_backup) websocket_api.async_register_command(hass, websocket_change_channel) - - -@callback -def async_unload_api(hass: HomeAssistant) -> None: - """Unload the ZHA API.""" - hass.services.async_remove(DOMAIN, SERVICE_PERMIT) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE) - hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) diff --git a/tests/components/zha/test_services.py b/tests/components/zha/test_services.py new file mode 100644 index 00000000000000..565f998403878c --- /dev/null +++ b/tests/components/zha/test_services.py @@ -0,0 +1,95 @@ +"""Tests for the ZHA services.""" + +import pytest + +from homeassistant.components.zha.const import DOMAIN +from homeassistant.components.zha.services import ( + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + SERVICE_PERMIT, + SERVICE_REMOVE, + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + SERVICE_WARNING_DEVICE_SQUAWK, + SERVICE_WARNING_DEVICE_WARN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.setup import async_setup_component + +IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" + + +async def test_services_registered_without_gateway(hass: HomeAssistant) -> None: + """Test the actions are registered during component setup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + for service in ( + SERVICE_PERMIT, + SERVICE_REMOVE, + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + SERVICE_WARNING_DEVICE_SQUAWK, + SERVICE_WARNING_DEVICE_WARN, + ): + assert hass.services.has_service(DOMAIN, service) + + +@pytest.mark.parametrize( + ("service", "data"), + [ + pytest.param(SERVICE_PERMIT, {}, id="permit"), + pytest.param(SERVICE_REMOVE, {"ieee": IEEE_SWITCH_DEVICE}, id="remove"), + pytest.param( + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + { + "ieee": IEEE_SWITCH_DEVICE, + "endpoint_id": 1, + "cluster_id": 6, + "attribute": 0, + "value": 1, + }, + id="set_zigbee_cluster_attribute", + ), + pytest.param( + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + { + "ieee": IEEE_SWITCH_DEVICE, + "endpoint_id": 1, + "cluster_id": 6, + "command": 0, + "command_type": "server", + "params": {}, + }, + id="issue_zigbee_cluster_command", + ), + pytest.param( + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + {"group": 1, "cluster_id": 6, "command": 0}, + id="issue_zigbee_group_command", + ), + pytest.param( + SERVICE_WARNING_DEVICE_SQUAWK, + {"ieee": IEEE_SWITCH_DEVICE}, + id="warning_device_squawk", + ), + pytest.param( + SERVICE_WARNING_DEVICE_WARN, + {"ieee": IEEE_SWITCH_DEVICE}, + id="warning_device_warn", + ), + ], +) +async def test_service_without_gateway_raises( + hass: HomeAssistant, service: str, data: dict +) -> None: + """Test the actions raise a user-facing error when no gateway is loaded.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call(DOMAIN, service, data, blocking=True) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "no_gateway" diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 77272ead3a7cc6..7b2d1aee75c4d6 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -42,25 +42,27 @@ TYPE_RESULT, ) from homeassistant.components.zha import DOMAIN -from homeassistant.components.zha.const import EZSP_OVERWRITE_EUI64 +from homeassistant.components.zha.const import ( + ATTR_DURATION, + ATTR_INSTALL_CODE, + ATTR_QR_CODE, + ATTR_SOURCE_IEEE, + EZSP_OVERWRITE_EUI64, +) from homeassistant.components.zha.helpers import ( ZHADeviceProxy, ZHAGatewayProxy, get_zha_gateway, get_zha_gateway_proxy, ) +from homeassistant.components.zha.services import SERVICE_PERMIT from homeassistant.components.zha.websocket_api import ( - ATTR_DURATION, - ATTR_INSTALL_CODE, - ATTR_QR_CODE, - ATTR_SOURCE_IEEE, ATTR_TARGET_IEEE, BINDINGS, GROUP_ID, GROUP_IDS, GROUP_NAME, ID, - SERVICE_PERMIT, TYPE, async_load_api, ) From 401bcae006188daa436eccf888ff38c8b08fb9cd Mon Sep 17 00:00:00 2001 From: Onero-testdev Date: Mon, 14 Sep 2026 17:46:06 +0800 Subject: [PATCH 07/25] Add SwitchBot Universal Remote Control battery sensor (#174101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 Co-authored-by: Fan Kai Co-authored-by: Ludovic BOUÉ --- .../components/switchbot/__init__.py | 2 + homeassistant/components/switchbot/const.py | 2 + tests/components/switchbot/__init__.py | 25 ++++++++++++ tests/components/switchbot/test_sensor.py | 40 +++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 519825582b337d..324233f464cda0 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -203,6 +203,7 @@ Platform.SENSOR, ], SupportedModels.WEATHER_STATION.value: [Platform.SENSOR], + SupportedModels.UNIVERSAL_REMOTE.value: [Platform.SENSOR], SupportedModels.CANDLE_WARMER_LAMP.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBIC_NEON_ROPE_LIGHT.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBIC_NEON_WIRE_ROPE_LIGHT.value: [ @@ -226,6 +227,7 @@ SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade, SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan, SupportedModels.STANDING_FAN.value: switchbot.SwitchbotStandingFan, + SupportedModels.UNIVERSAL_REMOTE.value: switchbot.SwitchbotUniversalRemote, SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum, SupportedModels.S20_VACUUM.value: switchbot.SwitchbotVacuum, SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum, diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index cf0d8e2364b35c..2d04fa982fbeac 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -73,6 +73,7 @@ class SupportedModels(StrEnum): LOCK_PRO_WIFI = "lock_pro_wifi" WEATHER_STATION = "weather_station" STANDING_FAN = "standing_fan" + UNIVERSAL_REMOTE = "universal_remote" CANDLE_WARMER_LAMP = "candle_warmer_lamp" RGBIC_NEON_ROPE_LIGHT = "rgbic_neon_rope_light" RGBIC_NEON_WIRE_ROPE_LIGHT = "rgbic_neon_wire_rope_light" @@ -127,6 +128,7 @@ class SupportedModels(StrEnum): SwitchbotModel.LOCK_VISION: SupportedModels.LOCK_VISION, SwitchbotModel.LOCK_PRO_WIFI: SupportedModels.LOCK_PRO_WIFI, SwitchbotModel.STANDING_FAN: SupportedModels.STANDING_FAN, + SwitchbotModel.UNIVERSAL_REMOTE: SupportedModels.UNIVERSAL_REMOTE, SwitchbotModel.CANDLE_WARMER_LAMP: SupportedModels.CANDLE_WARMER_LAMP, SwitchbotModel.RGBIC_NEON_ROPE_LIGHT: SupportedModels.RGBIC_NEON_ROPE_LIGHT, SwitchbotModel.RGBIC_NEON_WIRE_ROPE_LIGHT: ( diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index d6518d5b82e0cd..9f836a265f8520 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -589,6 +589,31 @@ def make_advertisement( ) +UNIVERSAL_REMOTE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Universal Remote", + manufacturer_data={ + 2409: b"\xaa\xbb\xcc\xdd\xee\xff\x00\x50\x00\x00\x00\x00\x00\x00\x00\x00" + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"'\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Universal Remote", + manufacturer_data={ + 2409: b"\xaa\xbb\xcc\xdd\xee\xff\x00\x50\x00\x00\x00\x00\x00\x00\x00\x00" + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"'\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Universal Remote"), + time=0, + connectable=True, + tx_power=-127, +) + + K20_VACUUM_SERVICE_INFO = BluetoothServiceInfoBleak( name="K20 Vacuum", manufacturer_data={ diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 5cf154164630cd..b3c5577baafe41 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -41,6 +41,7 @@ PRESENCE_SENSOR_SERVICE_INFO, RELAY_SWITCH_2PM_SERVICE_INFO, REMOTE_SERVICE_INFO, + UNIVERSAL_REMOTE_SERVICE_INFO, WEATHER_STATION_SERVICE_INFO, WOHAND_SERVICE_INFO, WOHUB2_SERVICE_INFO, @@ -293,6 +294,45 @@ async def test_remote(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_universal_remote_sensor(hass: HomeAssistant) -> None: + """Test setting up the Universal Remote battery sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, UNIVERSAL_REMOTE_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "universal_remote", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor.state == "80" + assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery" + assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_hub2_sensor(hass: HomeAssistant) -> None: """Test setting up creates the sensor for WoHub2.""" From 17fe09622ef1d5369f437b84e39208a09a08d88a Mon Sep 17 00:00:00 2001 From: Samuel Siburian Date: Mon, 14 Sep 2026 16:48:23 +0700 Subject: [PATCH 08/25] Add AirGradient measurement interval number (#180319) Co-authored-by: Ariel Ebersberger --- homeassistant/components/airgradient/const.py | 2 + .../components/airgradient/number.py | 22 ++++++- .../components/airgradient/strings.json | 3 + .../airgradient/snapshots/test_number.ambr | 61 +++++++++++++++++++ tests/components/airgradient/test_number.py | 57 ++++++++++++++++- 5 files changed, 141 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airgradient/const.py b/homeassistant/components/airgradient/const.py index 69c7ae79758965..f4de03e3ca98ee 100644 --- a/homeassistant/components/airgradient/const.py +++ b/homeassistant/components/airgradient/const.py @@ -25,6 +25,7 @@ LED_BAR_MODE = "led_bar_mode" LED_BAR_BRIGHTNESS = "led_bar_brightness" DISPLAY_BRIGHTNESS = "display_brightness" +MEASUREMENT_INTERVAL = "measurement_interval" GPS_MODE = "gps_mode" FRONT_LED_BRIGHTNESS = "front_led_brightness" BACK_LED_BRIGHTNESS = "back_led_brightness" @@ -62,6 +63,7 @@ class ModelCapabilities: TVOC_LEARNING_OFFSET, PM_STANDARD_CONFIG, TEMPERATURE_UNIT, + MEASUREMENT_INTERVAL, GPS_MODE, FRONT_LED_BRIGHTNESS, BACK_LED_BRIGHTNESS, diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py index 0a6ba95e251e15..ffd6ac6d410752 100644 --- a/homeassistant/components/airgradient/number.py +++ b/homeassistant/components/airgradient/number.py @@ -9,10 +9,12 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, + NumberDeviceClass, NumberEntity, NumberEntityDescription, + NumberMode, ) -from homeassistant.const import EntityCategory, UnitOfRatio +from homeassistant.const import EntityCategory, UnitOfRatio, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,6 +23,7 @@ from .const import ( DISPLAY_BRIGHTNESS as DISPLAY_BRIGHTNESS_CONFIG, DOMAIN, + MEASUREMENT_INTERVAL as MEASUREMENT_INTERVAL_CONFIG, supports_config, ) from .coordinator import AirGradientCoordinator @@ -64,6 +67,21 @@ class AirGradientNumberEntityDescription(NumberEntityDescription): set_value_fn=lambda client, value: client.set_led_bar_brightness(value), ) +MEASUREMENT_INTERVAL = AirGradientNumberEntityDescription( + key="measurement_interval", + translation_key="measurement_interval", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + mode=NumberMode.BOX, + native_min_value=1, + native_max_value=3600, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + config_key=MEASUREMENT_INTERVAL_CONFIG, + value_fn=lambda config: config.measurement_interval, + set_value_fn=lambda client, value: client.set_measurement_interval(value), +) + async def async_setup_entry( hass: HomeAssistant, @@ -74,7 +92,7 @@ async def async_setup_entry( coordinator = entry.runtime_data model = coordinator.data.measures.model - descriptions = (DISPLAY_BRIGHTNESS, LED_BAR_BRIGHTNESS) + descriptions = (DISPLAY_BRIGHTNESS, LED_BAR_BRIGHTNESS, MEASUREMENT_INTERVAL) descriptions_by_key = {description.key: description for description in descriptions} added_entities: set[str] = set() diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index f1343f974e940c..dddbd32c6adcec 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -39,6 +39,9 @@ }, "led_bar_brightness": { "name": "LED bar brightness" + }, + "measurement_interval": { + "name": "Measurement interval" } }, "select": { diff --git a/tests/components/airgradient/snapshots/test_number.ambr b/tests/components/airgradient/snapshots/test_number.ambr index 4f1007c975fc57..dba0f3897da9ca 100644 --- a/tests/components/airgradient/snapshots/test_number.ambr +++ b/tests/components/airgradient/snapshots/test_number.ambr @@ -119,3 +119,64 @@ 'state': '100', }) # --- +# name: test_v1_entities[number.airgradient_measurement_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 3600, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.airgradient_measurement_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Measurement interval', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Measurement interval', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measurement_interval', + 'unique_id': '84fce612f5b8-measurement_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_v1_entities[number.airgradient_measurement_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Airgradient Measurement interval', + : 3600, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.airgradient_measurement_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py index 6dbe74575b4368..7648dbd50e4805 100644 --- a/tests/components/airgradient/test_number.py +++ b/tests/components/airgradient/test_number.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import AirGradientConnectionError, AirGradientError +from airgradient import AirGradientConnectionError, AirGradientError, ApiVersion from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -18,7 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import async_load_config_fixture, setup_integration +from . import async_load_config_fixture, load_config_fixture, setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -64,6 +64,59 @@ async def test_setting_value( mock_airgradient_client.set_led_bar_brightness.assert_called_once() +@pytest.mark.parametrize( + ("entity_id", "value", "method"), + [ + ( + "number.airgradient_measurement_interval", + 300, + "set_measurement_interval", + ), + ], +) +async def test_v1_number_writes( + hass: HomeAssistant, + mock_v1_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + value: int, + method: str, +) -> None: + """Test V1 number writes.""" + mock_v1_airgradient_client.get_config.return_value = load_config_fixture( + "config_v1_local.json", ApiVersion.V1 + ) + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: value}, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + getattr(mock_v1_airgradient_client, method).assert_awaited_once_with(value) + + +async def test_v1_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_v1_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test V1 number entities.""" + mock_v1_airgradient_client.get_config.return_value = load_config_fixture( + "config_v1_local.json", ApiVersion.V1 + ) + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + async def test_cloud_creates_no_number( hass: HomeAssistant, mock_cloud_airgradient_client: AsyncMock, From b3a105c8b590485bd522d9b688a2402e3d1660f1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 14 Sep 2026 20:07:09 +1000 Subject: [PATCH 09/25] Add energy site reconfigure flow to Teslemetry (#181335) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/teslemetry/config_flow.py | 75 +++++- .../components/teslemetry/strings.json | 4 +- .../components/teslemetry/test_config_flow.py | 220 +++++++++++++++++- 3 files changed, 293 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index 3529757397525c..bd166b3e9f7a21 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -25,6 +25,7 @@ TeslaFleetError, WhitelistOperationAttemptingToAddExistingKey, ) +from tesla_fleet_api.tesla import EnergySiteRouter from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth from tesla_fleet_api.teslemetry import Teslemetry from tesla_fleet_api.teslemetry.energysite import AuthorizedClient, TeslemetryEnergySite @@ -70,6 +71,7 @@ SUBENTRY_TYPE_VEHICLE, ) from .helpers import async_get_ble_parent +from .models import TeslemetryEnergyData class PowerwallLookupError(Exception): @@ -80,6 +82,21 @@ class PowerwallKeyRejectedError(Exception): """Signal that the gateway refused a v1r-signed read with our RSA key.""" +def _cloud_energy_site(energy_data: TeslemetryEnergyData) -> TeslemetryEnergySite: + """Return the cloud energy-site API for pairing. + + Pairing always registers the key through the Teslemetry cloud; a paired + site's api is an EnergySiteRouter, so unwrap its cloud secondary rather + than routing to the local Powerwall primary. + """ + return cast( + TeslemetryEnergySite, + energy_data.api.secondary + if isinstance(energy_data.api, EnergySiteRouter) + else energy_data.api, + ) + + class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): @@ -468,9 +485,8 @@ async def async_step_user( energy_data = available[user_input[CONF_SITE_ID]] self._site_id = energy_data.id self._site_name = energy_data.device.get("name") or "Energy Site" - # Only unpaired sites are offered, so api is always the cloud EnergySite. if abort := await self._prepare_energy_site( - cast(TeslemetryEnergySite, energy_data.api) + _cloud_energy_site(energy_data) ): return abort return await self._async_begin_pairing() @@ -489,6 +505,29 @@ async def async_step_user( ), ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Re-pair an added site's local Powerwall to update its credentials.""" + subentry = self._get_reconfigure_subentry() + entry = cast(TeslemetryConfigEntry, self._get_entry()) + # runtime_data (the resolved energy sites) exists only while loaded. + if entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + energy_data = next( + ( + energysite + for energysite in entry.runtime_data.energysites + if energysite.subentry_id == subentry.subentry_id + ), + None, + ) + if energy_data is None: + return self.async_abort(reason="cannot_connect") + if abort := await self._prepare_energy_site(_cloud_energy_site(energy_data)): + return abort + return await self._async_begin_pairing() + async def _prepare_energy_site( self, energy_site: TeslemetryEnergySite ) -> SubentryFlowResult | None: @@ -620,6 +659,20 @@ async def _verify_local_gateway(self, host: str, password: str) -> None: except PowerwallAuthenticationError as err: raise PowerwallKeyRejectedError from err + def _default_gateway_host(self) -> str: + """Return the host to pre-fill on the credentials form, or "" for blank. + + Discovery wins; on reconfigure a failed discovery falls back to the + subentry's known host rather than leaving the field blank, so a + password-only change is verified against the right gateway. A new + site whose discovery failed is left blank. + """ + if self._discovered_host: + return self._discovered_host + if self.source == SOURCE_RECONFIGURE: + return cast(str, self._get_reconfigure_subentry().data[CONF_HOST]) + return "" + async def async_step_credentials( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -650,7 +703,7 @@ async def async_step_credentials( { probatio.Required( CONF_HOST, - default=self._discovered_host or probatio.UNDEFINED, + default=self._default_gateway_host() or probatio.UNDEFINED, ): str, probatio.Required(CONF_PASSWORD): str, } @@ -660,7 +713,21 @@ async def async_step_credentials( @callback def _async_save_credentials(self, host: str, password: str) -> SubentryFlowResult: - """Persist the verified gateway credentials to a new subentry.""" + """Persist the verified gateway credentials to the subentry.""" + if self.source == SOURCE_RECONFIGURE: + entry = self._get_entry() + subentry = self._get_reconfigure_subentry() + self._async_update( + entry, + subentry, + data_updates={CONF_HOST: host, CONF_PASSWORD: password}, + ) + # Always reload, even when credentials are unchanged: an earlier + # local-control initialization failure leaves only the cloud API active, + # and successful re-verification must install the local-first router. + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + return self.async_create_entry( title=self._site_name, data={ diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 43e8704544393d..1d69696b2b6797 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -51,7 +51,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "entry_not_loaded": "The Teslemetry account must be loaded before setting up local control. Try again once it has finished loading.", - "no_powerwall": "Local control requires a Powerwall, and no energy site with one is currently accessible on your Teslemetry account." + "no_powerwall": "Local control requires a Powerwall, and no energy site with one is currently accessible on your Teslemetry account.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "Energy site", "error": { @@ -62,6 +63,7 @@ "key_pending": "Home Assistant's key has not been approved yet. Flick the On/Off switch on your primary Powerwall off and back on to approve it, then submit again." }, "initiate_flow": { + "reconfigure": "Reconfigure energy site", "user": "Add local energy site" }, "step": { diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 493a3b7f884a3a..50db98dd7ed8b1 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -31,7 +31,7 @@ TeslaFleetError, WhitelistOperationAttemptingToAddExistingKey, ) -from tesla_fleet_api.tesla import VehicleRouter +from tesla_fleet_api.tesla import EnergySiteRouter, VehicleRouter from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth from tesla_fleet_api.teslemetry.energysite import AuthorizedClient, AuthorizedClients @@ -2263,3 +2263,221 @@ async def test_rsa_key_load_failure_aborts( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def _setup_paired_account(hass: HomeAssistant) -> MockConfigEntry: + """Set up an account whose battery site is already paired for local control.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.teslemetry._async_get_rsa_key_pem", + return_value=_TEST_RSA_KEY_PEM, + ), + patch("homeassistant.components.teslemetry.PLATFORMS", []), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +@pytest.mark.usefixtures("mock_rsa_key") +async def test_reconfigure_updates_credentials_and_schedules_reload( + hass: HomeAssistant, +) -> None: + """Reconfiguring a paired site updates its credentials and reloads the entry.""" + entry = await _setup_paired_account(hass) + subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id + new_host = "192.168.1.50" + + client = _mock_powerwall_client() + with ( + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients", + new=AsyncMock( + return_value=_own_key_clients(AuthorizedClientState.VERIFIED) + ), + ), + patch( + "homeassistant.components.teslemetry.config_flow.PowerwallClient", + return_value=client, + ), + patch.object(hass.config_entries, "async_schedule_reload") as mock_reload, + ): + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["step_id"] == "credentials" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_HOST: new_host, CONF_PASSWORD: "wxyz9"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + subentry = entry.subentries[subentry_id] + assert subentry.data[CONF_HOST] == new_host + assert subentry.data[CONF_PASSWORD] == "wxyz9" + # The subentry-change reload listener fires only on membership changes, so the + # step must schedule the reload itself for the new credentials to take effect. + mock_reload.assert_called_once_with(entry.entry_id) + + +@pytest.mark.usefixtures("mock_rsa_key") +async def test_reconfigure_unchanged_credentials_still_schedules_reload( + hass: HomeAssistant, +) -> None: + """Reconfiguring with unchanged credentials still reloads the entry.""" + entry = await _setup_paired_account(hass) + subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id + + client = _mock_powerwall_client() + with ( + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients", + new=AsyncMock( + return_value=_own_key_clients(AuthorizedClientState.VERIFIED) + ), + ), + patch( + "homeassistant.components.teslemetry.config_flow.PowerwallClient", + return_value=client, + ), + patch.object(hass.config_entries, "async_schedule_reload") as mock_reload, + ): + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["step_id"] == "credentials" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + subentry = entry.subentries[subentry_id] + assert subentry.data[CONF_HOST] == HOST + assert subentry.data[CONF_PASSWORD] == PASSWORD + # The stored data is unchanged, but a re-verify must still re-enable local + # control after an earlier cloud fallback, so the reload is scheduled anyway. + mock_reload.assert_called_once_with(entry.entry_id) + + +async def test_reconfigure_aborts_when_entry_not_loaded(hass: HomeAssistant) -> None: + """Reconfigure aborts when the account entry is not loaded.""" + entry = _entry_with_powerwall() + entry.add_to_hass(hass) + subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id + + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +@pytest.mark.usefixtures("mock_rsa_key") +async def test_reconfigure_aborts_when_site_not_resolved(hass: HomeAssistant) -> None: + """Reconfigure aborts when no resolved energy site matches the subentry.""" + entry = await _setup_paired_account(hass) + subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id + # Drop the resolved sites so the subentry matches no runtime energy site. + entry.runtime_data.energysites = [] + + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("mock_rsa_key") +async def test_reconfigure_prefills_existing_host_when_discovery_fails( + hass: HomeAssistant, +) -> None: + """A failed discovery on reconfigure keeps the subentry's known host default. + + Otherwise a password-only change would default to the setup-AP address and + be verified against the wrong gateway. + """ + entry = await _setup_paired_account(hass) + subentry = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0] + # The fixture host equals DEFAULT_GATEWAY_HOST, so set a distinct known host + # to prove the default is the subentry's value and not the setup-AP fallback. + known_host = "192.168.1.50" + hass.config_entries.async_update_subentry( + entry, subentry, data={**subentry.data, CONF_HOST: known_host} + ) + + with ( + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_gateway_address", + new=AsyncMock(side_effect=ClientError), + ), + patch( + "tesla_fleet_api.teslemetry.energysite.TeslemetryEnergySite.find_authorized_clients", + new=AsyncMock( + return_value=_own_key_clients(AuthorizedClientState.VERIFIED) + ), + ), + ): + result = await entry.start_subentry_reconfigure_flow(hass, subentry.subentry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "credentials" + assert _credentials_host_default(result) == known_host + + +@pytest.mark.usefixtures("mock_rsa_key") +async def test_reconfigure_aborts_when_rsa_key_load_fails(hass: HomeAssistant) -> None: + """Reconfigure aborts when the integration's RSA key cannot be loaded.""" + entry = await _setup_paired_account(hass) + subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id + + with patch( + "homeassistant.components.teslemetry.config_flow.Teslemetry.get_rsa_private_key", + side_effect=OSError, + ): + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("mock_rsa_key") +async def test_reconfigure_pairs_via_cloud_secondary_not_local_primary( + hass: HomeAssistant, +) -> None: + """Reconfiguring a paired site pairs through the cloud site, not the local gateway. + + A paired site's api is a local-first router, so the pairing lookup must be + unwrapped to the cloud secondary; routing it would hit the local Powerwall. + """ + entry = await _setup_paired_account(hass) + subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_ENERGY_SITE)[0].subentry_id + energy_data = entry.runtime_data.energysites[0] + assert isinstance(energy_data.api, EnergySiteRouter) + + cloud_lookup = AsyncMock( + return_value=_own_key_clients(AuthorizedClientState.VERIFIED) + ) + # find_authorized_clients is a cloud-only method; adding it to the local + # backend makes the router route to it local-first, so an accidentally + # routed lookup would land on the primary and this test would catch it. + local_lookup = AsyncMock( + return_value=_own_key_clients(AuthorizedClientState.VERIFIED) + ) + with ( + patch.object( + energy_data.api.secondary, "find_authorized_clients", cloud_lookup + ), + patch.object( + energy_data.api.primary, + "find_authorized_clients", + local_lookup, + create=True, + ), + ): + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + # Reaching credentials means the cloud lookup reported the key verified. + assert result["step_id"] == "credentials" + cloud_lookup.assert_awaited() + local_lookup.assert_not_awaited() From 59272ae7b25aff57f0d74c681f4c580d800f5cfc Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:07:36 +0200 Subject: [PATCH 10/25] Add explicit device creation for DALI lines of lunatone (#180895) --- homeassistant/components/lunatone/__init__.py | 24 +++++++++++++++++ homeassistant/components/lunatone/light.py | 27 +++---------------- tests/components/lunatone/test_init.py | 7 +++++ tests/components/lunatone/test_light.py | 17 ------------ 4 files changed, 34 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py index e6d8d6bde5e202..b274df83a8bccc 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -99,6 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> assert entry.unique_id device_registry = dr.async_get(hass) + device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.unique_id)}, @@ -114,6 +115,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> ), ) + for line_id, line_info in info_api.data.lines.items(): + line_unique_id = f"{entry.unique_id}-line{line_id}" + + extra_info: dict = {} + if line_info.device.serial != info_api.data.device.serial: + extra_info.update( + serial_number=str(line_info.device.serial), + hw_version=line_info.device.pcb, + model_id=( + f"{line_info.device.article_number}{line_info.device.article_info}" + ), + ) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, line_unique_id)}, + name=f"DALI Line {line_id}", + via_device_id=dr.async_get_device_id_by_identifier( + hass, (DOMAIN, entry.unique_id), config_entry_id=entry.entry_id + ), + **extra_info, + ) + devices_api = Devices(auth_api, info_api.data.version) coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api) await coordinator_devices.async_config_entry_first_refresh() diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index 7c1d3d6a9ecc6c..a2c267a7b7b51c 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -250,30 +250,9 @@ def __init__( self._coordinator_info = coordinator_info self._broadcast = broadcast - line = broadcast.line - - self._attr_unique_id = f"{config_entry_unique_id}-line{line}" - - line_device = self._coordinator_info.data.lines[str(line)].device - extra_info: dict = {} - if line_device.serial != self._coordinator_info.data.device.serial: - extra_info.update( - serial_number=str(line_device.serial), - hw_version=line_device.pcb, - model_id=f"{line_device.article_number}{line_device.article_info}", - ) - - assert self.unique_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - name=f"DALI Line {line}", - via_device_id=dr.async_get_device_id_by_identifier( - self.coordinator.hass, - (DOMAIN, config_entry_unique_id), - config_entry_id=self.coordinator.config_entry.entry_id, - ), - **extra_info, - ) + line_unique_id = f"{config_entry_unique_id}-line{broadcast.line}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, line_unique_id)}) + self._attr_unique_id = line_unique_id @override async def async_added_to_hass(self) -> None: diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py index 6a9c30c78f22b4..93702fe2c2badf 100644 --- a/tests/components/lunatone/test_init.py +++ b/tests/components/lunatone/test_init.py @@ -47,6 +47,13 @@ async def test_load_unload_config_entry( assert device_entry.configuration_url == BASE_URL assert device_entry.model == PRODUCT_NAME + for line_id in mock_lunatone_info.data.lines: + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{mock_config_entry.unique_id}-line{line_id}"), + mock_config_entry.entry_id, + ) + assert device_entry is not None + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lunatone/test_light.py b/tests/components/lunatone/test_light.py index 9705fdb3104c78..d56306b4375e5b 100644 --- a/tests/components/lunatone/test_light.py +++ b/tests/components/lunatone/test_light.py @@ -256,23 +256,6 @@ async def fake_update(): assert state.state == "unavailable" -async def test_line_broadcast_line_present( - hass: HomeAssistant, - mock_lunatone_info: AsyncMock, - mock_lunatone_devices: AsyncMock, - mock_lunatone_sensors: AsyncMock, - mock_lunatone_scan: AsyncMock, - mock_lunatone_dali_broadcast: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test if the broadcast light line is present.""" - mock_lunatone_dali_broadcast.line = None - - await setup_integration(hass, mock_config_entry) - - assert not hass.states.async_entity_ids("light") - - @pytest.mark.parametrize( "color_temp_kelvin", [10000, 5000, 1000], From 1d38f3627ba11a1951784d93eb9f1ae019bd547e Mon Sep 17 00:00:00 2001 From: Yoav Mor Date: Mon, 14 Sep 2026 13:08:48 +0300 Subject: [PATCH 11/25] Add Besen charger display temperature unit select (#180888) --- homeassistant/components/besen/const.py | 2 +- homeassistant/components/besen/coordinator.py | 5 + homeassistant/components/besen/select.py | 103 +++++++++++ homeassistant/components/besen/strings.json | 9 + tests/components/besen/conftest.py | 7 + .../besen/snapshots/test_select.ambr | 60 +++++++ tests/components/besen/test_select.py | 164 ++++++++++++++++++ 7 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/besen/select.py create mode 100644 tests/components/besen/snapshots/test_select.ambr create mode 100644 tests/components/besen/test_select.py diff --git a/homeassistant/components/besen/const.py b/homeassistant/components/besen/const.py index 8e9b9159206677..f1b70b47d83ba0 100644 --- a/homeassistant/components/besen/const.py +++ b/homeassistant/components/besen/const.py @@ -7,4 +7,4 @@ DOMAIN: Final = "besen" NAME: Final = "Besen" -PLATFORMS: Final = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: Final = [Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/besen/coordinator.py b/homeassistant/components/besen/coordinator.py index 841a884f81345e..d9d13e3cd78e60 100644 --- a/homeassistant/components/besen/coordinator.py +++ b/homeassistant/components/besen/coordinator.py @@ -95,6 +95,11 @@ async def async_set_charge_amps(self, amps: int) -> None: await self._async_run_command(self.client.async_set_charge_amps(amps)) + async def async_set_temperature_unit(self, unit: str) -> None: + """Set the charger display temperature unit.""" + + await self._async_run_command(self.client.async_set_temperature_unit(unit)) + async def _async_run_command(self, command: Awaitable[None]) -> None: """Run a charger command and translate command failures.""" diff --git a/homeassistant/components/besen/select.py b/homeassistant/components/besen/select.py new file mode 100644 index 00000000000000..f1ebbec555e3d3 --- /dev/null +++ b/homeassistant/components/besen/select.py @@ -0,0 +1,103 @@ +"""Select platform for Besen.""" + +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from typing import Final, override + +from besen.models import BesenData + +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 BesenConfigEntry +from .coordinator import BesenCoordinator +from .entity import BesenEntity + +PARALLEL_UPDATES = 0 + +TEMPERATURE_UNIT_OPTIONS: Final = { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit", +} +TEMPERATURE_UNIT_VALUES: Final = { + value: key for key, value in TEMPERATURE_UNIT_OPTIONS.items() +} + + +def _option_value(value: str | None, options: Mapping[str, str]) -> str | None: + """Return a Home Assistant option for a charger value.""" + + return options.get(value) if value is not None else None + + +@dataclass(frozen=True, kw_only=True) +class BesenSelectEntityDescription(SelectEntityDescription): + """Describe a Besen select entity.""" + + current_option_fn: Callable[[BesenData], str | None] + option_values: dict[str, str] + select_option_fn: Callable[[BesenCoordinator, str], Awaitable[None]] + + +SELECT_DESCRIPTIONS: tuple[BesenSelectEntityDescription, ...] = ( + BesenSelectEntityDescription( + key="temperature_unit", + translation_key="temperature_unit", + entity_category=EntityCategory.CONFIG, + options=list(TEMPERATURE_UNIT_OPTIONS), + current_option_fn=lambda data: _option_value( + data.config.temperature_unit, TEMPERATURE_UNIT_VALUES + ), + option_values=TEMPERATURE_UNIT_OPTIONS, + select_option_fn=lambda coordinator, option: ( + coordinator.async_set_temperature_unit(option) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BesenConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Besen select platform.""" + + async_add_entities( + BesenSelect(entry.runtime_data, description) + for description in SELECT_DESCRIPTIONS + ) + + +class BesenSelect(BesenEntity, SelectEntity): + """Representation of a Besen select.""" + + entity_description: BesenSelectEntityDescription + + def __init__( + self, + coordinator: BesenCoordinator, + description: BesenSelectEntityDescription, + ) -> None: + """Initialize a Besen select.""" + + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + @override + def current_option(self) -> str | None: + """Return the current option.""" + + return self.entity_description.current_option_fn(self.coordinator.data) + + @override + async def async_select_option(self, option: str) -> None: + """Set the selected option.""" + + await self.entity_description.select_option_fn( + self.coordinator, + self.entity_description.option_values[option], + ) diff --git a/homeassistant/components/besen/strings.json b/homeassistant/components/besen/strings.json index 547a52e1feed10..b7dcb666924268 100644 --- a/homeassistant/components/besen/strings.json +++ b/homeassistant/components/besen/strings.json @@ -41,6 +41,15 @@ "number": { "charging_current": { "name": "Charging current" } }, + "select": { + "temperature_unit": { + "name": "Temperature unit", + "state": { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit" + } + } + }, "sensor": { "charging_message": { "name": "Charging message", diff --git a/tests/components/besen/conftest.py b/tests/components/besen/conftest.py index 8f79e287e6a79a..fd2bab82fffe94 100644 --- a/tests/components/besen/conftest.py +++ b/tests/components/besen/conftest.py @@ -52,6 +52,7 @@ def charger_state( charger_status: bool | None = True, charge_amps: int | None = 16, output_max_amps: int | None = 32, + temperature_unit: str | None = "Celsius", available: bool = True, authenticated: bool = True, phases: int = 1, @@ -72,6 +73,7 @@ def charger_state( ), config=ChargerConfig( charge_amps=charge_amps, + temperature_unit=temperature_unit, device_name="Garage", rssi=-55, ), @@ -114,6 +116,7 @@ def _configure_client_mock(client: Mock) -> None: client.async_start_charging = AsyncMock() client.async_stop_charging = AsyncMock() client.async_set_charge_amps = AsyncMock() + client.async_set_temperature_unit = AsyncMock() client.add_listener.return_value = Mock() @@ -174,9 +177,13 @@ async def async_stop_charging() -> None: async def async_set_charge_amps(amps: int) -> None: publish_besen_state(client, charger_state(charge_amps=amps)) + async def async_set_temperature_unit(unit: str) -> None: + publish_besen_state(client, charger_state(temperature_unit=unit)) + client.async_start_charging.side_effect = async_start_charging client.async_stop_charging.side_effect = async_stop_charging client.async_set_charge_amps.side_effect = async_set_charge_amps + client.async_set_temperature_unit.side_effect = async_set_temperature_unit yield client diff --git a/tests/components/besen/snapshots/test_select.ambr b/tests/components/besen/snapshots/test_select.ambr new file mode 100644 index 00000000000000..28fa6102271078 --- /dev/null +++ b/tests/components/besen/snapshots/test_select.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_select_state[select.garage_temperature_unit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garage_temperature_unit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature unit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature unit', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_unit', + 'unique_id': 'AA:BB_temperature_unit', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state[select.garage_temperature_unit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Garage Temperature unit', + : list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.garage_temperature_unit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'celsius', + }) +# --- diff --git a/tests/components/besen/test_select.py b/tests/components/besen/test_select.py new file mode 100644 index 00000000000000..2eabd28b44ae40 --- /dev/null +++ b/tests/components/besen/test_select.py @@ -0,0 +1,164 @@ +"""Tests for the Besen select platform.""" + +from unittest.mock import AsyncMock, Mock + +from besen.exceptions import CommandFailed +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.besen.const import DOMAIN +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import publish_besen_state +from .conftest import charger_state, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +TEMPERATURE_UNIT_ENTITY_ID = "select.garage_temperature_unit" + + +async def test_select_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test select entity states and registry data.""" + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + mock_besen_client.async_start.assert_awaited_once() + + +async def test_select_updates_from_client( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test select states update from client push data.""" + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + publish_besen_state( + mock_besen_client, + charger_state(temperature_unit="Fahrenheit"), + ) + await hass.async_block_till_done() + + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == "fahrenheit" + + +@pytest.mark.parametrize( + ("available", "authenticated"), + [ + (False, True), + (True, False), + ], +) +async def test_select_unavailable_from_client_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + available: bool, + authenticated: bool, +) -> None: + """Test select availability follows client state.""" + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + publish_besen_state( + mock_besen_client, + charger_state(available=available, authenticated=authenticated), + ) + await hass.async_block_till_done() + + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("temperature_unit", [None, "Kelvin"]) +async def test_select_unknown_for_unreported_or_unsupported_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + temperature_unit: str | None, +) -> None: + """Test unreported and unsupported values are unknown.""" + + mock_besen_client.state = charger_state(temperature_unit=temperature_unit) + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("option", "wire_value"), + [("celsius", "Celsius"), ("fahrenheit", "Fahrenheit")], +) +async def test_select_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + option: str, + wire_value: str, +) -> None: + """Test selecting an option sends its protocol value and updates state.""" + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: TEMPERATURE_UNIT_ENTITY_ID, ATTR_OPTION: option}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_besen_client.async_set_temperature_unit.assert_awaited_once_with(wire_value) + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == option + + +async def test_select_command_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test select command failures are translated.""" + + mock_besen_client.async_set_temperature_unit = AsyncMock( + side_effect=CommandFailed("failed") + ) + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: TEMPERATURE_UNIT_ENTITY_ID, ATTR_OPTION: "fahrenheit"}, + blocking=True, + ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "command_failed" + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == "celsius" From 42ed09d58bbf557b610483ff8ce2a078bd63b23e Mon Sep 17 00:00:00 2001 From: syl-craft Date: Mon, 14 Sep 2026 12:16:17 +0200 Subject: [PATCH 12/25] Add Tuya sfkzq water valve test fixture (#182169) --- .../tuya/fixtures/sfkzq_kcdiut0eqeni7b8n.json | 132 ++++++++++++++++++ .../components/tuya/snapshots/test_init.ambr | 30 ++++ .../tuya/snapshots/test_number.ambr | 61 ++++++++ .../tuya/snapshots/test_select.ambr | 63 +++++++++ .../components/tuya/snapshots/test_valve.ambr | 53 +++++++ 5 files changed, 339 insertions(+) create mode 100644 tests/components/tuya/fixtures/sfkzq_kcdiut0eqeni7b8n.json diff --git a/tests/components/tuya/fixtures/sfkzq_kcdiut0eqeni7b8n.json b/tests/components/tuya/fixtures/sfkzq_kcdiut0eqeni7b8n.json new file mode 100644 index 00000000000000..c82164a3f2a138 --- /dev/null +++ b/tests/components/tuya/fixtures/sfkzq_kcdiut0eqeni7b8n.json @@ -0,0 +1,132 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smart Water Valve BV05", + "category": "sfkzq", + "product_id": "kcdiut0eqeni7b8n", + "product_name": "Smart Water Valve BV05", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-23T16:37:28+00:00", + "create_time": "2025-03-23T16:37:28+00:00", + "update_time": "2025-03-23T16:37:28+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "percent_state": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "water_once": { + "type": "Integer", + "value": { + "unit": "L", + "min": 0, + "max": 10000, + "scale": 1, + "step": 1 + } + }, + "water_total": { + "type": "Integer", + "value": { + "unit": "L", + "min": 0, + "max": 999999999, + "scale": 0, + "step": 1 + } + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "sensor_temperature": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "percent_control": 20, + "percent_state": 20, + "water_once": 10000, + "water_total": 78841, + "weather_delay": "cancel", + "countdown": 0, + "sensor_temperature": 25 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index a4d2cea87e8ebf..73e20d98523c67 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -5849,6 +5849,36 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[n8b7ineqe0tuidckqzkfs] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'n8b7ineqe0tuidckqzkfs', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Smart Water Valve BV05', + 'model_id': 'kcdiut0eqeni7b8n', + 'name': 'Smart Water Valve BV05', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[nc4e9nlZPTuTNfYEzc] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 2ab761a8cb3db4..f3294c5282a8fb 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -3210,6 +3210,67 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[number.smart_water_valve_bv05_irrigation_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 86400.0, + : 0.0, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.smart_water_valve_bv05_irrigation_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Irrigation duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irrigation duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'irrigation_duration', + 'unique_id': 'tuya.n8b7ineqe0tuidckqzkfscountdown', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[number.smart_water_valve_bv05_irrigation_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Smart Water Valve BV05 Irrigation duration', + : 86400.0, + : 0.0, + : , + : 1.0, + : 's', + }), + 'context': , + 'entity_id': 'number.smart_water_valve_bv05_irrigation_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[number.smart_white_noise_machine_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 52cc3d2f35cd86..9b3f85a7c78cbf 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -6131,6 +6131,69 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[select.smart_water_valve_bv05_weather_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.smart_water_valve_bv05_weather_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Weather delay', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weather delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'weather_delay', + 'unique_id': 'tuya.n8b7ineqe0tuidckqzkfsweather_delay', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.smart_water_valve_bv05_weather_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Smart Water Valve BV05 Weather delay', + : list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'context': , + 'entity_id': 'select.smart_water_valve_bv05_weather_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- # name: test_platform_setup_and_discovery[select.socket3_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/snapshots/test_valve.ambr b/tests/components/tuya/snapshots/test_valve.ambr index 74bedd56a9f34b..e8188d65591a04 100644 --- a/tests/components/tuya/snapshots/test_valve.ambr +++ b/tests/components/tuya/snapshots/test_valve.ambr @@ -581,6 +581,59 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[valve.smart_water_valve_bv05_valve-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': 'valve', + 'entity_category': None, + 'entity_id': 'valve.smart_water_valve_bv05_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Valve', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'valve', + 'unique_id': 'tuya.n8b7ineqe0tuidckqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[valve.smart_water_valve_bv05_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'water', + : 'Smart Water Valve BV05 Valve', + : False, + : , + }), + 'context': , + 'entity_id': 'valve.smart_water_valve_bv05_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_platform_setup_and_discovery[valve.sprinkler_cesare_valve-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 0349bfc8bccfdc5e0453ad0cde0fa5eb0f844800 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:24:46 +0200 Subject: [PATCH 13/25] Move remaining lifx actions to services module (#182172) --- homeassistant/components/lifx/const.py | 2 + homeassistant/components/lifx/light.py | 35 ------------------ homeassistant/components/lifx/services.py | 45 ++++++++++++++++++++++- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index e29bca60053ff1..48a05af2c9e7e0 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -80,6 +80,8 @@ SERVICE_EFFECT_SKY = "effect_sky" SERVICE_EFFECT_STOP = "effect_stop" SERVICE_PAINT_THEME = "paint_theme" +SERVICE_SET_HEV_CYCLE_STATE = "set_hev_cycle_state" +SERVICE_SET_STATE = "set_state" HEV_CYCLE_STATE = "hev_cycle_state" INFRARED_BRIGHTNESS = "infrared_brightness" diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 28e6a18ff7b8c0..6d91489e5297e6 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -5,7 +5,6 @@ from typing import Any, override import aiolifx_effects as aiolifx_effects_module -import probatio from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -13,7 +12,6 @@ ATTR_BRIGHTNESS_STEP_PCT, ATTR_EFFECT, ATTR_TRANSITION, - LIGHT_TURN_ON_SCHEMA, ColorMode, LightEntity, LightEntityFeature, @@ -21,13 +19,10 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.typing import VolDictType from .const import ( - ATTR_DURATION, ATTR_INFRARED, ATTR_POWER, ATTR_ZONES, @@ -51,25 +46,6 @@ LIFX_STATE_SETTLE_DELAY = 0.3 -SERVICE_LIFX_SET_STATE = "set_state" - -LIFX_SET_STATE_SCHEMA: VolDictType = { - **LIGHT_TURN_ON_SCHEMA, - ATTR_INFRARED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=0, max=255)), - ATTR_ZONES: probatio.All(cv.ensure_list, [cv.positive_int]), - ATTR_POWER: cv.boolean, -} - - -SERVICE_LIFX_SET_HEV_CYCLE_STATE = "set_hev_cycle_state" - -LIFX_SET_HEV_CYCLE_STATE_SCHEMA: VolDictType = { - probatio.Required(ATTR_POWER): cv.boolean, - ATTR_DURATION: probatio.All( - probatio.Coerce(float), probatio.Clamp(min=0, max=86400) - ), -} - HSBK_HUE = 0 HSBK_SATURATION = 1 HSBK_BRIGHTNESS = 2 @@ -85,17 +61,6 @@ async def async_setup_entry( coordinator = entry.runtime_data manager = hass.data[DATA_LIFX_MANAGER] device = coordinator.device - platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - SERVICE_LIFX_SET_STATE, - LIFX_SET_STATE_SCHEMA, - "set_state", - ) - platform.async_register_entity_service( - SERVICE_LIFX_SET_HEV_CYCLE_STATE, - LIFX_SET_HEV_CYCLE_STATE_SCHEMA, - "set_hev_cycle_state", - ) if lifx_features(device)["matrix"]: if device.product in LIFX_CEILING_PRODUCT_IDS: entity: LIFXLight = LIFXCeiling(coordinator, manager, entry) diff --git a/homeassistant/components/lifx/services.py b/homeassistant/components/lifx/services.py index a170dd54f89a9c..e0fe9c94f36300 100644 --- a/homeassistant/components/lifx/services.py +++ b/homeassistant/components/lifx/services.py @@ -15,17 +15,20 @@ ATTR_TRANSITION, ATTR_XY_COLOR, COLOR_GROUP, + LIGHT_TURN_ON_SCHEMA, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, ) -from homeassistant.const import ATTR_MODE +from homeassistant.const import ATTR_MODE, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_register_platform_entity_service from homeassistant.helpers.target import ( TargetSelection, async_extract_referenced_entity_ids, ) +from homeassistant.helpers.typing import VolDictType from .const import ( ATTR_CHANGE, @@ -33,8 +36,11 @@ ATTR_CLOUD_SATURATION_MIN, ATTR_CYCLES, ATTR_DIRECTION, + ATTR_DURATION, + ATTR_INFRARED, ATTR_PALETTE, ATTR_PERIOD, + ATTR_POWER, ATTR_POWER_ON, ATTR_SATURATION_MAX, ATTR_SATURATION_MIN, @@ -42,6 +48,7 @@ ATTR_SPEED, ATTR_SPREAD, ATTR_THEME, + ATTR_ZONES, DATA_LIFX_MANAGER, DOMAIN, SERVICE_EFFECT_COLORLOOP, @@ -52,6 +59,8 @@ SERVICE_EFFECT_SKY, SERVICE_EFFECT_STOP, SERVICE_PAINT_THEME, + SERVICE_SET_HEV_CYCLE_STATE, + SERVICE_SET_STATE, ) from .util import async_entry_is_legacy @@ -225,6 +234,21 @@ } +LIFX_SET_STATE_SCHEMA: VolDictType = { + **LIGHT_TURN_ON_SCHEMA, + ATTR_INFRARED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=0, max=255)), + ATTR_ZONES: probatio.All(cv.ensure_list, [cv.positive_int]), + ATTR_POWER: cv.boolean, +} + +LIFX_SET_HEV_CYCLE_STATE_SCHEMA: VolDictType = { + probatio.Required(ATTR_POWER): cv.boolean, + ATTR_DURATION: probatio.All( + probatio.Coerce(float), probatio.Clamp(min=0, max=86400) + ), +} + + def _get_manager(service: ServiceCall) -> LIFXManager: """Return the LIFX manager, raising a user-facing error if unavailable.""" hass = service.hass @@ -255,8 +279,25 @@ async def _async_start_effect(service: ServiceCall) -> None: @callback def async_setup_services(hass: HomeAssistant) -> None: - """Register the LIFX effect services.""" + """Register the LIFX services.""" for service, schema in SERVICES_SCHEMA.items(): hass.services.async_register( DOMAIN, service, _async_start_effect, schema=schema ) + + async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_STATE, + entity_domain=Platform.LIGHT, + schema=LIFX_SET_STATE_SCHEMA, + func="set_state", + ) + async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_HEV_CYCLE_STATE, + entity_domain=Platform.LIGHT, + schema=LIFX_SET_HEV_CYCLE_STATE_SCHEMA, + func="set_hev_cycle_state", + ) From 25fc54e50e87a316003f8badc571c912b48fafa1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Sep 2026 06:30:52 -0400 Subject: [PATCH 14/25] Handle gone next flow in improv_ble provisioning (#182147) Co-authored-by: Claude --- .../components/improv_ble/config_flow.py | 18 ++-- .../components/improv_ble/test_config_flow.py | 85 +++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 2746c091f6bf08..1c668b05c10a05 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -28,7 +28,7 @@ ) from homeassistant.const import CONF_ADDRESS from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, UnknownFlow from homeassistant.helpers.device_registry import format_mac from . import async_get_provisioning_futures @@ -407,11 +407,17 @@ async def _do_provision() -> None: if next_flow_id: _LOGGER.debug("Received next flow ID: %s", next_flow_id) - self._provision_result = self.async_abort( - reason="provision_successful", - next_flow=(FlowType.CONFIG_FLOW, next_flow_id), - ) - return + try: + self._provision_result = self.async_abort( + reason="provision_successful", + next_flow=(FlowType.CONFIG_FLOW, next_flow_id), + ) + except UnknownFlow: + # The other integration aborted its flow, for example + # because the device is already configured + _LOGGER.debug("Next flow %s is gone", next_flow_id) + else: + return if redirect_url: self._provision_result = self.async_abort( diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 73acf905aaee71..84e74974b3698d 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -1108,6 +1108,91 @@ async def test_flow_chaining_with_redirect_url(hass: HomeAssistant) -> None: assert result["next_flow"] == (FlowType.CONFIG_FLOW, esphome_flow_id) +@pytest.mark.parametrize( + ("redirect_url", "expected_reason"), + [ + pytest.param(None, "provision_successful", id="no_redirect_url"), + pytest.param( + "http://blabla.local", "provision_successful_url", id="redirect_url" + ), + ], +) +async def test_flow_chaining_next_flow_gone( + hass: HomeAssistant, redirect_url: str | None, expected_reason: str +) -> None: + """Test the next flow is aborted before provisioning completes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Confirm bluetooth setup + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Start provisioning + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", + return_value=False, + new_callable=PropertyMock, + ), + patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.ensure_connected"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "provision" + + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value=redirect_url, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "TestNetwork", "password": "secret"} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + # Yield to allow the background task to create the future + await asyncio.sleep(0) # task is created with eager_start=False + + # Create a dummy target flow using a different device address + target_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO_TARGET1, + ) + next_config_flow_id = target_result["flow_id"] + + # Simulate another integration registering a flow which it then aborts, + # for example because the device is already configured + improv_ble.async_register_next_flow( + hass, IMPROV_BLE_DISCOVERY_INFO.address, next_config_flow_id + ) + hass.config_entries.flow.async_abort(next_config_flow_id) + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + assert "next_flow" not in result + + async def test_flow_chaining_future_already_done( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From ae3fae987956f91976a4de6128d3721ff488c93b Mon Sep 17 00:00:00 2001 From: rubempoli <66323535+rubempoli@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:32:05 -0300 Subject: [PATCH 15/25] Add TP-Link Omada controller device and update support (#181993) --- .../components/tplink_omada/__init__.py | 12 +- .../components/tplink_omada/config_flow.py | 3 +- .../components/tplink_omada/controller.py | 32 ++- .../components/tplink_omada/coordinator.py | 80 +++++- .../components/tplink_omada/entity.py | 55 +++- .../components/tplink_omada/sensor.py | 29 +- .../components/tplink_omada/strings.json | 5 + .../components/tplink_omada/update.py | 159 ++++++++++- tests/components/tplink_omada/conftest.py | 53 +++- .../tplink_omada/snapshots/test_sensor.ambr | 70 +++++ .../tplink_omada/snapshots/test_update.ambr | 64 +++++ .../tplink_omada/test_config_flow.py | 6 +- tests/components/tplink_omada/test_update.py | 255 +++++++++++++++++- 13 files changed, 799 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 470c56af2adec9..299db284767e9a 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -75,12 +75,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo ) from ex site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE])) - controller = OmadaSiteController(hass, entry, site_client) + controller = OmadaSiteController(hass, entry, client, site_client) await controller.initialize_first_refresh() entry.runtime_data = controller - _remove_old_devices(hass, entry, controller.devices_coordinator.data) + _remove_old_devices( + hass, + entry, + controller.devices_coordinator.data, + controller.controller_status_coordinator.data.mac, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -96,6 +101,7 @@ def _remove_old_devices( hass: HomeAssistant, entry: OmadaConfigEntry, omada_devices: dict[str, OmadaListDevice], + controller_mac: str, ) -> None: device_registry = dr.async_get(hass) @@ -105,7 +111,7 @@ def _remove_old_devices( mac = next( (i[1] for i in registered_device.identifiers if i[0] == DOMAIN), None ) - if mac and mac not in omada_devices: + if mac and mac != controller_mac and mac not in omada_devices: device_registry.async_remove_device(registered_device.id) diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index f29716335d999d..7bacf8d53ea635 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -84,7 +84,8 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> HubInfo: client = await create_omada_client(hass, data) controller_id = await client.login() - name = await client.get_controller_name() + controller_status = await client.get_controller_status() + name = controller_status.name or controller_status.model sites = await client.get_sites() return HubInfo(controller_id, name, sites) diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index fbb4b6712964fd..4060699e61575b 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING -from tplink_omada_client import OmadaSiteClient +from tplink_omada_client import OmadaClient, OmadaSiteClient from tplink_omada_client.devices import OmadaListDevice, OmadaSwitch from homeassistant.core import HomeAssistant, callback @@ -13,6 +13,8 @@ from .coordinator import ( OmadaClientsCoordinator, + OmadaControllerStatusCoordinator, + OmadaControllerUpdateCoordinator, OmadaDevicesCoordinator, OmadaGatewayCoordinator, OmadaSwitchPortCoordinator, @@ -28,13 +30,21 @@ def __init__( self, hass: HomeAssistant, config_entry: OmadaConfigEntry, + controller_client: OmadaClient, omada_client: OmadaSiteClient, ) -> None: """Create the controller.""" self._hass = hass self._config_entry = config_entry + self._controller_client = controller_client self._omada_client = omada_client + self._controller_status_coordinator = OmadaControllerStatusCoordinator( + hass, config_entry, controller_client + ) + self._controller_update_coordinator = OmadaControllerUpdateCoordinator( + hass, config_entry, controller_client + ) self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {} self._devices_coordinator = OmadaDevicesCoordinator( hass, config_entry, omada_client @@ -45,6 +55,8 @@ def __init__( async def initialize_first_refresh(self) -> None: """Initialize the all coordinators, and perform first refresh.""" + await self._controller_status_coordinator.async_config_entry_first_refresh() + await self._controller_update_coordinator.async_request_refresh() await self._devices_coordinator.async_config_entry_first_refresh() devices = self._devices_coordinator.data.values() @@ -69,8 +81,7 @@ async def async_register_device_entities( Args: device_filter: Function that returns True if a device should be processed. - entity_callback: Given a discovered Omada device, - creates entities for that device. + entity_callback: Given a discovered Omada device, creates entities for that device. """ # Track which devices have been processed already processed_devices: set[str] = set() @@ -101,6 +112,11 @@ def _handle_devices_update() -> None: # Call once on initial setup await _async_register_entities() + @property + def controller_client(self) -> OmadaClient: + """Get the connected client API for the Omada Controller.""" + return self._controller_client + @property def omada_client(self) -> OmadaSiteClient: """Get the connected client API for the site to manage.""" @@ -117,6 +133,16 @@ def get_switch_port_coordinator( return self._switch_port_coordinators[switch.mac] + @property + def controller_status_coordinator(self) -> OmadaControllerStatusCoordinator: + """Get the coordinator for the Omada Controller status.""" + return self._controller_status_coordinator + + @property + def controller_update_coordinator(self) -> OmadaControllerUpdateCoordinator: + """Get the coordinator for Omada Controller firmware updates.""" + return self._controller_update_coordinator + @property def gateway_coordinator(self) -> OmadaGatewayCoordinator | None: """Gets the coordinator for site's gateway, or None if there is no gateway.""" diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index c15dfc9d96c4ed..3388da0811b420 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -5,7 +5,13 @@ import logging from typing import TYPE_CHECKING, NamedTuple, override -from tplink_omada_client import OmadaSiteClient, OmadaSwitchPortDetails +from tplink_omada_client import ( + OmadaClient, + OmadaControllerStatus, + OmadaControllerUpdateInfo, + OmadaSiteClient, + OmadaSwitchPortDetails, +) from tplink_omada_client.clients import OmadaWirelessClient from tplink_omada_client.devices import ( OmadaFirmwareUpdate, @@ -29,6 +35,8 @@ POLL_GATEWAY = 300 POLL_CLIENTS = 300 POLL_DEVICES = 300 +POLL_CONTROLLER = 300 +POLL_CONTROLLER_UPDATE = 3600 POLL_UPGRADE = 60 @@ -72,6 +80,76 @@ async def poll_update(self) -> dict[str, _T]: raise NotImplementedError("Update method not implemented") +class OmadaControllerStatusCoordinator(DataUpdateCoordinator[OmadaControllerStatus]): + """Coordinator for getting status information about the Omada Controller.""" + + config_entry: OmadaConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: OmadaConfigEntry, + omada_client: OmadaClient, + ) -> None: + """Initialize the controller status coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="Omada API Data - Controller Status", + update_interval=timedelta(seconds=POLL_CONTROLLER), + ) + self.omada_client = omada_client + + @override + async def _async_update_data(self) -> OmadaControllerStatus: + """Fetch controller status from the API.""" + try: + async with asyncio.timeout(10): + return await self.omada_client.get_controller_status() + except OmadaClientException as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + ) from err + + +class OmadaControllerUpdateCoordinator( + DataUpdateCoordinator[OmadaControllerUpdateInfo] +): + """Coordinator for controller firmware update information.""" + + config_entry: OmadaConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: OmadaConfigEntry, + omada_client: OmadaClient, + ) -> None: + """Initialize the controller update coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="Omada API Data - Controller Firmware Update", + update_interval=timedelta(seconds=POLL_CONTROLLER_UPDATE), + ) + self.omada_client = omada_client + + @override + async def _async_update_data(self) -> OmadaControllerUpdateInfo: + """Fetch controller firmware update information from the API.""" + try: + async with asyncio.timeout(10): + return await self.omada_client.check_firmware_updates() + except OmadaClientException as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + ) from err + + class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): """Coordinator for getting details about ports on a switch.""" diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index 609582f6bc2ddc..23578e22a34b49 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -1,14 +1,16 @@ """Base entity definitions.""" -from typing import Any +from typing import Any, override +from tplink_omada_client import OmadaControllerStatus from tplink_omada_client.devices import OmadaDevice, OmadaSwitchPortDetails +from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import OmadaCoordinator +from .coordinator import OmadaControllerStatusCoordinator, OmadaCoordinator class OmadaDeviceEntity[_T: OmadaCoordinator[Any]](CoordinatorEntity[_T]): @@ -34,3 +36,52 @@ def get_switch_port_base_name(port: OmadaSwitchPortDetails) -> str: if port.name == f"Port{port.port}": return str(port.port) return f"{port.port} ({port.name})" + + +class OmadaControllerEntity(CoordinatorEntity[OmadaControllerStatusCoordinator]): + """Common base class for entities associated with the Omada Controller.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: OmadaControllerStatusCoordinator) -> None: + """Initialize the controller entity.""" + super().__init__(coordinator) + + controller: OmadaControllerStatus = coordinator.data + self._controller_identifier = (DOMAIN, controller.mac) + + device_name = ( + f"{controller.model} - {controller.name}" + if controller.name + else controller.model + ) + + self._attr_device_info = dr.DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, controller.mac)}, + identifiers={(DOMAIN, controller.mac)}, + manufacturer="TP-Link", + model=controller.model, + name=device_name, + sw_version=controller.current_version, + ) + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated controller status data.""" + device_registry = dr.async_get(self.hass) + controller = self.coordinator.data + device_entry = device_registry.async_get_device_by_identifier( + self._controller_identifier, + self.coordinator.config_entry.entry_id, + ) + if ( + device_entry is not None + and device_entry.sw_version != controller.current_version + ): + device_registry.async_update_device( + device_entry.id, + sw_version=controller.current_version, + ) + + super()._handle_coordinator_update() diff --git a/homeassistant/components/tplink_omada/sensor.py b/homeassistant/components/tplink_omada/sensor.py index fbbea0268d636c..3fdb42ea71d20d 100644 --- a/homeassistant/components/tplink_omada/sensor.py +++ b/homeassistant/components/tplink_omada/sensor.py @@ -24,9 +24,14 @@ from homeassistant.helpers.typing import StateType from . import OmadaConfigEntry +from .config_flow import CONF_SITE from .const import OmadaDeviceStatus -from .coordinator import OmadaDevicesCoordinator, OmadaSwitchPortCoordinator -from .entity import OmadaDeviceEntity, get_switch_port_base_name +from .coordinator import ( + OmadaControllerStatusCoordinator, + OmadaDevicesCoordinator, + OmadaSwitchPortCoordinator, +) +from .entity import OmadaControllerEntity, OmadaDeviceEntity, get_switch_port_base_name PARALLEL_UPDATES = 0 @@ -68,6 +73,10 @@ async def async_setup_entry( """Set up sensors.""" controller = config_entry.runtime_data + async_add_entities( + [OmadaControllerStatusSensor(controller.controller_status_coordinator)] + ) + devices_coordinator = controller.devices_coordinator async def _create_device_sensor_entities( @@ -153,6 +162,22 @@ class OmadaDeviceSensorEntityDescription(SensorEntityDescription): ] +class OmadaControllerStatusSensor(OmadaControllerEntity, SensorEntity): + """Status sensor for the Omada Controller.""" + + _attr_translation_key = "device_status" + _attr_device_class = SensorDeviceClass.ENUM + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_options = [v.value for v in OmadaDeviceStatus] + _attr_native_value = OmadaDeviceStatus.CONNECTED.value + + def __init__(self, coordinator: OmadaControllerStatusCoordinator) -> None: + """Initialize the controller status sensor.""" + super().__init__(coordinator) + site_id = coordinator.config_entry.data[CONF_SITE] + self._attr_unique_id = f"{coordinator.data.mac}_{site_id}_device_status" + + class OmadaDeviceSensor(OmadaDeviceEntity[OmadaDevicesCoordinator], SensorEntity): """Sensor for property of a generic Omada device.""" diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 36d0e189404598..1fd655c87e9d14 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -99,6 +99,11 @@ "wan_connect_ipv6": { "name": "Port {port_name} Internet connected (IPv6)" } + }, + "update": { + "firmware": { + "name": "Firmware" + } } }, "exceptions": { diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index ede62897db4553..ebb085a4b2a23b 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -1,7 +1,8 @@ """Support for TPLink Omada device firmware updates.""" -from typing import Any, override +from typing import Any, cast, override +from tplink_omada_client import OmadaControllerUpdateInfo from tplink_omada_client.devices import OmadaListDevice from tplink_omada_client.exceptions import OmadaClientException, RequestFailed @@ -10,14 +11,20 @@ UpdateEntity, UpdateEntityFeature, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OmadaConfigEntry +from .config_flow import CONF_SITE from .const import DOMAIN -from .coordinator import OmadaFirmwareUpdateCoordinator -from .entity import OmadaDeviceEntity +from .coordinator import ( + OmadaControllerStatusCoordinator, + OmadaControllerUpdateCoordinator, + OmadaFirmwareUpdateCoordinator, +) +from .entity import OmadaControllerEntity, OmadaDeviceEntity PARALLEL_UPDATES = 0 @@ -27,7 +34,7 @@ async def async_setup_entry( config_entry: OmadaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up firmware updates.""" controller = config_entry.runtime_data devices = controller.devices_coordinator.data @@ -37,11 +44,153 @@ async def async_setup_entry( ) async_add_entities( - OmadaDeviceUpdate(coordinator, device) for device in devices.values() + [ + OmadaControllerUpdate( + controller.controller_status_coordinator, + controller.controller_update_coordinator, + ), + *(OmadaDeviceUpdate(coordinator, device) for device in devices.values()), + ] ) await coordinator.async_request_refresh() +class OmadaControllerUpdate(OmadaControllerEntity, UpdateEntity): + """Firmware update status for the Omada Controller.""" + + _attr_translation_key = "firmware" + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + status_coordinator: OmadaControllerStatusCoordinator, + update_coordinator: OmadaControllerUpdateCoordinator, + ) -> None: + """Initialize the controller update entity.""" + super().__init__(status_coordinator) + self._update_coordinator = update_coordinator + self._omada_client = update_coordinator.omada_client + site_id = status_coordinator.config_entry.data[CONF_SITE] + self._attr_unique_id = f"{status_coordinator.data.mac}_{site_id}_firmware" + + self._update_attrs() + + @override + async def async_added_to_hass(self) -> None: + """Register for controller update coordinator changes.""" + await super().async_added_to_hass() + self.async_on_remove( + self._update_coordinator.async_add_listener( + self._handle_update_coordinator_update + ) + ) + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._update_coordinator.last_update_success + + @callback + def _handle_update_coordinator_update(self) -> None: + """Handle updated controller firmware information.""" + self._update_attrs() + self.async_write_ha_state() + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated controller status data.""" + self._update_attrs() + super()._handle_coordinator_update() + + @property + def _update_data(self) -> OmadaControllerUpdateInfo | None: + """Return controller update data when the optional refresh succeeded.""" + return cast(OmadaControllerUpdateInfo | None, self._update_coordinator.data) + + def _update_attrs(self) -> None: + """Update installed and latest controller versions.""" + update = self._update_data + if update is None: + self._attr_installed_version = self.coordinator.data.current_version + self._attr_latest_version = self._attr_installed_version + self._attr_supported_features = UpdateEntityFeature(0) + return + + active_update = update.update + self._attr_installed_version = ( + active_update.current_version + if update.hardware is not None and active_update is not None + else self.coordinator.data.current_version or update.current_version + ) + self._attr_latest_version = ( + active_update.latest_version + if active_update is not None + else self._attr_installed_version + ) + self._attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + if update.hardware is not None: + self._attr_supported_features |= UpdateEntityFeature.INSTALL + + @override + def release_notes(self) -> str | None: + """Return the release notes for the latest controller update.""" + if (update := self._update_data) is None: + return None + return update.release_notes + + @property + @override + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the controller update download URL.""" + update = self._update_data + if ( + update is None + or update.update is None + or update.update.download_link is None + ): + return None + + return {"download_url": update.update.download_link} + + @override + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install a controller firmware update.""" + update = self._update_data + + if update is None or update.hardware is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_update_rejected", + ) + + target_version = version or update.latest_version + if target_version is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_update_rejected", + ) + + try: + await self._omada_client.install_controller_firmware(target_version) + except RequestFailed as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_update_rejected", + ) from ex + except OmadaClientException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_update_failed", + ) from ex + finally: + await self._update_coordinator.async_request_refresh() + + class OmadaDeviceUpdate( OmadaDeviceEntity[OmadaFirmwareUpdateCoordinator], UpdateEntity, diff --git a/tests/components/tplink_omada/conftest.py b/tests/components/tplink_omada/conftest.py index d0209fdee923ec..7964e2c68aea2b 100644 --- a/tests/components/tplink_omada/conftest.py +++ b/tests/components/tplink_omada/conftest.py @@ -5,7 +5,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from tplink_omada_client import OmadaSite +from tplink_omada_client import ( + OmadaControllerStatus, + OmadaControllerUpdateInfo, + OmadaSite, +) from tplink_omada_client.clients import ( OmadaConnectedClient, OmadaNetworkClient, @@ -192,6 +196,28 @@ def mock_omada_client(mock_omada_site_client: AsyncMock) -> Generator[MagicMock] client.get_site_client.return_value = mock_omada_site_client client.login.return_value = "12345" client.get_controller_name.return_value = "OC200" + client.get_controller_status.return_value = OmadaControllerStatus( + { + "name": "Test Omada Controller", + "macAddress": "00-11-22-33-44-55", + "upTime": 123456, + "controllerVersion": "6.2.10.17", + "model": "OC200", + } + ) + client.check_firmware_updates.return_value = OmadaControllerUpdateInfo( + { + "software": { + "upgrade": True, + "currentVersion": "6.2.10.17", + "latestVersion": "6.3.0.45 Build 20260903171910", + "releaseLog": "Release notes for Omada SDN Controller.", + "releaseUrl": "https://example.com/controller-release-notes", + "downloadLink": "https://example.com/controller-update.tar.gz", + } + } + ) + client.install_controller_firmware = AsyncMock() client.get_sites.return_value = [OmadaSite("Display Name", "SiteId")] yield client @@ -208,6 +234,31 @@ def mock_omada_clients_only_client( client = client_mock.return_value client.get_site_client.return_value = mock_omada_clients_only_site_client + client.login.return_value = "12345" + client.get_controller_name.return_value = "OC200" + client.get_controller_status.return_value = OmadaControllerStatus( + { + "name": "Test Omada Controller", + "macAddress": "00-11-22-33-44-55", + "upTime": 123456, + "controllerVersion": "6.2.10.17", + "model": "OC200", + } + ) + client.check_firmware_updates.return_value = OmadaControllerUpdateInfo( + { + "software": { + "upgrade": True, + "currentVersion": "6.2.10.17", + "latestVersion": "6.3.0.45 Build 20260903171910", + "releaseLog": "Release notes for Omada SDN Controller.", + "releaseUrl": "https://example.com/controller-release-notes", + "downloadLink": "https://example.com/controller-update.tar.gz", + } + } + ) + client.install_controller_firmware = AsyncMock() + client.get_sites.return_value = [OmadaSite("Display Name", "SiteId")] yield client diff --git a/tests/components/tplink_omada/snapshots/test_sensor.ambr b/tests/components/tplink_omada/snapshots/test_sensor.ambr index baab53e1b3476f..eeac3be5208aa4 100644 --- a/tests/components/tplink_omada/snapshots/test_sensor.ambr +++ b/tests/components/tplink_omada/snapshots/test_sensor.ambr @@ -1,4 +1,74 @@ # serializer version: 1 +# name: test_entities[sensor.oc200_test_omada_controller_device_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'disconnected', + 'connected', + 'pending', + 'heartbeat_missed', + 'isolated', + 'adopt_failed', + 'managed_externally', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oc200_test_omada_controller_device_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Device status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Device status', + 'platform': 'tplink_omada', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_status', + 'unique_id': '00-11-22-33-44-55_Default_device_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.oc200_test_omada_controller_device_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'OC200 - Test Omada Controller Device status', + : list([ + 'disconnected', + 'connected', + 'pending', + 'heartbeat_missed', + 'isolated', + 'adopt_failed', + 'managed_externally', + ]), + }), + 'context': , + 'entity_id': 'sensor.oc200_test_omada_controller_device_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- # name: test_entities[sensor.test_poe_switch_cpu_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tplink_omada/snapshots/test_update.ambr b/tests/components/tplink_omada/snapshots/test_update.ambr index 46cd30814fcd91..ad5960727c68fd 100644 --- a/tests/components/tplink_omada/snapshots/test_update.ambr +++ b/tests/components/tplink_omada/snapshots/test_update.ambr @@ -1,4 +1,68 @@ # serializer version: 1 +# name: test_entities[update.oc200_test_omada_controller_firmware-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': 'update', + 'entity_category': , + 'entity_id': 'update.oc200_test_omada_controller_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'tplink_omada', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'firmware', + 'unique_id': '00-11-22-33-44-55_Default_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[update.oc200_test_omada_controller_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + 'download_url': 'https://example.com/controller-update.tar.gz', + : '/api/brands/integration/tplink_omada/icon.png', + : 'OC200 - Test Omada Controller Firmware', + : False, + : '6.2.10.17', + : '6.3.0.45 Build 20260903171910', + : None, + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.oc200_test_omada_controller_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entities[update.test_poe_switch_firmware-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tplink_omada/test_config_flow.py b/tests/components/tplink_omada/test_config_flow.py index f2ac7107f186af..f6967243004f20 100644 --- a/tests/components/tplink_omada/test_config_flow.py +++ b/tests/components/tplink_omada/test_config_flow.py @@ -54,7 +54,7 @@ async def test_form_single_site( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "OC200 (Display Name)" + assert result["title"] == "Test Omada Controller (Display Name)" assert result["data"] == MOCK_ENTRY_DATA assert result["result"].unique_id == "12345_SiteId" assert len(mock_setup_entry.mock_calls) == 1 @@ -94,7 +94,7 @@ async def test_form_multiple_sites( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "OC200 (Site 2)" + assert result["title"] == "Test Omada Controller (Site 2)" assert result["data"] == { "host": "https://fake.omada.host", "verify_ssl": True, @@ -148,7 +148,7 @@ async def test_form_errors_and_recovery( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "OC200 (Display Name)" + assert result["title"] == "Test Omada Controller (Display Name)" assert result["data"] == MOCK_ENTRY_DATA assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/tplink_omada/test_update.py b/tests/components/tplink_omada/test_update.py index d2d4457fee53d7..8fc42fc97bddf6 100644 --- a/tests/components/tplink_omada/test_update.py +++ b/tests/components/tplink_omada/test_update.py @@ -6,20 +6,36 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from tplink_omada_client import OmadaControllerStatus, OmadaControllerUpdateInfo from tplink_omada_client.devices import OmadaListDevice from tplink_omada_client.exceptions import OmadaClientException, RequestFailed from homeassistant.components.tplink_omada.const import DOMAIN -from homeassistant.components.tplink_omada.coordinator import POLL_DEVICES +from homeassistant.components.tplink_omada.coordinator import ( + POLL_CONTROLLER, + POLL_DEVICES, +) from homeassistant.components.update import ( ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DATA_COMPONENT, DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, + UpdateEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN from tests.common import ( MockConfigEntry, @@ -30,6 +46,8 @@ from tests.typing import WebSocketGenerator POLL_INTERVAL = timedelta(seconds=POLL_DEVICES) +CONTROLLER_POLL_INTERVAL = timedelta(seconds=POLL_CONTROLLER) +REFRESH_COOLDOWN = timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) async def _rebuild_device_list_with_update( @@ -131,6 +149,160 @@ async def test_install_firmware_success( assert await_args[0].mac == "54-AF-97-00-00-01" +async def test_install_controller_firmware_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test successful controller firmware installation.""" + entity_id = "update.oc200_test_omada_controller_firmware" + mock_omada_client.check_firmware_updates.return_value = OmadaControllerUpdateInfo( + { + "hardware": { + "upgrade": True, + "currentVersion": "1.0.0", + "latestVersion": "1.0.1", + "fwReleaseLog": "Fixed things.", + "releaseUrl": "https://example.com/firmware-release-notes", + "downloadLink": "https://example.com/firmware.bin", + } + } + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.state == STATE_ON + assert entity.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" + assert entity.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert entity.attributes[ATTR_SUPPORTED_FEATURES] == ( + UpdateEntityFeature.RELEASE_NOTES | UpdateEntityFeature.INSTALL + ) + + mock_omada_client.check_firmware_updates.reset_mock() + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + freezer.tick(REFRESH_COOLDOWN) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_omada_client.install_controller_firmware.assert_awaited_once_with("1.0.1") + mock_omada_client.check_firmware_updates.assert_awaited_once() + + +async def test_controller_update_check_failure_does_not_block_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, +) -> None: + """Test controller update check failures do not block setup.""" + entity_id = "update.oc200_test_omada_controller_firmware" + mock_omada_client.check_firmware_updates.side_effect = OmadaClientException( + "Connection error" + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.state == STATE_UNAVAILABLE + + +async def test_controller_software_update_installed_version_prefers_status_coordinator( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, +) -> None: + """Test controller software update installed version prefers controller status.""" + entity_id = "update.oc200_test_omada_controller_firmware" + mock_omada_client.get_controller_status.return_value = OmadaControllerStatus( + { + "name": "Test Omada Controller", + "macAddress": "00-11-22-33-44-55", + "upTime": 123456, + "controllerVersion": "6.3.0.45", + "model": "OC200", + } + ) + mock_omada_client.check_firmware_updates.return_value = OmadaControllerUpdateInfo( + { + "software": { + "upgrade": True, + "currentVersion": "6.2.10.17", + "latestVersion": "6.3.0.45", + } + } + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.state == STATE_OFF + assert entity.attributes[ATTR_INSTALLED_VERSION] == "6.3.0.45" + assert entity.attributes[ATTR_LATEST_VERSION] == "6.3.0.45" + + +async def test_controller_device_sw_version_updates_with_status_coordinator( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test controller device software version updates with controller status.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "00-11-22-33-44-55"), + mock_config_entry.entry_id, + ) + assert device_entry is not None + assert device_entry.sw_version == "6.2.10.17" + + mock_omada_client.get_controller_status.return_value = OmadaControllerStatus( + { + "name": "Test Omada Controller", + "macAddress": "00-11-22-33-44-55", + "upTime": 123456, + "controllerVersion": "6.3.0.45", + "model": "OC200", + } + ) + + freezer.tick(CONTROLLER_POLL_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "00-11-22-33-44-55"), + mock_config_entry.entry_id, + ) + assert device_entry is not None + assert device_entry.sw_version == "6.3.0.45" + + @pytest.mark.parametrize( ("exception_type", "translation_key"), [ @@ -172,9 +344,86 @@ async def test_install_firmware_exceptions( assert err.value.translation_domain == DOMAIN +@pytest.mark.parametrize( + ("exception_type", "translation_key"), + [ + ( + RequestFailed(500, "Update rejected"), + "firmware_update_rejected", + ), + ( + OmadaClientException("Connection error"), + "firmware_update_failed", + ), + ], +) +async def test_install_controller_firmware_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, + freezer: FrozenDateTimeFactory, + exception_type: Exception, + translation_key: str, +) -> None: + """Test controller firmware installation exception handling.""" + entity_id = "update.oc200_test_omada_controller_firmware" + mock_omada_client.check_firmware_updates.return_value = OmadaControllerUpdateInfo( + { + "hardware": { + "upgrade": True, + "currentVersion": "1.0.0", + "latestVersion": "1.0.1", + } + } + ) + mock_omada_client.install_controller_firmware = AsyncMock( + side_effect=exception_type + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_omada_client.check_firmware_updates.reset_mock() + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert err.value.translation_key == translation_key + assert err.value.translation_domain == DOMAIN + freezer.tick(REFRESH_COOLDOWN) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_omada_client.check_firmware_updates.assert_awaited_once() + + +async def test_install_controller_firmware_rejected_without_hardware( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test controller firmware installation rejects software-only updates.""" + entity = hass.data[DATA_COMPONENT].get_entity( + "update.oc200_test_omada_controller_firmware" + ) + assert entity is not None + + with pytest.raises(HomeAssistantError) as err: + await entity.async_install(version=None, backup=False) + + assert err.value.translation_key == "firmware_update_rejected" + assert err.value.translation_domain == DOMAIN + + @pytest.mark.parametrize( ("entity_name", "expected_notes"), [ + ("oc200_test_omada_controller", "Release notes for Omada SDN Controller."), ("test_router", None), ("test_poe_switch", "Bug fixes and performance improvements"), ], From 74639e3063769b42411e2c0eff48a773bbde3db6 Mon Sep 17 00:00:00 2001 From: shbatm Date: Mon, 14 Sep 2026 06:22:38 -0500 Subject: [PATCH 16/25] Include disabled entities in search/related when requested (#178024) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/search/__init__.py | 18 +++- tests/components/search/conftest.py | 107 ++++++++++++++++++++ tests/components/search/test_init.py | 93 +++++++++++++++++ 3 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 tests/components/search/conftest.py diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 0d8c6bfde765b0..9bd4cb8ca17b9b 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -63,6 +63,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: probatio.Required("type"): "search/related", probatio.Required("item_type"): probatio.Coerce(ItemType), probatio.Required("item_id"): str, + probatio.Optional("include_disabled_entities", default=False): bool, } ) @callback @@ -72,7 +73,11 @@ def websocket_search_related( msg: dict[str, Any], ) -> None: """Handle search.""" - searcher = Searcher(hass, get_entity_sources(hass)) + searcher = Searcher( + hass, + get_entity_sources(hass), + include_disabled_entities=msg["include_disabled_entities"], + ) connection.send_result( msg["id"], searcher.async_search(msg["item_type"], msg["item_id"]) ) @@ -87,6 +92,8 @@ def __init__( self, hass: HomeAssistant, entity_sources: dict[str, EntityInfo], + *, + include_disabled_entities: bool = False, ) -> None: """Search results.""" self.hass = hass @@ -94,6 +101,7 @@ def __init__( self._device_registry = dr.async_get(hass) self._entity_registry = er.async_get(hass) self._entity_sources = entity_sources + self._include_disabled_entities = include_disabled_entities self.results: defaultdict[ItemType, set[str]] = defaultdict(set) @callback @@ -154,7 +162,9 @@ def _async_search_area(self, area_id: str, *, entry_point: bool = True) -> None: # Entities of this device for entity_entry in er.async_entries_for_device( - self._entity_registry, device.id + self._entity_registry, + device.id, + include_disabled_entities=self._include_disabled_entities, ): # Skip the entity if it's in a different area if entity_entry.area_id is not None: @@ -326,7 +336,9 @@ def _async_search_device(self, device_id: str, *, entry_point: bool = True) -> N # Entities of this device for entity_entry in er.async_entries_for_device( - self._entity_registry, device_id + self._entity_registry, + device_id, + include_disabled_entities=self._include_disabled_entities, ): self._add(ItemType.ENTITY, entity_entry.entity_id) # Add all entity information as well diff --git a/tests/components/search/conftest.py b/tests/components/search/conftest.py new file mode 100644 index 00000000000000..5e599da336860b --- /dev/null +++ b/tests/components/search/conftest.py @@ -0,0 +1,107 @@ +"""Test fixtures for the search integration.""" + +import pytest + +from homeassistant.components.search import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + floor_registry as fr, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="search_item_ids") +async def search_item_ids_fixture( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> dict[str, str]: + """Set up a device and a child device, each owning enabled and disabled entities. + + The child device inherits the area from its parent, so it is reached by an area + or floor search without having an area of its own. + + Returns the item id to search by, per key. + """ + assert await async_setup_component(hass, DOMAIN, {}) + + floor = floor_registry.async_create("First floor") + area = area_registry.async_create("Kitchen", floor_id=floor.floor_id) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("test", "1")} + ) + device_registry.async_update_device(device.id, area_id=area.id) + + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={("test", "1-child")}, + parent_device_id=device.id, + name="Child", + ) + + entity_registry.async_get_or_create( + "light", + "test", + "enabled", + suggested_object_id="enabled", + config_entry=config_entry, + device_id=device.id, + ) + entity_registry.async_get_or_create( + "light", + "test", + "disabled", + suggested_object_id="disabled", + config_entry=config_entry, + device_id=device.id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + # A disabled entity that overrides its area instead of inheriting it from the + # device is reached through the area index, which has no disabled filter. + disabled_area_override_entity = entity_registry.async_get_or_create( + "light", + "test", + "disabled_area_override", + suggested_object_id="disabled_area_override", + config_entry=config_entry, + device_id=device.id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + entity_registry.async_update_entity( + disabled_area_override_entity.entity_id, area_id=area.id + ) + + entity_registry.async_get_or_create( + "light", + "test", + "child_enabled", + suggested_object_id="child_enabled", + config_entry=config_entry, + device_id=child_device.id, + ) + entity_registry.async_get_or_create( + "light", + "test", + "child_disabled", + suggested_object_id="child_disabled", + config_entry=config_entry, + device_id=child_device.id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + return { + "floor": floor.floor_id, + "area": area.id, + "device": device.id, + "child_device": child_device.id, + } diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 883f9a766c6090..51f42cdd393454 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -1,6 +1,7 @@ """Tests for Search integration.""" import attr +import pytest from pytest_unordered import unordered from homeassistant.components.search import DOMAIN, ItemType, Searcher @@ -1371,3 +1372,95 @@ def search(item_type: ItemType, item_id: str) -> dict[str, set[str]]: ItemType.CONFIG_ENTRY: {config_entry.entry_id}, ItemType.INTEGRATION: {"test"}, } + + +ALL_ENTITIES = { + "light.enabled", + "light.disabled", + "light.disabled_area_override", + "light.child_enabled", + "light.child_disabled", +} +PARENT_DEVICE_DEFAULT = {"light.enabled", "light.child_enabled"} +AREA_DEFAULT = {"light.enabled", "light.disabled_area_override", "light.child_enabled"} + + +@pytest.mark.parametrize( + ("item_type", "item_key", "expected_default", "expected_included"), + [ + pytest.param( + ItemType.DEVICE, "device", PARENT_DEVICE_DEFAULT, ALL_ENTITIES, id="device" + ), + pytest.param( + ItemType.DEVICE, + "child_device", + {"light.child_enabled"}, + {"light.child_enabled", "light.child_disabled"}, + id="child_device", + ), + pytest.param(ItemType.AREA, "area", AREA_DEFAULT, ALL_ENTITIES, id="area"), + pytest.param(ItemType.FLOOR, "floor", AREA_DEFAULT, ALL_ENTITIES, id="floor"), + ], +) +async def test_search_include_disabled_entities( + hass: HomeAssistant, + search_item_ids: dict[str, str], + item_type: ItemType, + item_key: str, + expected_default: set[str], + expected_included: set[str], +) -> None: + """Test device-inherited disabled entities are only returned when requested. + + A child device is searched both directly and through the area and floor it + inherits from its parent. Searching the child does not return the parent's + entities, because the parent is only resolved up. + """ + item_id = search_item_ids[item_key] + + searcher = Searcher(hass, {}) + assert ( + searcher.async_search(item_type, item_id)[ItemType.ENTITY] == expected_default + ) + + searcher = Searcher(hass, {}, include_disabled_entities=True) + assert ( + searcher.async_search(item_type, item_id)[ItemType.ENTITY] == expected_included + ) + + +@pytest.mark.parametrize( + ("extra_msg", "expected"), + [ + pytest.param({}, PARENT_DEVICE_DEFAULT, id="key_omitted"), + pytest.param( + {"include_disabled_entities": False}, + PARENT_DEVICE_DEFAULT, + id="explicit_false", + ), + pytest.param( + {"include_disabled_entities": True}, ALL_ENTITIES, id="explicit_true" + ), + ], +) +async def test_search_related_include_disabled_entities_websocket( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + search_item_ids: dict[str, str], + extra_msg: dict[str, bool], + expected: set[str], +) -> None: + """Test the websocket command accepts the new option, and defaults it to False.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "search/related", + "item_type": "device", + "item_id": search_item_ids["device"], + } + | extra_msg + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"][ItemType.ENTITY] == unordered(list(expected)) From 2b993ee8ac453713d4d9cef0dcc0f04d7af921b4 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Sep 2026 14:53:01 +0200 Subject: [PATCH 17/25] Bump aiocomelit to 3.0.0 (#182193) --- homeassistant/components/comelit/climate.py | 4 ++-- homeassistant/components/comelit/const.py | 4 ++-- homeassistant/components/comelit/coordinator.py | 5 +++-- homeassistant/components/comelit/cover.py | 4 ++-- homeassistant/components/comelit/diagnostics.py | 8 ++------ homeassistant/components/comelit/entity.py | 4 ++-- homeassistant/components/comelit/humidifier.py | 4 ++-- homeassistant/components/comelit/manifest.json | 2 +- homeassistant/components/comelit/sensor.py | 4 ++-- homeassistant/components/comelit/switch.py | 4 ++-- homeassistant/components/comelit/utils.py | 6 +++--- requirements_all.txt | 2 +- tests/components/comelit/const.py | 10 +++++----- tests/components/comelit/test_climate.py | 6 +++--- tests/components/comelit/test_coordinator.py | 4 ++-- tests/components/comelit/test_cover.py | 8 ++++---- tests/components/comelit/test_humidifier.py | 6 +++--- tests/components/comelit/test_light.py | 6 +++--- tests/components/comelit/test_sensor.py | 6 +++--- tests/components/comelit/test_switch.py | 6 +++--- tests/components/comelit/test_utils.py | 4 ++-- 21 files changed, 52 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index db08aa83c5fc53..cc02bf6315dd9f 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -3,7 +3,7 @@ from enum import StrEnum from typing import Any, TypedDict, cast, override -from aiocomelit import ComelitSerialBridgeObject +from aiocomelit import ComelitDeviceObject from aiocomelit.const import CLIMATE from homeassistant.components.climate import ( @@ -128,7 +128,7 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity): def __init__( self, coordinator: ComelitSerialBridge, - device: ComelitSerialBridgeObject, + device: ComelitDeviceObject, config_entry_entry_id: str, ) -> None: """Init light entity.""" diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index 14959238f654b6..0e97ba4a2ee6e2 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -3,7 +3,7 @@ import logging from aiocomelit.api import ( - ComelitSerialBridgeObject, + ComelitDeviceObject, ComelitVedoAreaObject, ComelitVedoZoneObject, ) @@ -12,7 +12,7 @@ LOGGER = logging.getLogger(__package__) type ObjectClassType = ( - ComelitSerialBridgeObject | ComelitVedoAreaObject | ComelitVedoZoneObject + ComelitDeviceObject | ComelitVedoAreaObject | ComelitVedoZoneObject ) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index a4dbc9957c673e..517b2b51c6890b 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -5,7 +5,8 @@ from datetime import timedelta from typing import TypeVar, cast, override -from aiocomelit.api import ComelitCommonApi, ComeliteSerialBridgeApi, ComelitVedoApi +from aiocomelit import ComeliteSerialBridgeApi, ComelitVedoApi +from aiocomelit.api import ComelitHttpApi from aiocomelit.const import ( ALARM_AREA, ALARM_ZONE, @@ -51,7 +52,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): _hw_version: str config_entry: ComelitConfigEntry - api: ComelitCommonApi + api: ComelitHttpApi def __init__( self, hass: HomeAssistant, entry: ComelitConfigEntry, device: str, host: str diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index bca5029cb60421..c60cb92452806a 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -2,7 +2,7 @@ from typing import Any, cast, override -from aiocomelit import ComelitSerialBridgeObject +from aiocomelit import ComelitDeviceObject from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState @@ -52,7 +52,7 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): def __init__( self, coordinator: ComelitSerialBridge, - device: ComelitSerialBridgeObject, + device: ComelitDeviceObject, config_entry_entry_id: str, ) -> None: """Init cover entity.""" diff --git a/homeassistant/components/comelit/diagnostics.py b/homeassistant/components/comelit/diagnostics.py index c6df3a5a041044..4579b8348a8a83 100644 --- a/homeassistant/components/comelit/diagnostics.py +++ b/homeassistant/components/comelit/diagnostics.py @@ -2,11 +2,7 @@ from typing import Any -from aiocomelit import ( - ComelitSerialBridgeObject, - ComelitVedoAreaObject, - ComelitVedoZoneObject, -) +from aiocomelit import ComelitDeviceObject, ComelitVedoAreaObject, ComelitVedoZoneObject from aiocomelit.const import BRIDGE from homeassistant.components.diagnostics import async_redact_data @@ -31,7 +27,7 @@ async def async_get_config_entry_diagnostics( for dev_type in coordinator.data: dev_type_list = [] for sensor_data in coordinator.data[dev_type].values(): - if isinstance(sensor_data, ComelitSerialBridgeObject): + if isinstance(sensor_data, ComelitDeviceObject): dev_type_list.append( { sensor_data.index: { diff --git a/homeassistant/components/comelit/entity.py b/homeassistant/components/comelit/entity.py index 53394bf06db92a..b420d5d1e7971a 100644 --- a/homeassistant/components/comelit/entity.py +++ b/homeassistant/components/comelit/entity.py @@ -1,6 +1,6 @@ """Base entity for Comelit.""" -from aiocomelit import ComelitSerialBridgeObject +from aiocomelit import ComelitDeviceObject from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -15,7 +15,7 @@ class ComelitBridgeBaseEntity(CoordinatorEntity[ComelitSerialBridge]): def __init__( self, coordinator: ComelitSerialBridge, - device: ComelitSerialBridgeObject, + device: ComelitDeviceObject, config_entry_entry_id: str, ) -> None: """Init cover entity.""" diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index 22dff58ef6e10e..50fefedaf1b2de 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -3,7 +3,7 @@ from enum import StrEnum from typing import Any, cast, override -from aiocomelit import ComelitSerialBridgeObject +from aiocomelit import ComelitDeviceObject from aiocomelit.const import CLIMATE from homeassistant.components.humidifier import ( @@ -120,7 +120,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity): def __init__( self, coordinator: ComelitSerialBridge, - device: ComelitSerialBridgeObject, + device: ComelitDeviceObject, config_entry_entry_id: str, active_mode: HumidifierComelitMode, active_action: HumidifierAction, diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 965dfc88ed95d0..9f75517f5bc184 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.7"] + "requirements": ["aiocomelit==3.0.0"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 798ee64dac4edd..b1dde222b16d2e 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Final, cast, override -from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject +from aiocomelit.api import ComelitDeviceObject, ComelitVedoZoneObject from aiocomelit.const import ALARM_ZONE, OTHER, AlarmZoneState from homeassistant.components.sensor import ( @@ -111,7 +111,7 @@ class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): def __init__( self, coordinator: ComelitSerialBridge, - device: ComelitSerialBridgeObject, + device: ComelitDeviceObject, config_entry_entry_id: str, description: SensorEntityDescription, ) -> None: diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index ae98ade39eab1c..221ec97bd23dff 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -2,7 +2,7 @@ from typing import Any, cast, override -from aiocomelit import ComelitSerialBridgeObject +from aiocomelit import ComelitDeviceObject from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity @@ -51,7 +51,7 @@ class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): def __init__( self, coordinator: ComelitSerialBridge, - device: ComelitSerialBridgeObject, + device: ComelitDeviceObject, config_entry_entry_id: str, ) -> None: """Init switch entity.""" diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py index 1207cf41ec249c..03ecc169472b3c 100644 --- a/homeassistant/components/comelit/utils.py +++ b/homeassistant/components/comelit/utils.py @@ -4,7 +4,7 @@ from functools import wraps from typing import TYPE_CHECKING, Any, Concatenate, Literal -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.api import ComelitDeviceObject from aiocomelit.exceptions import ( CannotAuthenticate, CannotConnect, @@ -36,7 +36,7 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession: def load_api_data( - device: ComelitSerialBridgeObject, + device: ComelitDeviceObject, domain: Literal["climate", "humidifier"], ) -> list[Any]: """Load data from the API.""" @@ -54,7 +54,7 @@ async def cleanup_stale_entity( hass: HomeAssistant, config_entry: ConfigEntry, entry_unique_id: str, - device: ComelitSerialBridgeObject, + device: ComelitDeviceObject, ) -> None: """Cleanup stale entity.""" entity_reg: er.EntityRegistry = er.async_get(hass) diff --git a/requirements_all.txt b/requirements_all.txt index 97aee4a066a355..801096ff13d351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -233,7 +233,7 @@ aiobotocore==3.7.0 aiocentriconnect==0.2.3 # homeassistant.components.comelit -aiocomelit==2.0.7 +aiocomelit==3.0.0 # homeassistant.components.dhcp aiodhcpwatcher==1.2.7 diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 0e803dbc34da70..cbd1d02d3529f0 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,7 +1,7 @@ """Common stuff for Comelit SimpleHome tests.""" from aiocomelit.api import ( - ComelitSerialBridgeObject, + ComelitDeviceObject, ComelitVedoAreaObject, ComelitVedoZoneObject, ) @@ -31,7 +31,7 @@ FAKE_PIN = "0000" BAD_PIN = "abcd" -LIGHT0 = ComelitSerialBridgeObject( +LIGHT0 = ComelitDeviceObject( index=0, name="Light0", status=0, @@ -45,7 +45,7 @@ ) BRIDGE_DEVICE_QUERY = { CLIMATE: { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Climate0", status=0, @@ -63,7 +63,7 @@ ), }, COVER: { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Cover0", status=0, @@ -80,7 +80,7 @@ 0: LIGHT0, }, OTHER: { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Switch0", status=0, diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py index 54906c7e58d267..ed6984421d13a0 100644 --- a/tests/components/comelit/test_climate.py +++ b/tests/components/comelit/test_climate.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import AsyncMock, patch -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.api import ComelitDeviceObject from aiocomelit.const import CLIMATE, WATT from freezegun.api import FrozenDateTimeFactory import pytest @@ -103,7 +103,7 @@ async def test_climate_data_update( assert state.attributes[ATTR_TEMPERATURE] == 5.0 mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Climate0", status=0, @@ -331,7 +331,7 @@ async def test_climate_remove_stale( assert state.attributes[ATTR_TEMPERATURE] == 5.0 mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Climate0", status=0, diff --git a/tests/components/comelit/test_coordinator.py b/tests/components/comelit/test_coordinator.py index aa5973f01ebc34..96814fdc01261d 100644 --- a/tests/components/comelit/test_coordinator.py +++ b/tests/components/comelit/test_coordinator.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from aiocomelit.api import ( - ComelitSerialBridgeObject, + ComelitDeviceObject, ComelitVedoAreaObject, ComelitVedoZoneObject, ) @@ -90,7 +90,7 @@ async def test_coordinator_stale_device_serial_bridge( COVER: {}, LIGHT: { 0: LIGHT0, - 1: ComelitSerialBridgeObject( + 1: ComelitDeviceObject( index=1, name="Light1", status=0, diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py index 01c488c347a2e9..bea56322958116 100644 --- a/tests/components/comelit/test_cover.py +++ b/tests/components/comelit/test_cover.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.api import ComelitDeviceObject from aiocomelit.const import COVER, WATT from freezegun.api import FrozenDateTimeFactory import pytest @@ -79,7 +79,7 @@ async def test_cover_open( # Finish opening, update status mock_serial_bridge.get_all_devices.return_value[COVER] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Cover0", status=0, @@ -203,7 +203,7 @@ async def test_cover_dynamic( entity_id_2 = "cover.cover1" mock_serial_bridge.get_all_devices.return_value[COVER] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Cover0", status=0, @@ -215,7 +215,7 @@ async def test_cover_dynamic( power=0.0, power_unit=WATT, ), - 1: ComelitSerialBridgeObject( + 1: ComelitDeviceObject( index=1, name="Cover1", status=0, diff --git a/tests/components/comelit/test_humidifier.py b/tests/components/comelit/test_humidifier.py index 8789d94fef3d37..73b245fba5dd87 100644 --- a/tests/components/comelit/test_humidifier.py +++ b/tests/components/comelit/test_humidifier.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import AsyncMock, patch -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.api import ComelitDeviceObject from aiocomelit.const import CLIMATE, WATT from freezegun.api import FrozenDateTimeFactory import pytest @@ -103,7 +103,7 @@ async def test_humidifier_data_update( assert state.attributes[ATTR_HUMIDITY] == 50.0 mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Climate0", status=0, @@ -269,7 +269,7 @@ async def test_humidifier_dehumidifier_remove_stale( assert state.attributes[ATTR_HUMIDITY] == 50.0 mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Climate0", status=0, diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py index af2ff22a380e59..5e9a4aafcc9e4c 100644 --- a/tests/components/comelit/test_light.py +++ b/tests/components/comelit/test_light.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.api import ComelitDeviceObject from aiocomelit.const import LIGHT, WATT from freezegun.api import FrozenDateTimeFactory import pytest @@ -96,7 +96,7 @@ async def test_light_dynamic( entity_id_2 = "light.light1" mock_serial_bridge.get_all_devices.return_value[LIGHT] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Light0", status=0, @@ -108,7 +108,7 @@ async def test_light_dynamic( power=0.0, power_unit=WATT, ), - 1: ComelitSerialBridgeObject( + 1: ComelitDeviceObject( index=1, name="Light1", status=0, diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py index fe2d306cd7bab7..4a3381552de745 100644 --- a/tests/components/comelit/test_sensor.py +++ b/tests/components/comelit/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from aiocomelit.api import ( - ComelitSerialBridgeObject, + ComelitDeviceObject, ComelitVedoAreaObject, ComelitVedoZoneObject, ) @@ -117,7 +117,7 @@ async def test_serial_bridge_sensor_dynamic( assert hass.states.get(entity_id) mock_serial_bridge.get_all_devices.return_value[OTHER] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Switch0", status=0, @@ -129,7 +129,7 @@ async def test_serial_bridge_sensor_dynamic( power=0.0, power_unit=WATT, ), - 1: ComelitSerialBridgeObject( + 1: ComelitDeviceObject( index=1, name="Switch1", status=0, diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py index 38955bfad40e5b..9645c6e3e075ee 100644 --- a/tests/components/comelit/test_switch.py +++ b/tests/components/comelit/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.api import ComelitDeviceObject from aiocomelit.const import IRRIGATION, WATT from freezegun.api import FrozenDateTimeFactory import pytest @@ -96,7 +96,7 @@ async def test_switch_dynamic( assert hass.states.get(entity_id) mock_serial_bridge.get_all_devices.return_value[IRRIGATION] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Switch0", status=0, @@ -108,7 +108,7 @@ async def test_switch_dynamic( power=0.0, power_unit=WATT, ), - 1: ComelitSerialBridgeObject( + 1: ComelitDeviceObject( index=1, name="Switch1", status=0, diff --git a/tests/components/comelit/test_utils.py b/tests/components/comelit/test_utils.py index 7466b6c85dca50..c1af67eebd63ef 100644 --- a/tests/components/comelit/test_utils.py +++ b/tests/components/comelit/test_utils.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.api import ComelitDeviceObject from aiocomelit.const import CLIMATE, WATT from aiocomelit.exceptions import ( CannotAuthenticate, @@ -53,7 +53,7 @@ async def test_device_remove_stale( assert state.attributes[ATTR_HUMIDITY] == 50.0 mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { - 0: ComelitSerialBridgeObject( + 0: ComelitDeviceObject( index=0, name="Climate0", status=0, From 9fedf1e252be7f49bf9903b090597f48d9665c57 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:54:43 +0200 Subject: [PATCH 18/25] Drop "unsupported" suffix in Tuya device info (#182171) --- homeassistant/components/tuya/coordinator.py | 2 +- homeassistant/components/tuya/entity.py | 6 +- homeassistant/components/tuya/util.py | 8 +- .../components/tuya/snapshots/test_init.ambr | 78 +++++++++---------- tests/components/tuya/test_init.py | 23 ++---- 5 files changed, 49 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/tuya/coordinator.py b/homeassistant/components/tuya/coordinator.py index da1aad1007598c..c96e3b5fd29d51 100644 --- a/homeassistant/components/tuya/coordinator.py +++ b/homeassistant/components/tuya/coordinator.py @@ -151,7 +151,7 @@ def async_register_device( device_registry.async_get_or_create( config_entry_id=self._entry.entry_id, - **get_device_info(device, initial=True), + **get_device_info(device), ) def remove_device(self, device_id: str) -> None: diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index cbefb534acc7cf..fb5326038388d5 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -5,11 +5,11 @@ from tuya_device_handlers.device_wrapper import DeviceWrapper from tuya_sharing import CustomerDevice, Manager +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription -from .const import LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY -from .util import get_device_info +from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY class TuyaEntity(Entity): @@ -25,7 +25,7 @@ def __init__( description: EntityDescription, ) -> None: """Init TuyaEntity.""" - self._attr_device_info = get_device_info(device) + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device.id)}) self._attr_unique_id = f"tuya.{device.id}{description.key}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain self.entity_description = description # TuyaEntity initialize mq can subscribe diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index bca752d8114382..f340c2d8072778 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -63,18 +63,12 @@ def __init__( ) -def get_device_info(device: CustomerDevice, *, initial: bool = False) -> DeviceInfo: +def get_device_info(device: CustomerDevice) -> DeviceInfo: """Get device info.""" manufacturer = "Tuya" model: str | None = device.product_name model_id: str | None = device.product_id - if initial: - # Note: the model is overridden via entity.device_info property - # when the entity is created. If no entities are generated, it will - # stay as unsupported - model = f"{device.product_name} (unsupported)" - if ( quirk := TUYA_QUIRKS_REGISTRY.get_quirk_for_device(device) ) and quirk.manufacturer: diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 73e20d98523c67..364e22de103790 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -20,7 +20,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'LCDÕ▒ŵ©®µ╣┐Õ║ªõ©çÞâ¢ÚüѵĺÕÖ¿ (unsupported)', + 'model': 'LCDÕ▒ŵ©®µ╣┐Õ║ªõ©çÞâ¢ÚüѵĺÕÖ¿', 'model_id': 'ay30hrndaogxclh0', 'name': 'LCDÕ▒ŵ©®µ╣┐Õ║ªõ©çÞâ¢ÚüѵĺÕÖ¿', 'name_by_user': None, @@ -80,7 +80,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'TV (unsupported)', + 'model': 'TV', 'model_id': '47pew0', 'name': 'TV', 'name_by_user': None, @@ -140,7 +140,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'SORIA (unsupported)', + 'model': 'SORIA', 'model_id': '5l1ht8jygsyr1wn1', 'name': 'Panneaux solaires 2', 'name_by_user': None, @@ -770,7 +770,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Gateway (unsupported)', + 'model': 'Gateway', 'model_id': 'v7owd9tzcaninc36', 'name': 'Gateway2', 'name_by_user': None, @@ -830,7 +830,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Smart IR (unsupported)', + 'model': 'Smart IR', 'model_id': 'rqhxdyusjrwxyff6', 'name': 'Smart IR', 'name_by_user': None, @@ -890,7 +890,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'S1-TY-BLE-PRO (unsupported)', + 'model': 'S1-TY-BLE-PRO', 'model_id': 'xqeob8h6', 'name': 'S1-TY-BLE-PRO 2', 'name_by_user': None, @@ -1610,7 +1610,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Curtain switch (unsupported)', + 'model': 'Curtain switch', 'model_id': 'TtXKwTMwiPpURWLJ', 'name': 'Dining-Blinds', 'name_by_user': None, @@ -2210,7 +2210,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'T & H Sensor (unsupported)', + 'model': 'T & H Sensor', 'model_id': 'x3o8epevyeo3z3oa', 'name': 'Interior Bedroom Sensor', 'name_by_user': None, @@ -2420,7 +2420,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Curtain switch (unsupported)', + 'model': 'Curtain switch', 'model_id': 'uoa3mayicscacseb', 'name': 'Living room left', 'name_by_user': None, @@ -2630,7 +2630,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Double Digital Meter (unsupported)', + 'model': 'Double Digital Meter', 'model_id': '79a7z01v3n35kytb', 'name': 'Double Digital Meter', 'name_by_user': None, @@ -2720,7 +2720,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Tower bladeless fan (unsupported)', + 'model': 'Tower bladeless fan ', 'model_id': 'ibytpo6fpnugft1c', 'name': 'Ventilador Cama', 'name_by_user': None, @@ -2780,7 +2780,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'QNECT WI-FI PIR SENSOR (unsupported)', + 'model': 'QNECT WI-FI PIR SENSOR', 'model_id': 'j5jgnjvdaczeb6dc', 'name': 'QNECT WI-FI PIR SENSOR', 'name_by_user': None, @@ -3230,7 +3230,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'ISV-100W2.0 (unsupported)', + 'model': 'ISV-100W2.0', 'model_id': 'jlapoy5liocmtdvd', 'name': 'ISV-100W2.0', 'name_by_user': None, @@ -3260,7 +3260,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'DOLCECLIMA 10 HP WIFI (unsupported)', + 'model': 'DOLCECLIMA 10 HP WIFI', 'model_id': 'jevroj5aguwdbs2e', 'name': 'DOLCECLIMA 10 HP WIFI', 'name_by_user': None, @@ -3290,7 +3290,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'VITAL+ (unsupported)', + 'model': 'VITAL+', 'model_id': 'urzivdhumrwfakie', 'name': 'VITAL+', 'name_by_user': None, @@ -3320,7 +3320,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Air Conditioning (unsupported)', + 'model': 'Air Conditioning', 'model_id': 'qzktzhehinzsz2je', 'name': 'Air', 'name_by_user': None, @@ -3380,7 +3380,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Contact Sensor (unsupported)', + 'model': 'Contact Sensor', 'model_id': 'p6sqiuesvhmhvv4f', 'name': 'Entrance Door', 'name_by_user': None, @@ -3590,7 +3590,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Smart IR+RF Remote Control (unsupported)', + 'model': 'Smart IR+RF Remote Control', 'model_id': 'x0lyfgjuguuh1vof', 'name': 'Smart IR+RF Remote Control', 'name_by_user': None, @@ -3890,7 +3890,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Smart IR (unsupported)', + 'model': 'Smart IR ', 'model_id': 'kzwdw5bpxlbs9h9g', 'name': 'IR Minero', 'name_by_user': None, @@ -4550,7 +4550,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Smart IR (unsupported)', + 'model': 'Smart IR', 'model_id': 'om518smspsaltzdi', 'name': 'Smart IR Theater', 'name_by_user': None, @@ -5150,7 +5150,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'ZigBee Gateway (unsupported)', + 'model': 'ZigBee Gateway', 'model_id': 'b25mh8sxawsgndck', 'name': 'ZigBee Gateway', 'name_by_user': None, @@ -5360,7 +5360,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Smart IR (unsupported)', + 'model': 'Smart IR', 'model_id': 'npbbca46yiug8ysk', 'name': 'Bedroom IR', 'name_by_user': None, @@ -5390,7 +5390,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': '水泵 (unsupported)', + 'model': '水泵', 'model_id': 'l6ax0u6jwbz82atk', 'name': 'Pond', 'name_by_user': None, @@ -5600,7 +5600,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Double Digital Meter (unsupported)', + 'model': 'Double Digital Meter', 'model_id': 'dhto3y4uachr1wll', 'name': 'Meter', 'name_by_user': None, @@ -5750,7 +5750,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'INTELAR IR288 (unsupported)', + 'model': 'INTELAR IR288', 'model_id': '5vlawhjm', 'name': 'INTELAR IR288', 'name_by_user': None, @@ -6170,7 +6170,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Zigbee Gateway (unsupported)', + 'model': 'Zigbee Gateway', 'model_id': 'plp0gnfcacdeqk5o', 'name': 'Zigbee Gateway', 'name_by_user': None, @@ -6530,7 +6530,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'TV Sync Backlights (unsupported)', + 'model': 'TV Sync Backlights', 'model_id': 'gaobbrxqiblcng2p', 'name': 'TV Sync Backlights', 'name_by_user': None, @@ -6560,7 +6560,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'PC321-W-TY(Bi-Directional) (unsupported)', + 'model': 'PC321-W-TY(Bi-Directional)', 'model_id': 'gqmmtjclqb7reg5p', 'name': 'Wi-Fi Meter(Bi-Directional)', 'name_by_user': None, @@ -6950,7 +6950,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Genio Nebula & Blue Star Projector (unsupported)', + 'model': 'Genio Nebula & Blue Star Projector', 'model_id': '3djw12ln4xtvv8eq', 'name': 'Genio Nebula & Blue Star Projector', 'name_by_user': None, @@ -7340,7 +7340,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Zigbee Smart Gateway (unsupported)', + 'model': 'Zigbee Smart Gateway', 'model_id': 'lieerjyy6l4ykjor', 'name': 'Zigbee Gateway', 'name_by_user': None, @@ -7820,7 +7820,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Gateway (unsupported)', + 'model': 'Gateway', 'model_id': 'tmwhss6ntjfc7prs', 'name': 'Gateway', 'name_by_user': None, @@ -7970,7 +7970,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Air Conditioner (unsupported)', + 'model': 'Air Conditioner', 'model_id': '47peys', 'name': 'Ar', 'name_by_user': None, @@ -8030,7 +8030,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'TION Breezer Bio X (unsupported)', + 'model': 'TION Breezer Bio X', 'model_id': '9mqdhwklpvnnvb7t', 'name': 'Бризер Зал', 'name_by_user': None, @@ -8240,7 +8240,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'GIEX Watering Timer (unsupported)', + 'model': 'GIEX Watering Timer', 'model_id': '7ytb3h8u', 'name': 'GIEX Watering Timer', 'name_by_user': None, @@ -8300,7 +8300,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Siren (unsupported)', + 'model': 'Siren', 'model_id': 'DYgId0sz6zWlmmYu', 'name': 'Siren', 'name_by_user': None, @@ -8690,7 +8690,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Zigbee Repeater (unsupported)', + 'model': 'Zigbee Repeater', 'model_id': 'nkkl7uzv', 'name': 'Zigby répéteur ', 'name_by_user': None, @@ -8810,7 +8810,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'InverFlow (unsupported)', + 'model': 'InverFlow', 'model_id': 'ircs2n82vgrozoew', 'name': 'InverFlow', 'name_by_user': None, @@ -9620,7 +9620,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'TV (unsupported)', + 'model': 'TV', 'model_id': 'lplun31mo1xaonwz', 'name': 'TV', 'name_by_user': None, @@ -9650,7 +9650,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'REGISTER BOOSTER FAN (unsupported)', + 'model': 'REGISTER BOOSTER FAN ', 'model_id': 'pjabraecffsfrmxz', 'name': 'Register booster fan', 'name_by_user': None, diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index 4faa9014643409..91425c85ab60f3 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -117,7 +117,6 @@ async def test_device_registry( mock_config_entry: MockConfigEntry, mock_devices: list[CustomerDevice], device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Validate device registry snapshots for all devices.""" @@ -136,31 +135,20 @@ async def test_device_registry( name=list(device_registry_entry.identifiers)[0][1] ) - # Ensure model is suffixed with "(unsupported)" when no entities are generated - assert (" (unsupported)" in device_registry_entry.model) == ( - not er.async_entries_for_device( - entity_registry, - device_registry_entry.id, - include_disabled_entities=True, - ) - ) - @pytest.mark.parametrize( ("mock_device_code", "platforms", "manufacturer", "model", "model_id", "quirks"), [ - # Ensure model is suffixed with "(unsupported)" when no entities - # are generated + # Device information is registered even when no entities are generated ( "mal_gyitctrjj1kefxp2", [], "Tuya", - "Multifunction alarm (unsupported)", + "Multifunction alarm", "gyitctrjj1kefxp2", {}, ), - # Ensure model is not suffixed with "(unsupported)" when entities - # are generated + # Creating entities does not alter the registered device information ( "mal_gyitctrjj1kefxp2", [Platform.ALARM_CONTROL_PANEL], @@ -170,8 +158,7 @@ async def test_device_registry( {}, ), # With a quirk that has manufacturer, model and model_id are - # taken from quirk (and not suffixed with "(unsupported)" even if - # no entities are generated) + # taken from quirk ( "mal_gyitctrjj1kefxp2", [], @@ -208,7 +195,7 @@ async def test_device_registry( "mal_gyitctrjj1kefxp2", [], "Tuya", - "Multifunction alarm (unsupported)", + "Multifunction alarm", "gyitctrjj1kefxp2", { "gyitctrjj1kefxp2": MagicMock( From 3e18e6c5d99345517810f443f521267894f02cb9 Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Mon, 14 Sep 2026 22:09:57 +0800 Subject: [PATCH 19/25] Name the two ZhongHong fan speeds the climate component does not (#182075) --- .../components/zhong_hong/climate.py | 3 + .../components/zhong_hong/strings.json | 14 ++++ tests/components/zhong_hong/test_climate.py | 69 ++++++++++++++++++- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index c31547529a1874..adf252e28ec457 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -186,6 +186,9 @@ class ZhongHongClimate(CoordinatorEntity[ZhongHongCoordinator], ClimateEntity): ) _attr_target_temperature_step = 1 _attr_temperature_unit = UnitOfTemperature.CELSIUS + # Two of the five speeds the gateway addresses have no name of their own in + # the climate component, so they are named here. + _attr_translation_key = "air_conditioner" def __init__( self, diff --git a/homeassistant/components/zhong_hong/strings.json b/homeassistant/components/zhong_hong/strings.json index 4e8646fee24195..b67f52c06e20bc 100644 --- a/homeassistant/components/zhong_hong/strings.json +++ b/homeassistant/components/zhong_hong/strings.json @@ -24,6 +24,20 @@ } } }, + "entity": { + "climate": { + "air_conditioner": { + "state_attributes": { + "fan_mode": { + "state": { + "medium_high": "Medium high", + "medium_low": "Medium low" + } + } + } + } + } + }, "exceptions": { "cannot_connect": { "message": "Failed to connect to the ZhongHong gateway at {host}." diff --git a/tests/components/zhong_hong/test_climate.py b/tests/components/zhong_hong/test_climate.py index d6f6d72ff0cf4f..45a19de0b4d739 100644 --- a/tests/components/zhong_hong/test_climate.py +++ b/tests/components/zhong_hong/test_climate.py @@ -12,7 +12,9 @@ ATTR_FAN_MODES, ATTR_HVAC_MODE, DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, FAN_LOW, + FAN_MIDDLE, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, @@ -20,7 +22,12 @@ SERVICE_TURN_ON, HVACMode, ) -from homeassistant.components.zhong_hong.const import ALL_FAN_MODES, FAN_MEDIUM_HIGH +from homeassistant.components.zhong_hong.const import ( + ALL_FAN_MODES, + DOMAIN, + FAN_MEDIUM_HIGH, + FAN_MEDIUM_LOW, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -31,6 +38,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.translation import async_get_translations from . import setup_integration from .conftest import DEVICE_ADDRESS, ENTITY_ID, FakeGateway, build_status @@ -385,3 +393,62 @@ async def test_device_address_is_used_for_the_entity( assert hass.states.get(ENTITY_ID) is not None assert hass.states.get("climate.ac_1_2") is not None + + +async def test_every_fan_mode_has_a_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, +) -> None: + """Test every speed the entity offers is named in the interface. + + The climate component names three of the five speeds the gateway can + address. The other two have to be named here, or they reach the user as + the keys themselves. + """ + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state + entry = entity_registry.async_get(ENTITY_ID) + assert entry + + # Asked for one integration at a time, these come back as the cache itself + # rather than a copy of it, so neither is merged into the other. + named_by_component = await async_get_translations( + hass, "en", "entity_component", [CLIMATE_DOMAIN] + ) + named_here = await async_get_translations(hass, "en", "entity", [DOMAIN]) + + by_component = ( + f"component.{CLIMATE_DOMAIN}.entity_component._" + ".state_attributes.fan_mode.state." + ) + by_integration = ( + f"component.{DOMAIN}.entity.climate.{entry.translation_key}" + ".state_attributes.fan_mode.state." + ) + fan_modes = state.attributes[ATTR_FAN_MODES] + + assert fan_modes == ALL_FAN_MODES + + # The two the component has no name for are named here, and only those + # two: the others are left to it, which is what lets them keep the names + # it gives them. + assert {mode: named_here.get(f"{by_integration}{mode}") for mode in fan_modes} == { + FAN_LOW: None, + FAN_MEDIUM_LOW: "Medium low", + FAN_MIDDLE: None, + FAN_MEDIUM_HIGH: "Medium high", + FAN_HIGH: None, + } + assert { + mode: named_by_component.get(f"{by_component}{mode}") for mode in fan_modes + } == { + FAN_LOW: "Low", + FAN_MEDIUM_LOW: None, + FAN_MIDDLE: "Middle", + FAN_MEDIUM_HIGH: None, + FAN_HIGH: "High", + } From 2109e42092ace2534f59b261e3d1ec9d919c4968 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Sep 2026 16:29:30 +0200 Subject: [PATCH 20/25] Fix Google Tasks error handling on DNS failure (#182195) Co-authored-by: Claude --- homeassistant/components/google_tasks/api.py | 8 ++++++-- tests/components/google_tasks/test_init.py | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index 4b1cca8089d3b2..f59b48061630b7 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -152,9 +152,13 @@ async def move( async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any: try: result = await self._hass.async_add_executor_job(request.execute) - except (HttpError, ServerNotFoundError) as err: + except HttpError as err: raise GoogleTasksApiError( - f"Google Tasks API responded with: {err.reason or err.status_code})" + f"Google Tasks API responded with: {err.reason or err.status_code}" + ) from err + except ServerNotFoundError as err: + raise GoogleTasksApiError( + f"Unable to reach the Google Tasks API: {err}" ) from err if result: _raise_if_error(result) diff --git a/tests/components/google_tasks/test_init.py b/tests/components/google_tasks/test_init.py index c1ed1cf609f059..adceebead0d99e 100644 --- a/tests/components/google_tasks/test_init.py +++ b/tests/components/google_tasks/test_init.py @@ -8,7 +8,7 @@ from unittest.mock import Mock, patch from aiohttp import ClientError -from httplib2 import Response +from httplib2 import Response, ServerNotFoundError import pytest from homeassistant.components.google_tasks import DOMAIN @@ -131,16 +131,23 @@ async def test_expired_token_refresh_failure( @pytest.mark.parametrize( "response_handler", [ - ([(Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b"")]), - # First request succeeds, second request fails - ( + pytest.param( + [(Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b"")], + id="first_request_fails", + ), + pytest.param( [ ( Response({"status": HTTPStatus.OK}), json.dumps(LIST_TASK_LIST_RESPONSE), ), (Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b""), - ] + ], + id="second_request_fails", + ), + pytest.param( + [ServerNotFoundError("Unable to find the server at tasks.googleapis.com")], + id="server_not_found", ), ], ) From 21937494d191f438f875df3a0bf8b359dbe2a2c1 Mon Sep 17 00:00:00 2001 From: Jonas Petersson Date: Mon, 14 Sep 2026 16:49:38 +0200 Subject: [PATCH 21/25] Add glass break device class for binary_sensor (#181332) Co-authored-by: Jonas Petersson <499325+catellie@users.noreply.github.com> Co-authored-by: Codex --- .../components/binary_sensor/__init__.py | 3 +++ .../binary_sensor/device_condition.py | 8 ++++++++ .../components/binary_sensor/device_trigger.py | 6 ++++++ .../components/binary_sensor/icons.json | 3 +++ .../components/binary_sensor/strings.json | 11 +++++++++++ homeassistant/generated/device_classes.json | 1 + tests/components/binary_sensor/test_init.py | 18 ++++++++++++++++++ 7 files changed, 50 insertions(+) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 14767114bb23ce..50acd4c6fa9b6f 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -55,6 +55,9 @@ class BinarySensorDeviceClass(StrEnum): # On means gas detected, Off means no gas (clear) GAS = "gas" + # On means glass break detected, Off means no glass break (clear) + GLASS_BREAK = "glass_break" + # On means hot, Off means normal HEAT = "heat" diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index 7e5c1f514043e5..ea27a1f727a174 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -37,6 +37,8 @@ CONF_IS_NOT_CONNECTED = "is_not_connected" CONF_IS_GAS = "is_gas" CONF_IS_NO_GAS = "is_no_gas" +CONF_IS_GLASS_BREAK = "is_glass_break" +CONF_IS_NO_GLASS_BREAK = "is_no_glass_break" CONF_IS_HOT = "is_hot" CONF_IS_NOT_HOT = "is_not_hot" CONF_IS_LIGHT = "is_light" @@ -83,6 +85,7 @@ CONF_IS_COLD, CONF_IS_CONNECTED, CONF_IS_GAS, + CONF_IS_GLASS_BREAK, CONF_IS_HOT, CONF_IS_LIGHT, CONF_IS_NOT_LOCKED, @@ -123,6 +126,7 @@ CONF_IS_NOT_UNSAFE, CONF_IS_NO_CO, CONF_IS_NO_GAS, + CONF_IS_NO_GLASS_BREAK, CONF_IS_NO_LIGHT, CONF_IS_NO_MOTION, CONF_IS_NO_PROBLEM, @@ -167,6 +171,10 @@ {CONF_TYPE: CONF_IS_GAS}, {CONF_TYPE: CONF_IS_NO_GAS}, ], + BinarySensorDeviceClass.GLASS_BREAK: [ + {CONF_TYPE: CONF_IS_GLASS_BREAK}, + {CONF_TYPE: CONF_IS_NO_GLASS_BREAK}, + ], BinarySensorDeviceClass.HEAT: [ {CONF_TYPE: CONF_IS_HOT}, {CONF_TYPE: CONF_IS_NOT_HOT}, diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index f9958ae0a0467e..dbba3ddbed7073 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -31,6 +31,8 @@ CONF_NOT_CONNECTED = "not_connected" CONF_GAS = "gas" CONF_NO_GAS = "no_gas" +CONF_GLASS_BREAK = "glass_break" +CONF_NO_GLASS_BREAK = "no_glass_break" CONF_HOT = "hot" CONF_NOT_HOT = "not_hot" CONF_LIGHT = "light" @@ -104,6 +106,10 @@ {CONF_TYPE: CONF_GAS}, {CONF_TYPE: CONF_NO_GAS}, ], + BinarySensorDeviceClass.GLASS_BREAK: [ + {CONF_TYPE: CONF_GLASS_BREAK}, + {CONF_TYPE: CONF_NO_GLASS_BREAK}, + ], BinarySensorDeviceClass.HEAT: [ {CONF_TYPE: CONF_HOT}, {CONF_TYPE: CONF_NOT_HOT}, diff --git a/homeassistant/components/binary_sensor/icons.json b/homeassistant/components/binary_sensor/icons.json index 929ca8114e37fb..94b26e7d4edbab 100644 --- a/homeassistant/components/binary_sensor/icons.json +++ b/homeassistant/components/binary_sensor/icons.json @@ -54,6 +54,9 @@ "on": "mdi:alert-circle" } }, + "glass_break": { + "default": "mdi:glass-fragile" + }, "heat": { "default": "mdi:thermometer", "state": { diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 08d16fd03966a4..cb9e08a69a896b 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -7,6 +7,7 @@ "is_cold": "{entity_name} is cold", "is_connected": "{entity_name} is connected", "is_gas": "{entity_name} is detecting gas", + "is_glass_break": "{entity_name} is detecting glass break", "is_hot": "{entity_name} is hot", "is_light": "{entity_name} is detecting light", "is_locked": "{entity_name} is locked", @@ -15,6 +16,7 @@ "is_moving": "{entity_name} is moving", "is_no_co": "{entity_name} is not detecting carbon monoxide", "is_no_gas": "{entity_name} is not detecting gas", + "is_no_glass_break": "{entity_name} is not detecting glass break", "is_no_light": "{entity_name} is not detecting light", "is_no_motion": "{entity_name} is not detecting motion", "is_no_problem": "{entity_name} is not detecting problem", @@ -64,6 +66,7 @@ "cold": "{entity_name} became cold", "connected": "{entity_name} connected", "gas": "{entity_name} started detecting gas", + "glass_break": "{entity_name} detected glass break", "hot": "{entity_name} became hot", "light": "{entity_name} started detecting light", "locked": "{entity_name} locked", @@ -72,6 +75,7 @@ "moving": "{entity_name} started moving", "no_co": "{entity_name} stopped detecting carbon monoxide", "no_gas": "{entity_name} stopped detecting gas", + "no_glass_break": "{entity_name} stopped detecting glass break", "no_light": "{entity_name} stopped detecting light", "no_motion": "{entity_name} stopped detecting motion", "no_problem": "{entity_name} stopped detecting problem", @@ -176,6 +180,13 @@ "on": "Detected" } }, + "glass_break": { + "name": "Glass break", + "state": { + "off": "Clear", + "on": "Glass break detected" + } + }, "heat": { "name": "Heat", "state": { diff --git a/homeassistant/generated/device_classes.json b/homeassistant/generated/device_classes.json index ecae4ad378f5c9..c2c19177da3584 100644 --- a/homeassistant/generated/device_classes.json +++ b/homeassistant/generated/device_classes.json @@ -8,6 +8,7 @@ "door", "garage_door", "gas", + "glass_break", "heat", "light", "lock", diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index b0a26faf927e4b..c5721f4b7f2dfb 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -6,6 +6,9 @@ import pytest from homeassistant.components import binary_sensor +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.binary_sensor.device_condition import ENTITY_CONDITIONS +from homeassistant.components.binary_sensor.device_trigger import ENTITY_TRIGGERS from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory, Platform from homeassistant.core import HomeAssistant @@ -196,3 +199,18 @@ async def async_setup_entry_platform( "Entity binary_sensor.test2 cannot be added as the" " entity category is set to config" in caplog.text ) + + +def test_glass_break_device_class() -> None: + """Test glass break device class enum value and automation mappings.""" + assert BinarySensorDeviceClass.GLASS_BREAK == "glass_break" + assert BinarySensorDeviceClass.GLASS_BREAK in ENTITY_CONDITIONS + assert BinarySensorDeviceClass.GLASS_BREAK in ENTITY_TRIGGERS + conditions = ENTITY_CONDITIONS[BinarySensorDeviceClass.GLASS_BREAK] + assert len(conditions) == 2 + condition_types = {c["type"] for c in conditions} + assert condition_types == {"is_glass_break", "is_no_glass_break"} + triggers = ENTITY_TRIGGERS[BinarySensorDeviceClass.GLASS_BREAK] + assert len(triggers) == 2 + trigger_types = {t["type"] for t in triggers} + assert trigger_types == {"glass_break", "no_glass_break"} From 5ba6d59c89c4668456c5540ea87de3990cda0333 Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Mon, 14 Sep 2026 22:52:57 +0800 Subject: [PATCH 22/25] Re-read a ZhongHong unit shortly after commanding it (#182074) --- .../components/zhong_hong/climate.py | 45 +++-- .../components/zhong_hong/coordinator.py | 61 +++++- tests/components/zhong_hong/conftest.py | 8 + tests/components/zhong_hong/test_climate.py | 182 +++++++++++++++++- 4 files changed, 274 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index adf252e28ec457..f2f3fa5ea9be85 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -1,5 +1,6 @@ """Support for ZhongHong HVAC Controller.""" +from collections.abc import Callable from typing import Any, override import probatio @@ -251,53 +252,61 @@ def max_temp(self) -> float: """Return the maximum temperature.""" return self._device.max_temp - def _command(self, sent: bool, command: str) -> None: - """Fail if the command did not go out. + async def _command( + self, command: str, send: Callable[..., bool], *args: Any + ) -> None: + """Send a command to the unit, and re-read it shortly after. - Nothing is written here on success: the unit reports the state it - actually reached, which is not always the one it was asked for. + The library talks to the gateway over a blocking socket, so the call + goes to the executor. The unit reports the new state itself once it + acts on the command, so the re-read is only there for the reports that + go missing. """ - if not sent: + if not await self.hass.async_add_executor_job(send, *args): raise _send_failed(command) + self.coordinator.async_schedule_readback() + @override - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn on ac.""" - self._command(self._device.turn_on(), "turn-on") + await self._command("turn-on", self._device.turn_on) @override - def turn_off(self) -> None: + async def async_turn_off(self) -> None: """Turn off ac.""" - self._command(self._device.turn_off(), "turn-off") + await self._command("turn-off", self._device.turn_off) @override - def set_temperature(self, **kwargs: Any) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: - self._command(self._device.set_temperature(temperature), "temperature") + await self._command( + "temperature", self._device.set_temperature, temperature + ) if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: - self.set_hvac_mode(operation_mode) + await self.async_set_hvac_mode(operation_mode) @override - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" if hvac_mode == HVACMode.OFF: if self.is_on: - self.turn_off() + await self.async_turn_off() return if not self.is_on: - self.turn_on() + await self.async_turn_on() - self._command(self._device.set_operation_mode(hvac_mode.upper()), "mode") + await self._command("mode", self._device.set_operation_mode, hvac_mode.upper()) @override - def set_fan_mode(self, fan_mode: str) -> None: + async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" mapped_mode = FAN_MODE_MAP.get(fan_mode) if not mapped_mode: LOGGER.error("Unsupported fan mode: %s", fan_mode) return - self._command(self._device.set_fan_mode(mapped_mode), "fan") + await self._command("fan", self._device.set_fan_mode, mapped_mode) diff --git a/homeassistant/components/zhong_hong/coordinator.py b/homeassistant/components/zhong_hong/coordinator.py index ec96c8ef5bff9e..5e7a26540684f5 100644 --- a/homeassistant/components/zhong_hong/coordinator.py +++ b/homeassistant/components/zhong_hong/coordinator.py @@ -1,18 +1,27 @@ """Coordinator for the ZhongHong integration.""" from dataclasses import dataclass -from typing import override +from datetime import datetime +from typing import Final, override from zhong_hong_hvac.hub import ZhongHongGateway from zhong_hong_hvac.hvac import HVAC as ZhongHongHVAC from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER, SCAN_INTERVAL +# A unit acts on a command and then reports the new state unprompted. This is +# how long to wait before asking for it anyway, to cover the reports that never +# arrive. Ten runs against a Haier unit took between one and 3.4 seconds to +# act, so this sits past the slowest of them: asking before the unit has moved +# would read back the state the command was meant to change. +READBACK_DELAY: Final = 5 + type DeviceAddress = tuple[int, int] @@ -81,10 +90,47 @@ def __init__( update_interval=SCAN_INTERVAL, ) self.hub = hub + self._readback_cancel: CALLBACK_TYPE | None = None for device in devices.values(): device.register_update_callback(self._handle_device_update) + @callback + def async_schedule_readback(self) -> None: + """Re-read the gateway shortly after it has been commanded. + + A unit takes a second or three to act on a command, and the gateway + pushes the new state once it has. That push is the only thing the + state comes from, so if it goes missing the entity keeps showing what + the unit was doing before, until the next poll a minute later. Asking + again a few seconds in costs one round trip and closes that window. + """ + # A command sits in the executor while it is sent, and the entry can + # be unloaded in the meantime, so this can be reached afterwards. + # Scheduling then would put back the timer the shutdown has just + # taken away. + if self._shutdown_requested: + return + + if self._readback_cancel is not None: + self._readback_cancel() + + @callback + def _readback(_now: datetime) -> None: + self._readback_cancel = None + # Refreshed rather than requested: a request goes through the + # coordinator's debouncer, whose cooldown is twice this delay, so + # a command given shortly after a re-read would have its own one + # held back past the point the unit has acted. The timer above is + # the rate limit this needs. + self.config_entry.async_create_background_task( + self.hass, + self.async_refresh(), + name=f"{self.name} readback", + ) + + self._readback_cancel = async_call_later(self.hass, READBACK_DELAY, _readback) + def _handle_device_update(self, device: ZhongHongHVAC) -> None: """Handle a state push from the gateway. @@ -107,3 +153,14 @@ async def _async_update_data(self) -> None: if not await self.hass.async_add_executor_job(self.hub.query_all_status): raise UpdateFailed(f"Failed to query the gateway at {self.hub.ip_addr}") + + @override + async def async_shutdown(self) -> None: + """Drop the pending re-read, which would outlive the entry.""" + # Shutting down first, so that anything on its way here from the + # executor finds the door already closed. + await super().async_shutdown() + + if self._readback_cancel is not None: + self._readback_cancel() + self._readback_cancel = None diff --git a/tests/components/zhong_hong/conftest.py b/tests/components/zhong_hong/conftest.py index dc70046ab53b88..4c1cc88498fbf0 100644 --- a/tests/components/zhong_hong/conftest.py +++ b/tests/components/zhong_hong/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the ZhongHong tests.""" from collections.abc import Callable, Generator +import threading from unittest.mock import AsyncMock, patch import pytest @@ -77,6 +78,10 @@ def __init__(self) -> None: self.send_result = True self.send_results: list[bool] = [] + # Set to hold a command inside the executor, so that a test can + # have one still on its way out while something else happens. + self.send_gate: threading.Event | None = None + self.send_entered = threading.Event() self.query_all_status_result = True # The wire names of the speeds the units behind this gateway have. self.supported_fan_modes = {mode.name for mode in StatusFanMode} @@ -138,6 +143,9 @@ def send(self, ac_data) -> bool: let one command succeed and the next one fail. """ self.send_calls += 1 + if self.send_gate is not None: + self.send_entered.set() + self.send_gate.wait(timeout=10) header = ac_data.header if header.func_code is FuncCode.CTL_FAN_MODE: diff --git a/tests/components/zhong_hong/test_climate.py b/tests/components/zhong_hong/test_climate.py index 45a19de0b4d739..94cdc16c49f4d2 100644 --- a/tests/components/zhong_hong/test_climate.py +++ b/tests/components/zhong_hong/test_climate.py @@ -1,6 +1,7 @@ """Test the zhong_hong climate platform.""" from datetime import timedelta +import threading from freezegun.api import FrozenDateTimeFactory import pytest @@ -28,6 +29,7 @@ FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -45,9 +47,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed -# Spelled out instead of importing SCAN_INTERVAL, so that changing it in the -# integration makes these tests fail instead of following along. +# Spelled out instead of importing SCAN_INTERVAL and READBACK_DELAY, so that +# changing either in the integration makes these tests fail instead of +# following along. POLL_INTERVAL = timedelta(seconds=60) +READBACK_DELAY = timedelta(seconds=5) async def test_entity_registration( @@ -395,6 +399,180 @@ async def test_device_address_is_used_for_the_entity( assert hass.states.get("climate.ac_1_2") is not None +async def test_a_command_is_read_back( + hass: HomeAssistant, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the gateway is re-read shortly after being commanded. + + A unit reports the new state itself once it acts, so this only matters + for the reports that go missing: without it the entity would show the old + state until the next scheduled poll. + """ + await setup_integration(hass, mock_config_entry) + + assert mock_gateway.query_all_status_calls == 1 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + + assert mock_gateway.query_all_status_calls == 1 + + freezer.tick(READBACK_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_gateway.query_all_status_calls == 2 + + +async def test_commands_in_a_row_are_read_back_once( + hass: HomeAssistant, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a burst of commands does not queue up a re-read for each one. + + Each command cancels the re-read the one before it scheduled, so only the + last should survive to query the gateway. The commands are spread out + rather than sent at once to put each re-read at its own moment: one left + over from an earlier command then comes due on its own, where the + assertion below catches it, instead of landing on the same tick as the + survivor and passing for it. + """ + await setup_integration(hass, mock_config_entry) + + assert mock_gateway.query_all_status_calls == 1 + + commands = (FAN_HIGH, FAN_LOW, FAN_MIDDLE) + for fan_mode in commands: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: fan_mode}, + blocking=True, + ) + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # The clock stands a second per command past the first of them. Take it + # the rest of the way to where that command's own re-read would have come + # due: one left over from it fires here, and the commands after it have + # not pushed their re-read this far forward. + freezer.tick(READBACK_DELAY - timedelta(seconds=len(commands))) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_gateway.query_all_status_calls == 1 + + freezer.tick(READBACK_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_gateway.query_all_status_calls == 2 + + +async def test_a_later_command_is_read_back_on_time( + hass: HomeAssistant, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a command soon after a re-read gets its own re-read on time. + + The delay is chosen to sit past the time a unit takes to act. A re-read + held back beyond it would read the state the command was meant to change. + """ + await setup_integration(hass, mock_config_entry) + + assert mock_gateway.query_all_status_calls == 1 + + for _ in range(2): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + freezer.tick(READBACK_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_gateway.query_all_status_calls == 3 + + +@pytest.mark.parametrize("expected_lingering_timers", [False]) +async def test_a_pending_readback_does_not_outlive_the_entry( + hass: HomeAssistant, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the entry drops a re-read that was still to come. + + The re-read sits on a timer of its own, which nothing else knows to + cancel. A leftover one does not reach the gateway — the coordinator it + would refresh has been shut down by then — but it stays on the loop, and + holds on to that coordinator until it comes due. So what this test looks + at is the timer rather than the gateway: the harness is asked not to + forgive a lingering one, and the test ends while it would still be there. + """ + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + + 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.parametrize("expected_lingering_timers", [False]) +async def test_a_command_landing_after_the_unload_schedules_nothing( + hass: HomeAssistant, + mock_gateway: FakeGateway, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a command still in flight at unload does not leave a re-read behind. + + A command sits in the executor while it is sent, so one held up there is + still on its way out when the entry is taken down, and asks for its + re-read once the unload has already been through and found nothing to + cancel. Asking then would put back the timer the unload has just taken + away, and it would outlive the entry. + """ + await setup_integration(hass, mock_config_entry) + + mock_gateway.send_gate = threading.Event() + command = hass.async_create_task( + hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + ) + + assert await hass.async_add_executor_job(mock_gateway.send_entered.wait, 10) + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + + mock_gateway.send_gate.set() + await command + await hass.async_block_till_done() + + async def test_every_fan_mode_has_a_name( hass: HomeAssistant, entity_registry: er.EntityRegistry, From b0e78232792bf0c9a9134d4398fd6bff419ba26c Mon Sep 17 00:00:00 2001 From: noantiq <39308834+noantiq@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:55:54 +0200 Subject: [PATCH 23/25] Add date to response for IMAP fetch service call (#181604) --- homeassistant/components/imap/__init__.py | 1 + tests/components/imap/const.py | 13 ++++++- tests/components/imap/test_init.py | 41 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 1cd0d37e1c6d1b..0ca5384f7e92b0 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -231,6 +231,7 @@ async def async_fetch(call: ServiceCall) -> ServiceResponse: "sender": message.sender, "subject": message.subject, "parts": get_parts(message.email_message), + "date": message.date.isoformat() if message.date else None, "uid": uid, } diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 5ddf86153cb2d8..779b69cd4b8a31 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -59,6 +59,7 @@ TEST_INVALID_DATE3 = ( TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID3 + TEST_MESSAGE_HEADERS2 ) +TEST_MISSING_DATE = TEST_MESSAGE_HEADERS1 + TEST_MESSAGE_HEADERS2 TEST_CONTENT_TEXT_BARE = b"\r\nTest body\r\n\r\n" @@ -277,7 +278,17 @@ b"Fetch completed (0.0001 + 0.000 secs).", ], ) - +TEST_FETCH_RESPONSE_MISSING_DATE = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MISSING_DATE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_MISSING_DATE + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) TEST_FETCH_RESPONSE_TEXT_OTHER = ( "OK", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 134e6a006154f8..e7628b72c07340 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -28,6 +28,7 @@ TEST_FETCH_RESPONSE_INVALID_DATE1, TEST_FETCH_RESPONSE_INVALID_DATE2, TEST_FETCH_RESPONSE_INVALID_DATE3, + TEST_FETCH_RESPONSE_MISSING_DATE, TEST_FETCH_RESPONSE_MULTIPART, TEST_FETCH_RESPONSE_MULTIPART_BASE64, TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, @@ -987,6 +988,7 @@ async def test_services( assert response["text"] == "*Multi* part Test body\n" assert response["sender"] == "john.doe@example.com" assert response["subject"] == "Test subject" + assert response["date"] == "2023-03-24T13:52:00+01:00" assert response["uid"] == "1" assert response["parts"] == message_parts @@ -1096,3 +1098,42 @@ async def test_services( assert exc.value.translation_domain == DOMAIN assert exc.value.translation_key == patch_error_translation_key[service][1] assert exc.value.translation_placeholders == {"error": "Bla"} + + +@pytest.mark.parametrize( + "imap_fetch", + [ + TEST_FETCH_RESPONSE_MISSING_DATE, + TEST_FETCH_RESPONSE_INVALID_DATE1, + TEST_FETCH_RESPONSE_INVALID_DATE2, + TEST_FETCH_RESPONSE_INVALID_DATE3, + ], +) +async def test_service_fetch_missing_date( + hass: HomeAssistant, + mock_imap_protocol: MagicMock, +) -> None: + """Test fetching a message that has no or an invalid date successfully.""" + + config = MOCK_CONFIG.copy() + config_entry = MockConfigEntry(domain=DOMAIN, data=config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(True) + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done(True) + + # Test fetch service with text response + mock_imap_protocol.reset_mock() + data = {"entry": config_entry.entry_id, "uid": "1"} + response = await hass.services.async_call( + DOMAIN, "fetch", data, blocking=True, return_response=True + ) + mock_imap_protocol.fetch.assert_called_with("1", "BODY.PEEK[]") + assert response["text"] == "Test body\r\n" + assert response["sender"] == "john.doe@example.com" + assert response["subject"] == "Test subject" + assert response["date"] is None + assert response["uid"] == "1" + assert response["parts"] == {} From 90da9856d6601116d8979715a0715695bac76a3b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:03:03 +0200 Subject: [PATCH 24/25] Fix race in conversation test_todo_add_item_fr (#182205) --- tests/components/conversation/test_default_agent_intents.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 011aefe83223d1..62106429375561 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -448,6 +448,8 @@ async def test_todo_add_item_fr( ) -> None: """Test that wildcard matches prioritize results with more literal text matched.""" assert await async_setup_component(hass, todo.DOMAIN, {}) + # The todo intent handlers are registered by a background task + await hass.async_block_till_done() hass.states.async_set("todo.liste_des_courses", 0, {}) with ( From 0475dc72d4cb0f0a184b0f6ab0e19289c6dbd114 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:23:48 +0200 Subject: [PATCH 25/25] Move imap service registration to services module (#182207) --- homeassistant/components/imap/__init__.py | 280 +-------------------- homeassistant/components/imap/services.py | 291 ++++++++++++++++++++++ tests/components/imap/test_init.py | 3 +- 3 files changed, 298 insertions(+), 276 deletions(-) create mode 100644 homeassistant/components/imap/services.py diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 0ca5384f7e92b0..c2369935d5fc69 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -1,27 +1,16 @@ """The imap integration.""" -import asyncio -from email.message import Message import logging -from typing import Any -from aioimaplib import IMAP4_SSL, AioImapException, Response -import probatio +from aioimaplib import IMAP4_SSL, AioImapException -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, - callback, -) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, - ServiceValidationError, ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -29,287 +18,28 @@ from .const import CONF_ENABLE_PUSH, DOMAIN from .coordinator import ( ImapDataUpdateCoordinator, - ImapMessage, ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator, connect_to_server, - get_parts, ) from .errors import InvalidAuth, InvalidFolder +from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.SENSOR] -CONF_ENTRY = "entry" -CONF_SEEN = "seen" -CONF_PART = "part" -CONF_UID = "uid" -CONF_TARGET_FOLDER = "target_folder" _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -_SERVICE_UID_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_ENTRY): cv.string, - probatio.Required(CONF_UID): cv.string, - } -) - -SERVICE_SEEN_SCHEMA = _SERVICE_UID_SCHEMA -SERVICE_MOVE_SCHEMA = _SERVICE_UID_SCHEMA.extend( - { - probatio.Optional(CONF_SEEN): cv.boolean, - probatio.Required(CONF_TARGET_FOLDER): cv.string, - } -) -SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA -SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA -SERVICE_FETCH_PART_SCHEMA = _SERVICE_UID_SCHEMA.extend( - { - probatio.Required(CONF_PART): cv.string, - } -) type ImapConfigEntry = ConfigEntry[ImapDataUpdateCoordinator] -async def async_get_imap_client(hass: HomeAssistant, entry_id: str) -> IMAP4_SSL: - """Get IMAP client and connect.""" - if (entry := hass.config_entries.async_get_entry(entry_id)) is None or ( - entry.state is not ConfigEntryState.LOADED - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_entry", - ) - try: - client = await connect_to_server(entry.data) - except InvalidAuth as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="invalid_auth" - ) from exc - except InvalidFolder as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="invalid_folder" - ) from exc - except (TimeoutError, AioImapException) as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="imap_server_fail", - translation_placeholders={"error": str(exc)}, - ) from exc - return client - - -@callback -def raise_on_error(response: Response, translation_key: str) -> None: - """Get error message from response.""" - if response.result != "OK": - error: str = response.lines[0].decode("utf-8") - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=translation_key, - translation_placeholders={"error": error}, - ) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up imap services.""" - - async def async_seen(call: ServiceCall) -> None: - """Process mark as seen service call.""" - entry_id: str = call.data[CONF_ENTRY] - uid: str = call.data[CONF_UID] - _LOGGER.debug( - "Mark message %s as seen. Entry: %s", - uid, - entry_id, - ) - client = await async_get_imap_client(hass, entry_id) - try: - response = await client.store(uid, "+FLAGS (\\Seen)") - except (TimeoutError, AioImapException) as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="imap_server_fail", - translation_placeholders={"error": str(exc)}, - ) from exc - raise_on_error(response, "seen_failed") - await client.close() - - hass.services.async_register(DOMAIN, "seen", async_seen, SERVICE_SEEN_SCHEMA) - - async def async_move(call: ServiceCall) -> None: - """Process move email service call.""" - entry_id: str = call.data[CONF_ENTRY] - uid: str = call.data[CONF_UID] - seen = bool(call.data.get(CONF_SEEN)) - target_folder: str = call.data[CONF_TARGET_FOLDER] - _LOGGER.debug( - "Move message %s to folder %s. Mark as seen: %s. Entry: %s", - uid, - target_folder, - seen, - entry_id, - ) - client = await async_get_imap_client(hass, entry_id) - try: - if seen: - response = await client.store(uid, "+FLAGS (\\Seen)") - raise_on_error(response, "seen_failed") - response = await client.copy(uid, target_folder) - raise_on_error(response, "copy_failed") - response = await client.store(uid, "+FLAGS (\\Deleted)") - raise_on_error(response, "delete_failed") - response = await asyncio.wait_for( - client.protocol.expunge(uid, by_uid=True), client.timeout - ) - raise_on_error(response, "expunge_failed") - except (TimeoutError, AioImapException) as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="imap_server_fail", - translation_placeholders={"error": str(exc)}, - ) from exc - await client.close() - - hass.services.async_register(DOMAIN, "move", async_move, SERVICE_MOVE_SCHEMA) - - async def async_delete(call: ServiceCall) -> None: - """Process deleting email service call.""" - entry_id: str = call.data[CONF_ENTRY] - uid: str = call.data[CONF_UID] - _LOGGER.debug( - "Delete message %s. Entry: %s", - uid, - entry_id, - ) - client = await async_get_imap_client(hass, entry_id) - try: - response = await client.store(uid, "+FLAGS (\\Deleted)") - raise_on_error(response, "delete_failed") - response = await asyncio.wait_for( - client.protocol.expunge(uid, by_uid=True), client.timeout - ) - raise_on_error(response, "expunge_failed") - except (TimeoutError, AioImapException) as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="imap_server_fail", - translation_placeholders={"error": str(exc)}, - ) from exc - await client.close() - - hass.services.async_register(DOMAIN, "delete", async_delete, SERVICE_DELETE_SCHEMA) - - async def async_fetch(call: ServiceCall) -> ServiceResponse: - """Process fetch email service and return content.""" - entry_id: str = call.data[CONF_ENTRY] - uid: str = call.data[CONF_UID] - _LOGGER.debug( - "Fetch text for message %s. Entry: %s", - uid, - entry_id, - ) - client = await async_get_imap_client(hass, entry_id) - try: - response = await client.fetch(uid, "BODY.PEEK[]") - except (TimeoutError, AioImapException) as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="imap_server_fail", - translation_placeholders={"error": str(exc)}, - ) from exc - raise_on_error(response, "fetch_failed") - # Index 1 of of the response lines contains the bytearray with the message data - message = ImapMessage(response.lines[1]) - await client.close() - return { - "text": message.text, - "sender": message.sender, - "subject": message.subject, - "parts": get_parts(message.email_message), - "date": message.date.isoformat() if message.date else None, - "uid": uid, - } - - hass.services.async_register( - DOMAIN, - "fetch", - async_fetch, - SERVICE_FETCH_TEXT_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - async def async_fetch_part(call: ServiceCall) -> ServiceResponse: - """Process fetch email part service and return content.""" - - @callback - def get_message_part(message: Message, part_key: str) -> Message: - part: Message | Any = message - for index in part_key.split(","): - sub_parts = part.get_payload() - try: - assert isinstance(sub_parts, list) - part = sub_parts[int(index)] - except (AssertionError, ValueError, IndexError) as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_part_index", - ) from exc - - return part - - entry_id: str = call.data[CONF_ENTRY] - uid: str = call.data[CONF_UID] - part_key: str = call.data[CONF_PART] - _LOGGER.debug( - "Fetch part %s for message %s. Entry: %s", - part_key, - uid, - entry_id, - ) - client = await async_get_imap_client(hass, entry_id) - try: - response = await client.fetch(uid, "BODY.PEEK[]") - except (TimeoutError, AioImapException) as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="imap_server_fail", - translation_placeholders={"error": str(exc)}, - ) from exc - raise_on_error(response, "fetch_failed") - # Index 1 of of the response lines contains the bytearray with the message data - message = ImapMessage(response.lines[1]) - await client.close() - part_data = get_message_part(message.email_message, part_key) - part_data_content = part_data.get_payload(decode=False) - try: - assert isinstance(part_data_content, str) - except AssertionError as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_part_index", - ) from exc - return { - "part_data": part_data_content, - "content_type": part_data.get_content_type(), - "content_transfer_encoding": part_data.get("Content-Transfer-Encoding"), - "filename": part_data.get_filename(), - "part": part_key, - "uid": uid, - } - - hass.services.async_register( - DOMAIN, - "fetch_part", - async_fetch_part, - SERVICE_FETCH_PART_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - + async_setup_services(hass) return True diff --git a/homeassistant/components/imap/services.py b/homeassistant/components/imap/services.py new file mode 100644 index 00000000000000..91fc674cf96611 --- /dev/null +++ b/homeassistant/components/imap/services.py @@ -0,0 +1,291 @@ +"""Support for the imap services.""" + +import asyncio +from email.message import Message +import logging +from typing import Any + +from aioimaplib import IMAP4_SSL, AioImapException, Response +import probatio + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import ImapMessage, connect_to_server, get_parts +from .errors import InvalidAuth, InvalidFolder + +_LOGGER = logging.getLogger(__name__) + +CONF_ENTRY = "entry" +CONF_SEEN = "seen" +CONF_PART = "part" +CONF_UID = "uid" +CONF_TARGET_FOLDER = "target_folder" + +_SERVICE_UID_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_ENTRY): cv.string, + probatio.Required(CONF_UID): cv.string, + } +) + +SERVICE_SEEN_SCHEMA = _SERVICE_UID_SCHEMA +SERVICE_MOVE_SCHEMA = _SERVICE_UID_SCHEMA.extend( + { + probatio.Optional(CONF_SEEN): cv.boolean, + probatio.Required(CONF_TARGET_FOLDER): cv.string, + } +) +SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA +SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA +SERVICE_FETCH_PART_SCHEMA = _SERVICE_UID_SCHEMA.extend( + { + probatio.Required(CONF_PART): cv.string, + } +) + + +async def async_get_imap_client(hass: HomeAssistant, entry_id: str) -> IMAP4_SSL: + """Get IMAP client and connect.""" + if (entry := hass.config_entries.async_get_entry(entry_id)) is None or ( + entry.state is not ConfigEntryState.LOADED + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entry", + ) + try: + client = await connect_to_server(entry.data) + except InvalidAuth as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from exc + except InvalidFolder as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_folder" + ) from exc + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + return client + + +@callback +def raise_on_error(response: Response, translation_key: str) -> None: + """Get error message from response.""" + if response.result != "OK": + error: str = response.lines[0].decode("utf-8") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"error": error}, + ) + + +@callback +def _get_message_part(message: Message, part_key: str) -> Message: + part: Message | Any = message + for index in part_key.split(","): + sub_parts = part.get_payload() + try: + assert isinstance(sub_parts, list) + part = sub_parts[int(index)] + except (AssertionError, ValueError, IndexError) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_part_index", + ) from exc + + return part + + +async def _async_seen(call: ServiceCall) -> None: + """Process mark as seen service call.""" + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + _LOGGER.debug( + "Mark message %s as seen. Entry: %s", + uid, + entry_id, + ) + client = await async_get_imap_client(call.hass, entry_id) + try: + response = await client.store(uid, "+FLAGS (\\Seen)") + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + raise_on_error(response, "seen_failed") + await client.close() + + +async def _async_move(call: ServiceCall) -> None: + """Process move email service call.""" + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + seen = bool(call.data.get(CONF_SEEN)) + target_folder: str = call.data[CONF_TARGET_FOLDER] + _LOGGER.debug( + "Move message %s to folder %s. Mark as seen: %s. Entry: %s", + uid, + target_folder, + seen, + entry_id, + ) + client = await async_get_imap_client(call.hass, entry_id) + try: + if seen: + response = await client.store(uid, "+FLAGS (\\Seen)") + raise_on_error(response, "seen_failed") + response = await client.copy(uid, target_folder) + raise_on_error(response, "copy_failed") + response = await client.store(uid, "+FLAGS (\\Deleted)") + raise_on_error(response, "delete_failed") + response = await asyncio.wait_for( + client.protocol.expunge(uid, by_uid=True), client.timeout + ) + raise_on_error(response, "expunge_failed") + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + await client.close() + + +async def _async_delete(call: ServiceCall) -> None: + """Process deleting email service call.""" + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + _LOGGER.debug( + "Delete message %s. Entry: %s", + uid, + entry_id, + ) + client = await async_get_imap_client(call.hass, entry_id) + try: + response = await client.store(uid, "+FLAGS (\\Deleted)") + raise_on_error(response, "delete_failed") + response = await asyncio.wait_for( + client.protocol.expunge(uid, by_uid=True), client.timeout + ) + raise_on_error(response, "expunge_failed") + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + await client.close() + + +async def _async_fetch(call: ServiceCall) -> ServiceResponse: + """Process fetch email service and return content.""" + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + _LOGGER.debug( + "Fetch text for message %s. Entry: %s", + uid, + entry_id, + ) + client = await async_get_imap_client(call.hass, entry_id) + try: + response = await client.fetch(uid, "BODY.PEEK[]") + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + raise_on_error(response, "fetch_failed") + # Index 1 of of the response lines contains the bytearray with the message data + message = ImapMessage(response.lines[1]) + await client.close() + return { + "text": message.text, + "sender": message.sender, + "subject": message.subject, + "parts": get_parts(message.email_message), + "date": message.date.isoformat() if message.date else None, + "uid": uid, + } + + +async def _async_fetch_part(call: ServiceCall) -> ServiceResponse: + """Process fetch email part service and return content.""" + entry_id: str = call.data[CONF_ENTRY] + uid: str = call.data[CONF_UID] + part_key: str = call.data[CONF_PART] + _LOGGER.debug( + "Fetch part %s for message %s. Entry: %s", + part_key, + uid, + entry_id, + ) + client = await async_get_imap_client(call.hass, entry_id) + try: + response = await client.fetch(uid, "BODY.PEEK[]") + except (TimeoutError, AioImapException) as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="imap_server_fail", + translation_placeholders={"error": str(exc)}, + ) from exc + raise_on_error(response, "fetch_failed") + # Index 1 of of the response lines contains the bytearray with the message data + message = ImapMessage(response.lines[1]) + await client.close() + part_data = _get_message_part(message.email_message, part_key) + part_data_content = part_data.get_payload(decode=False) + try: + assert isinstance(part_data_content, str) + except AssertionError as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_part_index", + ) from exc + return { + "part_data": part_data_content, + "content_type": part_data.get_content_type(), + "content_transfer_encoding": part_data.get("Content-Transfer-Encoding"), + "filename": part_data.get_filename(), + "part": part_key, + "uid": uid, + } + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the imap services.""" + hass.services.async_register(DOMAIN, "seen", _async_seen, SERVICE_SEEN_SCHEMA) + hass.services.async_register(DOMAIN, "move", _async_move, SERVICE_MOVE_SCHEMA) + hass.services.async_register(DOMAIN, "delete", _async_delete, SERVICE_DELETE_SCHEMA) + hass.services.async_register( + DOMAIN, + "fetch", + _async_fetch, + SERVICE_FETCH_TEXT_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + "fetch_part", + _async_fetch_part, + SERVICE_FETCH_PART_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index e7628b72c07340..27f8c52d56a800 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1034,7 +1034,8 @@ async def test_services( } for translation_key, attrs in exceptions.items(): with patch( - "homeassistant.components.imap.connect_to_server", side_effect=attrs["exc"] + "homeassistant.components.imap.services.connect_to_server", + side_effect=attrs["exc"], ): data = {"entry": config_entry.entry_id, "uid": "1"} with pytest.raises(ServiceValidationError) as exc: