Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions homeassistant/components/homekit_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
from .config_flow import load_old_pairings
from .connection import get_accessory_information, HKDevice
from .const import (
CONTROLLER, KNOWN_DEVICES
CONTROLLER, ENTITY_MAP, KNOWN_DEVICES
)
from .const import DOMAIN # noqa: pylint: disable=unused-import
from .storage import EntityMapStorage

HOMEKIT_IGNORE = [
'BSB002',
Expand Down Expand Up @@ -44,7 +45,7 @@ def setup(self):
# pylint: disable=import-error
from homekit.model.characteristics import CharacteristicsTypes

pairing_data = self._accessory.pairing.pairing_data
accessories = self._accessory.accessories

get_uuid = CharacteristicsTypes.get_uuid
characteristic_types = [
Expand All @@ -55,7 +56,7 @@ def setup(self):
self._chars = {}
self._char_names = {}

for accessory in pairing_data.get('accessories', []):
for accessory in accessories:
if accessory['aid'] != self._aid:
continue
self._accessory_info = get_accessory_information(accessory)
Expand Down Expand Up @@ -149,12 +150,15 @@ def get_characteristic_types(self):
raise NotImplementedError


def setup(hass, config):
async def async_setup(hass, config):
"""Set up for Homekit devices."""
# pylint: disable=import-error
import homekit
from homekit.controller.ip_implementation import IpPairing

map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass)
await map_storage.async_initialize()

hass.data[CONTROLLER] = controller = homekit.Controller()

for hkid, pairing_data in load_old_pairings(hass).items():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does I/O, which isn't allowed in a coroutine. Please add it as an executor job.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops. We already made this async in config flow and hadn't originally split this PR apart from config flow. Will fix this one now.

Expand Down Expand Up @@ -185,12 +189,22 @@ def discovery_dispatch(service, discovery_info):
device = hass.data[KNOWN_DEVICES][hkid]
if config_num > device.config_num and \
device.pairing is not None:
device.accessory_setup()
device.refresh_entity_map(config_num)
return

_LOGGER.debug('Discovered unique device %s', hkid)
HKDevice(hass, host, port, model, hkid, config_num, config)
device = HKDevice(hass, host, port, model, hkid, config_num, config)
device.setup()

hass.data[KNOWN_DEVICES] = {}
discovery.listen(hass, SERVICE_HOMEKIT, discovery_dispatch)

await hass.async_add_executor_job(
discovery.listen, hass, SERVICE_HOMEKIT, discovery_dispatch)

return True


async def async_remove_entry(hass, entry):
"""Cleanup caches before removing config entry."""
hkid = entry.data['AccessoryPairingID']
hass.data[ENTITY_MAP].async_delete_map(hkid)
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ async def async_step_discovery(self, discovery_info):
"HomeKit info %s: c# incremented, refreshing entities",
hkid)
self.hass.async_create_task(
conn.async_config_num_changed(config_num))
conn.async_refresh_entity_map(config_num))
return self.async_abort(reason='already_configured')

