Skip to content

Commit 8d8e008

Browse files
bdracofrenck
authored andcommitted
Fix HomeKit Controller overwhelming resource-limited devices by batching characteristic polling (home-assistant#152209)
1 parent b30667a commit 8d8e008

File tree

2 files changed

+127
-24
lines changed

2 files changed

+127
-24
lines changed

homeassistant/components/homekit_controller/connection.py

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@
5757

5858
RETRY_INTERVAL = 60 # seconds
5959
MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3
60-
60+
# HomeKit accessories have varying limits on how many characteristics
61+
# they can handle per request. Since we don't know each device's specific limit,
62+
# we batch requests to a conservative size to avoid overwhelming any device.
63+
MAX_CHARACTERISTICS_PER_REQUEST = 49
6164

6265
BLE_AVAILABILITY_CHECK_INTERVAL = 1800 # seconds
6366

@@ -326,16 +329,20 @@ async def async_setup(self) -> None:
326329
)
327330
entry.async_on_unload(self._async_cancel_subscription_timer)
328331

332+
if transport != Transport.BLE:
333+
# Although async_populate_accessories_state fetched the accessory database,
334+
# the /accessories endpoint may return cached values from the accessory's
335+
# perspective. For example, Ecobee thermostats may report stale temperature
336+
# values (like 100°C) in their /accessories response after restarting.
337+
# We need to explicitly poll characteristics to get fresh sensor readings
338+
# before processing the entity map and creating devices.
339+
# Use poll_all=True since entities haven't registered their characteristics yet.
340+
await self.async_update(poll_all=True)
341+
329342
await self.async_process_entity_map()
330343

331344
if transport != Transport.BLE:
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)
345+
# Start regular polling after entity map is processed
339346
self._async_start_polling()
340347

341348
# If everything is up to date, we can create the entities
@@ -938,20 +945,26 @@ async def async_update(
938945
async with self._polling_lock:
939946
_LOGGER.debug("Starting HomeKit device update: %s", self.unique_id)
940947

941-
try:
942-
new_values_dict = await self.get_characteristics(to_poll)
943-
except AccessoryNotFoundError:
944-
# Not only did the connection fail, but also the accessory is not
945-
# visible on the network.
946-
self.async_set_available_state(False)
947-
return
948-
except (AccessoryDisconnectedError, EncryptionError):
949-
# Temporary connection failure. Device may still available but our
950-
# connection was dropped or we are reconnecting
951-
self._poll_failures += 1
952-
if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE:
948+
new_values_dict: dict[tuple[int, int], dict[str, Any]] = {}
949+
to_poll_list = list(to_poll)
950+
951+
for i in range(0, len(to_poll_list), MAX_CHARACTERISTICS_PER_REQUEST):
952+
batch = to_poll_list[i : i + MAX_CHARACTERISTICS_PER_REQUEST]
953+
try:
954+
batch_values = await self.get_characteristics(batch)
955+
new_values_dict.update(batch_values)
956+
except AccessoryNotFoundError:
957+
# Not only did the connection fail, but also the accessory is not
958+
# visible on the network.
953959
self.async_set_available_state(False)
954-
return
960+
return
961+
except (AccessoryDisconnectedError, EncryptionError):
962+
# Temporary connection failure. Device may still available but our
963+
# connection was dropped or we are reconnecting
964+
self._poll_failures += 1
965+
if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE:
966+
self.async_set_available_state(False)
967+
return
955968

956969
self._poll_failures = 0
957970
self.process_new_events(new_values_dict)

tests/components/homekit_controller/test_connection.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
import pytest
1414

1515
from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE
16+
from homeassistant.components.homekit_controller.connection import (
17+
MAX_CHARACTERISTICS_PER_REQUEST,
18+
)
1619
from homeassistant.components.homekit_controller.const import (
1720
DEBOUNCE_COOLDOWN,
1821
DOMAIN,
@@ -377,9 +380,15 @@ def _create_accessory(accessory: Accessory) -> Service:
377380
state = await helper.poll_and_get_state()
378381
assert state.state == STATE_OFF
379382
assert mock_get_characteristics.call_count == 2
380-
# Verify everything is polled
381-
assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)}
382-
assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)}
383+
# Verify everything is polled (convert to set for comparison since batching changes the type)
384+
assert set(mock_get_characteristics.call_args_list[0][0][0]) == {
385+
(1, 10),
386+
(1, 11),
387+
}
388+
assert set(mock_get_characteristics.call_args_list[1][0][0]) == {
389+
(1, 10),
390+
(1, 11),
391+
}
383392

