diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 90e0760da46bb2..2f45d283e12baf 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -1053,6 +1053,10 @@ async def async_start(self) -> None: self._async_cleanup() if device_info.name: reconnect_logic.name = device_info.name + # Seed the backoff cap from the restored device_info so the first + # reconnect after a restart already caps for a deep-sleep device, + # before the first live connect refreshes it. + reconnect_logic.deep_sleep = device_info.has_deep_sleep if ( bluetooth_mac_address := device_info.bluetooth_mac_address ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 4c372bb876e74d..af9d377e6c5bfb 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -14,7 +14,7 @@ "xknx==3.16.0", "xknxproject==3.9.0", "knx-frontend==2026.6.23.203726", - "knx-telegram-store[sqlite,postgres]==0.10.1" + "knx-telegram-store[sqlite,postgres]==0.10.2" ], "single_config_entry": true } diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 96617d4c2fa913..1fae7472c9b1d3 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -182,13 +182,16 @@ def _handle_coordinator_update(self) -> None: what was available when the area mapping was last configured. """ super()._handle_coordinator_update() + # Avoid creating false-alarm issues if home map info is not yet loaded + if self._home_trait.home_map_info is None: + return last_seen = self.last_seen_segments if last_seen is None: # No area mapping has been configured yet; nothing to check. return current_ids = { f"{map_flag}_{room.segment_id}" - for map_flag, map_info in (self._home_trait.home_map_info or {}).items() + for map_flag, map_info in self._home_trait.home_map_info.items() for room in map_info.rooms } if current_ids != {seg.id for seg in last_seen}: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 092976d6e3821d..56fd23d974b1bc 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -2080,7 +2080,9 @@ def _split_device_id( for successor in successors: if successor.config_entry_id == config_entry_id: return successor.id - return successors[0].id + # No split device matches the entity's config entry; detach the entity + # rather than move it to an arbitrary split device it does not belong to. + return None if data is not None: for entity in data["entities"]: diff --git a/requirements_all.txt b/requirements_all.txt index f08b2f99ffdefa..399d9e2860aef8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1441,7 +1441,7 @@ knocki==0.4.2 knx-frontend==2026.6.23.203726 # homeassistant.components.knx -knx-telegram-store[sqlite,postgres]==0.10.1 +knx-telegram-store[sqlite,postgres]==0.10.2 # homeassistant.components.kraken krakenex==2.2.2 diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 70cdd63c5fa6de..89aeb811dbc5cd 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -21,6 +21,7 @@ InvalidAuthAPIError, InvalidEncryptionKeyAPIError, LogLevel, + ReconnectLogic, RequiresEncryptionAPIError, SubDeviceInfo, SupportsResponseType, @@ -182,6 +183,33 @@ async def test_esphome_device_service_calls_not_allowed( ) in caplog.text +@pytest.mark.parametrize("has_deep_sleep", [True, False]) +async def test_reconnect_logic_seeds_deep_sleep_from_restored_device_info( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + has_deep_sleep: bool, +) -> None: + """Restored device_info seeds reconnect_logic.deep_sleep before the first connect.""" + deep_sleep_at_start: list[bool] = [] + real_start = ReconnectLogic.start + + async def _start(self: ReconnectLogic) -> None: + # Captured before the first connect refreshes it, so this is the + # pre-populated value from the restored device_info. + deep_sleep_at_start.append(self.deep_sleep) + await real_start(self) + + with patch.object(ReconnectLogic, "start", _start): + await mock_esphome_device( + mock_client=mock_client, + device_info={"has_deep_sleep": has_deep_sleep}, + mock_storage=True, + ) + + assert deep_sleep_at_start == [has_deep_sleep] + + async def test_esphome_device_service_calls_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/harbor/snapshots/test_init.ambr b/tests/components/harbor/snapshots/test_init.ambr index 101e73248cb6ba..c28770ed0675ea 100644 --- a/tests/components/harbor/snapshots/test_init.ambr +++ b/tests/components/harbor/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Nursery', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '1.2.3', 'via_device_id': None, diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 67ef48ab82d468..bfb169b7c363f5 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -1,5 +1,6 @@ """Tests for Roborock vacuums.""" +from datetime import timedelta from typing import Any from unittest.mock import Mock, call @@ -45,11 +46,12 @@ issue_registry as ir, ) from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from .conftest import FakeDevice, set_trait_attributes from .mock_data import STATUS -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.typing import WebSocketGenerator ENTITY_ID = "vacuum.roborock_s7_maxv" @@ -582,8 +584,7 @@ async def test_segments_changed_issue( }, ) - coordinator = setup_entry.runtime_data.v1[0] - await coordinator.async_refresh() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() issue_id = f"segments_changed_{entity_entry.id}" @@ -593,6 +594,37 @@ async def test_segments_changed_issue( assert issue.translation_key == "segments_changed" +async def test_segments_changed_issue_no_map_info( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + fake_vacuum: FakeDevice, +) -> None: + """Test no repair issue is created when map info is not loaded/empty.""" + entity_entry = entity_registry.async_get(ENTITY_ID) + assert entity_entry is not None + entity_registry.async_update_entity_options( + ENTITY_ID, + VACUUM_DOMAIN, + { + "last_seen_segments": [ + {"id": "1_16", "name": "Example room 1", "group": "Downstairs"}, + {"id": "1_99", "name": "Old room", "group": "Downstairs"}, + ], + }, + ) + + # Map info not loaded + fake_vacuum.v1_properties.home.home_map_info = None + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + issue_id = f"segments_changed_{entity_entry.id}" + issue = ir.async_get(hass).async_get_issue(VACUUM_DOMAIN, issue_id) + assert issue is None + + @pytest.fixture(name="q7_vacuum_api", autouse=False) def fake_q7_vacuum_api_fixture( fake_q7_vacuum: FakeDevice, diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index b69752d296036e..a2a0bc6ce62847 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -5547,7 +5547,7 @@ async def test_migration_repoints_entities( async def test_migration_repoints_entities_fallbacks( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """An entity not exactly matching a split falls back by config entry, then first split.""" + """An entity not exactly matching a split falls back by config entry, then detaches.""" entry_a = MockConfigEntry( domain="domain_a", subentries_data=[ @@ -5608,8 +5608,8 @@ async def test_migration_repoints_entities_fallbacks( assert ( entity_registry.async_get("sensor.sub").device_id == by_entry[entry_a.entry_id] ) - # No matching config entry falls back to the first split - assert entity_registry.async_get("sensor.none").device_id in {d.id for d in splits} + # No split matches the config entry, so the entity is detached + assert entity_registry.async_get("sensor.none").device_id is None @pytest.mark.parametrize("load_registries", [False])