old_pairings = await self.hass.async_add_executor_job(
Expand Down
98 changes: 81 additions & 17 deletions homeassistant/components/homekit_controller/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@
import os

from homeassistant.helpers import discovery
from homeassistant.helpers.event import call_later

from .const import (
CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_DEVICES,
PAIRING_FILE, HOMEKIT_DIR
PAIRING_FILE, HOMEKIT_DIR, ENTITY_MAP
)


Expand Down Expand Up @@ -67,7 +66,7 @@ def __init__(self, hass, host, port, model, hkid, config_num, config):
self.config_num = config_num
self.config = config
self.configurator = hass.components.configurator
self._connection_warning_logged = False
self.accessories = {}

# This just tracks aid/iid pairs so we know if a HK service has been
# mapped to a HA entity.
Expand All @@ -79,27 +78,77 @@ def __init__(self, hass, host, port, model, hkid, config_num, config):

hass.data[KNOWN_DEVICES][hkid] = self

if self.pairing is not None:
self.accessory_setup()
else:
def setup(self):
"""Prepare to use a paired HomeKit device in homeassistant."""
if self.pairing is None:
self.configure()
return

self.pairing.pairing_data['AccessoryIP'] = self.host
self.pairing.pairing_data['AccessoryPort'] = self.port

cache = self.hass.data[ENTITY_MAP].get_map(self.unique_id)
if not cache or cache['config_num'] < self.config_num:
return self.refresh_entity_map(self.config_num)

self.accessories = cache['accessories']

def accessory_setup(self):
# Ensure the Pairing object has access to the latest version of the
# entity map.
self.pairing.pairing_data['accessories'] = self.accessories

self.add_entities()

return True

async def async_refresh_entity_map(self, config_num):
"""
Handle setup of a HomeKit accessory.

The sync version will be removed when homekit_controller migrates to
config flow.
"""
return await self.hass.async_add_executor_job(
self.refresh_entity_map,
config_num,
)

def refresh_entity_map(self, config_num):
"""Handle setup of a HomeKit accessory."""
# pylint: disable=import-error
from homekit.model.services import ServicesTypes
from homekit.exceptions import AccessoryDisconnectedError

self.pairing.pairing_data['AccessoryIP'] = self.host
self.pairing.pairing_data['AccessoryPort'] = self.port

try:
data = self.pairing.list_accessories_and_characteristics()
accessories = self.pairing.list_accessories_and_characteristics()
except AccessoryDisconnectedError:
call_later(
self.hass, RETRY_INTERVAL, lambda _: self.accessory_setup())
# If we fail to refresh this data then we will naturally retry
# later when Bonjour spots c# is still not up to date.
return
for accessory in data:

self.hass.data[ENTITY_MAP].async_create_or_update_map(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't call async functions from sync context and vice versa.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not actually a coroutine, but it does call store.async_delay_save and is, I believe, 'async safe'. So I could mark it with @callback or drop the async_ prefix?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async callbacks should also only be executed in the event loop.

self.unique_id,
config_num,
accessories,
)

self.accessories = accessories
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.
self.pairing.pairing_data['accessories'] = accessories

# Register add new entities that are available
self.add_entities()

return True

def add_entities(self):
"""Process the entity map and create HA entities."""
# pylint: disable=import-error
from homekit.model.services import ServicesTypes

for accessory in self.accessories:
aid = accessory['aid']
for service in accessory['services']:
iid = service['iid']
Expand All @@ -118,6 +167,7 @@ def accessory_setup(self):
if component is not None:
discovery.load_platform(self.hass, component, DOMAIN,
service_info, self.config)
self.entities.append((aid, iid))

def device_config_callback(self, callback_data):
"""Handle initial pairing."""
Expand Down Expand Up @@ -145,15 +195,20 @@ def device_config_callback(self, callback_data):

self.pairing = self.controller.pairings.get(self.hkid)
if self.pairing is not None:
pairing_file = os.path.join(
pairing_dir = os.path.join(
self.hass.config.path(),
HOMEKIT_DIR,
)
if not os.path.exists(pairing_dir):
os.makedirs(pairing_dir)
pairing_file = os.path.join(
pairing_dir,
PAIRING_FILE,
)
self.controller.save_data(pairing_file)
_configurator = self.hass.data[DOMAIN+self.hkid]
self.configurator.request_done(_configurator)
self.accessory_setup()
self.setup()
else:
error_msg = "Unable to pair, please try again"
_configurator = self.hass.data[DOMAIN+self.hkid]
Expand Down Expand Up @@ -197,3 +252,12 @@ async def put_characteristics(self, characteristics):
self.pairing.put_characteristics,
chars
)

@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.
"""
return self.hkid
1 change: 1 addition & 0 deletions homeassistant/components/homekit_controller/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

KNOWN_DEVICES = "{}-devices".format(DOMAIN)
CONTROLLER = "{}-controller".format(DOMAIN)
ENTITY_MAP = '{}-entity-map'.format(DOMAIN)

HOMEKIT_DIR = '.homekit'
PAIRING_FILE = 'pairing.json'
Expand Down
80 changes: 80 additions & 0 deletions homeassistant/components/homekit_controller/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Helpers for HomeKit data stored in HA storage."""

from homeassistant.helpers.storage import Store
from homeassistant.core import callback

from .const import DOMAIN

ENTITY_MAP_STORAGE_KEY = '{}-entity-map'.format(DOMAIN)
ENTITY_MAP_STORAGE_VERSION = 1
ENTITY_MAP_SAVE_DELAY = 10


class EntityMapStorage:
"""
Holds a cache of entity structure data from a paired HomeKit device.

HomeKit has a cacheable entity map that describes how an IP or BLE
endpoint is structured. This object holds the latest copy of that data.

An endpoint is made of accessories, services and characteristics. It is
safe to cache this data until the c# discovery data changes.

Caching this data means we can add HomeKit devices to HA immediately at
start even if discovery hasn't seen them yet or they are out of range. It
is also important for BLE devices - accessing the entity structure is
very slow for these devices.
"""

def __init__(self, hass):
"""Create a new entity map store."""
self.hass = hass
self.store = Store(
hass,
ENTITY_MAP_STORAGE_VERSION,
ENTITY_MAP_STORAGE_KEY
)
self.storage_data = {}

async def async_initialize(self):
"""Get the pairing cache data."""
raw_storage = await self.store.async_load()
if not raw_storage:
# There is no cached data about HomeKit devices yet
return

self.storage_data = raw_storage.get('pairings', {})

def get_map(self, homekit_id):
"""Get a pairing cache item."""
return self.storage_data.get(homekit_id)

def async_create_or_update_map(self, homekit_id, config_num, accessories):
"""Create a new pairing cache."""
data = {
'config_num': config_num,
'accessories': accessories,
}
self.storage_data[homekit_id] = data
self._async_schedule_save()
return data

def async_delete_map(self, homekit_id):
"""Delete pairing cache."""
if homekit_id not in self.storage_data:
return

self.storage_data.pop(homekit_id)
self._async_schedule_save()

@callback
def _async_schedule_save(self):
"""Schedule saving the entity map cache."""
self.store.async_delay_save(self._data_to_save, ENTITY_MAP_SAVE_DELAY)

@callback
def _data_to_save(self):
"""Return data of entity map to store in a file."""
return {
'pairings': self.storage_data,
}
1 change: 1 addition & 0 deletions tests/components/homekit_controller/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ async def device_config_changed(hass, accessories):

# Wait for services to reconfigure
await hass.async_block_till_done()
await hass.async_block_till_done()


async def setup_test_component(hass, services, capitalize=False, suffix=None):
Expand Down
Loading