2019-03-07 03:44:52 +00:00
|
|
|
"""Helpers for managing a pairing with a HomeKit accessory or bridge."""
|
2022-02-01 19:30:37 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2019-03-28 03:01:10 +00:00
|
|
|
import asyncio
|
2022-02-01 19:30:37 +00:00
|
|
|
from collections.abc import Callable
|
2019-07-22 16:22:44 +00:00
|
|
|
import datetime
|
2019-03-28 03:01:10 +00:00
|
|
|
import logging
|
2022-02-01 19:30:37 +00:00
|
|
|
from typing import Any
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2020-02-24 09:55:33 +00:00
|
|
|
from aiohomekit.exceptions import (
|
2019-08-14 16:14:15 +00:00
|
|
|
AccessoryDisconnectedError,
|
|
|
|
AccessoryNotFoundError,
|
|
|
|
EncryptionError,
|
|
|
|
)
|
2022-01-23 21:57:16 +00:00
|
|
|
from aiohomekit.model import Accessories, Accessory
|
2022-02-03 16:18:03 +00:00
|
|
|
from aiohomekit.model.characteristics import Characteristic
|
|
|
|
from aiohomekit.model.services import Service
|
2019-08-14 16:14:15 +00:00
|
|
|
|
2021-10-27 11:24:57 +00:00
|
|
|
from homeassistant.const import ATTR_VIA_DEVICE
|
2022-02-01 19:30:37 +00:00
|
|
|
from homeassistant.core import CALLBACK_TYPE, callback
|
2021-05-14 01:16:20 +00:00
|
|
|
from homeassistant.helpers import device_registry as dr
|
2021-10-22 15:04:25 +00:00
|
|
|
from homeassistant.helpers.entity import DeviceInfo
|
2019-07-22 16:22:44 +00:00
|
|
|
from homeassistant.helpers.event import async_track_time_interval
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2021-01-26 19:45:01 +00:00
|
|
|
from .const import (
|
|
|
|
CHARACTERISTIC_PLATFORMS,
|
|
|
|
CONTROLLER,
|
|
|
|
DOMAIN,
|
|
|
|
ENTITY_MAP,
|
|
|
|
HOMEKIT_ACCESSORY_DISPATCH,
|
2021-10-27 11:24:57 +00:00
|
|
|
IDENTIFIER_ACCESSORY_ID,
|
2022-01-23 23:00:05 +00:00
|
|
|
IDENTIFIER_LEGACY_ACCESSORY_ID,
|
|
|
|
IDENTIFIER_LEGACY_SERIAL_NUMBER,
|
2021-10-27 11:24:57 +00:00
|
|
|
IDENTIFIER_SERIAL_NUMBER,
|
2021-01-26 19:45:01 +00:00
|
|
|
)
|
2020-09-11 18:34:07 +00:00
|
|
|
from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry
|
2019-07-22 16:22:44 +00:00
|
|
|
|
|
|
|
DEFAULT_SCAN_INTERVAL = datetime.timedelta(seconds=60)
|
2019-03-28 03:01:10 +00:00
|
|
|
RETRY_INTERVAL = 60 # seconds
|
2021-05-25 16:47:28 +00:00
|
|
|
MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3
|
2019-03-28 03:01:10 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2019-03-07 03:44:52 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
AddAccessoryCb = Callable[[Accessory], bool]
|
|
|
|
AddServiceCb = Callable[[Service], bool]
|
|
|
|
AddCharacteristicCb = Callable[[Characteristic], bool]
|
|
|
|
|
2019-03-07 03:44:52 +00:00
|
|
|
|
2022-02-03 16:18:03 +00:00
|
|
|
def valid_serial_number(serial: str) -> bool:
|
2021-10-30 00:57:01 +00:00
|
|
|
"""Return if the serial number appears to be valid."""
|
|
|
|
if not serial:
|
|
|
|
return False
|
|
|
|
try:
|
|
|
|
return float("".join(serial.rsplit(".", 1))) > 1
|
|
|
|
except ValueError:
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
class HKDevice:
|
2019-03-28 03:01:10 +00:00
|
|
|
"""HomeKit device."""
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def __init__(self, hass, config_entry, pairing_data) -> None:
|
2019-03-28 03:01:10 +00:00
|
|
|
"""Initialise a generic HomeKit device."""
|
2019-05-13 06:56:05 +00:00
|
|
|
|
2019-03-28 03:01:10 +00:00
|
|
|
self.hass = hass
|
2019-05-13 06:56:05 +00:00
|
|
|
self.config_entry = config_entry
|
|
|
|
|
|
|
|
# We copy pairing_data because homekit_python may mutate it, but we
|
|
|
|
# don't want to mutate a dict owned by a config entry.
|
|
|
|
self.pairing_data = pairing_data.copy()
|
|
|
|
|
2020-02-26 17:44:04 +00:00
|
|
|
self.pairing = hass.data[CONTROLLER].load_pairing(
|
|
|
|
self.pairing_data["AccessoryPairingID"], self.pairing_data
|
|
|
|
)
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2020-03-06 15:47:40 +00:00
|
|
|
self.accessories = None
|
2019-05-13 06:56:05 +00:00
|
|
|
self.config_num = 0
|
|
|
|
|
2020-03-06 15:47:40 +00:00
|
|
|
self.entity_map = Accessories()
|
|
|
|
|
2020-11-14 12:07:22 +00:00
|
|
|
# A list of callbacks that turn HK accessories into entities
|
2022-02-01 19:30:37 +00:00
|
|
|
self.accessory_factories: list[AddAccessoryCb] = []
|
2020-11-14 12:07:22 +00:00
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
# A list of callbacks that turn HK service metadata into entities
|
2022-02-01 19:30:37 +00:00
|
|
|
self.listeners: list[AddServiceCb] = []
|
2019-05-13 06:56:05 +00:00
|
|
|
|
2021-01-26 19:45:01 +00:00
|
|
|
# A list of callbacks that turn HK characteristics into entities
|
2022-02-01 19:30:37 +00:00
|
|
|
self.char_factories: list[AddCharacteristicCb] = []
|
2021-01-26 19:45:01 +00:00
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
# The platorms we have forwarded the config entry so far. If a new
|
|
|
|
# accessory is added to a bridge we may have to load additional
|
|
|
|
# platforms. We don't want to load all platforms up front if its just
|
2020-01-31 16:33:00 +00:00
|
|
|
# a lightbulb. And we don't want to forward a config entry twice
|
2019-05-13 06:56:05 +00:00
|
|
|
# (triggers a Config entry already set up error)
|
2022-02-01 19:30:37 +00:00
|
|
|
self.platforms: set[str] = set()
|
2019-03-28 03:01:10 +00:00
|
|
|
|
|
|
|
# This just tracks aid/iid pairs so we know if a HK service has been
|
|
|
|
# mapped to a HA entity.
|
2022-02-01 19:30:37 +00:00
|
|
|
self.entities: list[tuple[int, int | None, int | None]] = []
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2020-08-20 18:12:36 +00:00
|
|
|
# A map of aid -> device_id
|
|
|
|
# Useful when routing events to triggers
|
2022-02-01 19:30:37 +00:00
|
|
|
self.devices: dict[int, str] = {}
|
2020-08-20 18:12:36 +00:00
|
|
|
|
2021-05-25 16:47:28 +00:00
|
|
|
self.available = False
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
self.signal_state_updated = "_".join((DOMAIN, self.unique_id, "state_updated"))
|
2019-07-22 16:22:44 +00:00
|
|
|
|
|
|
|
# Current values of all characteristics homekit_controller is tracking.
|
|
|
|
# Key is a (accessory_id, characteristic_id) tuple.
|
2022-02-01 19:30:37 +00:00
|
|
|
self.current_state: dict[tuple[int, int], Any] = {}
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
self.pollable_characteristics: list[tuple[int, int]] = []
|
2019-07-22 16:22:44 +00:00
|
|
|
|
|
|
|
# If this is set polling is active and can be disabled by calling
|
|
|
|
# this method.
|
2022-02-01 19:30:37 +00:00
|
|
|
self._polling_interval_remover: CALLBACK_TYPE | None = None
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2019-08-18 04:14:46 +00:00
|
|
|
# Never allow concurrent polling of the same accessory or bridge
|
|
|
|
self._polling_lock = asyncio.Lock()
|
|
|
|
self._polling_lock_warned = False
|
2021-05-25 16:47:28 +00:00
|
|
|
self._poll_failures = 0
|
2019-08-18 04:14:46 +00:00
|
|
|
|
2022-01-23 23:00:05 +00:00
|
|
|
# This is set to True if we can't rely on serial numbers to be unique
|
|
|
|
self.unreliable_serial_numbers = False
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
self.watchable_characteristics: list[tuple[int, int]] = []
|
2020-02-26 18:35:53 +00:00
|
|
|
|
|
|
|
self.pairing.dispatcher_connect(self.process_new_events)
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def add_pollable_characteristics(
|
|
|
|
self, characteristics: list[tuple[int, int]]
|
|
|
|
) -> None:
|
2019-07-22 16:22:44 +00:00
|
|
|
"""Add (aid, iid) pairs that we need to poll."""
|
|
|
|
self.pollable_characteristics.extend(characteristics)
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def remove_pollable_characteristics(self, accessory_id: int) -> None:
|
2019-07-22 16:22:44 +00:00
|
|
|
"""Remove all pollable characteristics by accessory id."""
|
|
|
|
self.pollable_characteristics = [
|
2019-07-31 19:25:30 +00:00
|
|
|
char for char in self.pollable_characteristics if char[0] != accessory_id
|
2019-07-22 16:22:44 +00:00
|
|
|
]
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def add_watchable_characteristics(
|
|
|
|
self, characteristics: list[tuple[int, int]]
|
|
|
|
) -> None:
|
2020-02-26 18:35:53 +00:00
|
|
|
"""Add (aid, iid) pairs that we need to poll."""
|
|
|
|
self.watchable_characteristics.extend(characteristics)
|
2020-02-27 01:10:05 +00:00
|
|
|
self.hass.async_create_task(self.pairing.subscribe(characteristics))
|
2020-02-26 18:35:53 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def remove_watchable_characteristics(self, accessory_id: int) -> None:
|
2020-02-26 18:35:53 +00:00
|
|
|
"""Remove all pollable characteristics by accessory id."""
|
|
|
|
self.watchable_characteristics = [
|
|
|
|
char for char in self.watchable_characteristics if char[0] != accessory_id
|
|
|
|
]
|
|
|
|
|
2020-01-29 21:59:45 +00:00
|
|
|
@callback
|
2022-02-01 19:30:37 +00:00
|
|
|
def async_set_available_state(self, available: bool) -> None:
|
2021-05-25 16:47:28 +00:00
|
|
|
"""Mark state of all entities on this connection when it becomes available or unavailable."""
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Called async_set_available_state with %s for %s", available, self.unique_id
|
|
|
|
)
|
|
|
|
if self.available == available:
|
|
|
|
return
|
|
|
|
self.available = available
|
2019-07-31 19:25:30 +00:00
|
|
|
self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated)
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
async def async_setup(self) -> bool:
|
2020-01-05 12:09:17 +00:00
|
|
|
"""Prepare to use a paired HomeKit device in Home Assistant."""
|
2019-04-18 15:55:34 +00:00
|
|
|
cache = self.hass.data[ENTITY_MAP].get_map(self.unique_id)
|
2019-05-13 06:56:05 +00:00
|
|
|
if not cache:
|
2019-07-22 16:22:44 +00:00
|
|
|
if await self.async_refresh_entity_map(self.config_num):
|
|
|
|
self._polling_interval_remover = async_track_time_interval(
|
2019-07-31 19:25:30 +00:00
|
|
|
self.hass, self.async_update, DEFAULT_SCAN_INTERVAL
|
2019-07-22 16:22:44 +00:00
|
|
|
)
|
|
|
|
return True
|
|
|
|
return False
|
2019-04-18 15:55:34 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
self.accessories = cache["accessories"]
|
|
|
|
self.config_num = cache["config_num"]
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2020-03-06 15:47:40 +00:00
|
|
|
self.entity_map = Accessories.from_list(self.accessories)
|
|
|
|
|
2020-01-03 20:22:27 +00:00
|
|
|
self._polling_interval_remover = async_track_time_interval(
|
|
|
|
self.hass, self.async_update, DEFAULT_SCAN_INTERVAL
|
|
|
|
)
|
|
|
|
|
|
|
|
self.hass.async_create_task(self.async_process_entity_map())
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
2022-01-23 21:57:16 +00:00
|
|
|
def device_info_for_accessory(self, accessory: Accessory) -> DeviceInfo:
|
|
|
|
"""Build a DeviceInfo for a given accessory."""
|
2022-01-23 23:00:05 +00:00
|
|
|
identifiers = {
|
|
|
|
(
|
|
|
|
IDENTIFIER_ACCESSORY_ID,
|
|
|
|
f"{self.unique_id}:aid:{accessory.aid}",
|
|
|
|
)
|
|
|
|
}
|
2022-01-23 21:57:16 +00:00
|
|
|
|
2022-01-23 23:00:05 +00:00
|
|
|
if not self.unreliable_serial_numbers:
|
2022-02-03 16:18:03 +00:00
|
|
|
identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number))
|
2022-01-23 21:57:16 +00:00
|
|
|
|
|
|
|
device_info = DeviceInfo(
|
|
|
|
identifiers=identifiers,
|
2022-02-03 16:18:03 +00:00
|
|
|
name=accessory.name,
|
|
|
|
manufacturer=accessory.manufacturer,
|
|
|
|
model=accessory.model,
|
|
|
|
sw_version=accessory.firmware_revision,
|
|
|
|
hw_version=accessory.hardware_revision,
|
2022-01-23 21:57:16 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
if accessory.aid != 1:
|
|
|
|
# Every pairing has an accessory 1
|
|
|
|
# It *doesn't* have a via_device, as it is the device we are connecting to
|
|
|
|
# Every other accessory should use it as its via device.
|
|
|
|
device_info[ATTR_VIA_DEVICE] = (
|
2022-01-23 23:00:05 +00:00
|
|
|
IDENTIFIER_ACCESSORY_ID,
|
|
|
|
f"{self.unique_id}:aid:1",
|
2022-01-23 21:57:16 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
return device_info
|
|
|
|
|
2022-01-23 23:00:05 +00:00
|
|
|
@callback
|
2022-02-01 19:30:37 +00:00
|
|
|
def async_migrate_devices(self) -> None:
|
2022-01-23 23:00:05 +00:00
|
|
|
"""Migrate legacy device entries from 3-tuples to 2-tuples."""
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Migrating device registry entries for pairing %s", self.unique_id
|
|
|
|
)
|
|
|
|
|
|
|
|
device_registry = dr.async_get(self.hass)
|
|
|
|
|
|
|
|
for accessory in self.entity_map.accessories:
|
|
|
|
identifiers = {
|
|
|
|
(
|
|
|
|
DOMAIN,
|
|
|
|
IDENTIFIER_LEGACY_ACCESSORY_ID,
|
|
|
|
f"{self.unique_id}_{accessory.aid}",
|
|
|
|
),
|
|
|
|
}
|
|
|
|
|
|
|
|
if accessory.aid == 1:
|
|
|
|
identifiers.add(
|
|
|
|
(DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, self.unique_id)
|
|
|
|
)
|
|
|
|
|
2022-02-03 16:18:03 +00:00
|
|
|
if valid_serial_number(accessory.serial_number):
|
2022-01-23 23:00:05 +00:00
|
|
|
identifiers.add(
|
2022-02-03 16:18:03 +00:00
|
|
|
(DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, accessory.serial_number)
|
2022-01-23 23:00:05 +00:00
|
|
|
)
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
device = device_registry.async_get_device(identifiers=identifiers) # type: ignore[arg-type]
|
2022-01-23 23:00:05 +00:00
|
|
|
if not device:
|
|
|
|
continue
|
|
|
|
|
|
|
|
if self.config_entry.entry_id not in device.config_entries:
|
|
|
|
_LOGGER.info(
|
|
|
|
"Found candidate device for %s:aid:%s, but owned by a different config entry, skipping",
|
|
|
|
self.unique_id,
|
|
|
|
accessory.aid,
|
|
|
|
)
|
|
|
|
continue
|
|
|
|
|
|
|
|
_LOGGER.info(
|
|
|
|
"Migrating device identifiers for %s:aid:%s",
|
|
|
|
self.unique_id,
|
|
|
|
accessory.aid,
|
|
|
|
)
|
|
|
|
|
|
|
|
new_identifiers = {
|
|
|
|
(
|
|
|
|
IDENTIFIER_ACCESSORY_ID,
|
|
|
|
f"{self.unique_id}:aid:{accessory.aid}",
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
if not self.unreliable_serial_numbers:
|
2022-02-03 16:18:03 +00:00
|
|
|
new_identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number))
|
2022-01-23 23:00:05 +00:00
|
|
|
else:
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Not migrating serial number identifier for %s:aid:%s (it is wrong, not unique or unreliable)",
|
|
|
|
self.unique_id,
|
|
|
|
accessory.aid,
|
|
|
|
)
|
|
|
|
|
|
|
|
device_registry.async_update_device(
|
|
|
|
device.id, new_identifiers=new_identifiers
|
|
|
|
)
|
|
|
|
|
2021-05-14 01:16:20 +00:00
|
|
|
@callback
|
2022-02-01 19:30:37 +00:00
|
|
|
def async_create_devices(self) -> None:
|
2020-08-20 18:12:36 +00:00
|
|
|
"""
|
|
|
|
Build device registry entries for all accessories paired with the bridge.
|
|
|
|
|
|
|
|
This is done as well as by the entities for 2 reasons. First, the bridge
|
|
|
|
might not have any entities attached to it. Secondly there are stateless
|
|
|
|
entities like doorbells and remote controls.
|
|
|
|
"""
|
2021-05-14 01:16:20 +00:00
|
|
|
device_registry = dr.async_get(self.hass)
|
2020-08-20 18:12:36 +00:00
|
|
|
|
|
|
|
devices = {}
|
|
|
|
|
2022-01-17 20:44:59 +00:00
|
|
|
# Accessories need to be created in the correct order or setting up
|
|
|
|
# relationships with ATTR_VIA_DEVICE may fail.
|
|
|
|
for accessory in sorted(
|
|
|
|
self.entity_map.accessories, key=lambda accessory: accessory.aid
|
|
|
|
):
|
2022-01-23 21:57:16 +00:00
|
|
|
device_info = self.device_info_for_accessory(accessory)
|
2022-01-23 23:00:05 +00:00
|
|
|
|
2020-08-20 18:12:36 +00:00
|
|
|
device = device_registry.async_get_or_create(
|
2020-08-27 11:56:20 +00:00
|
|
|
config_entry_id=self.config_entry.entry_id,
|
|
|
|
**device_info,
|
2020-08-20 18:12:36 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
devices[accessory.aid] = device.id
|
|
|
|
|
|
|
|
self.devices = devices
|
|
|
|
|
2022-01-23 23:00:05 +00:00
|
|
|
@callback
|
2022-02-01 19:30:37 +00:00
|
|
|
def async_detect_workarounds(self) -> None:
|
2022-01-23 23:00:05 +00:00
|
|
|
"""Detect any workarounds that are needed for this pairing."""
|
|
|
|
unreliable_serial_numbers = False
|
|
|
|
|
|
|
|
devices = set()
|
|
|
|
|
|
|
|
for accessory in self.entity_map.accessories:
|
2022-02-03 16:18:03 +00:00
|
|
|
if not valid_serial_number(accessory.serial_number):
|
2022-01-23 23:00:05 +00:00
|
|
|
_LOGGER.debug(
|
|
|
|
"Serial number %r is not valid, it cannot be used as a unique identifier",
|
2022-02-03 16:18:03 +00:00
|
|
|
accessory.serial_number,
|
2022-01-23 23:00:05 +00:00
|
|
|
)
|
|
|
|
unreliable_serial_numbers = True
|
|
|
|
|
2022-02-03 16:18:03 +00:00
|
|
|
elif accessory.serial_number in devices:
|
2022-01-23 23:00:05 +00:00
|
|
|
_LOGGER.debug(
|
|
|
|
"Serial number %r is duplicated within this pairing, it cannot be used as a unique identifier",
|
2022-02-03 16:18:03 +00:00
|
|
|
accessory.serial_number,
|
2022-01-23 23:00:05 +00:00
|
|
|
)
|
|
|
|
unreliable_serial_numbers = True
|
|
|
|
|
2022-02-03 16:18:03 +00:00
|
|
|
elif accessory.serial_number == accessory.hardware_revision:
|
2022-01-23 23:00:05 +00:00
|
|
|
# This is a known bug with some devices (e.g. RYSE SmartShades)
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Serial number %r is actually the hardware revision, it cannot be used as a unique identifier",
|
2022-02-03 16:18:03 +00:00
|
|
|
accessory.serial_number,
|
2022-01-23 23:00:05 +00:00
|
|
|
)
|
|
|
|
unreliable_serial_numbers = True
|
|
|
|
|
2022-02-03 16:18:03 +00:00
|
|
|
devices.add(accessory.serial_number)
|
2022-01-23 23:00:05 +00:00
|
|
|
|
|
|
|
self.unreliable_serial_numbers = unreliable_serial_numbers
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
async def async_process_entity_map(self) -> None:
|
2020-01-03 20:22:27 +00:00
|
|
|
"""
|
|
|
|
Process the entity map and load any platforms or entities that need adding.
|
|
|
|
|
|
|
|
This is idempotent and will be called at startup and when we detect metadata changes
|
|
|
|
via the c# counter on the zeroconf record.
|
|
|
|
"""
|
|
|
|
# Ensure the Pairing object has access to the latest version of the entity map. This
|
|
|
|
# is especially important for BLE, as the Pairing instance relies on the entity map
|
|
|
|
# to map aid/iid to GATT characteristics. So push it to there as well.
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
self.pairing.pairing_data["accessories"] = self.accessories
|
2019-04-18 15:55:34 +00:00
|
|
|
|
2022-01-23 23:00:05 +00:00
|
|
|
self.async_detect_workarounds()
|
|
|
|
|
|
|
|
# Migrate to new device ids
|
|
|
|
self.async_migrate_devices()
|
|
|
|
|
2020-01-03 20:22:27 +00:00
|
|
|
await self.async_load_platforms()
|
2019-05-13 06:56:05 +00:00
|
|
|
|
2021-05-14 01:16:20 +00:00
|
|
|
self.async_create_devices()
|
2020-08-20 18:12:36 +00:00
|
|
|
|
2020-09-11 18:34:07 +00:00
|
|
|
# Load any triggers for this config entry
|
|
|
|
await async_setup_triggers_for_entry(self.hass, self.config_entry)
|
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
self.add_entities()
|
|
|
|
|
2020-02-26 18:35:53 +00:00
|
|
|
if self.watchable_characteristics:
|
|
|
|
await self.pairing.subscribe(self.watchable_characteristics)
|
2022-02-12 14:12:27 +00:00
|
|
|
if not self.pairing.is_connected:
|
2021-05-25 16:47:28 +00:00
|
|
|
return
|
2020-02-26 18:35:53 +00:00
|
|
|
|
2019-07-22 16:22:44 +00:00
|
|
|
await self.async_update()
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
async def async_unload(self) -> None:
|
2019-07-22 16:22:44 +00:00
|
|
|
"""Stop interacting with device and prepare for removal from hass."""
|
|
|
|
if self._polling_interval_remover:
|
|
|
|
self._polling_interval_remover()
|
|
|
|
|
2021-05-23 18:39:22 +00:00
|
|
|
await self.pairing.close()
|
2020-02-26 18:35:53 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
await self.hass.config_entries.async_unload_platforms(
|
2021-04-27 14:09:59 +00:00
|
|
|
self.config_entry, self.platforms
|
|
|
|
)
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
async def async_refresh_entity_map(self, config_num: int) -> bool:
|
2019-03-28 03:01:10 +00:00
|
|
|
"""Handle setup of a HomeKit accessory."""
|
|
|
|
try:
|
2020-02-24 09:55:33 +00:00
|
|
|
self.accessories = await self.pairing.list_accessories_and_characteristics()
|
2019-03-28 03:01:10 +00:00
|
|
|
except AccessoryDisconnectedError:
|
2019-04-18 15:55:34 +00:00
|
|
|
# If we fail to refresh this data then we will naturally retry
|
|
|
|
# later when Bonjour spots c# is still not up to date.
|
2020-01-03 20:22:27 +00:00
|
|
|
return False
|
2019-04-18 15:55:34 +00:00
|
|
|
|
2020-03-06 15:47:40 +00:00
|
|
|
self.entity_map = Accessories.from_list(self.accessories)
|
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
self.hass.data[ENTITY_MAP].async_create_or_update_map(
|
2019-07-31 19:25:30 +00:00
|
|
|
self.unique_id, config_num, self.accessories
|
2019-04-18 15:55:34 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
self.config_num = config_num
|
2020-01-03 20:22:27 +00:00
|
|
|
self.hass.async_create_task(self.async_process_entity_map())
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
return True
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def add_accessory_factory(self, add_entities_cb) -> None:
|
2020-11-14 12:07:22 +00:00
|
|
|
"""Add a callback to run when discovering new entities for accessories."""
|
|
|
|
self.accessory_factories.append(add_entities_cb)
|
|
|
|
self._add_new_entities_for_accessory([add_entities_cb])
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def _add_new_entities_for_accessory(self, handlers) -> None:
|
2020-11-14 12:07:22 +00:00
|
|
|
for accessory in self.entity_map.accessories:
|
|
|
|
for handler in handlers:
|
2022-02-01 19:30:37 +00:00
|
|
|
if (accessory.aid, None, None) in self.entities:
|
2020-11-14 12:07:22 +00:00
|
|
|
continue
|
|
|
|
if handler(accessory):
|
2022-02-01 19:30:37 +00:00
|
|
|
self.entities.append((accessory.aid, None, None))
|
2020-11-14 12:07:22 +00:00
|
|
|
break
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def add_char_factory(self, add_entities_cb) -> None:
|
2021-01-26 19:45:01 +00:00
|
|
|
"""Add a callback to run when discovering new entities for accessories."""
|
|
|
|
self.char_factories.append(add_entities_cb)
|
|
|
|
self._add_new_entities_for_char([add_entities_cb])
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def _add_new_entities_for_char(self, handlers) -> None:
|
2021-01-26 19:45:01 +00:00
|
|
|
for accessory in self.entity_map.accessories:
|
|
|
|
for service in accessory.services:
|
|
|
|
for char in service.characteristics:
|
|
|
|
for handler in handlers:
|
|
|
|
if (accessory.aid, service.iid, char.iid) in self.entities:
|
|
|
|
continue
|
|
|
|
if handler(char):
|
|
|
|
self.entities.append((accessory.aid, service.iid, char.iid))
|
|
|
|
break
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def add_listener(self, add_entities_cb) -> None:
|
2020-11-14 12:07:22 +00:00
|
|
|
"""Add a callback to run when discovering new entities for services."""
|
2019-05-13 06:56:05 +00:00
|
|
|
self.listeners.append(add_entities_cb)
|
|
|
|
self._add_new_entities([add_entities_cb])
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def add_entities(self) -> None:
|
2019-04-18 15:55:34 +00:00
|
|
|
"""Process the entity map and create HA entities."""
|
2019-05-13 06:56:05 +00:00
|
|
|
self._add_new_entities(self.listeners)
|
2020-11-14 12:07:22 +00:00
|
|
|
self._add_new_entities_for_accessory(self.accessory_factories)
|
2021-01-26 19:45:01 +00:00
|
|
|
self._add_new_entities_for_char(self.char_factories)
|
2019-05-13 06:56:05 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def _add_new_entities(self, callbacks) -> None:
|
2020-11-16 23:11:39 +00:00
|
|
|
for accessory in self.entity_map.accessories:
|
|
|
|
aid = accessory.aid
|
|
|
|
for service in accessory.services:
|
|
|
|
iid = service.iid
|
2019-05-13 06:56:05 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
if (aid, None, iid) in self.entities:
|
2019-03-28 03:01:10 +00:00
|
|
|
# Don't add the same entity again
|
|
|
|
continue
|
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
for listener in callbacks:
|
2020-11-16 23:11:39 +00:00
|
|
|
if listener(service):
|
2022-02-01 19:30:37 +00:00
|
|
|
self.entities.append((aid, None, iid))
|
2019-05-13 06:56:05 +00:00
|
|
|
break
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
async def async_load_platform(self, platform: str) -> None:
|
2021-01-26 19:45:01 +00:00
|
|
|
"""Load a single platform idempotently."""
|
|
|
|
if platform in self.platforms:
|
|
|
|
return
|
|
|
|
|
|
|
|
self.platforms.add(platform)
|
|
|
|
try:
|
|
|
|
await self.hass.config_entries.async_forward_entry_setup(
|
|
|
|
self.config_entry, platform
|
|
|
|
)
|
|
|
|
except Exception:
|
|
|
|
self.platforms.remove(platform)
|
|
|
|
raise
|
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
async def async_load_platforms(self) -> None:
|
2019-05-13 06:56:05 +00:00
|
|
|
"""Load any platforms needed by this HomeKit device."""
|
2021-05-14 01:16:20 +00:00
|
|
|
tasks = []
|
2022-02-01 07:38:42 +00:00
|
|
|
for accessory in self.entity_map.accessories:
|
|
|
|
for service in accessory.services:
|
|
|
|
if service.type in HOMEKIT_ACCESSORY_DISPATCH:
|
|
|
|
platform = HOMEKIT_ACCESSORY_DISPATCH[service.type]
|
2021-05-14 01:16:20 +00:00
|
|
|
if platform not in self.platforms:
|
|
|
|
tasks.append(self.async_load_platform(platform))
|
2021-01-26 19:45:01 +00:00
|
|
|
|
2022-02-01 07:38:42 +00:00
|
|
|
for char in service.characteristics:
|
|
|
|
if char.type in CHARACTERISTIC_PLATFORMS:
|
|
|
|
platform = CHARACTERISTIC_PLATFORMS[char.type]
|
2021-05-14 01:16:20 +00:00
|
|
|
if platform not in self.platforms:
|
|
|
|
tasks.append(self.async_load_platform(platform))
|
|
|
|
|
|
|
|
if tasks:
|
|
|
|
await asyncio.gather(*tasks)
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2019-07-22 16:22:44 +00:00
|
|
|
async def async_update(self, now=None):
|
|
|
|
"""Poll state of all entities attached to this bridge/accessory."""
|
|
|
|
if not self.pollable_characteristics:
|
2022-02-12 14:12:27 +00:00
|
|
|
self.async_set_available_state(self.pairing.is_connected)
|
2021-05-25 16:47:28 +00:00
|
|
|
_LOGGER.debug(
|
|
|
|
"HomeKit connection not polling any characteristics: %s", self.unique_id
|
|
|
|
)
|
2019-07-22 16:22:44 +00:00
|
|
|
return
|
|
|
|
|
2019-08-18 04:14:46 +00:00
|
|
|
if self._polling_lock.locked():
|
|
|
|
if not self._polling_lock_warned:
|
|
|
|
_LOGGER.warning(
|
2021-05-25 16:47:28 +00:00
|
|
|
"HomeKit controller update skipped as previous poll still in flight: %s",
|
|
|
|
self.unique_id,
|
2019-08-18 04:14:46 +00:00
|
|
|
)
|
|
|
|
self._polling_lock_warned = True
|
|
|
|
return
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2019-08-18 04:14:46 +00:00
|
|
|
if self._polling_lock_warned:
|
|
|
|
_LOGGER.info(
|
2021-05-25 16:47:28 +00:00
|
|
|
"HomeKit controller no longer detecting back pressure - not skipping poll: %s",
|
|
|
|
self.unique_id,
|
2019-07-22 16:22:44 +00:00
|
|
|
)
|
2019-08-18 04:14:46 +00:00
|
|
|
self._polling_lock_warned = False
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2019-08-18 04:14:46 +00:00
|
|
|
async with self._polling_lock:
|
2021-05-25 16:47:28 +00:00
|
|
|
_LOGGER.debug("Starting HomeKit controller update: %s", self.unique_id)
|
2019-08-13 08:09:55 +00:00
|
|
|
|
2019-08-18 04:14:46 +00:00
|
|
|
try:
|
|
|
|
new_values_dict = await self.get_characteristics(
|
|
|
|
self.pollable_characteristics
|
|
|
|
)
|
|
|
|
except AccessoryNotFoundError:
|
|
|
|
# Not only did the connection fail, but also the accessory is not
|
|
|
|
# visible on the network.
|
2021-05-25 16:47:28 +00:00
|
|
|
self.async_set_available_state(False)
|
2019-08-18 04:14:46 +00:00
|
|
|
return
|
|
|
|
except (AccessoryDisconnectedError, EncryptionError):
|
2021-05-25 16:47:28 +00:00
|
|
|
# Temporary connection failure. Device may still available but our
|
|
|
|
# connection was dropped or we are reconnecting
|
|
|
|
self._poll_failures += 1
|
|
|
|
if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE:
|
|
|
|
self.async_set_available_state(False)
|
2019-08-18 04:14:46 +00:00
|
|
|
return
|
|
|
|
|
2021-05-25 16:47:28 +00:00
|
|
|
self._poll_failures = 0
|
2019-08-18 04:14:46 +00:00
|
|
|
self.process_new_events(new_values_dict)
|
|
|
|
|
2021-05-25 16:47:28 +00:00
|
|
|
_LOGGER.debug("Finished HomeKit controller update: %s", self.unique_id)
|
2019-08-13 08:09:55 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
def process_new_events(self, new_values_dict) -> None:
|
2019-08-13 08:09:55 +00:00
|
|
|
"""Process events from accessory into HA state."""
|
2021-05-25 16:47:28 +00:00
|
|
|
self.async_set_available_state(True)
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2020-09-11 18:34:07 +00:00
|
|
|
# Process any stateless events (via device_triggers)
|
|
|
|
async_fire_triggers(self, new_values_dict)
|
|
|
|
|
2019-07-22 16:22:44 +00:00
|
|
|
for (aid, cid), value in new_values_dict.items():
|
|
|
|
accessory = self.current_state.setdefault(aid, {})
|
|
|
|
accessory[cid] = value
|
|
|
|
|
2020-03-06 15:47:40 +00:00
|
|
|
# self.current_state will be replaced by entity_map in a future PR
|
|
|
|
# For now we update both
|
|
|
|
self.entity_map.process_changes(new_values_dict)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated)
|
2019-07-22 16:22:44 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
async def get_characteristics(self, *args, **kwargs) -> dict[str, Any]:
|
2019-03-28 03:01:10 +00:00
|
|
|
"""Read latest state from homekit accessory."""
|
2020-05-23 17:59:32 +00:00
|
|
|
return await self.pairing.get_characteristics(*args, **kwargs)
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2022-02-01 19:30:37 +00:00
|
|
|
async def put_characteristics(self, characteristics) -> None:
|
2019-03-28 03:01:10 +00:00
|
|
|
"""Control a HomeKit device state from Home Assistant."""
|
2020-05-23 17:59:32 +00:00
|
|
|
results = await self.pairing.put_characteristics(characteristics)
|
2019-04-18 15:55:34 +00:00
|
|
|
|
2019-08-13 08:09:55 +00:00
|
|
|
# Feed characteristics back into HA and update the current state
|
|
|
|
# results will only contain failures, so anythin in characteristics
|
|
|
|
# but not in results was applied successfully - we can just have HA
|
|
|
|
# reflect the change immediately.
|
|
|
|
|
|
|
|
new_entity_state = {}
|
2020-03-11 16:27:20 +00:00
|
|
|
for aid, iid, value in characteristics:
|
|
|
|
key = (aid, iid)
|
2019-08-13 08:09:55 +00:00
|
|
|
|
|
|
|
# If the key was returned by put_characteristics() then the
|
2020-01-31 16:33:00 +00:00
|
|
|
# change didn't work
|
2019-08-13 08:09:55 +00:00
|
|
|
if key in results:
|
|
|
|
continue
|
|
|
|
|
|
|
|
# Otherwise it was accepted and we can apply the change to
|
|
|
|
# our state
|
2020-03-11 16:27:20 +00:00
|
|
|
new_entity_state[key] = {"value": value}
|
2019-08-13 08:09:55 +00:00
|
|
|
|
|
|
|
self.process_new_events(new_entity_state)
|
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
@property
|
2022-02-01 19:30:37 +00:00
|
|
|
def unique_id(self) -> str:
|
2019-04-18 15:55:34 +00:00
|
|
|
"""
|
|
|
|
Return a unique id for this accessory or bridge.
|
|
|
|
|
|
|
|
This id is random and will change if a device undergoes a hard reset.
|
|
|
|
"""
|
2019-07-31 19:25:30 +00:00
|
|
|
return self.pairing_data["AccessoryPairingID"]
|