2019-03-07 03:44:52 +00:00
|
|
|
"""Helpers for managing a pairing with a HomeKit accessory or bridge."""
|
2019-03-28 03:01:10 +00:00
|
|
|
import asyncio
|
2019-07-22 16:22:44 +00:00
|
|
|
import datetime
|
2019-03-28 03:01:10 +00:00
|
|
|
import logging
|
|
|
|
|
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
|
|
|
|
2019-07-22 16:22:44 +00:00
|
|
|
from .const import DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, ENTITY_MAP
|
2019-03-28 03:01:10 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2019-03-07 03:44:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
def get_accessory_information(accessory):
|
|
|
|
"""Obtain the accessory information service of a HomeKit device."""
|
|
|
|
# pylint: disable=import-error
|
|
|
|
from homekit.model.services import ServicesTypes
|
|
|
|
from homekit.model.characteristics import CharacteristicsTypes
|
|
|
|
|
|
|
|
result = {}
|
|
|
|
for service in accessory['services']:
|
|
|
|
stype = service['type'].upper()
|
|
|
|
if ServicesTypes.get_short(stype) != 'accessory-information':
|
|
|
|
continue
|
|
|
|
for characteristic in service['characteristics']:
|
|
|
|
ctype = CharacteristicsTypes.get_short(characteristic['type'])
|
|
|
|
if 'value' in characteristic:
|
|
|
|
result[ctype] = characteristic['value']
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
def get_bridge_information(accessories):
|
|
|
|
"""Return the accessory info for the bridge."""
|
|
|
|
for accessory in accessories:
|
|
|
|
if accessory['aid'] == 1:
|
|
|
|
return get_accessory_information(accessory)
|
|
|
|
return get_accessory_information(accessories[0])
|
|
|
|
|
|
|
|
|
|
|
|
def get_accessory_name(accessory_info):
|
|
|
|
"""Return the name field of an accessory."""
|
|
|
|
for field in ('name', 'model', 'manufacturer'):
|
|
|
|
if field in accessory_info:
|
|
|
|
return accessory_info[field]
|
|
|
|
return None
|
2019-03-28 03:01:10 +00:00
|
|
|
|
|
|
|
|
|
|
|
class HKDevice():
|
|
|
|
"""HomeKit device."""
|
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
def __init__(self, hass, config_entry, pairing_data):
|
2019-03-28 03:01:10 +00:00
|
|
|
"""Initialise a generic HomeKit device."""
|
2019-05-13 06:56:05 +00:00
|
|
|
from homekit.controller.ip_implementation import IpPairing
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
self.pairing = IpPairing(self.pairing_data)
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
self.accessories = {}
|
2019-05-13 06:56:05 +00:00
|
|
|
self.config_num = 0
|
|
|
|
|
|
|
|
# A list of callbacks that turn HK service metadata into entities
|
|
|
|
self.listeners = []
|
|
|
|
|
|
|
|
# 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
|
|
|
|
# a lightbulb. And we dont want to forward a config entry twice
|
|
|
|
# (triggers a Config entry already set up error)
|
|
|
|
self.platforms = 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.
|
|
|
|
self.entities = []
|
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
# There are multiple entities sharing a single connection - only
|
|
|
|
# allow one entity to use pairing at once.
|
2019-05-23 04:09:59 +00:00
|
|
|
self.pairing_lock = asyncio.Lock()
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2019-07-22 16:22:44 +00:00
|
|
|
self.available = True
|
|
|
|
|
|
|
|
self.signal_state_updated = '_'.join((
|
|
|
|
DOMAIN,
|
|
|
|
self.unique_id,
|
|
|
|
'state_updated',
|
|
|
|
))
|
|
|
|
|
|
|
|
# Current values of all characteristics homekit_controller is tracking.
|
|
|
|
# Key is a (accessory_id, characteristic_id) tuple.
|
|
|
|
self.current_state = {}
|
|
|
|
|
|
|
|
self.pollable_characteristics = []
|
|
|
|
|
|
|
|
# If this is set polling is active and can be disabled by calling
|
|
|
|
# this method.
|
|
|
|
self._polling_interval_remover = None
|
|
|
|
|
|
|
|
def add_pollable_characteristics(self, characteristics):
|
|
|
|
"""Add (aid, iid) pairs that we need to poll."""
|
|
|
|
self.pollable_characteristics.extend(characteristics)
|
|
|
|
|
|
|
|
def remove_pollable_characteristics(self, accessory_id):
|
|
|
|
"""Remove all pollable characteristics by accessory id."""
|
|
|
|
self.pollable_characteristics = [
|
|
|
|
char for char in self.pollable_characteristics
|
|
|
|
if char[0] != accessory_id
|
|
|
|
]
|
|
|
|
|
|
|
|
def async_set_unavailable(self):
|
|
|
|
"""Mark state of all entities on this connection as unavailable."""
|
|
|
|
self.available = False
|
|
|
|
self.hass.helpers.dispatcher.async_dispatcher_send(
|
|
|
|
self.signal_state_updated,
|
|
|
|
)
|
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
async def async_setup(self):
|
2019-04-18 15:55:34 +00:00
|
|
|
"""Prepare to use a paired HomeKit device in homeassistant."""
|
|
|
|
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(
|
|
|
|
self.hass,
|
|
|
|
self.async_update,
|
|
|
|
DEFAULT_SCAN_INTERVAL
|
|
|
|
)
|
|
|
|
return True
|
|
|
|
return False
|
2019-04-18 15:55:34 +00:00
|
|
|
|
|
|
|
self.accessories = cache['accessories']
|
2019-05-13 06:56:05 +00:00
|
|
|
self.config_num = cache['config_num']
|
2019-03-28 03:01:10 +00:00
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
# Ensure the Pairing object has access to the latest version of the
|
|
|
|
# entity map.
|
|
|
|
self.pairing.pairing_data['accessories'] = self.accessories
|
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
self.async_load_platforms()
|
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
self.add_entities()
|
|
|
|
|
2019-07-22 16:22:44 +00:00
|
|
|
await self.async_update()
|
|
|
|
|
|
|
|
self._polling_interval_remover = async_track_time_interval(
|
|
|
|
self.hass,
|
|
|
|
self.async_update,
|
|
|
|
DEFAULT_SCAN_INTERVAL
|
|
|
|
)
|
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
return True
|
|
|
|
|
2019-07-22 16:22:44 +00:00
|
|
|
async def async_unload(self):
|
|
|
|
"""Stop interacting with device and prepare for removal from hass."""
|
|
|
|
if self._polling_interval_remover:
|
|
|
|
self._polling_interval_remover()
|
|
|
|
|
|
|
|
unloads = []
|
|
|
|
for platform in self.platforms:
|
|
|
|
unloads.append(
|
|
|
|
self.hass.config_entries.async_forward_entry_unload(
|
|
|
|
self.config_entry,
|
|
|
|
platform
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
results = await asyncio.gather(*unloads)
|
|
|
|
|
|
|
|
return False not in results
|
|
|
|
|
2019-04-19 17:59:54 +00:00
|
|
|
async def async_refresh_entity_map(self, config_num):
|
2019-03-28 03:01:10 +00:00
|
|
|
"""Handle setup of a HomeKit accessory."""
|
|
|
|
# pylint: disable=import-error
|
|
|
|
from homekit.exceptions import AccessoryDisconnectedError
|
|
|
|
|
|
|
|
try:
|
2019-05-13 06:56:05 +00:00
|
|
|
async with self.pairing_lock:
|
|
|
|
self.accessories = await self.hass.async_add_executor_job(
|
|
|
|
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.
|
2019-03-28 03:01:10 +00:00
|
|
|
return
|
2019-04-18 15:55:34 +00:00
|
|
|
|
|
|
|
self.hass.data[ENTITY_MAP].async_create_or_update_map(
|
|
|
|
self.unique_id,
|
|
|
|
config_num,
|
2019-04-19 17:59:54 +00:00
|
|
|
self.accessories,
|
2019-04-18 15:55:34 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
self.config_num = config_num
|
|
|
|
|
|
|
|
# For BLE, the Pairing instance relies on the entity map to map
|
|
|
|
# aid/iid to GATT characteristics. So push it to there as well.
|
2019-04-19 17:59:54 +00:00
|
|
|
self.pairing.pairing_data['accessories'] = self.accessories
|
2019-04-18 15:55:34 +00:00
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
self.async_load_platforms()
|
|
|
|
|
|
|
|
# Register and add new entities that are available
|
|
|
|
self.add_entities()
|
2019-04-18 15:55:34 +00:00
|
|
|
|
2019-07-22 16:22:44 +00:00
|
|
|
await self.async_update()
|
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
return True
|
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
def add_listener(self, add_entities_cb):
|
|
|
|
"""Add a callback to run when discovering new entities."""
|
|
|
|
self.listeners.append(add_entities_cb)
|
|
|
|
self._add_new_entities([add_entities_cb])
|
|
|
|
|
2019-04-18 15:55:34 +00:00
|
|
|
def add_entities(self):
|
|
|
|
"""Process the entity map and create HA entities."""
|
2019-05-13 06:56:05 +00:00
|
|
|
self._add_new_entities(self.listeners)
|
|
|
|
|
|
|
|
def _add_new_entities(self, callbacks):
|
2019-04-18 15:55:34 +00:00
|
|
|
from homekit.model.services import ServicesTypes
|
|
|
|
|
|
|
|
for accessory in self.accessories:
|
2019-03-28 03:01:10 +00:00
|
|
|
aid = accessory['aid']
|
|
|
|
for service in accessory['services']:
|
|
|
|
iid = service['iid']
|
2019-05-13 06:56:05 +00:00
|
|
|
stype = ServicesTypes.get_short(service['type'].upper())
|
|
|
|
service['stype'] = stype
|
|
|
|
|
2019-03-28 03:01:10 +00:00
|
|
|
if (aid, iid) in self.entities:
|
|
|
|
# Don't add the same entity again
|
|
|
|
continue
|
|
|
|
|
2019-05-13 06:56:05 +00:00
|
|
|
for listener in callbacks:
|
|
|
|
if listener(aid, service):
|
|
|
|
self.entities.append((aid, iid))
|
|
|
|
break
|
|
|
|
|
|
|
|
def async_load_platforms(self):
|
|
|
|
"""Load any platforms needed by this HomeKit device."""
|
|
|
|
from homekit.model.services import ServicesTypes
|
|
|
|
|
|
|
|
for accessory in self.accessories:
|
|
|
|
for service in accessory['services']:
|
|
|
|
stype = ServicesTypes.get_short(service['type'].upper())
|
|
|
|
if stype not in HOMEKIT_ACCESSORY_DISPATCH:
|
|
|
|
continue
|
|
|
|
|
|
|
|
platform = HOMEKIT_ACCESSORY_DISPATCH[stype]
|
|
|
|
if platform in self.platforms:
|
|
|
|
continue
|
|
|
|
|
|
|
|
self.hass.async_create_task(
|
|
|
|
self.hass.config_entries.async_forward_entry_setup(
|
|
|
|
self.config_entry,
|
|
|
|
platform,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
self.platforms.add(platform)
|
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."""
|
|
|
|
# pylint: disable=import-error
|
|
|
|
from homekit.exceptions import (
|
|
|
|
AccessoryDisconnectedError, AccessoryNotFoundError,
|
|
|
|
EncryptionError)
|
|
|
|
|
|
|
|
if not self.pollable_characteristics:
|
|
|
|
_LOGGER.debug(
|
|
|
|
"HomeKit connection not polling any characteristics."
|
|
|
|
)
|
|
|
|
return
|
|
|
|
|
|
|
|
_LOGGER.debug("Starting HomeKit controller update")
|
|
|
|
|
|
|
|
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.
|
|
|
|
self.async_set_unavailable()
|
|
|
|
return
|
|
|
|
except (AccessoryDisconnectedError, EncryptionError):
|
|
|
|
# Temporary connection failure. Device is still available but our
|
|
|
|
# connection was dropped.
|
|
|
|
return
|
|
|
|
|
|
|
|
self.available = True
|
|
|
|
|
|
|
|
for (aid, cid), value in new_values_dict.items():
|
|
|
|
accessory = self.current_state.setdefault(aid, {})
|
|
|
|
accessory[cid] = value
|
|
|
|
|
|
|
|
self.hass.helpers.dispatcher.async_dispatcher_send(
|
|
|
|
self.signal_state_updated,
|
|
|
|
)
|
|
|
|
|
|
|
|
_LOGGER.debug("Finished HomeKit controller update")
|
|
|
|
|
2019-03-28 03:01:10 +00:00
|
|
|
async def get_characteristics(self, *args, **kwargs):
|
|
|
|
"""Read latest state from homekit accessory."""
|
|
|
|
async with self.pairing_lock:
|
|
|
|
chars = await self.hass.async_add_executor_job(
|
|
|
|
self.pairing.get_characteristics,
|
|
|
|
*args,
|
|
|
|
**kwargs,
|
|
|
|
)
|
|
|
|
return chars
|
|
|
|
|
|
|
|
async def put_characteristics(self, characteristics):
|
|
|
|
"""Control a HomeKit device state from Home Assistant."""
|
|
|
|
chars = []
|
|
|
|
for row in characteristics:
|
|
|
|
chars.append((
|
|
|
|
row['aid'],
|
|
|
|
row['iid'],
|
|
|
|
row['value'],
|
|
|
|
))
|
|
|
|
|
|
|
|
async with self.pairing_lock:
|
|
|
|
await self.hass.async_add_executor_job(
|
|
|
|
self.pairing.put_characteristics,
|
|
|
|
chars
|
|
|
|
)
|
2019-04-18 15:55:34 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def unique_id(self):
|
|
|
|
"""
|
|
|
|
Return a unique id for this accessory or bridge.
|
|
|
|
|
|
|
|
This id is random and will change if a device undergoes a hard reset.
|
|
|
|
"""
|
2019-05-13 06:56:05 +00:00
|
|
|
return self.pairing_data['AccessoryPairingID']
|
2019-05-17 06:41:21 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def connection_info(self):
|
|
|
|
"""Return accessory information for the main accessory."""
|
|
|
|
return get_bridge_information(self.accessories)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def name(self):
|
|
|
|
"""Name of the bridge accessory."""
|
|
|
|
return get_accessory_name(self.connection_info) or self.unique_id
|