From 0830988687c862baaaf3563c062d7a831b22f87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luka=20Matijevi=C4=87?= Date: Sun, 10 May 2026 18:44:46 +0200 Subject: [PATCH 01/39] Bump qbittorrent-api to 2026.5.1 (#170181) --- homeassistant/components/qbittorrent/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 2f813e355572f0..601f96a30c1b70 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], - "requirements": ["qbittorrent-api==2024.9.67"] + "requirements": ["qbittorrent-api==2026.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bd3bfcc9b42679..d13bf5275c441e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2796,7 +2796,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qbittorrent -qbittorrent-api==2024.9.67 +qbittorrent-api==2026.5.1 # homeassistant.components.qbus qbusmqttapi==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0885b970669075..9c7ddf827a6e06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2386,7 +2386,7 @@ pyyardian==1.1.1 pyzerproc==0.4.8 # homeassistant.components.qbittorrent -qbittorrent-api==2024.9.67 +qbittorrent-api==2026.5.1 # homeassistant.components.qbus qbusmqttapi==1.4.3 From b26c2f3854846967ca3d35db91c5021d458d1220 Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Tue, 19 May 2026 00:24:09 -0700 Subject: [PATCH 02/39] Improve iaqualink 429 handling (#170231) --- .../components/iaqualink/coordinator.py | 7 +++ tests/components/iaqualink/test_init.py | 57 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/iaqualink/coordinator.py b/homeassistant/components/iaqualink/coordinator.py index 0b510263ec27a0..ffaf502c8bdbb2 100644 --- a/homeassistant/components/iaqualink/coordinator.py +++ b/homeassistant/components/iaqualink/coordinator.py @@ -8,6 +8,7 @@ import httpx from iaqualink.exception import ( AqualinkServiceException, + AqualinkServiceThrottledException, AqualinkServiceUnauthorizedException, ) @@ -46,6 +47,12 @@ async def _async_update_data(self) -> None: await self.system.update() except AqualinkServiceUnauthorizedException as err: raise ConfigEntryAuthFailed("Invalid credentials for iAquaLink") from err + except AqualinkServiceThrottledException: + _LOGGER.warning( + "Rate limited by iAquaLink system %s, will retry later", + self.system.serial, + ) + return except (AqualinkServiceException, httpx.HTTPError) as err: raise UpdateFailed( f"Unable to update iAquaLink system {self.system.serial}: {err}" diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index 57d205c9caaf8e..fe4058fa5e5580 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -6,6 +6,7 @@ from iaqualink.client import AqualinkClient from iaqualink.exception import ( AqualinkServiceException, + AqualinkServiceThrottledException, AqualinkServiceUnauthorizedException, ) from iaqualink.systems.iaqua.device import ( @@ -16,6 +17,7 @@ IaquaThermostat, ) from iaqualink.systems.iaqua.system import IaquaSystem +import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN @@ -46,7 +48,9 @@ async def _advance_coordinator_time( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Advance time to trigger coordinator update interval.""" - freezer.tick(delta=UPDATE_INTERVAL_BY_SYSTEM_TYPE["iaqua"]) + update_interval = UPDATE_INTERVAL_BY_SYSTEM_TYPE["iaqua"] + + freezer.tick(delta=update_interval) async_fire_time_changed(hass, dt_util.utcnow()) await hass.async_block_till_done(wait_background_tasks=True) @@ -104,6 +108,57 @@ async def fail_update() -> None: assert state.state == STATE_UNAVAILABLE +async def test_system_rate_limited_keeps_entities_available( + hass: HomeAssistant, + config_entry: MockConfigEntry, + client: AqualinkClient, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a rate-limited update keeps entities at their last known state.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client, cls=IaquaSystem) + system.online = True + system.update = AsyncMock() + systems = {system.serial: system} + light = get_aqualink_device( + system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} + ) + devices = {light.name: light} + system.get_devices = AsyncMock(return_value=devices) + + with ( + patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), + patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_ids = hass.states.async_entity_ids(LIGHT_DOMAIN) + assert len(entity_ids) == 1 + entity_id = entity_ids[0] + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + system.update = AsyncMock(side_effect=AqualinkServiceThrottledException) + + await _advance_coordinator_time(hass, freezer) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + assert "Rate limited by iAquaLink" in caplog.text + + async def test_light_service_calls_update_entity_state( hass: HomeAssistant, config_entry: MockConfigEntry, From 356e6a691b2e3d9054522393aec68ae7109780ad Mon Sep 17 00:00:00 2001 From: Keith Roehrenbeck Date: Tue, 19 May 2026 02:38:48 -0500 Subject: [PATCH 03/39] Fix Apple TV keyboard focus binary_sensor missing on cold start (#170360) --- .../components/apple_tv/binary_sensor.py | 24 ++++--- .../components/apple_tv/test_binary_sensor.py | 62 +++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 tests/components/apple_tv/test_binary_sensor.py diff --git a/homeassistant/components/apple_tv/binary_sensor.py b/homeassistant/components/apple_tv/binary_sensor.py index 84560111006166..71ad3755f2177a 100644 --- a/homeassistant/components/apple_tv/binary_sensor.py +++ b/homeassistant/components/apple_tv/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import CONF_NAME -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,23 +23,33 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Apple TV binary sensor based on a config entry.""" - # apple_tv config entries always have a unique id manager = config_entry.runtime_data - cb: CALLBACK_TYPE + added = False + @callback def setup_entities(atv: AppleTV) -> None: + nonlocal added + if added: + return if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState): assert config_entry.unique_id is not None name: str = config_entry.data[CONF_NAME] async_add_entities( [AppleTVKeyboardFocused(name, config_entry.unique_id, manager)] ) - cb() + added = True - cb = async_dispatcher_connect( - hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities + config_entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities + ) ) - config_entry.async_on_unload(cb) + + # The manager may have already connected (and dispatched SIGNAL_CONNECTED) + # before this platform was forwarded, in which case the signal above was + # missed; handle that case directly. + if manager.atv is not None: + setup_entities(manager.atv) class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener): diff --git a/tests/components/apple_tv/test_binary_sensor.py b/tests/components/apple_tv/test_binary_sensor.py new file mode 100644 index 00000000000000..5b877a1c6629dc --- /dev/null +++ b/tests/components/apple_tv/test_binary_sensor.py @@ -0,0 +1,62 @@ +"""Tests for Apple TV binary sensor.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from pyatv.const import DeviceModel, KeyboardFocusState, Protocol + +from homeassistant.components.apple_tv.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.core import HomeAssistant + +from .common import create_conf, mrp_service + +from tests.common import MockConfigEntry + + +async def test_keyboard_focus_entity_created_on_setup( + hass: HomeAssistant, + mock_async_zeroconf: MagicMock, +) -> None: + """Test the keyboard focus binary sensor is created when the device supports it. + + Regression test for https://github.com/home-assistant/core/issues/170075 — the + initial SIGNAL_CONNECTED dispatch happens in async_first_connect (before platform + forwarding), so the binary_sensor platform must also handle the already-connected + case rather than relying solely on the dispatcher signal. + """ + atv = AsyncMock() + atv.close = MagicMock() + atv.features = MagicMock() + atv.features.in_state = MagicMock(return_value=True) + atv.keyboard = AsyncMock() + atv.keyboard.text_focus_state = KeyboardFocusState.Unfocused + atv.push_updater = MagicMock() + atv.device_info.model = DeviceModel.Gen4K + atv.device_info.raw_model = "AppleTV6,2" + atv.device_info.version = "15.0" + atv.device_info.mac = "AA:BB:CC:DD:EE:FF" + + entry = MockConfigEntry( + domain=DOMAIN, + title="Living Room", + unique_id="mrpid", + data={ + CONF_ADDRESS: "127.0.0.1", + CONF_NAME: "Living Room", + "credentials": {str(Protocol.MRP.value): "mrp_creds"}, + "identifiers": ["mrpid"], + }, + ) + entry.add_to_hass(hass) + + scan_result = create_conf("127.0.0.1", "Living Room", mrp_service()) + + with ( + patch("homeassistant.components.apple_tv.scan", return_value=[scan_result]), + patch("homeassistant.components.apple_tv.connect", return_value=atv), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.living_room_keyboard_focus") + assert state is not None From 47d8adc77ca83ba9a2cde55a67c3379e8f517730 Mon Sep 17 00:00:00 2001 From: Daniil Karpenko Date: Sat, 16 May 2026 19:27:12 +0400 Subject: [PATCH 04/39] Add tilt controls for UpDownSheerScreen in Overkiz (#170563) Co-authored-by: Mick Vleeshouwer --- homeassistant/components/overkiz/cover.py | 14 ++++ .../setup/cloud_somfy_connexoon_rts_asia.json | 80 +++++++++++++++++++ .../overkiz/snapshots/test_cover.ambr | 54 +++++++++++++ tests/components/overkiz/test_cover.py | 35 ++++++++ 4 files changed, 183 insertions(+) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index f8c587218c4f4b..03101b174deea9 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -146,6 +146,20 @@ class OverkizCoverDescription(CoverEntityDescription): close_tilt_command_args=(15, 1), # position (1-127), speed (1-15) stop_tilt_command=OverkizCommand.STOP, ), + # Needs override to support very specific tilt commands (rts:SheerBlindRTSComponent) + # uiClass is VenetianBlind + OverkizCoverDescription( + key=UIWidget.UP_DOWN_SHEER_SCREEN, + device_class=CoverDeviceClass.BLIND, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + open_tilt_command=OverkizCommand.TILT_POSITIVE, + open_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + close_tilt_command=OverkizCommand.TILT_NEGATIVE, + close_tilt_command_args=(15, 1), # position (1-127), speed (1-15) + stop_tilt_command=OverkizCommand.STOP, + ), # Needs override since PositionableGarageDoor reports # core:OpenClosedUnknownState instead of core:OpenClosedState # uiClass is GarageDoor diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json index d3559a1344f21d..16cd191f1a4553 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json @@ -464,6 +464,86 @@ "type": 1, "oid": "3496a041-cafc-4d5d-ab3b-7947985812dc", "uiClass": "VenetianBlind" + }, + { + "creationTime": 1613676710000, + "lastUpdateTime": 1613676710000, + "label": "Kitchen Sheer Screen", + "deviceURL": "rts://1234-1234-6362/16753206", + "shortcut": false, + "controllableName": "rts:SheerBlindRTSComponent", + "definition": { + "commands": [ + { + "commandName": "open", + "nparams": 0 + }, + { + "commandName": "up", + "nparams": 0 + }, + { + "commandName": "tiltPositive", + "nparams": 2 + }, + { + "commandName": "tiltNegative", + "nparams": 2 + }, + { + "commandName": "down", + "nparams": 0 + }, + { + "commandName": "rest", + "nparams": 0 + }, + { + "commandName": "openConfiguration", + "nparams": 0 + }, + { + "commandName": "test", + "nparams": 0 + }, + { + "commandName": "close", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "moveOf", + "nparams": 1 + }, + { + "commandName": "my", + "nparams": 0 + }, + { + "commandName": "stop", + "nparams": 0 + } + ], + "states": [], + "dataProperties": [], + "widgetName": "UpDownSheerScreen", + "uiProfiles": ["OpenCloseBlind", "OpenClose"], + "uiClass": "VenetianBlind", + "qualifiedName": "rts:SheerBlindRTSComponent", + "type": "ACTUATOR" + }, + "states": [], + "attributes": [], + "available": true, + "enabled": true, + "placeOID": "6133b4a0-f514-4553-b635-d1b7beb7e7b2", + "widget": "UpDownSheerScreen", + "type": 1, + "oid": "c198bcdd-8b8b-4dc6-a2b0-f86f7dc7c001", + "uiClass": "VenetianBlind" } ], "zones": [], diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index c5e9f9a7fe0b9e..af5d25b784a13f 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -377,6 +377,60 @@ 'state': 'unknown', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.kitchen_sheer_screen-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_sheer_screen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'rts://1234-1234-6362/16753206', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.kitchen_sheer_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'device_class': 'blind', + 'friendly_name': 'Kitchen Sheer Screen', + 'is_closed': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_sheer_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.kitchen_shutter-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 64bfbb9d69a5d2..eb8db54d812514 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -110,6 +110,11 @@ "rts://1234-1234-6362/16747291", "cover.office_venetian_blind", ) +UP_DOWN_SHEER_SCREEN = FixtureDevice( + "setup/cloud_somfy_connexoon_rts_asia.json", + "rts://1234-1234-6362/16753206", + "cover.kitchen_sheer_screen", +) DYNAMIC_GARAGE_DOOR = FixtureDevice( "setup/cloud_somfy_tahoma_v2_europe.json", "io://1234-1234-6233/16730050", @@ -188,6 +193,7 @@ async def test_cover_entities_snapshot( ), (TILT_ONLY_VENETIAN_BLIND, SERVICE_OPEN_COVER, "open", [0], CoverState.OPENING), (UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER, "open", [0], CoverState.OPENING), + (UP_DOWN_SHEER_SCREEN, SERVICE_OPEN_COVER, "open", [0], CoverState.OPENING), (SHUTTER, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), (AWNING, SERVICE_CLOSE_COVER, "undeploy", None, CoverState.CLOSING), (GARAGE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), @@ -216,6 +222,7 @@ async def test_cover_entities_snapshot( CoverState.CLOSING, ), (UP_DOWN_VENETIAN_BLIND, SERVICE_CLOSE_COVER, "close", [0], CoverState.CLOSING), + (UP_DOWN_SHEER_SCREEN, SERVICE_CLOSE_COVER, "close", [0], CoverState.CLOSING), (SHUTTER, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (AWNING, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (GARAGE, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), @@ -253,6 +260,7 @@ async def test_cover_entities_snapshot( STATE_UNKNOWN, ), (UP_DOWN_VENETIAN_BLIND, SERVICE_STOP_COVER, "stop", [0], STATE_UNKNOWN), + (UP_DOWN_SHEER_SCREEN, SERVICE_STOP_COVER, "stop", [0], STATE_UNKNOWN), ( UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER_TILT, @@ -274,6 +282,27 @@ async def test_cover_entities_snapshot( [0], STATE_UNKNOWN, ), + ( + UP_DOWN_SHEER_SCREEN, + SERVICE_OPEN_COVER_TILT, + "tiltPositive", + [15, 1], + CoverState.OPENING, + ), + ( + UP_DOWN_SHEER_SCREEN, + SERVICE_CLOSE_COVER_TILT, + "tiltNegative", + [15, 1], + CoverState.CLOSING, + ), + ( + UP_DOWN_SHEER_SCREEN, + SERVICE_STOP_COVER_TILT, + "stop", + [0], + STATE_UNKNOWN, + ), ], ids=[ "open-roller-shutter", @@ -286,6 +315,7 @@ async def test_cover_entities_snapshot( "open-up-down-bioclimatic-pergola", "open-tilt-only-venetian-blind", "open-venetian-blind-rts", + "open-sheer-screen-rts", "close-roller-shutter", "close-awning", "close-garage-door", @@ -296,6 +326,7 @@ async def test_cover_entities_snapshot( "close-up-down-bioclimatic-pergola", "close-tilt-only-venetian-blind", "close-venetian-blind-rts", + "close-sheer-screen-rts", "stop-roller-shutter", "stop-awning", "stop-garage-door", @@ -309,9 +340,13 @@ async def test_cover_entities_snapshot( "close-tilt-tilt-only-venetian-blind", "stop-tilt-tilt-only-venetian-blind", "stop-venetian-blind-rts", + "stop-sheer-screen-rts", "open-tilt-venetian-blind-rts", "close-tilt-venetian-blind-rts", "stop-tilt-venetian-blind-rts", + "open-tilt-sheer-screen-rts", + "close-tilt-sheer-screen-rts", + "stop-tilt-sheer-screen-rts", ], ) async def test_cover_service_actions( From 3293ebcea59bf7d03cb170622072bdaf2cc5f14b Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Mon, 18 May 2026 12:41:30 +0200 Subject: [PATCH 05/39] Fix ValueError when turning on blebox light with brightness set to 0 (#170769) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/blebox/light.py | 4 +++ tests/components/blebox/test_light.py | 42 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 4db64d998f53f9..026c7ab192ae6b 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -170,6 +170,10 @@ async def async_turn_on(self, **kwargs: Any) -> None: else: value = feature.apply_brightness(value, brightness) + if isinstance(value, (list, tuple)) and not any(value): + await self._feature.async_off() + return + try: await self._feature.async_on(value) except ValueError as exc: diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index efb7f4c60341de..58f1d80b3554aa 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -461,6 +461,48 @@ def turn_on(value): assert state.state == STATE_ON +async def test_wlightbox_turn_on_with_zero_brightness_turns_off( + wlightbox, hass: HomeAssistant +) -> None: + """Test that setting brightness to 0 turns the light off instead of raising ValueError.""" + + feature_mock, entity_id = wlightbox + + def initial_update(): + feature_mock.is_on = True + feature_mock.rgbw_hex = "c1d2f3c7" + feature_mock.white_value = 0xC7 + + feature_mock.async_update = AsyncMock(side_effect=initial_update) + await async_setup_entity(hass, entity_id) + feature_mock.async_update = AsyncMock() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + feature_mock.apply_brightness = MagicMock(return_value=[0, 0, 0, 0]) + + def turn_off(): + feature_mock.is_on = False + feature_mock.white_value = 0x0 + feature_mock.rgbw_hex = "00000000" + + feature_mock.async_off = AsyncMock(side_effect=turn_off) + + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {"entity_id": entity_id, ATTR_BRIGHTNESS: 0}, + blocking=True, + ) + + feature_mock.async_off.assert_called_once() + feature_mock.async_on.assert_not_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None: """Test light off.""" From 249b5435d9a464b3ca486ccfa371e18723fe9b6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 May 2026 20:39:55 -0700 Subject: [PATCH 06/39] Bump aiodns to 4.0.3 (#170865) Co-authored-by: Paulus Schoutsen --- homeassistant/components/dnsip/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index d700c89ab6f737..b383ccd9049dda 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/dnsip", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["aiodns==4.0.0"] + "requirements": ["aiodns==4.0.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 598ccce4057002..49eadf3b8917a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 -aiodns==4.0.0 +aiodns==4.0.3 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 diff --git a/pyproject.toml b/pyproject.toml index c0f04ef68bf61d..474b4a5ae1d4bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.14.2" dependencies = [ - "aiodns==4.0.0", + "aiodns==4.0.3", # aiogithubapi is needed by frontend; frontend is unconditionally imported at # module level in `bootstrap.py` and its requirements thus need to be in # requirements.txt to ensure they are always installed diff --git a/requirements.txt b/requirements.txt index d0eb579f49555b..8d6f411a247bd5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiodns==4.0.0 +aiodns==4.0.3 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index d13bf5275c441e..bc62e5f56b669b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -233,7 +233,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 # homeassistant.components.dnsip -aiodns==4.0.0 +aiodns==4.0.3 # homeassistant.components.eafm aioeafm==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c7ddf827a6e06..831fbc11dd3b8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -224,7 +224,7 @@ aiodhcpwatcher==1.2.1 aiodiscover==2.7.1 # homeassistant.components.dnsip -aiodns==4.0.0 +aiodns==4.0.3 # homeassistant.components.eafm aioeafm==0.1.2 From 37d6449a49c6f0acfea87090316cb6816ed1a64c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 16 May 2026 18:22:54 +0200 Subject: [PATCH 07/39] Populate uid and recurrence_id in CalDAV calendar events (#170910) --- .../components/caldav/coordinator.py | 12 ++++ tests/components/caldav/test_calendar.py | 58 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index c6bbd15bdff708..20d998b42851fd 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -81,6 +81,12 @@ async def async_get_events( end=self.to_local(self.get_end_date(vevent)), location=get_attr_value(vevent, "location"), description=get_attr_value(vevent, "description"), + uid=get_attr_value(vevent, "uid"), + recurrence_id=( + str(v) + if (v := get_attr_value(vevent, "recurrence_id")) is not None + else None + ), ) ) @@ -176,6 +182,12 @@ async def _async_update_data(self) -> CalendarEvent | None: end=self.to_local(self.get_end_date(vevent)), location=get_attr_value(vevent, "location"), description=get_attr_value(vevent, "description"), + uid=get_attr_value(vevent, "uid"), + recurrence_id=( + str(v) + if (v := get_attr_value(vevent, "recurrence_id")) is not None + else None + ), ) @staticmethod diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 8b9b84cee620b9..c55d7a41bd2d6e 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -4,7 +4,7 @@ import datetime from http import HTTPStatus from typing import Any -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock, Mock, patch import zoneinfo from caldav.objects import Event @@ -1063,13 +1063,67 @@ async def test_get_events_custom_calendars( "summary": "This is a normal event", "location": "Hamburg", "description": "Surprisingly rainy", - "uid": None, + "uid": "0", "recurrence_id": None, "rrule": None, } ] +async def test_get_events_with_recurrence_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test that uid and recurrence_id are populated from VEVENT data.""" + vevent_with_recurrence_id = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:original-event-uid +RECURRENCE-ID:20171127T170000Z +DTSTAMP:20171125T000000Z +DTSTART:20171127T180000Z +DTEND:20171127T190000Z +SUMMARY:Modified occurrence +LOCATION:Hamburg +DESCRIPTION:This occurrence was moved +END:VEVENT +END:VCALENDAR""" + calendar = Mock() + calendar.name = "Example" + calendar.get_supported_components = MagicMock(return_value=["VEVENT"]) + calendar.search = MagicMock( + return_value=[ + Event( + None, "0.ics", vevent_with_recurrence_id, calendar, "original-event-uid" + ) + ] + ) + + with patch( + "homeassistant.components.caldav.calendar.caldav.DAVClient" + ) as mock_client: + mock_client.return_value.principal.return_value.calendars.return_value = [ + calendar + ] + assert await async_setup_component( + hass, "calendar", {"calendar": CALDAV_CONFIG} + ) + await hass.async_block_till_done() + + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start=2017-11-27&end=2017-11-28" + ) + assert response.status == HTTPStatus.OK + events = await response.json() + + assert len(events) == 1 + assert events[0]["uid"] == "original-event-uid" + assert events[0]["recurrence_id"] == "2017-11-27 17:00:00+00:00" + assert events[0]["summary"] == "Modified occurrence" + + @pytest.mark.parametrize( ("calendars"), [ From 95cc9aed64d0e0d4aeea37c22e473e292405489f Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 16 May 2026 18:58:22 +0300 Subject: [PATCH 08/39] Fix is_closed state for SlidingDiscreteGateWithPedestrianPosition covers in Overkiz (#170913) --- homeassistant/components/overkiz/cover.py | 11 ++ .../setup/cloud_somfy_tahoma_v2_europe.json | 122 ++++++++++++++++++ .../overkiz/snapshots/test_cover.ambr | 53 ++++++++ tests/components/overkiz/test_cover.py | 11 ++ 4 files changed, 197 insertions(+) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 03101b174deea9..fa32f613eeb140 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -197,6 +197,17 @@ class OverkizCoverDescription(CoverEntityDescription): is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, stop_command=OverkizCommand.STOP, ), + # Needs override since SlidingDiscreteGateWithPedestrianPosition reports + # core:OpenClosedPedestrianState instead of core:OpenClosedState + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.SLIDING_DISCRETE_GATE_WITH_PEDESTRIAN_POSITION, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + stop_command=OverkizCommand.STOP, + ), # Needs override to support this Generic device (rts:GenericRTSComponent) # uiClass is Generic (not mapped to cover as this is a Generic device class) OverkizCoverDescription( diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json index d69c75e548ff1c..a8b4aaf4349c30 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json @@ -7441,6 +7441,128 @@ "oid": "6ba9b1de-8037-41d7-9150-21f7d5f49a3f", "uiClass": "Gate" }, + { + "creationTime": 1654894302000, + "lastUpdateTime": 1654894302000, + "label": "Sliding Gate", + "deviceURL": "io://1234-1234-6233/16730051", + "shortcut": false, + "controllableName": "io:SlidingDiscreteGateOpenerIOComponent", + "definition": { + "commands": [ + { + "commandName": "close", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "open", + "nparams": 0 + }, + { + "commandName": "refreshPedestrianPosition", + "nparams": 0 + }, + { + "commandName": "setPedestrianPosition", + "nparams": 0 + }, + { + "commandName": "stop", + "nparams": 0 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": ["closed", "open", "pedestrian", "unknown"], + "qualifiedName": "core:OpenClosedPedestrianState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:PedestrianPositionState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + } + ], + "dataProperties": [], + "widgetName": "SlidingDiscreteGateWithPedestrianPosition", + "uiProfiles": ["OpenCloseGateOpener", "OpenClose"], + "uiClass": "Gate", + "qualifiedName": "io:SlidingDiscreteGateOpenerIOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "Sliding Gate" + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "normal" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 50.0 + }, + { + "name": "core:OpenClosedPedestrianState", + "type": 3, + "value": "closed" + }, + { + "name": "core:PedestrianPositionState", + "type": 1, + "value": 50 + } + ], + "attributes": [ + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5107456G05" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "SlidingDiscreteGateWithPedestrianPosition", + "type": 1, + "oid": "c1e2f3a4-b5d6-7890-abcd-ef1234567890", + "uiClass": "Gate" + }, { "creationTime": 1521964729000, "lastUpdateTime": 1521964729000, diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index af5d25b784a13f..d8cc0f9347bc8b 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -2047,6 +2047,59 @@ 'state': 'closed', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.sliding_gate-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.sliding_gate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'io://1234-1234-6233/16730051', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.sliding_gate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gate', + 'friendly_name': 'Sliding Gate', + 'is_closed': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.sliding_gate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.studio_shutter-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index eb8db54d812514..2653a2bf1074dc 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -130,6 +130,11 @@ "io://1234-1234-6233/7433515", "cover.partial_garage_door", ) +SLIDING_DISCRETE_GATE = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "io://1234-1234-6233/16730051", + "cover.sliding_gate", +) DYNAMIC_GATE = FixtureDevice( "setup/cloud_somfy_tahoma_v2_europe.json", "ogp://1234-1234-6233/10410217", @@ -183,6 +188,7 @@ async def test_cover_entities_snapshot( (DYNAMIC_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (DYNAMIC_GARAGE_DOOR_OGP, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (DYNAMIC_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), + (SLIDING_DISCRETE_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (PARTIAL_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), ( UP_DOWN_BIOCLIMATIC_PERGOLA, @@ -206,6 +212,7 @@ async def test_cover_entities_snapshot( CoverState.CLOSING, ), (DYNAMIC_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), + (SLIDING_DISCRETE_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), (PARTIAL_GARAGE_DOOR, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), ( UP_DOWN_BIOCLIMATIC_PERGOLA, @@ -229,6 +236,7 @@ async def test_cover_entities_snapshot( (DYNAMIC_GARAGE_DOOR, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (DYNAMIC_GARAGE_DOOR_OGP, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (DYNAMIC_GATE, SERVICE_STOP_COVER, "stop", None, CoverState.OPEN), + (SLIDING_DISCRETE_GATE, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (PARTIAL_GARAGE_DOOR, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), ( UP_DOWN_BIOCLIMATIC_PERGOLA, @@ -311,6 +319,7 @@ async def test_cover_entities_snapshot( "open-dynamic-garage-door", "open-dynamic-garage-door-ogp", "open-dynamic-gate", + "open-sliding-discrete-gate", "open-partial-garage-door", "open-up-down-bioclimatic-pergola", "open-tilt-only-venetian-blind", @@ -322,6 +331,7 @@ async def test_cover_entities_snapshot( "close-dynamic-garage-door", "close-dynamic-garage-door-ogp", "close-dynamic-gate", + "close-sliding-discrete-gate", "close-partial-garage-door", "close-up-down-bioclimatic-pergola", "close-tilt-only-venetian-blind", @@ -333,6 +343,7 @@ async def test_cover_entities_snapshot( "stop-dynamic-garage-door", "stop-dynamic-garage-door-ogp", "stop-dynamic-gate", + "stop-sliding-discrete-gate", "stop-partial-garage-door", "stop-up-down-bioclimatic-pergola", "stop-tilt-only-venetian-blind", From 2f35ad2a8a0b98ad29307dc98dd29b8fd2885bea Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sat, 16 May 2026 18:59:46 -0400 Subject: [PATCH 09/39] Disable USB discovery for teleinfo (#170933) --- .../components/teleinfo/config_flow.py | 24 +++++++++++-------- .../components/teleinfo/manifest.json | 12 +--------- homeassistant/generated/usb.py | 10 -------- tests/components/teleinfo/test_config_flow.py | 20 ++++++++++++++++ 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/teleinfo/config_flow.py b/homeassistant/components/teleinfo/config_flow.py index dd19ece346d332..64b2e6581901a3 100644 --- a/homeassistant/components/teleinfo/config_flow.py +++ b/homeassistant/components/teleinfo/config_flow.py @@ -92,16 +92,6 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu usb.get_serial_by_id, discovery_info.device ) - # Validate by reading a real Teleinfo frame — silent abort on failure - errors, decoded_data = await self._validate_serial_port(dev_path) - if errors or decoded_data is None: - return self.async_abort(reason="not_teleinfo_device") - - # Use ADCO (meter serial number) as unique_id — same as manual entry - adco = decoded_data["ADCO"] - await self.async_set_unique_id(adco) - self._abort_if_unique_id_configured(updates={CONF_SERIAL_PORT: dev_path}) - self._discovered_device = dev_path self.context["title_placeholders"] = { "name": human_readable_device_name( @@ -122,6 +112,20 @@ async def async_step_usb_confirm( if TYPE_CHECKING: assert self._discovered_device is not None if user_input is not None: + # Validate by reading a real Teleinfo frame — silent abort on failure + errors, decoded_data = await self._validate_serial_port( + self._discovered_device + ) + if errors or decoded_data is None: + return self.async_abort(reason="not_teleinfo_device") + + # Use ADCO (meter serial number) as unique_id — same as manual entry + adco = decoded_data["ADCO"] + await self.async_set_unique_id(adco) + self._abort_if_unique_id_configured( + updates={CONF_SERIAL_PORT: self._discovered_device} + ) + return self.async_create_entry( title=f"Teleinfo ({self._discovered_device})", data={CONF_SERIAL_PORT: self._discovered_device}, diff --git a/homeassistant/components/teleinfo/manifest.json b/homeassistant/components/teleinfo/manifest.json index 32b72035a528e8..5bb7f8615311f1 100644 --- a/homeassistant/components/teleinfo/manifest.json +++ b/homeassistant/components/teleinfo/manifest.json @@ -8,15 +8,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["pyteleinfo==0.4.0"], - "usb": [ - { - "pid": "6015", - "vid": "0403" - }, - { - "pid": "EA60", - "vid": "10C4" - } - ] + "requirements": ["pyteleinfo==0.4.0"] } diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 70da80846d8f5c..d1974f23d6e5b8 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -58,16 +58,6 @@ "pid": "0003", "vid": "04B4", }, - { - "domain": "teleinfo", - "pid": "6015", - "vid": "0403", - }, - { - "domain": "teleinfo", - "pid": "EA60", - "vid": "10C4", - }, { "domain": "velbus", "pid": "0B1B", diff --git a/tests/components/teleinfo/test_config_flow.py b/tests/components/teleinfo/test_config_flow.py index 393400276af956..2bddc5c7ac5b55 100644 --- a/tests/components/teleinfo/test_config_flow.py +++ b/tests/components/teleinfo/test_config_flow.py @@ -170,6 +170,11 @@ async def test_usb_discovery_not_teleinfo( data=USB_DISCOVERY_INFO, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_teleinfo_device" @@ -202,6 +207,11 @@ async def test_usb_discovery_already_configured_updates_path( ), ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" # Path should be updated to the new device path @@ -225,6 +235,11 @@ async def test_usb_discovery_manual_entry_duplicate( data=USB_DISCOVERY_INFO, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -244,5 +259,10 @@ async def test_usb_discovery_decode_error_aborts( data=USB_DISCOVERY_INFO, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_teleinfo_device" From d366027e6b06f2df001312f7b7ab27e9488ef839 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 16:31:56 +0200 Subject: [PATCH 10/39] Fix utility meter next_reset shifting forward on entity rename (#170957) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/utility_meter/sensor.py | 14 +++-- tests/components/utility_meter/test_sensor.py | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index c9c737c4999dc8..75d8cf255e3faa 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -408,11 +408,12 @@ def __init__( self._current_tz = None self._config_scheduler() - def _config_scheduler(self): + def _config_scheduler(self, start_time: datetime | None = None) -> None: self.scheduler = ( CronSim( self._cron_pattern, - dt_util.now( + start_time + or dt_util.now( dt_util.get_default_time_zone() ), # we need timezone for DST purposes (see issue #102984) ) @@ -610,8 +611,6 @@ async def async_added_to_hass(self) -> None: # and we need to reconfigure the scheduler self._current_tz = self.hass.config.time_zone - await self._program_reset() - self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_RESET_METER, self.async_reset_meter @@ -630,6 +629,13 @@ async def async_added_to_hass(self) -> None: if last_sensor_data.status == COLLECTING: # Null lambda to allow cancelling the collection on tariff change self._collecting = lambda: None + # Reconfigure the scheduler from the restored last_reset so that + # next_reset is not shifted forward on entity restore/rename. + self._config_scheduler( + dt_util.as_local(self._last_reset) if self._last_reset else None + ) + + await self._program_reset() @callback def async_source_tracking(event): diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index f684cdb16a0bbf..7bcb2f5f8b412a 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1799,6 +1799,63 @@ async def test_tz_changes(hass: HomeAssistant) -> None: assert state.attributes.get("next_reset") != "2024-10-28T00:00:00+01:00" +async def test_next_reset_not_shifted_on_restore(hass: HomeAssistant) -> None: + """Test that a missed reset fires on restore after entity rename. + + When an entity is restored (e.g. after rename) and a reset was missed, + the scheduler should catch up from last_reset rather than starting from + now(), which would skip the missed reset entirely. + """ + last_reset = "2024-10-27T00:00:00+00:00" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.energy_bill", + "3", + attributes={ + ATTR_STATUS: COLLECTING, + ATTR_LAST_RESET: last_reset, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + }, + ), + { + "native_value": { + "__type": "", + "decimal_str": "3", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "last_valid_state": "3", + "status": "collecting", + "input_device_class": "energy", + }, + ), + ], + ) + + # Restore at noon on Oct 28 - the Oct 28 midnight reset was missed + now = dt_util.parse_datetime("2024-10-28T12:00:00+00:00") + with freeze_time(now): + assert await async_setup_component(hass, DOMAIN, gen_config("daily")) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state is not None + # The missed reset should have fired as a catch-up, updating last_reset + # and recording the previous period value. Without the fix, last_reset + # stays at the original value because the scheduler starts from now() + # and skips the missed period entirely. + assert state.attributes.get("last_reset") != last_reset + assert state.attributes.get("last_period") == "3" + assert state.state == "0" + + async def test_self_reset_daily(hass: HomeAssistant) -> None: """Test daily reset of meter.""" await _test_self_reset( From 228ac011249e7d624f7d178ad5a7081d9272c0d4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 16:32:21 +0200 Subject: [PATCH 11/39] Use correct state_class for utility meters with device classes that don't support total_increasing (#170962) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/utility_meter/sensor.py | 19 +++++++---- tests/components/utility_meter/test_sensor.py | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 75d8cf255e3faa..1dd208031eb3fd 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( ATTR_LAST_RESET, + DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, RestoreSensor, SensorDeviceClass, @@ -703,12 +704,18 @@ def device_class(self) -> SensorDeviceClass | None: @property def state_class(self) -> SensorStateClass: - """Return the device class of the sensor.""" - return ( - SensorStateClass.TOTAL - if self._sensor_net_consumption - else SensorStateClass.TOTAL_INCREASING - ) + """Return the state class of the sensor.""" + if self._sensor_net_consumption: + return SensorStateClass.TOTAL + if ( + self._input_device_class is not None + and SensorStateClass.TOTAL_INCREASING + not in DEVICE_CLASS_STATE_CLASSES.get( + self._input_device_class, {SensorStateClass.TOTAL_INCREASING} + ) + ): + return SensorStateClass.TOTAL + return SensorStateClass.TOTAL_INCREASING @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 7bcb2f5f8b412a..c13adeddad4ab6 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -657,6 +657,40 @@ async def test_device_class( assert state.attributes.get(attr) == value +async def test_state_class_monetary_device_class(hass: HomeAssistant) -> None: + """Test that monetary device class uses state_class total, not total_increasing.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "utility_meter": { + "cost_meter": { + "source": "sensor.energy_cost", + } + } + }, + ) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.states.async_set( + "sensor.energy_cost", + 2, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.MONETARY, + ATTR_UNIT_OF_MEASUREMENT: "EUR", + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.cost_meter") + assert state is not None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + + @pytest.mark.parametrize( ("yaml_config", "config_entry_config"), [ From 4a96880f5163f3ad1168472d4ae37c0a3b74f717 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 12:45:09 +0200 Subject: [PATCH 12/39] Reduce GoodWe connect retries to avoid blocking startup (#170964) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/goodwe/__init__.py | 2 +- .../components/goodwe/config_flow.py | 4 +-- tests/components/goodwe/test_init.py | 36 ++++++++++++++++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index d191ecb15a2992..a5d0b94faa18d5 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo host=host, port=port, family=model_family, - retries=10, + retries=3, ) except InverterError as err: try: diff --git a/homeassistant/components/goodwe/config_flow.py b/homeassistant/components/goodwe/config_flow.py index 5faa2b867680c4..6abd0c88906926 100644 --- a/homeassistant/components/goodwe/config_flow.py +++ b/homeassistant/components/goodwe/config_flow.py @@ -73,8 +73,8 @@ async def async_detect_inverter_port( """Detects the port of the Inverter.""" port = GOODWE_UDP_PORT try: - inverter = await connect(host=host, port=port, retries=10) + inverter = await connect(host=host, port=port, retries=3) except InverterError: port = GOODWE_TCP_PORT - inverter = await connect(host=host, port=port, retries=10) + inverter = await connect(host=host, port=port, retries=3) return inverter, port diff --git a/tests/components/goodwe/test_init.py b/tests/components/goodwe/test_init.py index ee8060018aec73..03b716e3f3f9b8 100644 --- a/tests/components/goodwe/test_init.py +++ b/tests/components/goodwe/test_init.py @@ -1,8 +1,11 @@ """Test the GoodWe initialization.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +from goodwe import InverterError from homeassistant.components.goodwe import CONF_MODEL_FAMILY, DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -34,3 +37,34 @@ async def test_migration( assert config_entry.data[CONF_HOST] == TEST_HOST assert config_entry.data[CONF_MODEL_FAMILY] == "MagicMock" assert config_entry.data[CONF_PORT] == TEST_PORT + + +async def test_setup_connect_not_ready(hass: HomeAssistant) -> None: + """Test that setup raises ConfigEntryNotReady when inverter is unreachable.""" + config_entry = MockConfigEntry( + version=2, + domain=DOMAIN, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_MODEL_FAMILY: "ET", + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.goodwe.connect", + side_effect=InverterError, + ) as mock_connect, + patch( + "homeassistant.components.goodwe.config_flow.connect", + side_effect=InverterError, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + # Verify connect is called with limited retries to avoid blocking startup + assert mock_connect.call_args.kwargs["retries"] <= 3 From 5e45f37ee68b93f13ceec38704c9dfe08f89e350 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 18 May 2026 13:40:48 +0200 Subject: [PATCH 13/39] Fix is_closed state for DiscretePositionableGarageDoor in Overkiz (#170981) --- homeassistant/components/overkiz/cover.py | 11 + .../setup/local_somfy_tahoma_v2_europe.json | 230 ++++++++++++++++++ .../overkiz/snapshots/test_cover.ambr | 53 ++++ tests/components/overkiz/test_cover.py | 11 + 4 files changed, 305 insertions(+) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index fa32f613eeb140..40514ac0fe0d83 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -173,6 +173,17 @@ class OverkizCoverDescription(CoverEntityDescription): stop_command=OverkizCommand.STOP, is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN, ), + # Needs override since DiscretePositionableGarageDoor reports + # core:OpenClosedUnknownState instead of core:OpenClosedState + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.DISCRETE_POSITIONABLE_GARAGE_DOOR, + device_class=CoverDeviceClass.GARAGE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN, + ), # Needs override since PositionableGarageDoorWithPartialPosition reports # core:OpenClosedPartialState instead of core:OpenClosedState # uiClass is GarageDoor diff --git a/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_v2_europe.json b/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_v2_europe.json index e309c17526f552..61879cc1cac9d9 100644 --- a/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_v2_europe.json +++ b/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_v2_europe.json @@ -1674,6 +1674,236 @@ ], "uiClass": "Light" } + }, + { + "deviceURL": "io://1234-5678-3293/12745774", + "available": true, + "synced": true, + "type": 1, + "states": [ + { + "type": 3, + "name": "core:StatusState", + "value": "available" + }, + { + "type": 11, + "name": "core:CommandLockLevelsState", + "value": [] + }, + { + "type": 3, + "name": "core:DiscreteRSSILevelState", + "value": "good" + }, + { + "type": 1, + "name": "core:RSSILevelState", + "value": 100 + }, + { + "type": 3, + "name": "core:OpenClosedUnknownState", + "value": "closed" + }, + { + "type": 3, + "name": "core:NameState", + "value": "Garage Door Rollixo" + }, + { + "type": 1, + "name": "core:PriorityLockTimerState", + "value": 0 + }, + { + "type": 3, + "name": "io:PriorityLockOriginatorState", + "value": "unknown" + } + ], + "attributes": [ + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5105491C15" + } + ], + "enabled": true, + "label": "Garage Door Rollixo", + "controllableName": "io:DiscreteGarageOpenerIOComponent", + "subsystemId": 0, + "definition": { + "commands": [ + { + "nparams": 0, + "commandName": "up" + }, + { + "nparams": 0, + "commandName": "close" + }, + { + "nparams": 1, + "commandName": "addLockLevel", + "paramsSig": "p1,*p2" + }, + { + "nparams": 0, + "commandName": "resetLockLevels" + }, + { + "nparams": 1, + "commandName": "removeLockLevel", + "paramsSig": "p1" + }, + { + "nparams": 1, + "commandName": "executeManufacturerProcedure", + "paramsSig": "p1,*p2" + }, + { + "nparams": 1, + "commandName": "writeManufacturerData", + "paramsSig": "p1" + }, + { + "nparams": 1, + "commandName": "readManufacturerData", + "paramsSig": "p1" + }, + { + "nparams": 0, + "commandName": "unpairAllOneWayControllers" + }, + { + "nparams": 0, + "commandName": "stopIdentify" + }, + { + "nparams": 0, + "commandName": "startIdentify" + }, + { + "nparams": 1, + "commandName": "pairOneWayController", + "paramsSig": "p1,*p2" + }, + { + "nparams": 1, + "commandName": "delayedStopIdentify", + "paramsSig": "p1" + }, + { + "nparams": 0, + "commandName": "sendIOKey" + }, + { + "nparams": 0, + "commandName": "unpairAllOneWayControllersAndDeleteNode" + }, + { + "nparams": 1, + "commandName": "wink", + "paramsSig": "p1" + }, + { + "nparams": 1, + "commandName": "setConfigState", + "paramsSig": "p1" + }, + { + "nparams": 1, + "commandName": "advancedRefresh", + "paramsSig": "p1,*p2" + }, + { + "nparams": 1, + "commandName": "setName", + "paramsSig": "p1" + }, + { + "nparams": 1, + "commandName": "unpairOneWayController", + "paramsSig": "p1,*p2" + }, + { + "nparams": 2, + "commandName": "runManufacturerSettingsCommand", + "paramsSig": "p1,p2" + }, + { + "nparams": 0, + "commandName": "getName" + }, + { + "nparams": 0, + "commandName": "stop" + }, + { + "nparams": 0, + "commandName": "open" + }, + { + "nparams": 0, + "commandName": "keepOneWayControllersAndDeleteNode" + }, + { + "nparams": 0, + "commandName": "identify" + }, + { + "nparams": 0, + "commandName": "down" + } + ], + "states": [ + { + "name": "core:AdditionalStatusState" + }, + { + "name": "core:PriorityLockTimerState" + }, + { + "name": "io:PriorityLockLevelState" + }, + { + "name": "io:PriorityLockOriginatorState" + }, + { + "name": "core:StatusState" + }, + { + "name": "core:ManufacturerSettingsState" + }, + { + "name": "core:ManufacturerDiagnosticsState" + }, + { + "name": "core:CommandLockLevelsState" + }, + { + "name": "core:NameState" + }, + { + "name": "core:DiscreteRSSILevelState" + }, + { + "name": "core:RSSILevelState" + }, + { + "name": "core:OpenClosedUnknownState" + } + ], + "widgetName": "DiscretePositionableGarageDoor", + "uiClass": "GarageDoor", + "type": "ACTUATOR" + } } ] } diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index d8cc0f9347bc8b..e25995d7dbaf76 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -3516,6 +3516,59 @@ 'state': 'open', }) # --- +# name: test_cover_entities_snapshot[local_somfy_tahoma_v2_europe.json][cover.garage_door_rollixo-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.garage_door_rollixo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'io://1234-5678-3293/12745774', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[local_somfy_tahoma_v2_europe.json][cover.garage_door_rollixo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Garage Door Rollixo', + 'is_closed': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.garage_door_rollixo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_cover_entities_snapshot[local_somfy_tahoma_v2_europe.json][cover.garden_pergola-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 2653a2bf1074dc..5101df5eacf959 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -115,6 +115,11 @@ "rts://1234-1234-6362/16753206", "cover.kitchen_sheer_screen", ) +DISCRETE_GARAGE_DOOR = FixtureDevice( + "setup/local_somfy_tahoma_v2_europe.json", + "io://1234-5678-3293/12745774", + "cover.garage_door_rollixo", +) DYNAMIC_GARAGE_DOOR = FixtureDevice( "setup/cloud_somfy_tahoma_v2_europe.json", "io://1234-1234-6233/16730050", @@ -185,6 +190,7 @@ async def test_cover_entities_snapshot( (SHUTTER, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (AWNING, SERVICE_OPEN_COVER, "deploy", None, CoverState.OPENING), (GARAGE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), + (DISCRETE_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (DYNAMIC_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (DYNAMIC_GARAGE_DOOR_OGP, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (DYNAMIC_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), @@ -203,6 +209,7 @@ async def test_cover_entities_snapshot( (SHUTTER, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), (AWNING, SERVICE_CLOSE_COVER, "undeploy", None, CoverState.CLOSING), (GARAGE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), + (DISCRETE_GARAGE_DOOR, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), (DYNAMIC_GARAGE_DOOR, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), ( DYNAMIC_GARAGE_DOOR_OGP, @@ -233,6 +240,7 @@ async def test_cover_entities_snapshot( (SHUTTER, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (AWNING, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (GARAGE, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), + (DISCRETE_GARAGE_DOOR, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (DYNAMIC_GARAGE_DOOR, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (DYNAMIC_GARAGE_DOOR_OGP, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (DYNAMIC_GATE, SERVICE_STOP_COVER, "stop", None, CoverState.OPEN), @@ -316,6 +324,7 @@ async def test_cover_entities_snapshot( "open-roller-shutter", "open-awning", "open-garage-door", + "open-discrete-garage-door", "open-dynamic-garage-door", "open-dynamic-garage-door-ogp", "open-dynamic-gate", @@ -328,6 +337,7 @@ async def test_cover_entities_snapshot( "close-roller-shutter", "close-awning", "close-garage-door", + "close-discrete-garage-door", "close-dynamic-garage-door", "close-dynamic-garage-door-ogp", "close-dynamic-gate", @@ -340,6 +350,7 @@ async def test_cover_entities_snapshot( "stop-roller-shutter", "stop-awning", "stop-garage-door", + "stop-discrete-garage-door", "stop-dynamic-garage-door", "stop-dynamic-garage-door-ogp", "stop-dynamic-gate", From 070de13c14863642fed4a4724b97af92a90b8ade Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 18 May 2026 12:39:48 +0200 Subject: [PATCH 14/39] Fix controls for OpenCloseGate4T (rts:GateOpenerRTS4TComponent) in Overkiz (#170987) --- homeassistant/components/overkiz/cover.py | 34 ++ .../setup/cloud_somfy_tahoma_v2_europe.json | 411 ++++++++++++++++++ .../overkiz/snapshots/test_cover.ambr | 160 +++++++ tests/components/overkiz/test_cover.py | 29 ++ 4 files changed, 634 insertions(+) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 40514ac0fe0d83..eda18cb5a9e513 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -208,6 +208,40 @@ class OverkizCoverDescription(CoverEntityDescription): is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, stop_command=OverkizCommand.STOP, ), + # Needs override since OpenCloseGate4T only supports the cycle command + # (rts:GateOpenerRTS4TComponent) + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_GATE_4T, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since CyclicGarageDoor only supports the cycle command + # (io:CyclicGarageOpenerIOComponent) + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.CYCLIC_GARAGE_DOOR, + device_class=CoverDeviceClass.GARAGE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since CyclicSlidingGateOpener only supports the cycle command + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.CYCLIC_SLIDING_GATE_OPENER, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since CyclicSwingingGateOpener only supports the cycle command + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.CYCLIC_SWINGING_GATE_OPENER, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), # Needs override since SlidingDiscreteGateWithPedestrianPosition reports # core:OpenClosedPedestrianState instead of core:OpenClosedState # uiClass is Gate diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json index a8b4aaf4349c30..b674741430742e 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json @@ -7441,6 +7441,38 @@ "oid": "6ba9b1de-8037-41d7-9150-21f7d5f49a3f", "uiClass": "Gate" }, + { + "creationTime": 1434967095000, + "lastUpdateTime": 1434967095000, + "label": "RTS Gate", + "deviceURL": "rts://1234-1234-6233/16730717", + "shortcut": false, + "controllableName": "rts:GateOpenerRTS4TComponent", + "definition": { + "commands": [ + { + "commandName": "cycle", + "nparams": 1 + } + ], + "states": [], + "dataProperties": [], + "widgetName": "OpenCloseGate4T", + "uiProfiles": ["CyclicGateOpener", "Cyclic"], + "uiClass": "Gate", + "qualifiedName": "rts:GateOpenerRTS4TComponent", + "type": "ACTUATOR" + }, + "states": [], + "attributes": [], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "OpenCloseGate4T", + "type": 1, + "oid": "a1b2c3d4-e5f6-7890-abcd-ef1234567891", + "uiClass": "Gate" + }, { "creationTime": 1654894302000, "lastUpdateTime": 1654894302000, @@ -7724,6 +7756,385 @@ "oid": "2492f7ae-3711-4160-9dae-e8910b708ce1", "uiClass": "GarageDoor" }, + { + "creationTime": 1685039520000, + "lastUpdateTime": 1685039520000, + "label": "Cyclic Garage Door", + "deviceURL": "io://1234-1234-6233/6416929", + "shortcut": false, + "controllableName": "io:CyclicGarageOpenerIOComponent", + "definition": { + "commands": [ + { + "commandName": "advancedRefresh", + "nparams": 2 + }, + { + "commandName": "cycle", + "nparams": 0 + }, + { + "commandName": "delayedStopIdentify", + "nparams": 1 + }, + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "wink", + "nparams": 1 + }, + { + "commandName": "pairOneWayController", + "nparams": 2 + }, + { + "commandName": "setConfigState", + "nparams": 1 + }, + { + "commandName": "unpairAllOneWayControllers", + "nparams": 0 + }, + { + "commandName": "unpairOneWayController", + "nparams": 2 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:PriorityLockTimerState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "DiscreteState", + "values": [ + "comfortLevel1", + "comfortLevel2", + "comfortLevel3", + "comfortLevel4", + "environmentProtection", + "humanProtection", + "userLevel1", + "userLevel2" + ], + "qualifiedName": "io:PriorityLockLevelState" + }, + { + "type": "DiscreteState", + "values": [ + "LSC", + "SAAC", + "SFC", + "UPS", + "externalGateway", + "localUser", + "myself", + "rain", + "security", + "temperature", + "timer", + "user", + "wind" + ], + "qualifiedName": "io:PriorityLockOriginatorState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "CyclicGarageDoor", + "uiProfiles": ["CyclicGarageOpener", "Cyclic"], + "uiClass": "GarageDoor", + "qualifiedName": "io:CyclicGarageOpenerIOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "Cyclic Garage Door" + }, + { + "name": "core:PriorityLockTimerState", + "type": 1, + "value": 0 + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "normal" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 72.0 + } + ], + "attributes": [ + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5127170A01" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "CyclicGarageDoor", + "type": 1, + "oid": "42a91c8c-2f55-4442-9af8-fa93862fbeff", + "uiClass": "GarageDoor" + }, + { + "creationTime": 1711109596000, + "lastUpdateTime": 1711109596000, + "label": "Swinging Gate", + "deviceURL": "io://1234-1234-8983/1959462", + "shortcut": false, + "controllableName": "io:CyclicSwingingGateOpenerIOComponent", + "definition": { + "commands": [ + { + "commandName": "addLockLevel", + "nparams": 2 + }, + { + "commandName": "advancedRefresh", + "nparams": 2 + }, + { + "commandName": "cycle", + "nparams": 0 + }, + { + "commandName": "delayedStopIdentify", + "nparams": 1 + }, + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "removeLockLevel", + "nparams": 1 + }, + { + "commandName": "resetLockLevels", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "wink", + "nparams": 1 + }, + { + "commandName": "pairOneWayController", + "nparams": 2 + }, + { + "commandName": "setConfigState", + "nparams": 1 + }, + { + "commandName": "unpairAllOneWayControllers", + "nparams": 0 + }, + { + "commandName": "unpairOneWayController", + "nparams": 2 + } + ], + "states": [ + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:CommandLockLevelsState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:PriorityLockTimerState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "DiscreteState", + "values": [ + "comfortLevel1", + "comfortLevel2", + "comfortLevel3", + "comfortLevel4", + "environmentProtection", + "humanProtection", + "userLevel1", + "userLevel2" + ], + "qualifiedName": "io:PriorityLockLevelState" + }, + { + "type": "DiscreteState", + "values": [ + "LSC", + "SAAC", + "SFC", + "UPS", + "externalGateway", + "localUser", + "myself", + "rain", + "security", + "temperature", + "timer", + "user", + "wind" + ], + "qualifiedName": "io:PriorityLockOriginatorState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "CyclicSwingingGateOpener", + "uiProfiles": ["CyclicGateOpener", "Cyclic"], + "uiClass": "Gate", + "qualifiedName": "io:CyclicSwingingGateOpenerIOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "Swinging Gate" + }, + { + "name": "core:PriorityLockTimerState", + "type": 1, + "value": 0 + }, + { + "name": "core:CommandLockLevelsState", + "type": 3, + "value": "[]", + "lastUpdateTime": 1742634864000 + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "low" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 32.0 + } + ], + "attributes": [ + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5127170B02" + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "CyclicSwingingGateOpener", + "type": 1, + "oid": "83371762-4f7c-4e1b-846b-d5b7f8e9aa53", + "uiClass": "Gate" + }, { "creationTime": 1521964729000, "lastUpdateTime": 1521964729000, diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index e25995d7dbaf76..48641a7ce366d2 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -1242,6 +1242,59 @@ 'state': 'closed', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.cyclic_garage_door-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.cyclic_garage_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'io://1234-1234-6233/6416929', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.cyclic_garage_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Cyclic Garage Door', + 'is_closed': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cyclic_garage_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.dining_room_shutter-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1993,6 +2046,60 @@ 'state': 'closed', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.rts_gate-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.rts_gate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'rts://1234-1234-6233/16730717', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.rts_gate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'device_class': 'gate', + 'friendly_name': 'RTS Gate', + 'is_closed': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.rts_gate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.side_garage_door-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -2154,6 +2261,59 @@ 'state': 'closed', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.swinging_gate-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.swinging_gate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'io://1234-1234-8983/1959462', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.swinging_gate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gate', + 'friendly_name': 'Swinging Gate', + 'is_closed': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.swinging_gate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_cover_entities_snapshot[local_somfy_connexoon_europe.json][cover.terrace_awning-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 5101df5eacf959..964dcbfc7872b2 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -135,6 +135,21 @@ "io://1234-1234-6233/7433515", "cover.partial_garage_door", ) +RTS_GATE_4T = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "rts://1234-1234-6233/16730717", + "cover.rts_gate", +) +CYCLIC_GARAGE_DOOR = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "io://1234-1234-6233/6416929", + "cover.cyclic_garage_door", +) +CYCLIC_SWINGING_GATE = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "io://1234-1234-8983/1959462", + "cover.swinging_gate", +) SLIDING_DISCRETE_GATE = FixtureDevice( "setup/cloud_somfy_tahoma_v2_europe.json", "io://1234-1234-6233/16730051", @@ -194,6 +209,9 @@ async def test_cover_entities_snapshot( (DYNAMIC_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (DYNAMIC_GARAGE_DOOR_OGP, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (DYNAMIC_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), + (RTS_GATE_4T, SERVICE_OPEN_COVER, "cycle", [0], CoverState.OPENING), + (CYCLIC_GARAGE_DOOR, SERVICE_OPEN_COVER, "cycle", None, CoverState.OPENING), + (CYCLIC_SWINGING_GATE, SERVICE_OPEN_COVER, "cycle", None, CoverState.OPENING), (SLIDING_DISCRETE_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (PARTIAL_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), ( @@ -219,6 +237,11 @@ async def test_cover_entities_snapshot( CoverState.CLOSING, ), (DYNAMIC_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), + # Cycle command is used for both open and close; device reports OPENING + # since the RTS protocol has no directional feedback. + (RTS_GATE_4T, SERVICE_CLOSE_COVER, "cycle", [0], CoverState.OPENING), + (CYCLIC_GARAGE_DOOR, SERVICE_CLOSE_COVER, "cycle", None, CoverState.OPENING), + (CYCLIC_SWINGING_GATE, SERVICE_CLOSE_COVER, "cycle", None, CoverState.OPENING), (SLIDING_DISCRETE_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), (PARTIAL_GARAGE_DOOR, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), ( @@ -328,6 +351,9 @@ async def test_cover_entities_snapshot( "open-dynamic-garage-door", "open-dynamic-garage-door-ogp", "open-dynamic-gate", + "open-rts-gate-4t", + "open-cyclic-garage-door", + "open-cyclic-swinging-gate", "open-sliding-discrete-gate", "open-partial-garage-door", "open-up-down-bioclimatic-pergola", @@ -341,6 +367,9 @@ async def test_cover_entities_snapshot( "close-dynamic-garage-door", "close-dynamic-garage-door-ogp", "close-dynamic-gate", + "close-rts-gate-4t", + "close-cyclic-garage-door", + "close-cyclic-swinging-gate", "close-sliding-discrete-gate", "close-partial-garage-door", "close-up-down-bioclimatic-pergola", From 2456753cafec0b6b55ce46d21d661abda2705158 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 23:13:15 +0200 Subject: [PATCH 15/39] Prevent Google Assistant entity sync from blocking startup (#170991) Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/google_assistant/helpers.py | 2 +- tests/components/cloud/test_google_config.py | 4 +++ .../components/google_assistant/test_http.py | 27 +++++++++++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 929944cb4893e1..14190c02fd1676 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -114,7 +114,7 @@ async def sync_google(_): """Sync entities to Google.""" await self.async_sync_entities_all() - self._on_deinitialize.append(start.async_at_start(self.hass, sync_google)) + self._on_deinitialize.append(start.async_at_started(self.hass, sync_google)) @callback def async_deinitialize(self) -> None: diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 91a7b2169c3588..3083c24a6137ea 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -372,6 +372,10 @@ async def test_sync_google_on_home_assistant_start( hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() + assert len(mock_sync.mock_calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_sync.mock_calls) == 1 diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 273aac1559efe3..3c9d47fb9f3ba5 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -28,8 +28,12 @@ _get_homegraph_token, async_get_users, ) -from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant, State +from homeassistant.const import ( + CLOUD_NEVER_EXPOSED_ENTITIES, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -60,6 +64,25 @@ } +async def test_sync_google_does_not_block_startup(hass: HomeAssistant) -> None: + """Test that Google entity sync runs after startup, not during.""" + hass.set_state(CoreState.not_running) + config = GoogleConfig(hass, DUMMY_CONFIG) + + with patch.object(config, "async_sync_entities_all") as mock_sync: + await config.async_initialize() + + # Fire EVENT_HOMEASSISTANT_START - sync should NOT run yet + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_sync.assert_not_called() + + # Fire EVENT_HOMEASSISTANT_STARTED - now sync should run + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + mock_sync.assert_called_once() + + async def test_get_jwt(hass: HomeAssistant) -> None: """Test signing of key.""" From ea084797d3982474995a59fcede3bd361d5bc15f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 22:55:54 +0200 Subject: [PATCH 16/39] Load template extensions by class to prevent import deadlock (#170995) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/helpers/template/__init__.py | 71 ++++++++++++---------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index fb2aeb5b03c7f7..5dbb1bc6b4f090 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -37,6 +37,27 @@ template_context_manager, template_cv, ) +from .extensions import ( + AreaExtension, + Base64Extension, + CollectionExtension, + ConfigEntryExtension, + CryptoExtension, + DateTimeExtension, + DeviceExtension, + EntityExtension, + FloorExtension, + FunctionalExtension, + IssuesExtension, + LabelExtension, + MathExtension, + RegexExtension, + SerializationExtension, + StateExtension, + StringExtension, + TypeCastExtension, + VersionExtension, +) from .helpers import result_as_boolean as result_as_boolean from .render_info import RenderInfo, render_info_cv from .states import ( @@ -722,37 +743,25 @@ def __init__( ] = weakref.WeakValueDictionary() self.add_extension("jinja2.ext.loopcontrols") self.add_extension("jinja2.ext.do") - self.add_extension("homeassistant.helpers.template.extensions.AreaExtension") - self.add_extension("homeassistant.helpers.template.extensions.Base64Extension") - self.add_extension( - "homeassistant.helpers.template.extensions.CollectionExtension" - ) - self.add_extension( - "homeassistant.helpers.template.extensions.ConfigEntryExtension" - ) - self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension") - self.add_extension( - "homeassistant.helpers.template.extensions.DateTimeExtension" - ) - self.add_extension("homeassistant.helpers.template.extensions.DeviceExtension") - self.add_extension("homeassistant.helpers.template.extensions.EntityExtension") - self.add_extension("homeassistant.helpers.template.extensions.FloorExtension") - self.add_extension( - "homeassistant.helpers.template.extensions.FunctionalExtension" - ) - self.add_extension("homeassistant.helpers.template.extensions.IssuesExtension") - self.add_extension("homeassistant.helpers.template.extensions.LabelExtension") - self.add_extension("homeassistant.helpers.template.extensions.MathExtension") - self.add_extension("homeassistant.helpers.template.extensions.RegexExtension") - self.add_extension( - "homeassistant.helpers.template.extensions.SerializationExtension" - ) - self.add_extension("homeassistant.helpers.template.extensions.StateExtension") - self.add_extension("homeassistant.helpers.template.extensions.StringExtension") - self.add_extension( - "homeassistant.helpers.template.extensions.TypeCastExtension" - ) - self.add_extension("homeassistant.helpers.template.extensions.VersionExtension") + self.add_extension(AreaExtension) + self.add_extension(Base64Extension) + self.add_extension(CollectionExtension) + self.add_extension(ConfigEntryExtension) + self.add_extension(CryptoExtension) + self.add_extension(DateTimeExtension) + self.add_extension(DeviceExtension) + self.add_extension(EntityExtension) + self.add_extension(FloorExtension) + self.add_extension(FunctionalExtension) + self.add_extension(IssuesExtension) + self.add_extension(LabelExtension) + self.add_extension(MathExtension) + self.add_extension(RegexExtension) + self.add_extension(SerializationExtension) + self.add_extension(StateExtension) + self.add_extension(StringExtension) + self.add_extension(TypeCastExtension) + self.add_extension(VersionExtension) if hass is not None: # This environment has access to hass, attach its loader From 0bc0745e8c1ce50462fc0251baba52ca3c017a1d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 22:53:11 +0200 Subject: [PATCH 17/39] Use asyncio.get_running_loop() in emulated_hue UPnP responder (#171000) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/emulated_hue/upnp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index 4fb0be81814a3f..df9ac47eb6efe4 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -171,7 +171,7 @@ async def async_create_upnp_datagram_endpoint( ssdp_socket.bind(("" if upnp_bind_multicast else host_ip_addr, BROADCAST_PORT)) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() transport_protocol = await loop.create_datagram_endpoint( lambda: UPNPResponderProtocol(loop, ssdp_socket, advertise_ip, advertise_port), From 771b016f3366dcbef6468097a2204cf7e11e5798 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 20:55:24 +0200 Subject: [PATCH 18/39] Fix Netatmo valve KeyError when hvac_action state is unavailable in Overkiz (#171004) --- .../valve_heating_temperature_interface.py | 10 +-- tests/components/overkiz/test_climate.py | 67 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/components/overkiz/test_climate.py diff --git a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py index 54c00b33167acc..bc7359eb76741c 100644 --- a/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py @@ -74,11 +74,13 @@ def __init__( ) @property - def hvac_action(self) -> HVACAction: + def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation.""" - return OVERKIZ_TO_HVAC_ACTION[ - cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE)) - ] + if ( + state := self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE) + ) is None: + return None + return OVERKIZ_TO_HVAC_ACTION[cast(str, state)] @property def target_temperature(self) -> float: diff --git a/tests/components/overkiz/test_climate.py b/tests/components/overkiz/test_climate.py new file mode 100644 index 00000000000000..835f38b7220e43 --- /dev/null +++ b/tests/components/overkiz/test_climate.py @@ -0,0 +1,67 @@ +"""Tests for the Overkiz climate platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from pyoverkiz.enums import EventName, OverkizState +import pytest + +from homeassistant.components.climate import ATTR_HVAC_ACTION, HVACAction +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration +from .helpers import async_deliver_events, build_event + +VALVE = FixtureDevice( + "setup/cloud_nexity_rail_din_europe.json", + "io://1234-5678-1698/15702199#1", + "climate.garden_radiator", +) + + +@pytest.fixture(autouse=True) +def fixture_platforms() -> Generator[None]: + """Limit platforms to climate only.""" + with patch("homeassistant.components.overkiz.PLATFORMS", [Platform.CLIMATE]): + yield + + +async def test_valve_hvac_action_none_state( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, +) -> None: + """Test that hvac_action handles None valve state without crashing.""" + await setup_overkiz_integration(fixture=VALVE.fixture) + + state = hass.states.get(VALVE.entity_id) + assert state is not None + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + + # Deliver an event that clears the valve state + await async_deliver_events( + hass, + freezer, + mock_client, + [ + build_event( + EventName.DEVICE_STATE_CHANGED, + device_url=VALVE.device_url, + device_states=[ + { + "name": OverkizState.CORE_OPEN_CLOSED_VALVE, + "type": 3, + "value": None, + } + ], + ) + ], + ) + + # hvac_action should be None (unknown) rather than raising KeyError + state = hass.states.get(VALVE.entity_id) + assert state is not None + assert state.attributes.get(ATTR_HVAC_ACTION) is None From db8589b2bc286f0650b7614b3cd4586ee80dd60b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 23:13:32 +0200 Subject: [PATCH 19/39] Fix time trigger crash when using entity_id dict format without offset (#171006) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Paulus Schoutsen --- .../components/homeassistant/triggers/time.py | 6 +-- .../homeassistant/triggers/test_time.py | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 0fc5618c122fb3..0690334ee12181 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -265,9 +265,9 @@ def update_entity_trigger( # entity update_entity_trigger(at_time, new_state=hass.states.get(at_time)) to_track.append(TrackEntity(at_time, update_entity_trigger_event)) - elif isinstance(at_time, dict) and CONF_OFFSET in at_time: - # entity with offset - entity_id: str = at_time.get(CONF_ENTITY_ID, "") + elif isinstance(at_time, dict): + # entity with optional offset + entity_id: str = at_time[CONF_ENTITY_ID] offset: timedelta = at_time.get(CONF_OFFSET, timedelta(0)) update_entity_trigger( entity_id, new_state=hass.states.get(entity_id), offset=offset diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 79f07c5285d3bb..1bf7c48b187a8d 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -717,6 +717,52 @@ async def test_if_fires_using_at_sensor_with_offset( ) +async def test_if_fires_using_at_sensor_dict_without_offset( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], +) -> None: + """Test for firing at sensor time using dict format without offset.""" + now = dt_util.now() + + trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) + + hass.states.async_set( + "sensor.next_alarm", + trigger_dt.isoformat(), + {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + ) + + time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": { + "entity_id": "sensor.next_alarm", + }, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "{{ trigger.entity_id }}"}, + }, + } + }, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert service_calls[0].data["some"] == "sensor.next_alarm" + + @pytest.mark.parametrize( "conf", [ From 17e105083ed4cf58b3cc9aac5b4003f36f708dde Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 22:48:36 +0200 Subject: [PATCH 20/39] Fix threshold preview crash when hysteresis is not provided (#171009) --- homeassistant/components/threshold/config_flow.py | 2 +- .../threshold/snapshots/test_config_flow.ambr | 15 +++++++++++++++ tests/components/threshold/test_config_flow.py | 14 +++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index 93468e89b46af5..58586e875cc61f 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -139,7 +139,7 @@ def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: name=name, lower=msg["user_input"].get(CONF_LOWER), upper=msg["user_input"].get(CONF_UPPER), - hysteresis=msg["user_input"].get(CONF_HYSTERESIS), + hysteresis=msg["user_input"].get(CONF_HYSTERESIS, DEFAULT_HYSTERESIS), device_class=None, unique_id=None, ) diff --git a/tests/components/threshold/snapshots/test_config_flow.ambr b/tests/components/threshold/snapshots/test_config_flow.ambr index 668c534f9f0440..144fbc6e8d98c6 100644 --- a/tests/components/threshold/snapshots/test_config_flow.ambr +++ b/tests/components/threshold/snapshots/test_config_flow.ambr @@ -6,6 +6,21 @@ 'state': 'unavailable', }) # --- +# name: test_config_flow_preview_success[missing_hysteresis] + dict({ + 'attributes': dict({ + 'entity_id': 'sensor.test_monitored', + 'friendly_name': 'Test Sensor', + 'hysteresis': 0.0, + 'lower': 20.0, + 'position': 'below', + 'sensor_value': 16.0, + 'type': 'lower', + 'upper': None, + }), + 'state': 'on', + }) +# --- # name: test_config_flow_preview_success[missing_upper_lower] dict({ 'attributes': dict({ diff --git a/tests/components/threshold/test_config_flow.py b/tests/components/threshold/test_config_flow.py index 8281321c4e33cb..2c474fb40347a6 100644 --- a/tests/components/threshold/test_config_flow.py +++ b/tests/components/threshold/test_config_flow.py @@ -182,8 +182,20 @@ async def test_options(hass: HomeAssistant) -> None: "lower": 20.0, } ), + ( + { + "name": "Test Sensor", + "entity_id": "sensor.test_monitored", + "lower": 20.0, + } + ), ], - ids=("success", "missing_upper_lower", "missing_entity_id"), + ids=( + "success", + "missing_upper_lower", + "missing_entity_id", + "missing_hysteresis", + ), ) async def test_config_flow_preview_success( hass: HomeAssistant, From 5a76f3bd19e78f7bca9773f9b85fe22c28c90094 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 22:47:59 +0200 Subject: [PATCH 21/39] Fix Growatt mix device IndexError when chart data is empty (#171012) --- .../components/growatt_server/coordinator.py | 17 +++++++------- .../components/growatt_server/test_sensor.py | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index 7fc81e9975d246..7d1109f0172ff2 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -209,14 +209,15 @@ def _sync_update_data(self) -> dict[str, Any]: mix_chart_entries = mix_detail["chartData"] sorted_keys = sorted(mix_chart_entries) - # Create datetime from the latest entry - date_now = dt_util.now().date() - last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) - mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, - last_updated_time, # type: ignore[arg-type] - dt_util.get_default_time_zone(), - ) + if sorted_keys: + # Create datetime from the latest entry + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) + mix_detail["lastdataupdate"] = datetime.datetime.combine( + date_now, + last_updated_time, # type: ignore[arg-type] + dt_util.get_default_time_zone(), + ) # Dashboard data for mix system dashboard_data = self.api.dashboard_data(self.plant_id) diff --git a/tests/components/growatt_server/test_sensor.py b/tests/components/growatt_server/test_sensor.py index e1a5a514207392..a363cd1a57b0bf 100644 --- a/tests/components/growatt_server/test_sensor.py +++ b/tests/components/growatt_server/test_sensor.py @@ -125,6 +125,29 @@ async def test_sensors_classic_api( ) +@pytest.mark.freeze_time("2023-10-21") +async def test_mix_empty_chart_data( + hass: HomeAssistant, + mock_growatt_classic_api, + mock_config_entry_classic: MockConfigEntry, +) -> None: + """Test mix device handles empty chart data without crashing.""" + mock_growatt_classic_api.device_list.return_value = [ + {"deviceSn": "MIX123456", "deviceType": "mix"} + ] + mock_growatt_classic_api.mix_detail.return_value = { + "deviceSn": "MIX123456", + "chartData": {}, + } + + with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry_classic) + + # Should not crash - entities should still be created + states = hass.states.async_entity_ids("sensor") + assert len(states) > 0 + + async def test_sensor_coordinator_updates( hass: HomeAssistant, mock_growatt_v1_api, From 37478d33eb91714f0ad6b37afd0920aee42805bd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 22:45:55 +0200 Subject: [PATCH 22/39] Fix SleepIQ timer units: seconds should be minutes for core climate and foot warmer (#171013) --- homeassistant/components/sleepiq/number.py | 4 +++- tests/components/sleepiq/test_number.py | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 57ccd5457f5d87..a887ac633a196f 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -158,6 +158,8 @@ def _get_core_climate_unique_id( set_value_fn=_async_set_foot_warmer_time, get_name_fn=_get_foot_warming_name, get_unique_id_fn=_get_foot_warming_unique_id, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=NumberDeviceClass.DURATION, ), CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( key=CORE_CLIMATE_TIMER, @@ -170,7 +172,7 @@ def _get_core_climate_unique_id( set_value_fn=_async_set_core_climate_time, get_name_fn=_get_core_climate_name, get_unique_id_fn=_get_core_climate_unique_id, - native_unit_of_measurement=UnitOfTime.SECONDS, + native_unit_of_measurement=UnitOfTime.MINUTES, device_class=NumberDeviceClass.DURATION, ), } diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index d6ec33bb56ce60..e3b86bce41a6df 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -8,7 +8,13 @@ DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -178,6 +184,7 @@ async def test_foot_warmer_timer( ) assert state.state == "120.0" assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.MINUTES assert state.attributes.get(ATTR_MIN) == 30 assert state.attributes.get(ATTR_MAX) == 360 assert state.attributes.get(ATTR_STEP) == 30 @@ -217,6 +224,7 @@ async def test_core_climate_timer( ) assert state.state == "240.0" assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.MINUTES assert state.attributes.get(ATTR_MIN) == 0 assert state.attributes.get(ATTR_MAX) == 600 assert state.attributes.get(ATTR_STEP) == 30 From a314f7bf64d58bfc89e7e8d5eb4e744d61106447 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 22:45:09 +0200 Subject: [PATCH 23/39] Fix Control4 climate crash when humidity is 'Undefined' (#171015) --- homeassistant/components/control4/climate.py | 5 ++- tests/components/control4/test_climate.py | 34 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index ba0005cbf3ade2..2276aa9c83aea7 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -273,7 +273,10 @@ def current_humidity(self) -> int | None: if data is None: return None humidity = data.get(CONTROL4_HUMIDITY) - return int(humidity) if humidity is not None else None + try: + return int(humidity) if humidity is not None else None + except ValueError, TypeError: + return None @property def hvac_mode(self) -> HVACMode: diff --git a/tests/components/control4/test_climate.py b/tests/components/control4/test_climate.py index c77ebee1f654b0..d7e348db9bb59e 100644 --- a/tests/components/control4/test_climate.py +++ b/tests/components/control4/test_climate.py @@ -385,6 +385,40 @@ async def test_climate_missing_variables( assert state.attributes["temperature"] == 68.0 +@pytest.mark.parametrize( + "mock_climate_variables", + [ + { + 123: { + "HVAC_STATE": "Off", + "HVAC_MODE": "Heat", + "TEMPERATURE_F": 72.0, + "HUMIDITY": "Undefined", + "COOL_SETPOINT_F": 75.0, + "HEAT_SETPOINT_F": 68.0, + "SCALE": "FAHRENHEIT", + } + } + ], +) +@pytest.mark.usefixtures( + "mock_c4_account", + "mock_c4_director", + "mock_climate_update_variables", + "init_integration", +) +async def test_climate_undefined_humidity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate entity handles 'Undefined' humidity string gracefully.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == HVACMode.HEAT + assert state.attributes.get("current_temperature") == 72.0 + assert state.attributes.get("current_humidity") is None + + @pytest.mark.parametrize( "mock_climate_variables", [ From d39775ac34d5607351be181bdeddb4a45ef6da39 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 22:44:39 +0200 Subject: [PATCH 24/39] Fix manual alarm panel crash on restore with invalid state (#171016) --- .../components/manual/alarm_control_panel.py | 15 ++++++----- .../manual/test_alarm_control_panel.py | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 648368db6d0727..d29da80e851422 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -455,12 +455,15 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if state := await self.async_get_last_state(): self._state_ts = state.last_updated - if next_state := state.attributes.get(ATTR_NEXT_STATE): - # If in arming or pending state we record the transition, - # not the current state - self._state = AlarmControlPanelState(next_state) - else: - self._state = AlarmControlPanelState(state.state) + try: + if next_state := state.attributes.get(ATTR_NEXT_STATE): + # If in arming or pending state we record the transition, + # not the current state + self._state = AlarmControlPanelState(next_state) + else: + self._state = AlarmControlPanelState(state.state) + except ValueError: + return if prev_state := state.attributes.get(ATTR_PREVIOUS_STATE): self._previous_state = prev_state diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index fa9248e8f38349..80555219ed2121 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -1338,6 +1338,33 @@ async def test_restore_state(hass: HomeAssistant, expected_state) -> None: assert state.state == expected_state +async def test_restore_state_invalid(hass: HomeAssistant) -> None: + """Ensure invalid restored state does not crash entity setup.""" + mock_restore_cache(hass, (State("alarm_control_panel.test", "unknown"),)) + + hass.set_state(CoreState.starting) + mock_component(hass, "recorder") + + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + { + "alarm_control_panel": { + "platform": "manual", + "name": "test", + "arming_time": 0, + "trigger_time": 0, + "disarm_after_trigger": False, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test") + assert state + assert state.state == AlarmControlPanelState.DISARMED + + @pytest.mark.parametrize( "expected_state", [ From 266767e37df8ed4e3264648d1862f08a05f041c6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 22:44:02 +0200 Subject: [PATCH 25/39] Handle Daikin connection errors gracefully in coordinator (#171017) --- homeassistant/components/daikin/coordinator.py | 12 ++++++++++-- homeassistant/components/daikin/strings.json | 3 +++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/daikin/coordinator.py b/homeassistant/components/daikin/coordinator.py index 9bd8d17bf485ba..d7ee2730929e14 100644 --- a/homeassistant/components/daikin/coordinator.py +++ b/homeassistant/components/daikin/coordinator.py @@ -4,10 +4,11 @@ import logging from pydaikin.daikin_base import Appliance +from pydaikin.exceptions import DaikinException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, TIMEOUT_SEC @@ -33,4 +34,11 @@ def __init__( self.device = device async def _async_update_data(self) -> None: - await self.device.update_status() + try: + await self.device.update_status() + except DaikinException as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="error_communicating", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index b3326454d375b4..b7efa24a31d1ac 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -59,6 +59,9 @@ } }, "exceptions": { + "error_communicating": { + "message": "Error communicating with Daikin device: {error}" + }, "zone_hvac_mode_unsupported": { "message": "Zone temperature can only be changed when the main climate mode is heat or cool." }, From 8d66752556ea99a5052206503db936bc9aa9ba32 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 23:25:37 +0200 Subject: [PATCH 26/39] Fix shorthand template conditions in choose blocks crashing all automations (#171018) --- homeassistant/helpers/condition.py | 7 ++++++- tests/helpers/test_condition.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index f36d8d5db9be83..648e25bfbc59dd 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1722,9 +1722,14 @@ def state_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType async def async_validate_condition_config( - hass: HomeAssistant, config: ConfigType + hass: HomeAssistant, config: ConfigType | str ) -> ConfigType: """Validate config.""" + if isinstance(config, str): + config = { + CONF_CONDITION: "template", + CONF_VALUE_TEMPLATE: cv.dynamic_template(config), + } condition_key: str = config[CONF_CONDITION] if condition_key in ("and", "not", "or"): diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 2381e0c2fca41d..490030049c6fb9 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -743,6 +743,25 @@ async def test_or_condition_shorthand(hass: HomeAssistant) -> None: assert test.async_check() +async def test_shorthand_template_condition_in_or(hass: HomeAssistant) -> None: + """Test shorthand template condition inside or block doesn't crash.""" + config = { + "condition": "or", + "conditions": [ + '{{ states("sensor.test") == "on" }}', + {"condition": "state", "entity_id": "sensor.other", "state": "on"}, + ], + } + config = await condition.async_validate_condition_config(hass, config) + assert config["conditions"][0]["condition"] == "template" + + # Verify the condition can actually be evaluated at runtime + test = await condition.async_from_config(hass, config) + hass.states.async_set("sensor.test", "on") + hass.states.async_set("sensor.other", "off") + assert test.async_check() + + async def test_not_condition(hass: HomeAssistant) -> None: """Test the 'not' condition.""" config = { From 6b15f9a2ece688c9f8804bf4740723b039a29b27 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 18 May 2026 09:49:37 +0300 Subject: [PATCH 27/39] Add additional overrides to cover entity in Overkiz (#171019) --- homeassistant/components/overkiz/cover.py | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index eda18cb5a9e513..c04d1acf36654d 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -253,6 +253,41 @@ class OverkizCoverDescription(CoverEntityDescription): is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, stop_command=OverkizCommand.STOP, ), + # Needs override since OpenCloseGateWithPedestrianPosition reports + # core:OpenClosedPedestrianState instead of core:OpenClosedState + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_GATE_WITH_PEDESTRIAN_POSITION, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + stop_command=OverkizCommand.STOP, + ), + # Needs override since OpenCloseSlidingGateWithPedestrianPosition reports + # core:OpenClosedPedestrianState instead of core:OpenClosedState + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_SLIDING_GATE_WITH_PEDESTRIAN_POSITION, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + stop_command=OverkizCommand.STOP, + ), + # Needs override since PositionableGateWithPedestrianPosition reports + # core:OpenClosedPedestrianState instead of core:OpenClosedState + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.POSITIONABLE_GATE_WITH_PEDESTRIAN_POSITION, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + stop_command=OverkizCommand.STOP, + ), # Needs override to support this Generic device (rts:GenericRTSComponent) # uiClass is Generic (not mapped to cover as this is a Generic device class) OverkizCoverDescription( @@ -362,6 +397,9 @@ class OverkizCoverDescription(CoverEntityDescription): close_command=OverkizCommand.CLOSE, is_closed_state=OverkizState.CORE_OPEN_CLOSED, stop_command=OverkizCommand.STOP, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + stop_tilt_command=OverkizCommand.STOP, ), OverkizCoverDescription( key=UIClass.SCREEN, From 8e1a04dc8249c76a5f480176f5c19b45f1bd96f3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 17 May 2026 22:41:26 +0200 Subject: [PATCH 28/39] Fix Verisure alarm crash when cloud rejects arm/disarm command (#171024) --- homeassistant/components/verisure/alarm_control_panel.py | 9 +++++++++ homeassistant/components/verisure/strings.json | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index a01f608bc42c63..33410ca1a3010a 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -11,6 +11,7 @@ CodeFormat, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -65,6 +66,12 @@ async def _async_set_arm_state( self.coordinator.verisure.request, command_data ) LOGGER.debug("Verisure set arm state %s", state) + if arm_state is None or "data" not in arm_state: + await self.coordinator.async_refresh() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="arm_state_failed", + ) result = None attempts = 0 while result is None: @@ -79,6 +86,8 @@ async def _async_set_arm_state( list(arm_state["data"].values())[0], state ), ) + if transaction is None: + continue result = ( transaction.get("data", {}) .get("installation", {}) diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index 364f2690e78738..797a455d69f629 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -51,6 +51,11 @@ } } }, + "exceptions": { + "arm_state_failed": { + "message": "Failed to change alarm state. Verify your code is correct and that your account is not temporarily locked." + } + }, "options": { "step": { "init": { From 51589ec2ffca5147516df324d28567754748c114 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 May 2026 08:57:44 +0200 Subject: [PATCH 29/39] Add stop command to Overkiz pergola horizontal awning covers (#171034) --- homeassistant/components/overkiz/cover.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index c04d1acf36654d..2845160ce165f0 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -70,6 +70,7 @@ class OverkizCoverDescription(CoverEntityDescription): set_position_command=OverkizCommand.SET_DEPLOYMENT, open_command=OverkizCommand.DEPLOY, close_command=OverkizCommand.UNDEPLOY, + stop_command=OverkizCommand.STOP, invert_position=False, is_closed_state=OverkizState.CORE_OPEN_CLOSED, ), @@ -80,6 +81,7 @@ class OverkizCoverDescription(CoverEntityDescription): set_position_command=OverkizCommand.SET_DEPLOYMENT, open_command=OverkizCommand.DEPLOY, close_command=OverkizCommand.UNDEPLOY, + stop_command=OverkizCommand.STOP, invert_position=False, is_closed_state=OverkizState.CORE_OPEN_CLOSED, ), From cd6c3c878ba4d120904def36b23e8e3720273814 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 May 2026 21:43:42 +0200 Subject: [PATCH 30/39] Fix WeatherFlow websocket crash when data payload is None (#171037) --- .../components/weatherflow_cloud/coordinator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index 645f5796603ef9..02b91f51bbe344 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -185,8 +185,10 @@ def _create_listen_message(self, device_id: int) -> RapidWindListenStartMessage: """Create rapid wind listen message.""" return RapidWindListenStartMessage(device_id=str(device_id)) - async def _handle_websocket_message(self, data: RapidWindWS) -> None: + async def _handle_websocket_message(self, data: RapidWindWS | None) -> None: """Handle rapid wind websocket data.""" + if data is None: + return device_id = data.device_id station_id = self.device_to_station_map[device_id] @@ -204,8 +206,12 @@ def _create_listen_message(self, device_id: int) -> ListenStartMessage: """Create observation listen message.""" return ListenStartMessage(device_id=str(device_id)) - async def _handle_websocket_message(self, data: ObservationTempestWS) -> None: + async def _handle_websocket_message( + self, data: ObservationTempestWS | None + ) -> None: """Handle observation websocket data.""" + if data is None: + return device_id = data.device_id station_id = self.device_to_station_map[device_id] From 311e5a9bd2c0600dcef5b8b27df4d187529710b8 Mon Sep 17 00:00:00 2001 From: James Nimmo Date: Mon, 18 May 2026 19:51:42 +1200 Subject: [PATCH 31/39] Bump pyIntesishome to 1.8.8 (#171041) --- homeassistant/components/intesishome/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/intesishome/manifest.json b/homeassistant/components/intesishome/manifest.json index 82c5ad0dd9b51f..314dc927f24906 100644 --- a/homeassistant/components/intesishome/manifest.json +++ b/homeassistant/components/intesishome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["pyintesishome"], "quality_scale": "legacy", - "requirements": ["pyintesishome==1.8.7"] + "requirements": ["pyintesishome==1.8.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index bc62e5f56b669b..234491387ed680 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2192,7 +2192,7 @@ pyinsteon==1.6.4 pyintelliclima==0.3.1 # homeassistant.components.intesishome -pyintesishome==1.8.7 +pyintesishome==1.8.8 # homeassistant.components.ipma pyipma==3.0.9 From ebc582c813b35b49911cee1cc9166ce2d2cbd26b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 May 2026 12:26:45 +0200 Subject: [PATCH 32/39] Return media_content_id as string in forked_daapd (#171059) --- homeassistant/components/forked_daapd/media_player.py | 6 ++++-- tests/components/forked_daapd/test_media_player.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index eb9d361504d592..2a5e0436158212 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -470,9 +470,11 @@ def is_volume_muted(self): return self._player["volume"] == 0 @property - def media_content_id(self): + def media_content_id(self) -> str | None: """Content ID of current playing media.""" - return self._player["item_id"] + if (item_id := self._player["item_id"]) == 0: + return None + return str(item_id) @property def media_content_type(self): diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index 8f0105d48d78fa..d3bbcb4ec2bdae 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -367,7 +367,7 @@ def test_master_state(hass: HomeAssistant) -> None: assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORTED_FEATURES assert not state.attributes[ATTR_MEDIA_VOLUME_MUTED] assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2 - assert state.attributes[ATTR_MEDIA_CONTENT_ID] == 12322 + assert state.attributes[ATTR_MEDIA_CONTENT_ID] == "12322" assert state.attributes[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert state.attributes[ATTR_MEDIA_DURATION] == 0.05 assert state.attributes[ATTR_MEDIA_POSITION] == 0.005 From ee734dede6e1a881bc96dfcfb9be4f4d36ff57c3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 18 May 2026 22:25:51 +0200 Subject: [PATCH 33/39] Bump aioimmich to 0.14.1 (#171138) --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index ade1a5627eb8f8..7cc16c1979a243 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "platinum", - "requirements": ["aioimmich==0.14.0"] + "requirements": ["aioimmich==0.14.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 234491387ed680..12f61e9ed75d1f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -294,7 +294,7 @@ aiohue==4.8.1 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.14.0 +aioimmich==0.14.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 831fbc11dd3b8b..f91d9aa49fd784 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -282,7 +282,7 @@ aiohue==4.8.1 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.14.0 +aioimmich==0.14.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 7ebaaf129a2d437b61f7197ec6ec8d08e587cf3b Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 19 May 2026 09:22:31 +0200 Subject: [PATCH 34/39] Fix controls for UpDownGarageDoor4T and additional 4T covers in Overkiz (#171144) --- homeassistant/components/overkiz/cover.py | 25 +++++++++ .../setup/cloud_somfy_tahoma_v2_europe.json | 37 +++++++++++++ .../overkiz/snapshots/test_cover.ambr | 54 +++++++++++++++++++ tests/components/overkiz/test_cover.py | 9 ++++ 4 files changed, 125 insertions(+) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 2845160ce165f0..107246b03f1144 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -219,6 +219,31 @@ class OverkizCoverDescription(CoverEntityDescription): open_command=OverkizCommand.CYCLE, close_command=OverkizCommand.CYCLE, ), + # Needs override since UpDownGarageDoor4T only supports the cycle command + # (rts:GarageDoor4TRTSComponent) + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.UP_DOWN_GARAGE_DOOR_4T, + device_class=CoverDeviceClass.GARAGE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since OpenCloseSlidingGarageDoor4T only supports the cycle command + # uiClass is GarageDoor + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_SLIDING_GARAGE_DOOR_4T, + device_class=CoverDeviceClass.GARAGE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), + # Needs override since OpenCloseSlidingGate4T only supports the cycle command + # uiClass is Gate + OverkizCoverDescription( + key=UIWidget.OPEN_CLOSE_SLIDING_GATE_4T, + device_class=CoverDeviceClass.GATE, + open_command=OverkizCommand.CYCLE, + close_command=OverkizCommand.CYCLE, + ), # Needs override since CyclicGarageDoor only supports the cycle command # (io:CyclicGarageOpenerIOComponent) # uiClass is GarageDoor diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json index b674741430742e..f1cdae97c88785 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json @@ -7473,6 +7473,43 @@ "oid": "a1b2c3d4-e5f6-7890-abcd-ef1234567891", "uiClass": "Gate" }, + { + "creationTime": 1676547436000, + "lastUpdateTime": 1676547436000, + "label": "RTS Garage Door 4T", + "deviceURL": "rts://1234-1234-6233/16721270", + "shortcut": false, + "controllableName": "rts:GarageDoor4TRTSComponent", + "definition": { + "commands": [ + { + "commandName": "cycle", + "nparams": 1 + } + ], + "states": [], + "dataProperties": [ + { + "value": "0", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "UpDownGarageDoor4T", + "uiProfiles": ["CyclicGarageOpener", "Cyclic"], + "uiClass": "GarageDoor", + "qualifiedName": "rts:GarageDoor4TRTSComponent", + "type": "ACTUATOR" + }, + "states": [], + "attributes": [], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "UpDownGarageDoor4T", + "type": 1, + "oid": "a1b2c3d4-e5f6-7890-abcd-ef1234567892", + "uiClass": "GarageDoor" + }, { "creationTime": 1654894302000, "lastUpdateTime": 1654894302000, diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index 48641a7ce366d2..7ab0ddbf87d24f 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -2046,6 +2046,60 @@ 'state': 'closed', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.rts_garage_door_4t-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.rts_garage_door_4t', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'rts://1234-1234-6233/16721270', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.rts_garage_door_4t-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'device_class': 'garage', + 'friendly_name': 'RTS Garage Door 4T', + 'is_closed': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.rts_garage_door_4t', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.rts_gate-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 964dcbfc7872b2..b0c60f98b58b8d 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -140,6 +140,11 @@ "rts://1234-1234-6233/16730717", "cover.rts_gate", ) +RTS_GARAGE_DOOR_4T = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "rts://1234-1234-6233/16721270", + "cover.rts_garage_door_4t", +) CYCLIC_GARAGE_DOOR = FixtureDevice( "setup/cloud_somfy_tahoma_v2_europe.json", "io://1234-1234-6233/6416929", @@ -210,6 +215,7 @@ async def test_cover_entities_snapshot( (DYNAMIC_GARAGE_DOOR_OGP, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (DYNAMIC_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (RTS_GATE_4T, SERVICE_OPEN_COVER, "cycle", [0], CoverState.OPENING), + (RTS_GARAGE_DOOR_4T, SERVICE_OPEN_COVER, "cycle", [0], CoverState.OPENING), (CYCLIC_GARAGE_DOOR, SERVICE_OPEN_COVER, "cycle", None, CoverState.OPENING), (CYCLIC_SWINGING_GATE, SERVICE_OPEN_COVER, "cycle", None, CoverState.OPENING), (SLIDING_DISCRETE_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), @@ -240,6 +246,7 @@ async def test_cover_entities_snapshot( # Cycle command is used for both open and close; device reports OPENING # since the RTS protocol has no directional feedback. (RTS_GATE_4T, SERVICE_CLOSE_COVER, "cycle", [0], CoverState.OPENING), + (RTS_GARAGE_DOOR_4T, SERVICE_CLOSE_COVER, "cycle", [0], CoverState.OPENING), (CYCLIC_GARAGE_DOOR, SERVICE_CLOSE_COVER, "cycle", None, CoverState.OPENING), (CYCLIC_SWINGING_GATE, SERVICE_CLOSE_COVER, "cycle", None, CoverState.OPENING), (SLIDING_DISCRETE_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), @@ -352,6 +359,7 @@ async def test_cover_entities_snapshot( "open-dynamic-garage-door-ogp", "open-dynamic-gate", "open-rts-gate-4t", + "open-rts-garage-door-4t", "open-cyclic-garage-door", "open-cyclic-swinging-gate", "open-sliding-discrete-gate", @@ -368,6 +376,7 @@ async def test_cover_entities_snapshot( "close-dynamic-garage-door-ogp", "close-dynamic-gate", "close-rts-gate-4t", + "close-rts-garage-door-4t", "close-cyclic-garage-door", "close-cyclic-swinging-gate", "close-sliding-discrete-gate", From e8295e14b1547527badefc85d8eb5e12fa8a54bd Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 19 May 2026 03:14:48 -0400 Subject: [PATCH 35/39] Fix ZHA config entries using a URI without a port (#171164) --- homeassistant/components/zha/__init__.py | 37 +++++++++++- homeassistant/components/zha/config_flow.py | 10 +++- homeassistant/components/zha/const.py | 2 + homeassistant/components/zha/helpers.py | 25 --------- .../zha/snapshots/test_diagnostics.ambr | 2 +- tests/components/zha/test_init.py | 56 ++++++++----------- 6 files changed, 70 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 335a0939b05137..28bca8f634a1ea 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo import voluptuous as vol +from yarl import URL from zha.application.const import BAUD_RATES, RadioType from zha.application.gateway import Gateway from zha.application.helpers import ZHAData @@ -32,6 +33,7 @@ from homeassistant.helpers.typing import ConfigType from . import homeassistant_hardware, repairs, websocket_api +from .config_flow import ZhaConfigFlowHandler from .const import ( CONF_BAUDRATE, CONF_CUSTOM_QUIRKS_PATH, @@ -43,6 +45,7 @@ CONF_ZIGPY, DATA_ZHA, DOMAIN, + LEGACY_ZEROCONF_PORT, ) from .helpers import ( SIGNAL_ADD_ENTITIES, @@ -301,7 +304,18 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if (config_entry.version, config_entry.minor_version) > ( + ZhaConfigFlowHandler.VERSION, + ZhaConfigFlowHandler.MINOR_VERSION, + ): + # This means the user has downgraded from a future version + return False if config_entry.version == 1: data = { @@ -361,5 +375,24 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> version=5, ) - _LOGGER.info("Migration to version %s successful", config_entry.version) + if config_entry.version == 5 and config_entry.minor_version < 2: + data = {**config_entry.data, CONF_DEVICE: {**config_entry.data[CONF_DEVICE]}} + device_path = data[CONF_DEVICE][CONF_DEVICE_PATH] + + if device_path.startswith(("socket://", "tcp://")): + url = URL(device_path) + if url.explicit_port is None: + data[CONF_DEVICE][CONF_DEVICE_PATH] = str( + url.with_port(LEGACY_ZEROCONF_PORT) + ) + + hass.config_entries.async_update_entry( + config_entry, data=data, version=5, minor_version=2 + ) + + _LOGGER.info( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) return True diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 12472fc990da9b..ae5fc6fdb78cf4 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -47,7 +47,13 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util -from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN +from .const import ( + CONF_BAUDRATE, + CONF_FLOW_CONTROL, + CONF_RADIO_TYPE, + DOMAIN, + LEGACY_ZEROCONF_PORT, +) from .helpers import get_config_entry_unique_id, get_zha_gateway from .radio_manager import ( DEVICE_SCHEMA, @@ -89,7 +95,6 @@ REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/" -LEGACY_ZEROCONF_PORT = 6638 LEGACY_ZEROCONF_ESPHOME_API_PORT = 6053 ZEROCONF_SERVICE_TYPE = "_zigbee-coordinator._tcp.local." @@ -760,6 +765,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 5 + MINOR_VERSION = 2 async def _set_unique_id_and_update_ignored_flow( self, unique_id: str, device_path: str diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 0428e6f16609fd..b50a67898e8ee3 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -64,6 +64,8 @@ DOMAIN = "zha" +LEGACY_ZEROCONF_PORT = 6638 + GROUP_ID = "group_id" diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 09705321b41b1c..91f24ea14d10a8 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -1265,19 +1265,6 @@ def async_add_entities( entities.clear() -def _clean_serial_port_path(path: str) -> str: - """Clean the serial port path, applying corrections where necessary.""" - - if path.startswith("socket://"): - path = path.strip() - - # Removes extraneous brackets from IP addresses (they don't parse in CPython 3.11.4) - if re.match(r"^socket://\[\d+\.\d+\.\d+\.\d+\]:\d+$", path): - path = path.replace("[", "").replace("]", "") - - return path - - CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( { vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): vol.All( @@ -1316,18 +1303,6 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData: assert ha_zha_data.config_entry is not None assert ha_zha_data.yaml_config is not None - # Remove brackets around IP addresses, this no longer works in CPython 3.11.4 - # This will be removed in 2023.11.0 - path = ha_zha_data.config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - cleaned_path = _clean_serial_port_path(path) - - if path != cleaned_path: - _LOGGER.debug("Cleaned serial port path %r -> %r", path, cleaned_path) - ha_zha_data.config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path - hass.config_entries.async_update_entry( - ha_zha_data.config_entry, data=ha_zha_data.config_entry.data - ) - # deep copy the yaml config to avoid modifying the original and to safely # pass it to the ZHA library app_config = copy.deepcopy(ha_zha_data.yaml_config.get(CONF_ZIGPY, {})) diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 89ae58d2bc54a5..6ebee4ee365aea 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -100,7 +100,7 @@ 'discovery_keys': dict({ }), 'domain': 'zha', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ 'custom_configuration': dict({ 'zha_alarm_options': dict({ diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 50a5db9710032a..9701f645c2c1a9 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -4,7 +4,7 @@ from collections.abc import Callable import logging import typing -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, patch import zoneinfo import pytest @@ -141,52 +141,44 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: @pytest.mark.parametrize( - ("path", "cleaned_path"), + ("old_path", "new_path"), [ - # No corrections - ("/dev/path1", "/dev/path1"), - ("/dev/path1[asd]", "/dev/path1[asd]"), - ("/dev/path1 ", "/dev/path1 "), + ("/dev/ttyUSB0", "/dev/ttyUSB0"), ("socket://1.2.3.4:5678", "socket://1.2.3.4:5678"), - # Brackets around URI - ("socket://[1.2.3.4]:5678", "socket://1.2.3.4:5678"), - # Spaces - ("socket://dev/path1 ", "socket://dev/path1"), - # Both - ("socket://[1.2.3.4]:5678 ", "socket://1.2.3.4:5678"), + ("socket://1.2.3.4", "socket://1.2.3.4:6638"), + ("tcp://hostname", "tcp://hostname:6638"), + ("tcp://hostname:1234", "tcp://hostname:1234"), + ("socket://[::1]", "socket://[::1]:6638"), ], ) -@patch( - "homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True) -) -async def test_setup_with_v3_cleaning_uri( +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_v5_explicit_socket_port( + old_path: str, + new_path: str, hass: HomeAssistant, - path: str, - cleaned_path: str, - mock_zigpy_connect: ControllerApplication, + config_entry: MockConfigEntry, ) -> None: - """Test migration of config entry from v3, applying corrections to the port path.""" - config_entry_v4 = MockConfigEntry( - domain=DOMAIN, + """Test that socket:// and tcp:// paths get an explicit default port.""" + config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + config_entry, data={ - CONF_RADIO_TYPE: DATA_RADIO_TYPE, + **config_entry.data, CONF_DEVICE: { - CONF_DEVICE_PATH: path, - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, + **config_entry.data[CONF_DEVICE], + CONF_DEVICE_PATH: old_path, }, }, version=5, + minor_version=1, ) - config_entry_v4.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_v4.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - await hass.config_entries.async_unload(config_entry_v4.entry_id) - assert config_entry_v4.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE - assert config_entry_v4.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path - assert config_entry_v4.version == 5 + assert config_entry.version == 5 + assert config_entry.minor_version == 2 + assert config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] == new_path @pytest.mark.parametrize( From 1e90882918284b05d66cf1bac51c9de528d9bdf5 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 18 May 2026 13:41:28 +0200 Subject: [PATCH 36/39] Fix is_closed state and position for DynamicPergola covers in Overkiz (#170983) --- homeassistant/components/overkiz/cover.py | 25 ++- .../setup/cloud_somfy_tahoma_v2_europe.json | 211 ++++++++++++++++++ .../overkiz/snapshots/test_cover.ambr | 108 +++++++++ tests/components/overkiz/test_cover.py | 56 ++++- 4 files changed, 393 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 107246b03f1144..840e88a62e896e 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -162,6 +162,22 @@ class OverkizCoverDescription(CoverEntityDescription): close_tilt_command_args=(15, 1), # position (1-127), speed (1-15) stop_tilt_command=OverkizCommand.STOP, ), + # Needs override since BioclimaticPergola uses core:SlatsOpenClosedState + # and core:SlateOrientationState (tilt-only, no position) + # uiClass is Pergola + OverkizCoverDescription( + key=UIWidget.BIOCLIMATIC_PERGOLA, + device_class=CoverDeviceClass.AWNING, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_SLATS_OPEN_CLOSED, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, + open_tilt_command=OverkizCommand.OPEN_SLATS, + close_tilt_command=OverkizCommand.CLOSE_SLATS, + stop_tilt_command=OverkizCommand.STOP, + ), # Needs override since PositionableGarageDoor reports # core:OpenClosedUnknownState instead of core:OpenClosedState # uiClass is GarageDoor @@ -405,15 +421,12 @@ class OverkizCoverDescription(CoverEntityDescription): OverkizCoverDescription( key=UIClass.PERGOLA, device_class=CoverDeviceClass.AWNING, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, open_command=OverkizCommand.OPEN, close_command=OverkizCommand.CLOSE, stop_command=OverkizCommand.STOP, - is_closed_state=OverkizState.CORE_SLATS_OPEN_CLOSED, - current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, - set_tilt_position_command=OverkizCommand.SET_ORIENTATION, - open_tilt_command=OverkizCommand.OPEN_SLATS, - close_tilt_command=OverkizCommand.CLOSE_SLATS, - stop_tilt_command=OverkizCommand.STOP, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, ), OverkizCoverDescription( key=UIClass.ROLLER_SHUTTER, diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json index f1cdae97c88785..0d400bb06a3e3d 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json @@ -8342,6 +8342,217 @@ "widget": "DynamicGate", "oid": "a8d3e9f1-4b2c-4d5e-8f6a-1234567890ab", "uiClass": "Gate" + }, + { + "creationTime": 1722449164000, + "lastUpdateTime": 1722449164000, + "label": "Somfy Pergola", + "deviceURL": "ogp://1234-1234-6233/14356699", + "shortcut": false, + "controllableName": "ogp:Pergola", + "definition": { + "commands": [ + { + "commandName": "close", + "nparams": 0 + }, + { + "commandName": "open", + "nparams": 0 + }, + { + "commandName": "setClosure", + "nparams": 1 + }, + { + "commandName": "stop", + "nparams": 0 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ClosureState" + }, + { + "type": "DiscreteState", + "values": ["closed", "open"], + "qualifiedName": "core:OpenClosedState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + } + ], + "dataProperties": [], + "widgetName": "DynamicPergola", + "uiProfiles": [ + "StatefulCloseable", + "Closeable", + "StatefulOpenClose", + "OpenClose" + ], + "uiClass": "Pergola", + "qualifiedName": "ogp:Pergola", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:AvailabilityState", + "type": 3, + "value": "available" + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:OpenClosedState", + "type": 3, + "value": "closed" + }, + { + "name": "core:ClosureState", + "type": 1, + "value": 100 + } + ], + "attributes": [ + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "DynamicPergola", + "type": 1, + "oid": "60df5650-2d35-4fae-bfff-787521598e7f", + "uiClass": "Pergola" + }, + { + "creationTime": 1633183247000, + "lastUpdateTime": 1633183247000, + "label": "Pergola Awning", + "deviceURL": "io://1234-1234-6233/11447718", + "shortcut": false, + "controllableName": "io:PergolaRailGuidedAwningIOComponent", + "definition": { + "commands": [ + { + "commandName": "deploy", + "nparams": 0 + }, + { + "commandName": "open", + "nparams": 0 + }, + { + "commandName": "setClosure", + "nparams": 1 + }, + { + "commandName": "setDeployment", + "nparams": 1 + }, + { + "commandName": "stop", + "nparams": 0 + }, + { + "commandName": "undeploy", + "nparams": 0 + } + ], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:DeploymentState" + }, + { + "type": "DiscreteState", + "values": ["false", "true"], + "qualifiedName": "core:MovingState" + }, + { + "type": "DiscreteState", + "values": ["closed", "open"], + "qualifiedName": "core:OpenClosedState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetClosureState" + } + ], + "dataProperties": [], + "widgetName": "PergolaHorizontalAwning", + "uiProfiles": [ + "StatefulSlidingPergola", + "StatefulDeployable", + "Deployable", + "DeployUndeploy", + "StatefulOpenClose", + "OpenClose" + ], + "uiClass": "Pergola", + "qualifiedName": "io:PergolaRailGuidedAwningIOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DeploymentState", + "type": 1, + "value": 20 + }, + { + "name": "core:OpenClosedState", + "type": 3, + "value": "open" + }, + { + "name": "core:TargetClosureState", + "type": 1, + "value": 20 + }, + { + "name": "core:MovingState", + "type": 6, + "value": false + } + ], + "attributes": [ + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "PergolaHorizontalAwning", + "type": 1, + "oid": "71df5650-2d35-4fae-bfff-787521598e7f", + "uiClass": "Pergola" } ], "zones": [], diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index 7ab0ddbf87d24f..cd5fab3e414a4b 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -2046,6 +2046,60 @@ 'state': 'closed', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.pergola_awning-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.pergola_awning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'io://1234-1234-6233/11447718', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.pergola_awning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 20, + 'device_class': 'awning', + 'friendly_name': 'Pergola Awning', + 'is_closed': False, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.pergola_awning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.rts_garage_door_4t-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -2261,6 +2315,60 @@ 'state': 'closed', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.somfy_pergola-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.somfy_pergola', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ogp://1234-1234-6233/14356699', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.somfy_pergola-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'awning', + 'friendly_name': 'Somfy Pergola', + 'is_closed': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.somfy_pergola', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.studio_shutter-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index b0c60f98b58b8d..5322de1d892a90 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -165,6 +165,16 @@ "ogp://1234-1234-6233/10410217", "cover.ogp_gate", ) +DYNAMIC_PERGOLA = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "ogp://1234-1234-6233/14356699", + "cover.somfy_pergola", +) +PERGOLA_HORIZONTAL_AWNING = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "io://1234-1234-6233/11447718", + "cover.pergola_awning", +) SNAPSHOT_FIXTURES = [ AWNING, @@ -220,6 +230,14 @@ async def test_cover_entities_snapshot( (CYCLIC_SWINGING_GATE, SERVICE_OPEN_COVER, "cycle", None, CoverState.OPENING), (SLIDING_DISCRETE_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (PARTIAL_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), + (DYNAMIC_PERGOLA, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), + ( + PERGOLA_HORIZONTAL_AWNING, + SERVICE_OPEN_COVER, + "deploy", + None, + CoverState.OPENING, + ), ( UP_DOWN_BIOCLIMATIC_PERGOLA, SERVICE_OPEN_COVER, @@ -251,6 +269,14 @@ async def test_cover_entities_snapshot( (CYCLIC_SWINGING_GATE, SERVICE_CLOSE_COVER, "cycle", None, CoverState.OPENING), (SLIDING_DISCRETE_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), (PARTIAL_GARAGE_DOOR, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), + (DYNAMIC_PERGOLA, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), + ( + PERGOLA_HORIZONTAL_AWNING, + SERVICE_CLOSE_COVER, + "undeploy", + None, + CoverState.CLOSING, + ), ( UP_DOWN_BIOCLIMATIC_PERGOLA, SERVICE_CLOSE_COVER, @@ -276,6 +302,14 @@ async def test_cover_entities_snapshot( (DYNAMIC_GATE, SERVICE_STOP_COVER, "stop", None, CoverState.OPEN), (SLIDING_DISCRETE_GATE, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (PARTIAL_GARAGE_DOOR, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), + (DYNAMIC_PERGOLA, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), + ( + PERGOLA_HORIZONTAL_AWNING, + SERVICE_STOP_COVER, + "stop", + None, + CoverState.OPEN, + ), ( UP_DOWN_BIOCLIMATIC_PERGOLA, SERVICE_STOP_COVER, @@ -364,6 +398,8 @@ async def test_cover_entities_snapshot( "open-cyclic-swinging-gate", "open-sliding-discrete-gate", "open-partial-garage-door", + "open-dynamic-pergola", + "open-pergola-horizontal-awning", "open-up-down-bioclimatic-pergola", "open-tilt-only-venetian-blind", "open-venetian-blind-rts", @@ -381,6 +417,8 @@ async def test_cover_entities_snapshot( "close-cyclic-swinging-gate", "close-sliding-discrete-gate", "close-partial-garage-door", + "close-dynamic-pergola", + "close-pergola-horizontal-awning", "close-up-down-bioclimatic-pergola", "close-tilt-only-venetian-blind", "close-venetian-blind-rts", @@ -394,6 +432,8 @@ async def test_cover_entities_snapshot( "stop-dynamic-gate", "stop-sliding-discrete-gate", "stop-partial-garage-door", + "stop-dynamic-pergola", + "stop-pergola-horizontal-awning", "stop-up-down-bioclimatic-pergola", "stop-tilt-only-venetian-blind", "open-tilt-tilt-only-venetian-blind", @@ -458,8 +498,22 @@ async def test_cover_service_actions( [65, OverkizCommandParam.LOWSPEED], 35, ), + (DYNAMIC_PERGOLA, DYNAMIC_PERGOLA.entity_id, "setClosure", [60], 40), + ( + PERGOLA_HORIZONTAL_AWNING, + PERGOLA_HORIZONTAL_AWNING.entity_id, + "setDeployment", + [80], + 80, + ), + ], + ids=[ + "roller-shutter", + "awning", + "low-speed", + "dynamic-pergola", + "pergola-horizontal-awning", ], - ids=["roller-shutter", "awning", "low-speed"], ) async def test_cover_set_position( hass: HomeAssistant, From dc9116a7a7c75ea7b50446d4c286ea7e6ba61182 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 18 May 2026 14:51:26 +0200 Subject: [PATCH 37/39] Fix tilt and position support for VenetianBlind covers in Overkiz (#170974) --- homeassistant/components/overkiz/cover.py | 5 + .../setup/cloud_somfy_tahoma_v2_europe.json | 306 ++++++++++++ .../setup/local_somfy_tahoma_v2_europe.json | 470 ++++++++++++++++++ .../overkiz/snapshots/test_cover.ambr | 110 ++++ tests/components/overkiz/test_cover.py | 79 +++ 5 files changed, 970 insertions(+) diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 840e88a62e896e..425c9099f54553 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -474,9 +474,14 @@ class OverkizCoverDescription(CoverEntityDescription): OverkizCoverDescription( key=UIClass.VENETIAN_BLIND, device_class=CoverDeviceClass.BLIND, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, open_command=OverkizCommand.OPEN, close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, is_closed_state=OverkizState.CORE_OPEN_CLOSED, + current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION, + set_tilt_position_command=OverkizCommand.SET_ORIENTATION, open_tilt_command=OverkizCommand.TILT_UP, close_tilt_command=OverkizCommand.TILT_DOWN, stop_tilt_command=OverkizCommand.STOP, diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json index 0d400bb06a3e3d..26d524ea471e26 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json @@ -8553,6 +8553,312 @@ "type": 1, "oid": "71df5650-2d35-4fae-bfff-787521598e7f", "uiClass": "Pergola" + }, + { + "label": "Bedroom Venetian Blind", + "uiClass": "VenetianBlind", + "deviceURL": "ogp://1234-1234-6233/16730100", + "shortcut": false, + "controllableName": "ogp:VenetianBlind", + "creationTime": 1773580989000, + "lastUpdateTime": 1773580989000, + "definition": { + "commands": [ + { + "commandName": "close", + "nparams": 0 + }, + { + "commandName": "goToAlias", + "nparams": 1 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "open", + "nparams": 0 + }, + { + "commandName": "saveAlias", + "nparams": 1 + }, + { + "commandName": "setClosure", + "nparams": 1 + }, + { + "commandName": "setName", + "nparams": 1 + }, + { + "commandName": "setOrientation", + "nparams": 1 + }, + { + "commandName": "setTilt", + "nparams": 1 + }, + { + "commandName": "stop", + "nparams": 0 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "core:ActivePartitionState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:AvailabilityState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ClosureState" + }, + { + "eventBased": true, + "type": "DiscreteState", + "values": [ + "accessLimitation", + "internal", + "obstacleProtection", + "transportBusy", + "deviceNoAnswer", + "cancelled", + "deprecated", + "interrupted", + "thermalProtection", + "maintenanceRequired", + "alreadyUpToDate", + "notSupported", + "hardwareError", + "accessDenied", + "updateError", + "resourceAlreadyExist" + ], + "qualifiedName": "core:ErrorEventState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorsState" + }, + { + "type": "DataState", + "qualifiedName": "core:FirmwareHashState" + }, + { + "type": "DataState", + "qualifiedName": "core:FirmwareRevisionState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DataState", + "qualifiedName": "core:NewFirmwareAvailableState" + }, + { + "type": "DiscreteState", + "values": ["closed", "open"], + "qualifiedName": "core:OpenClosedState" + }, + { + "type": "DataState", + "qualifiedName": "core:ReachedAliasesState" + }, + { + "type": "DataState", + "qualifiedName": "core:RemovableState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:SignalQualityState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:SlateOrientationState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TiltState" + } + ], + "dataProperties": [], + "widgetName": "DynamicVenetianBlind", + "uiProfiles": [ + "StatefulOrientableShutter", + "StatefulOrientablePlusCloseable", + "OrientablePlusCloseable", + "StatefulOpenClose", + "OpenClose", + "StatefulCloseable", + "Closeable" + ], + "uiClass": "VenetianBlind", + "qualifiedName": "ogp:VenetianBlind", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "Bedroom Venetian Blind" + }, + { + "name": "core:AvailabilityState", + "type": 3, + "value": "available" + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:ErrorsState", + "type": 10, + "value": [] + }, + { + "name": "core:FirmwareRevisionState", + "type": 3, + "value": "5128194A13" + }, + { + "name": "core:FirmwareHashState", + "type": 3, + "value": "5128194A13" + }, + { + "name": "core:OpenClosedState", + "type": 3, + "value": "closed" + }, + { + "name": "core:ClosureState", + "type": 1, + "value": 100 + }, + { + "name": "core:TiltState", + "type": 1, + "value": 38 + }, + { + "name": "core:SlateOrientationState", + "type": 1, + "value": 38 + } + ], + "attributes": [ + { + "name": "core:SubType", + "type": 3, + "value": "outdoor" + }, + { + "name": "core:ManufacturerReference", + "type": 3, + "value": "venetianBlind" + }, + { + "name": "core:Technology", + "type": 3, + "value": "io2way" + }, + { + "name": "core:SupportedAliases", + "type": 10, + "value": [ + { + "id": "6", + "type": "favorite1", + "features": ["tiltPosition", "openClosePosition"] + }, + { + "id": "4", + "type": "favorite1", + "features": ["tiltPosition", "openClosePosition"] + }, + { + "id": "1", + "type": "favorite1", + "features": ["openClosePosition"] + }, + { + "id": "2", + "type": "favorite1", + "features": ["tiltPosition"] + }, + { + "id": "3", + "type": "favorite1", + "features": ["openClosePosition", "tiltPosition"] + }, + { + "id": "5", + "type": "favorite1", + "features": ["openClosePosition", "tiltPosition"] + } + ] + }, + { + "name": "ogp:Features", + "type": 10, + "value": [ + { + "name": "firmware", + "commandLess": true + }, + { + "name": "openClosePosition" + }, + { + "name": "tiltPosition" + }, + { + "name": "openClose" + }, + { + "name": "stop" + }, + { + "name": "identification" + }, + { + "name": "diagnosis" + }, + { + "name": "alias" + } + ] + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + } + ], + "available": true, + "enabled": true, + "placeOID": "972455eb-4f91-4095-bf42-55c234bb6238", + "oid": "aaaaaaaa-bbbb-cccc-dddd-ffffffffffff", + "widget": "DynamicVenetianBlind", + "type": 1 } ], "zones": [], diff --git a/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_v2_europe.json b/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_v2_europe.json index 61879cc1cac9d9..4b5499d7b38f3d 100644 --- a/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_v2_europe.json +++ b/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_v2_europe.json @@ -1904,6 +1904,476 @@ "uiClass": "GarageDoor", "type": "ACTUATOR" } + }, + { + "attributes": [ + { + "name": "zigbee:Role", + "type": 3, + "value": "endDevice" + }, + { + "name": "core:ManufacturerId", + "type": 3, + "value": 4640 + }, + { + "name": "zigbee:NotificationEnable", + "type": 6, + "value": true + }, + { + "name": "zigbee:InputClusters", + "type": 10, + "value": ["identify", "scenes", "windowCovering"] + }, + { + "name": "core:OrientationPhysicalUpperBound", + "type": 1, + "value": 90 + }, + { + "name": "core:Compatibilities", + "type": 10, + "value": ["homekit"] + }, + { + "name": "zigbee:SomfyEndProduct", + "type": 3, + "value": "2DInteriorBlind" + }, + { + "name": "core:MacAddress", + "type": 3, + "value": "dXsY/v8Gwkw=" + }, + { + "name": "core:OrientationPhysicalLowerBound", + "type": 1, + "value": -90 + } + ], + "available": true, + "definition": { + "uiClass": "VenetianBlind", + "commands": [ + { + "commandName": "my", + "nparams": 0 + }, + { + "commandName": "setPosition", + "nparams": 1, + "paramsSig": "p1" + }, + { + "commandName": "close", + "nparams": 0 + }, + { + "commandName": "removeLockLevel", + "nparams": 1, + "paramsSig": "p1" + }, + { + "commandName": "setPositionByStep", + "nparams": 2, + "paramsSig": "p1,p2" + }, + { + "commandName": "setOrientation", + "nparams": 1, + "paramsSig": "p1" + }, + { + "commandName": "advancedRefresh", + "nparams": 1, + "paramsSig": "p1" + }, + { + "commandName": "resetFactoryDefaultsAndConfiguration", + "nparams": 0 + }, + { + "commandName": "storeCurrentPositionToMy", + "nparams": 0 + }, + { + "commandName": "open", + "nparams": 0 + }, + { + "commandName": "leaveNetworkOnly", + "nparams": 0 + }, + { + "commandName": "ping", + "nparams": 0 + }, + { + "commandName": "setTiltingRangeConfiguration", + "nparams": 2, + "paramsSig": "p1,p2" + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "addLockLevel", + "nparams": 1, + "paramsSig": "p1,*p2" + }, + { + "commandName": "unbind", + "nparams": 2, + "paramsSig": "p1,p2" + }, + { + "commandName": "resetLockLevels", + "nparams": 0 + }, + { + "commandName": "setPositionNoLimit", + "nparams": 2, + "paramsSig": "p1,p2" + }, + { + "commandName": "stop", + "nparams": 0 + }, + { + "commandName": "reverseDirection", + "nparams": 0 + }, + { + "commandName": "bind", + "nparams": 2, + "paramsSig": "p1,p2" + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "setPositionLimitation", + "nparams": 1, + "paramsSig": "p1" + }, + { + "commandName": "setClosureAndOrientation", + "nparams": 2, + "paramsSig": "p1,p2" + }, + { + "commandName": "setName", + "nparams": 1, + "paramsSig": "p1" + }, + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "setClosure", + "nparams": 1, + "paramsSig": "p1" + } + ], + "type": "ACTUATOR", + "widgetName": "PositionableVenetianBlind", + "states": [ + { + "name": "core:OperationalStatusState" + }, + { + "name": "core:SlateOrientationState" + }, + { + "name": "zigbee:DirectionState" + }, + { + "name": "core:TargetClosureState" + }, + { + "name": "zigbee:EncoderLiftState" + }, + { + "name": "core:OrientationOperationalStatusState" + }, + { + "name": "core:TargetOrientationState" + }, + { + "name": "zigbee:EncoderTiltState" + }, + { + "name": "core:OpenLimitTiltState" + }, + { + "name": "core:ClosedLimitTiltState" + }, + { + "name": "zigbee:MotorRunModeState" + }, + { + "name": "core:ClosureOperationalStatusState" + }, + { + "name": "core:OpenLimitLiftState" + }, + { + "name": "zigbee:MotorRunningModeState" + }, + { + "name": "core:ErrorState" + }, + { + "name": "core:ConfigStatusState" + }, + { + "name": "zigbee:MotorLEDFeedbackState" + }, + { + "name": "zigbee:ControlTiltState" + }, + { + "name": "zigbee:ControlLiftState" + }, + { + "name": "zigbee:ReversalLiftState" + }, + { + "name": "zigbee:NetworkOnlineState" + }, + { + "name": "core:ClosureState" + }, + { + "name": "core:ClosedLimitLiftState" + }, + { + "name": "core:StatusState" + }, + { + "name": "core:DiscreteRSSILevelState" + }, + { + "name": "core:RSSILevelState" + }, + { + "name": "zigbee:LinkQualityIndicatorState" + }, + { + "name": "zigbee:ZigbeeUpdateDownloadProgressState" + }, + { + "name": "zigbee:ZigbeeUpdateState" + }, + { + "name": "core:FirmwareRevisionState" + }, + { + "name": "core:CommandLockLevelsState" + }, + { + "name": "core:ProductSoftwareVersionState" + }, + { + "name": "core:NameState" + }, + { + "name": "core:ProductHardwareVersionState" + }, + { + "name": "zigbee:PowerSourceState" + }, + { + "name": "core:ManufacturerNameState" + }, + { + "name": "core:ProductSoftwareBuildIdState" + }, + { + "name": "core:MotorBoardSoftwareVersionState" + }, + { + "name": "core:MotorBoardSoftwareBuildIdState" + }, + { + "name": "core:MotorBoardHardwareVersionState" + }, + { + "name": "core:ProductModelNameState" + } + ], + "attributes": [ + { + "name": "zigbee:Role" + }, + { + "name": "core:ManufacturerId" + }, + { + "name": "zigbee:NotificationEnable" + }, + { + "name": "zigbee:InputClusters" + }, + { + "name": "core:OrientationPhysicalUpperBound" + }, + { + "name": "core:Compatibilities" + }, + { + "name": "zigbee:SomfyEndProduct" + }, + { + "name": "core:MacAddress" + }, + { + "name": "core:OrientationPhysicalLowerBound" + }, + { + "name": "zigbee:OutputClusters" + } + ] + }, + "controllableName": "zigbee:SomfyVenetianBlindComponent", + "deviceURL": "zigbee://1234-5678-3293/16730099", + "states": [ + { + "name": "core:CommandLockLevelsState", + "type": 11, + "value": [] + }, + { + "name": "core:ClosureOperationalStatusState", + "type": 3, + "value": "not moving" + }, + { + "name": "core:OrientationOperationalStatusState", + "type": 3, + "value": "not moving" + }, + { + "name": "core:OperationalStatusState", + "type": 3, + "value": "not moving" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "verylow" + }, + { + "name": "zigbee:LinkQualityIndicatorState", + "type": 1, + "value": 54 + }, + { + "name": "core:RSSILevelState", + "type": 1, + "value": -86 + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:SlateOrientationState", + "type": 1, + "value": 0 + }, + { + "name": "core:ClosureState", + "type": 1, + "value": 100 + }, + { + "name": "zigbee:MotorRunningModeState", + "type": 3, + "value": "motor is running normally" + }, + { + "name": "zigbee:MotorLEDFeedbackState", + "type": 3, + "value": "LEDs are off" + }, + { + "name": "zigbee:DirectionState", + "type": 3, + "value": "counterClockwise" + }, + { + "name": "zigbee:MotorRunModeState", + "type": 3, + "value": "run in normal mode" + }, + { + "name": "zigbee:EncoderTiltState", + "type": 3, + "value": "encoder controlled" + }, + { + "name": "core:ConfigStatusState", + "type": 3, + "value": "operational" + }, + { + "name": "zigbee:ControlLiftState", + "type": 3, + "value": "lift control is closed loop" + }, + { + "name": "zigbee:ReversalLiftState", + "type": 3, + "value": "not reversal lift commands" + }, + { + "name": "zigbee:EncoderLiftState", + "type": 3, + "value": "encoder controlled" + }, + { + "name": "zigbee:NetworkOnlineState", + "type": 3, + "value": "online" + }, + { + "name": "zigbee:ControlTiltState", + "type": 3, + "value": "tilt control is closed loop" + }, + { + "name": "core:NameState", + "type": 3, + "value": "Living Room Venetian Blind" + }, + { + "name": "core:TargetOrientationState", + "type": 1, + "value": 0 + }, + { + "name": "core:TargetClosureState", + "type": 1, + "value": 100 + }, + { + "name": "core:ErrorState", + "type": 3, + "value": "no error" + } + ], + "label": "Living Room Venetian Blind", + "enabled": true, + "subsystemId": 0, + "synced": true, + "type": 1 } ] } diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index cd5fab3e414a4b..f91228669a28c0 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -1188,6 +1188,61 @@ 'state': 'closed', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.bedroom_venetian_blind-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.bedroom_venetian_blind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ogp://1234-1234-6233/16730100', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.bedroom_venetian_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'current_tilt_position': 62, + 'device_class': 'blind', + 'friendly_name': 'Bedroom Venetian Blind', + 'is_closed': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.bedroom_venetian_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][cover.bedroom_window-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -4053,6 +4108,61 @@ 'state': 'unknown', }) # --- +# name: test_cover_entities_snapshot[local_somfy_tahoma_v2_europe.json][cover.living_room_venetian_blind-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': 'cover', + 'entity_category': None, + 'entity_id': 'cover.living_room_venetian_blind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'zigbee://1234-5678-3293/16730099', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[local_somfy_tahoma_v2_europe.json][cover.living_room_venetian_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'current_tilt_position': 100, + 'device_class': 'blind', + 'friendly_name': 'Living Room Venetian Blind', + 'is_closed': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.living_room_venetian_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_cover_entities_snapshot[local_somfy_tahoma_v2_europe.json][cover.office_screen-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 5322de1d892a90..fde3c0ddb2ef0f 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -175,6 +175,16 @@ "io://1234-1234-6233/11447718", "cover.pergola_awning", ) +DYNAMIC_VENETIAN_BLIND = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "ogp://1234-1234-6233/16730100", + "cover.bedroom_venetian_blind", +) +POSITIONABLE_VENETIAN_BLIND = FixtureDevice( + "setup/local_somfy_tahoma_v2_europe.json", + "zigbee://1234-5678-3293/16730099", + "cover.living_room_venetian_blind", +) SNAPSHOT_FIXTURES = [ AWNING, @@ -248,6 +258,13 @@ async def test_cover_entities_snapshot( (TILT_ONLY_VENETIAN_BLIND, SERVICE_OPEN_COVER, "open", [0], CoverState.OPENING), (UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER, "open", [0], CoverState.OPENING), (UP_DOWN_SHEER_SCREEN, SERVICE_OPEN_COVER, "open", [0], CoverState.OPENING), + ( + DYNAMIC_VENETIAN_BLIND, + SERVICE_OPEN_COVER, + "open", + None, + CoverState.OPENING, + ), (SHUTTER, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), (AWNING, SERVICE_CLOSE_COVER, "undeploy", None, CoverState.CLOSING), (GARAGE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), @@ -293,6 +310,13 @@ async def test_cover_entities_snapshot( ), (UP_DOWN_VENETIAN_BLIND, SERVICE_CLOSE_COVER, "close", [0], CoverState.CLOSING), (UP_DOWN_SHEER_SCREEN, SERVICE_CLOSE_COVER, "close", [0], CoverState.CLOSING), + ( + DYNAMIC_VENETIAN_BLIND, + SERVICE_CLOSE_COVER, + "close", + None, + CoverState.CLOSING, + ), (SHUTTER, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (AWNING, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), (GARAGE, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED), @@ -318,6 +342,13 @@ async def test_cover_entities_snapshot( STATE_UNKNOWN, ), (TILT_ONLY_VENETIAN_BLIND, SERVICE_STOP_COVER, "stop", [0], STATE_UNKNOWN), + ( + DYNAMIC_VENETIAN_BLIND, + SERVICE_STOP_COVER, + "stop", + None, + CoverState.CLOSED, + ), ( TILT_ONLY_VENETIAN_BLIND, SERVICE_OPEN_COVER_TILT, @@ -404,6 +435,7 @@ async def test_cover_entities_snapshot( "open-tilt-only-venetian-blind", "open-venetian-blind-rts", "open-sheer-screen-rts", + "open-dynamic-venetian-blind", "close-roller-shutter", "close-awning", "close-garage-door", @@ -423,6 +455,7 @@ async def test_cover_entities_snapshot( "close-tilt-only-venetian-blind", "close-venetian-blind-rts", "close-sheer-screen-rts", + "close-dynamic-venetian-blind", "stop-roller-shutter", "stop-awning", "stop-garage-door", @@ -436,6 +469,7 @@ async def test_cover_entities_snapshot( "stop-pergola-horizontal-awning", "stop-up-down-bioclimatic-pergola", "stop-tilt-only-venetian-blind", + "stop-dynamic-venetian-blind", "open-tilt-tilt-only-venetian-blind", "close-tilt-tilt-only-venetian-blind", "stop-tilt-tilt-only-venetian-blind", @@ -506,6 +540,13 @@ async def test_cover_service_actions( [80], 80, ), + ( + DYNAMIC_VENETIAN_BLIND, + DYNAMIC_VENETIAN_BLIND.entity_id, + "setClosure", + [75], + 25, + ), ], ids=[ "roller-shutter", @@ -513,6 +554,7 @@ async def test_cover_service_actions( "low-speed", "dynamic-pergola", "pergola-horizontal-awning", + "dynamic-venetian-blind", ], ) async def test_cover_set_position( @@ -543,6 +585,43 @@ async def test_cover_set_position( ) +async def test_is_closed_falls_back_to_position( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test is_closed derives from position when OpenClosedState is absent.""" + await setup_overkiz_integration(fixture=POSITIONABLE_VENETIAN_BLIND.fixture) + + state = hass.states.get(POSITIONABLE_VENETIAN_BLIND.entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + + await async_deliver_events( + hass, + freezer, + mock_client, + [ + build_event( + EventName.DEVICE_STATE_CHANGED.value, + device_url=POSITIONABLE_VENETIAN_BLIND.device_url, + device_states=[ + { + "name": OverkizState.CORE_CLOSURE.value, + "type": 1, + "value": 50, + }, + ], + ) + ], + ) + + state = hass.states.get(POSITIONABLE_VENETIAN_BLIND.entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + async def test_cover_tilt_services( hass: HomeAssistant, setup_overkiz_integration: SetupOverkizIntegration, From 54aba110919c338f2c4057a073183c576820ebfc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 19 May 2026 08:39:50 +0000 Subject: [PATCH 38/39] Bump version to 2026.5.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 094418418dab46..deacb43706c17b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index 474b4a5ae1d4bc..58aaf7f0fe8401 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.5.2" +version = "2026.5.3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 0e0901993d67d1ba0abdab7927b5dcfe53a5feb6 Mon Sep 17 00:00:00 2001 From: bkobus-bbx Date: Thu, 14 May 2026 15:10:42 +0200 Subject: [PATCH 39/39] Fix blebox light temperature scaling (#170573) --- homeassistant/components/blebox/const.py | 4 ++ homeassistant/components/blebox/light.py | 51 ++++++++++++++--- tests/components/blebox/test_light.py | 71 +++++++++++++++++++++++- 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/blebox/const.py b/homeassistant/components/blebox/const.py index e9ea19223023ca..b25e880c48a491 100644 --- a/homeassistant/components/blebox/const.py +++ b/homeassistant/components/blebox/const.py @@ -13,3 +13,7 @@ DEFAULT_HOST = "192.168.0.2" DEFAULT_PORT = 80 + + +LIGHT_MAX_KELVINS = 6500 # 154 Mireds +LIGHT_MIN_KELVINS = 2700 # 370 Mireds diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 026c7ab192ae6b..e3b721e5a3640f 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +import math from typing import Any import blebox_uniapi.light @@ -22,9 +23,9 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import color as color_util from . import BleBoxConfigEntry +from .const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS from .entity import BleBoxEntity _LOGGER = logging.getLogger(__name__) @@ -59,8 +60,8 @@ async def async_setup_entry( class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): """Representation of BleBox lights.""" - _attr_min_color_temp_kelvin = 2700 # 370 Mireds - _attr_max_color_temp_kelvin = 6500 # 154 Mireds + _attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS + _attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS def __init__(self, feature: blebox_uniapi.light.Light) -> None: """Initialize a BleBox light.""" @@ -78,10 +79,43 @@ def brightness(self) -> int | None: """Return the name.""" return self._feature.brightness + def _color_temp_to_native_scale(self, x: int) -> int: + """Convert color temperature from Kelvin to native BleBox scale (0-255). + + BleBox native scale is inverted relative to Kelvin: 0=warm (2700K), 255=cold (6500K). + """ + scaled = ( + (self._attr_max_color_temp_kelvin - x) + / (self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin) + ) * 255 + # note: within the operating temperature range here the Kelvin + # scale has less "integer steps" than the native scale used + # by blebox devices. Thus we need to use rounding method that is opposite + # to the one used in _color_temp_from_native_scale in order to avoid + # temperature value jumping by one step when the temperature value is read + # back from the device + bounded = max(min(math.floor(scaled), 255), 0) + return int(bounded) + + def _color_temp_from_native_scale(self, x: int) -> int: + """Convert color temperature from native BleBox scale (0-255) to Kelvin. + + BleBox native scale is inverted relative to Kelvin: 0=warm (2700K), 255=cold (6500K). + """ + scaled = self._attr_max_color_temp_kelvin - (x / 255) * ( + self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin + ) + # note: see _color_temp_to_native_scale for explanation of rounding method + bounded = max( + min(math.ceil(scaled), self._attr_max_color_temp_kelvin), + self._attr_min_color_temp_kelvin, + ) + return int(bounded) + @property def color_temp_kelvin(self) -> int: """Return the color temperature value in Kelvin.""" - return color_util.color_temperature_mired_to_kelvin(self._feature.color_temp) + return self._color_temp_from_native_scale(self._feature.color_temp) @property def color_mode(self) -> ColorMode: @@ -139,15 +173,16 @@ async def async_turn_on(self, **kwargs: Any) -> None: effect = kwargs.get(ATTR_EFFECT) color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) rgbww = kwargs.get(ATTR_RGBWW_COLOR) + rgb = kwargs.get(ATTR_RGB_COLOR) + feature = self._feature value = feature.sensible_on_value - rgb = kwargs.get(ATTR_RGB_COLOR) if rgbw is not None: value = list(rgbw) if color_temp_kelvin is not None: value = feature.return_color_temp_with_brightness( - int(color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)), + self._color_temp_to_native_scale(color_temp_kelvin), self.brightness, ) @@ -162,9 +197,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: if brightness is not None: if self.color_mode == ColorMode.COLOR_TEMP: value = feature.return_color_temp_with_brightness( - color_util.color_temperature_kelvin_to_mired( - self.color_temp_kelvin - ), + self._color_temp_to_native_scale(self.color_temp_kelvin), brightness, ) else: diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index 58f1d80b3554aa..e921b3b5d2c6ec 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -1,13 +1,15 @@ """BleBox light entities tests.""" import logging -from unittest.mock import AsyncMock, PropertyMock +from unittest.mock import AsyncMock, MagicMock, PropertyMock import blebox_uniapi import pytest +from homeassistant.components.blebox.const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGBW_COLOR, ATTR_SUPPORTED_COLOR_MODES, @@ -329,6 +331,73 @@ def wlightbox_fixture(): return (feature, "light.my_wlightbox_wlightbox_color") +@pytest.fixture(name="wlightbox_ct") +def wlightbox_ct_fixture() -> tuple[MagicMock, str]: + """Return a default light entity mock for color temperature testing.""" + + feature = mock_feature( + "lights", + blebox_uniapi.light.Light, + unique_id="BleBox-wLightBox-1afe34e750b8-color", + full_name="wLightBox-ct", + device_class=None, + is_on=None, + supports_color=True, + supports_white=True, + white_value=None, + rgbw_hex=None, + color_mode=blebox_uniapi.light.BleboxColorMode.CT, + effect="NONE", + effect_list=["NONE", "PL", "POLICE"], + ) + product = feature.product + type(product).name = PropertyMock(return_value="My wLightBox") + type(product).model = PropertyMock(return_value="wLightBox") + return feature, "light.my_wlightbox_wlightbox_ct" + + +@pytest.mark.parametrize("kelvin_requested", [1000, 2700, 3000, 4000, 5000, 6500, 8000]) +async def test_wlightbox_on_color_temp( + hass: HomeAssistant, + wlightbox_ct: tuple[MagicMock, str], + kelvin_requested: int, +) -> None: + """Test light on with color temperature change.""" + + feature_mock, entity_id = wlightbox_ct + + # Capture the native scale value passed to the device to verify the + # conversion is correct without depending on blebox_uniapi internals. + transient_temp: int = -1 + + def return_color_temp_with_brightness(value: int, _brightness: int) -> list[int]: + nonlocal transient_temp + transient_temp = value + return [0x00, 0x39, 0xB0, 0xFF] + + def turn_on(_: list[int]) -> None: + feature_mock.is_on = True + feature_mock.color_temp = transient_temp + + feature_mock.return_color_temp_with_brightness = return_color_temp_with_brightness + feature_mock.async_on = AsyncMock(side_effect=turn_on) + + await async_setup_entity(hass, entity_id) + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {"entity_id": entity_id, ATTR_COLOR_TEMP_KELVIN: kelvin_requested}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert 0 <= transient_temp <= 255 + + kelvin_actual = state.attributes[ATTR_COLOR_TEMP_KELVIN] + assert LIGHT_MIN_KELVINS <= kelvin_actual <= LIGHT_MAX_KELVINS + + async def test_wlightbox_init( wlightbox, hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: