Fix availability in HKC for sleeping bluetooth devices (#75357)

This commit is contained in:
J. Nick Koston 2022-07-17 17:45:04 -05:00 committed by GitHub
parent 91f2550bc3
commit a8bb00f305
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 126 additions and 48 deletions

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
import datetime
from datetime import timedelta
import logging
from types import MappingProxyType
from typing import Any
@ -14,8 +14,8 @@ from aiohomekit.exceptions import (
AccessoryNotFoundError,
EncryptionError,
)
from aiohomekit.model import Accessories, Accessory
from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
from aiohomekit.model import Accessories, Accessory, Transport
from aiohomekit.model.characteristics import Characteristic
from aiohomekit.model.services import Service
from homeassistant.config_entries import ConfigEntry
@ -40,9 +40,9 @@ from .const import (
from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry
from .storage import EntityMapStorage
DEFAULT_SCAN_INTERVAL = datetime.timedelta(seconds=60)
RETRY_INTERVAL = 60 # seconds
MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3
BLE_AVAILABILITY_CHECK_INTERVAL = 1800 # seconds
_LOGGER = logging.getLogger(__name__)
@ -122,6 +122,7 @@ class HKDevice:
# If this is set polling is active and can be disabled by calling
# this method.
self._polling_interval_remover: CALLBACK_TYPE | None = None
self._ble_available_interval_remover: CALLBACK_TYPE | None = None
# Never allow concurrent polling of the same accessory or bridge
self._polling_lock = asyncio.Lock()
@ -133,9 +134,6 @@ class HKDevice:
self.watchable_characteristics: list[tuple[int, int]] = []
self.pairing.dispatcher_connect(self.process_new_events)
self.pairing.dispatcher_connect_config_changed(self.process_config_changed)
@property
def entity_map(self) -> Accessories:
"""Return the accessories from the pairing."""
@ -182,32 +180,15 @@ class HKDevice:
self.available = available
async_dispatcher_send(self.hass, self.signal_state_updated)
async def async_ensure_available(self) -> None:
"""Verify the accessory is available after processing the entity map."""
if self.available:
return
if self.watchable_characteristics and self.pollable_characteristics:
# We already tried, no need to try again
return
# We there are no watchable and not pollable characteristics,
# we need to force a connection to the device to verify its alive.
#
# This is similar to iOS's behavior for keeping alive connections
# to cameras.
#
primary = self.entity_map.accessories[0]
aid = primary.aid
iid = primary.accessory_information[CharacteristicsTypes.SERIAL_NUMBER].iid
await self.pairing.get_characteristics([(aid, iid)])
self.async_set_available_state(True)
async def async_setup(self) -> None:
"""Prepare to use a paired HomeKit device in Home Assistant."""
entity_storage: EntityMapStorage = self.hass.data[ENTITY_MAP]
pairing = self.pairing
transport = pairing.transport
entry = self.config_entry
if cache := entity_storage.get_map(self.unique_id):
self.pairing.restore_accessories_state(
cache["accessories"], cache["config_num"]
)
pairing.restore_accessories_state(cache["accessories"], cache["config_num"])
# We need to force an update here to make sure we have
# the latest values since the async_update we do in
@ -219,22 +200,47 @@ class HKDevice:
# Ideally we would know which entities we are about to add
# so we only poll those chars but that is not possible
# yet.
await self.pairing.async_populate_accessories_state(force_update=True)
try:
await self.pairing.async_populate_accessories_state(force_update=True)
except AccessoryNotFoundError:
if transport != Transport.BLE or not cache:
# BLE devices may sleep and we can't force a connection
raise
entry.async_on_unload(pairing.dispatcher_connect(self.process_new_events))
entry.async_on_unload(
pairing.dispatcher_connect_config_changed(self.process_config_changed)
)
entry.async_on_unload(
pairing.dispatcher_availability_changed(self.async_set_available_state)
)
await self.async_process_entity_map()
await self.async_ensure_available()
if not cache:
# If its missing from the cache, make sure we save it
self.async_save_entity_map()
# If everything is up to date, we can create the entities
# since we know the data is not stale.
await self.async_add_new_entities()
self.async_set_available_state(self.pairing.is_available)
self._polling_interval_remover = async_track_time_interval(
self.hass, self.async_update, DEFAULT_SCAN_INTERVAL
self.hass, self.async_update, self.pairing.poll_interval
)
if transport == Transport.BLE:
# If we are using BLE, we need to periodically check of the
# BLE device is available since we won't get callbacks
# when it goes away since we HomeKit supports disconnected
# notifications and we cannot treat a disconnect as unavailability.
self._ble_available_interval_remover = async_track_time_interval(
self.hass,
self.async_update_available_state,
timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL),
)
async def async_add_new_entities(self) -> None:
"""Add new entities to Home Assistant."""
await self.async_load_platforms()
@ -546,10 +552,15 @@ class HKDevice:
if tasks:
await asyncio.gather(*tasks)
@callback
def async_update_available_state(self, *_: Any) -> None:
"""Update the available state of the device."""
self.async_set_available_state(self.pairing.is_available)
async def async_update(self, now=None):
"""Poll state of all entities attached to this bridge/accessory."""
if not self.pollable_characteristics:
self.async_set_available_state(self.pairing.is_connected)
self.async_update_available_state()
_LOGGER.debug(
"HomeKit connection not polling any characteristics: %s", self.unique_id
)

View file

@ -3,7 +3,7 @@
"name": "HomeKit Controller",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"requirements": ["aiohomekit==1.1.4"],
"requirements": ["aiohomekit==1.1.5"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."],
"bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }],
"after_dependencies": ["zeroconf"],

View file

@ -168,7 +168,7 @@ aioguardian==2022.03.2
aioharmony==0.2.9
# homeassistant.components.homekit_controller
aiohomekit==1.1.4
aiohomekit==1.1.5
# homeassistant.components.emulated_hue
# homeassistant.components.http

View file

@ -152,7 +152,7 @@ aioguardian==2022.03.2
aioharmony==0.2.9
# homeassistant.components.homekit_controller
aiohomekit==1.1.4
aiohomekit==1.1.5
# homeassistant.components.emulated_hue
# homeassistant.components.http

View file

@ -3,15 +3,15 @@
from datetime import timedelta
from unittest.mock import patch
from aiohomekit import AccessoryDisconnectedError
from aiohomekit.model import Accessory
from aiohomekit import AccessoryNotFoundError
from aiohomekit.model import Accessory, Transport
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import ServicesTypes
from aiohomekit.testing import FakePairing
from homeassistant.components.homekit_controller.const import DOMAIN, ENTITY_MAP
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity_registry import EntityRegistry
@ -104,31 +104,35 @@ async def test_offline_device_raises(hass, controller):
is_connected = False
class OfflineFakePairing(FakePairing):
"""Fake pairing that always returns False for is_connected."""
"""Fake pairing that can flip is_connected."""
@property
def is_connected(self):
nonlocal is_connected
return is_connected
@property
def is_available(self):
return self.is_connected
async def async_populate_accessories_state(self, *args, **kwargs):
nonlocal is_connected
if not is_connected:
raise AccessoryDisconnectedError("any")
raise AccessoryNotFoundError("any")
async def get_characteristics(self, chars, *args, **kwargs):
nonlocal is_connected
if not is_connected:
raise AccessoryDisconnectedError("any")
raise AccessoryNotFoundError("any")
return {}
accessory = Accessory.create_with_info(
"TestDevice", "example.com", "Test", "0001", "0.1"
)
create_alive_service(accessory)
with patch("aiohomekit.testing.FakePairing", OfflineFakePairing):
await async_setup_component(hass, DOMAIN, {})
accessory = Accessory.create_with_info(
"TestDevice", "example.com", "Test", "0001", "0.1"
)
create_alive_service(accessory)
config_entry, _ = await setup_test_accessories_with_controller(
hass, [accessory], controller
)
@ -141,3 +145,66 @@ async def test_offline_device_raises(hass, controller):
async_fire_time_changed(hass, utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.LOADED
assert hass.states.get("light.testdevice").state == STATE_OFF
async def test_ble_device_only_checks_is_available(hass, controller):
"""Test a BLE device only checks is_available."""
is_available = False
class FakeBLEPairing(FakePairing):
"""Fake BLE pairing that can flip is_available."""
@property
def transport(self):
return Transport.BLE
@property
def is_connected(self):
return False
@property
def is_available(self):
nonlocal is_available
return is_available
async def async_populate_accessories_state(self, *args, **kwargs):
nonlocal is_available
if not is_available:
raise AccessoryNotFoundError("any")
async def get_characteristics(self, chars, *args, **kwargs):
nonlocal is_available
if not is_available:
raise AccessoryNotFoundError("any")
return {}
accessory = Accessory.create_with_info(
"TestDevice", "example.com", "Test", "0001", "0.1"
)
create_alive_service(accessory)
with patch("aiohomekit.testing.FakePairing", FakeBLEPairing):
await async_setup_component(hass, DOMAIN, {})
config_entry, _ = await setup_test_accessories_with_controller(
hass, [accessory], controller
)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.SETUP_RETRY
is_available = True
async_fire_time_changed(hass, utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.LOADED
assert hass.states.get("light.testdevice").state == STATE_OFF
is_available = False
async_fire_time_changed(hass, utcnow() + timedelta(hours=1))
assert hass.states.get("light.testdevice").state == STATE_UNAVAILABLE
is_available = True
async_fire_time_changed(hass, utcnow() + timedelta(hours=1))
assert hass.states.get("light.testdevice").state == STATE_OFF