Skip to content

Commit 2e33222

Browse files
bdracoTheJulianJES
authored andcommitted
Fix HomeKit Controller stale values at startup (home-assistant#152086)
Co-authored-by: TheJulianJES <[email protected]>
1 parent ab1c2c4 commit 2e33222

File tree

2 files changed

+134
-7
lines changed

2 files changed

+134
-7
lines changed

homeassistant/components/homekit_controller/connection.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@
2020
EncryptionError,
2121
)
2222
from aiohomekit.model import Accessories, Accessory, Transport
23-
from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
23+
from aiohomekit.model.characteristics import (
24+
EVENT_CHARACTERISTICS,
25+
Characteristic,
26+
CharacteristicPermissions,
27+
CharacteristicsTypes,
28+
)
2429
from aiohomekit.model.services import Service, ServicesTypes
2530

2631
from homeassistant.components.thread import async_get_preferred_dataset
@@ -179,6 +184,21 @@ def remove_pollable_characteristics(
179184
for aid_iid in characteristics:
180185
self.pollable_characteristics.discard(aid_iid)
181186

187+
def get_all_pollable_characteristics(self) -> set[tuple[int, int]]:
188+
"""Get all characteristics that can be polled.
189+
190+
This is used during startup to poll all readable characteristics
191+
before entities have registered what they care about.
192+
"""
193+
return {
194+
(accessory.aid, char.iid)
195+
for accessory in self.entity_map.accessories
196+
for service in accessory.services
197+
for char in service.characteristics
198+
if CharacteristicPermissions.paired_read in char.perms
199+
and char.type not in EVENT_CHARACTERISTICS
200+
}
201+
182202
def add_watchable_characteristics(
183203
self, characteristics: list[tuple[int, int]]
184204
) -> None:
@@ -309,9 +329,13 @@ async def async_setup(self) -> None:
309329
await self.async_process_entity_map()
310330

311331
if transport != Transport.BLE:
312-
# Do a single poll to make sure the chars are
313-
# up to date so we don't restore old data.
314-
await self.async_update()
332+
# When Home Assistant starts, we restore the accessory map from storage
333+
# which contains characteristic values from when HA was last running.
334+
# These values are stale and may be incorrect (e.g., Ecobee thermostats
335+
# report 100°C when restarting). We need to poll for fresh values before
336+
# creating entities. Use poll_all=True since entities haven't registered
337+
# their characteristics yet.
338+
await self.async_update(poll_all=True)
315339
self._async_start_polling()
316340

317341
# If everything is up to date, we can create the entities
@@ -863,9 +887,25 @@ async def async_request_update(self, now: datetime | None = None) -> None:
863887
"""Request an debounced update from the accessory."""
864888
await self._debounced_update.async_call()
865889

866-
async def async_update(self, now: datetime | None = None) -> None:
867-
"""Poll state of all entities attached to this bridge/accessory."""
868-
to_poll = self.pollable_characteristics
890+
async def async_update(
891+
self, now: datetime | None = None, *, poll_all: bool = False
892+
) -> None:
893+
"""Poll state of all entities attached to this bridge/accessory.
894+
895+
Args:
896+
now: The current time (used by time interval callbacks).
897+
poll_all: If True, poll all readable characteristics instead
898+
of just the registered ones.
899+
This is useful during initial setup before entities have
900+
registered their characteristics.
901+
"""
902+
if poll_all:
903+
# Poll all readable characteristics during initial startup
904+
# excluding device trigger characteristics (buttons, doorbell, etc.)
905+
to_poll = self.get_all_pollable_characteristics()
906+
else:
907+
to_poll = self.pollable_characteristics
908+
869909
if not to_poll:
870910
self.async_update_available_state()
871911
_LOGGER.debug(

tests/components/homekit_controller/test_connection.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from collections.abc import Callable
44
import dataclasses
5+
from typing import Any
56
from unittest import mock
67

78
from aiohomekit.controller import TransportType
@@ -11,6 +12,7 @@
1112
from aiohomekit.testing import FakeController
1213
import pytest
1314

15+
from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE
1416
from homeassistant.components.homekit_controller.const import (
1517
DEBOUNCE_COOLDOWN,
1618
DOMAIN,
@@ -439,3 +441,88 @@ def _create_accessory(accessory: Accessory) -> Service:
439441
await time_changed(hass, DEBOUNCE_COOLDOWN)
440442
await hass.async_block_till_done()
441443
assert len(mock_get_characteristics.call_args_list[0][0][0]) > 1
444+
445+
446+
async def test_poll_all_on_startup_refreshes_stale_values(
447+
hass: HomeAssistant, hass_storage: dict[str, Any]
448+
) -> None:
449+
"""Test that entities get fresh values on startup instead of stale stored values."""
450+
# Load actual Ecobee accessory fixture
451+
accessories = await setup_accessories_from_file(hass, "ecobee3.json")
452+
453+
# Pre-populate storage with the accessories data (already has stale values)
454+
hass_storage["homekit_controller-entity-map"] = {
455+
"version": 1,
456+
"minor_version": 1,
457+
"key": "homekit_controller-entity-map",
458+
"data": {
459+
"pairings": {
460+
"00:00:00:00:00:00": {
461+
"config_num": 1,
462+
"accessories": [
463+
a.to_accessory_and_service_list() for a in accessories
464+
],
465+
}
466+
}
467+
},
468+
}
469+
470+
# Track what gets polled during setup
471+
polled_chars: list[tuple[int, int]] = []
472+
473+
# Set up the test accessories
474+
fake_controller = await setup_platform(hass)
475+
476+
# Mock get_characteristics to track polling and return fresh temperature
477+
async def mock_get_characteristics(
478+
chars: set[tuple[int, int]], **kwargs: Any
479+
) -> dict[tuple[int, int], dict[str, Any]]:
480+
"""Return fresh temperature value when polled."""
481+
polled_chars.extend(chars)
482+
# Return fresh values for all characteristics
483+
result: dict[tuple[int, int], dict[str, Any]] = {}
484+
for aid, iid in chars:
485+
# Find the characteristic and return appropriate value
486+
for accessory in accessories:
487+
if accessory.aid != aid:
488+
continue
489+
for service in accessory.services:
490+
for char in service.characteristics:
491+
if char.iid != iid:
492+
continue
493+
# Return fresh temperature instead of stale fixture value
494+
if char.type == CharacteristicsTypes.TEMPERATURE_CURRENT:
495+
result[(aid, iid)] = {"value": 22.5} # Fresh value
496+
else:
497+
result[(aid, iid)] = {"value": char.value}
498+
break
499+
return result
500+
501+
# Add the paired device with our mock
502+
await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00")
503+
config_entry = MockConfigEntry(
504+
version=1,
505+
domain="homekit_controller",
506+
entry_id="TestData",
507+
data={"AccessoryPairingID": "00:00:00:00:00:00"},
508+
title="test",
509+
)
510+
config_entry.add_to_hass(hass)
511+
512+
# Get the pairing and patch its get_characteristics
513+
pairing = fake_controller.pairings["00:00:00:00:00:00"]
514+
515+
with mock.patch.object(pairing, "get_characteristics", mock_get_characteristics):
516+
# Set up the config entry (this should trigger poll_all=True)
517+
await hass.config_entries.async_setup(config_entry.entry_id)
518+
await hass.async_block_till_done()
519+
520+
# Verify that polling happened during setup (poll_all=True was used)
521+
assert (
522+
len(polled_chars) == 79
523+
) # The Ecobee fixture has exactly 79 readable characteristics
524+
525+
# Check that the climate entity has the fresh temperature (22.5°C) not the stale fixture value (21.8°C)
526+
state = hass.states.get("climate.homew")
527+
assert state is not None
528+
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5

0 commit comments

Comments
 (0)