-
-
Notifications
You must be signed in to change notification settings - Fork 37.4k
Add storage for cacheable homekit entity maps. #23191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -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. | ||
|
|
@@ -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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't call async functions from sync context and vice versa.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not actually a coroutine, but it does call
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'] | ||
|
|
@@ -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.""" | ||
|
|
@@ -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] | ||
|
|
@@ -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 | ||
| 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, | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.