384393
# Test device goes offline
385394
helper.pairing.available = False
@@ -526,3 +535,84 @@ async def mock_get_characteristics(
526535
state = hass.states.get("climate.homew")
527536
assert state is not None
528537
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5
538+
539+
540+
async def test_characteristic_polling_batching(
541+
hass: HomeAssistant, get_next_aid: Callable[[], int]
542+
) -> None:
543+
"""Test that characteristic polling is batched to MAX_CHARACTERISTICS_PER_REQUEST."""
544+
545+
# Create a large accessory with many characteristics (more than 49)
546+
def create_large_accessory_with_many_chars(accessory: Accessory) -> None:
547+
"""Create an accessory with many characteristics to test batching."""
548+
# Add multiple services with many characteristics each
549+
for service_num in range(10): # 10 services
550+
service = accessory.add_service(
551+
ServicesTypes.LIGHTBULB, name=f"Light {service_num}"
552+
)
553+
# Each lightbulb service gets several characteristics
554+
service.add_char(CharacteristicsTypes.ON)
555+
service.add_char(CharacteristicsTypes.BRIGHTNESS)
556+
service.add_char(CharacteristicsTypes.HUE)
557+
service.add_char(CharacteristicsTypes.SATURATION)
558+
service.add_char(CharacteristicsTypes.COLOR_TEMPERATURE)
559+
# Set initial values
560+
for char in service.characteristics:
561+
if char.type != CharacteristicsTypes.IDENTIFY:
562+
char.value = 0
563+
564+
helper = await setup_test_component(
565+
hass, get_next_aid(), create_large_accessory_with_many_chars
566+
)
567+
568+
# Track the get_characteristics calls
569+
get_chars_calls = []
570+
original_get_chars = helper.pairing.get_characteristics
571+
572+
async def mock_get_characteristics(chars):
573+
"""Mock get_characteristics to track batch sizes."""
574+
get_chars_calls.append(list(chars))
575+
return await original_get_chars(chars)
576+
577+
# Clear any calls from setup
578+
get_chars_calls.clear()
579+
580+
# Patch get_characteristics to track calls
581+
with mock.patch.object(
582+
helper.pairing, "get_characteristics", side_effect=mock_get_characteristics
583+
):
584+
# Trigger an update through time_changed which simulates regular polling
585+
# time_changed expects seconds, not a datetime
586+
await time_changed(hass, 300) # 5 minutes in seconds
587+
await hass.async_block_till_done()
588+
589+
# We created 10 lightbulb services with 5 characteristics each = 50 total
590+
# Plus any base accessory characteristics that are pollable
591+
# This should result in exactly 2 batches
592+
assert len(get_chars_calls) == 2, (
593+
f"Should have made exactly 2 batched calls, got {len(get_chars_calls)}"
594+
)
595+
596+
# Check that no batch exceeded MAX_CHARACTERISTICS_PER_REQUEST
597+
for i, batch in enumerate(get_chars_calls):
598+
assert len(batch) <= MAX_CHARACTERISTICS_PER_REQUEST, (
599+
f"Batch {i} size {len(batch)} exceeded maximum {MAX_CHARACTERISTICS_PER_REQUEST}"
600+
)
601+
602+
# Verify the total number of characteristics polled
603+
total_chars = sum(len(batch) for batch in get_chars_calls)
604+
# Each lightbulb has: ON, BRIGHTNESS, HUE, SATURATION, COLOR_TEMPERATURE = 5
605+
# 10 lightbulbs = 50 characteristics
606+
assert total_chars == 50, (
607+
f"Should have polled exactly 50 characteristics, got {total_chars}"
608+
)
609+
610+
# The first batch should be full (49 characteristics)
611+
assert len(get_chars_calls[0]) == 49, (
612+
f"First batch should have exactly 49 characteristics, got {len(get_chars_calls[0])}"
613+
)
614+
615+
# The second batch should have exactly 1 characteristic
616+
assert len(get_chars_calls[1]) == 1, (
617+
f"Second batch should have exactly 1 characteristic, got {len(get_chars_calls[1])}"
618+
)

0 commit comments

Comments
 (0)