diff --git a/.coveragerc b/.coveragerc index 2b5f328466c..ffe89740432 100644 --- a/.coveragerc +++ b/.coveragerc @@ -250,7 +250,6 @@ omit = homeassistant/components/hitron_coda/device_tracker.py homeassistant/components/hive/* homeassistant/components/hlk_sw16/* - homeassistant/components/homekit_controller/* homeassistant/components/homematic/* homeassistant/components/homematic/climate.py homeassistant/components/homematic/cover.py diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 900cbda74d4..99879b60e66 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -62,6 +62,7 @@ CONFIG_ENTRY_HANDLERS = { SERVICE_IKEA_TRADFRI: 'tradfri', 'sonos': 'sonos', SERVICE_IGD: 'upnp', + SERVICE_HOMEKIT: 'homekit_controller', } SERVICE_HANDLERS = { @@ -101,7 +102,6 @@ SERVICE_HANDLERS = { } OPTIONAL_SERVICE_HANDLERS = { - SERVICE_HOMEKIT: ('homekit_controller', None), SERVICE_DLNA_DMR: ('media_player', 'dlna_dmr'), } diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 1b1c7b96b58..f5e61c6060f 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,11 +1,11 @@ """Support for Homekit device discovery.""" import logging -from homeassistant.components.discovery import SERVICE_HOMEKIT -from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity +from homeassistant.exceptions import ConfigEntryNotReady -from .config_flow import load_old_pairings +# We need an import from .config_flow, without it .config_flow is never loaded. +from .config_flow import HomekitControllerFlowHandler # noqa: F401 from .connection import get_accessory_information, HKDevice from .const import ( CONTROLLER, ENTITY_MAP, KNOWN_DEVICES @@ -13,12 +13,6 @@ from .const import ( from .const import DOMAIN # noqa: pylint: disable=unused-import from .storage import EntityMapStorage -HOMEKIT_IGNORE = [ - 'BSB002', - 'Home Assistant Bridge', - 'TRADFRI gateway', -] - _LOGGER = logging.getLogger(__name__) @@ -150,61 +144,29 @@ class HomeKitEntity(Entity): raise NotImplementedError +async def async_setup_entry(hass, entry): + """Set up a HomeKit connection on a config entry.""" + conn = HKDevice(hass, entry, entry.data) + hass.data[KNOWN_DEVICES][conn.unique_id] = conn + + if not await conn.async_setup(): + del hass.data[KNOWN_DEVICES][conn.unique_id] + raise ConfigEntryNotReady + + return True + + 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() - - old_pairings = await hass.async_add_executor_job( - load_old_pairings, - hass - ) - for hkid, pairing_data in old_pairings.items(): - controller.pairings[hkid] = IpPairing(pairing_data) - - def discovery_dispatch(service, discovery_info): - """Dispatcher for Homekit discovery events.""" - # model, id - host = discovery_info['host'] - port = discovery_info['port'] - - # Fold property keys to lower case, making them effectively - # case-insensitive. Some HomeKit devices capitalize them. - properties = { - key.lower(): value - for (key, value) in discovery_info['properties'].items() - } - - model = properties['md'] - hkid = properties['id'] - config_num = int(properties['c#']) - - if model in HOMEKIT_IGNORE: - return - - # Only register a device once, but rescan if the config has changed - if hkid in hass.data[KNOWN_DEVICES]: - device = hass.data[KNOWN_DEVICES][hkid] - if config_num > device.config_num and \ - device.pairing is not None: - device.refresh_entity_map(config_num) - return - - _LOGGER.debug('Discovered unique device %s', hkid) - device = HKDevice(hass, host, port, model, hkid, config_num, config) - device.setup() - + hass.data[CONTROLLER] = homekit.Controller() hass.data[KNOWN_DEVICES] = {} - await hass.async_add_executor_job( - discovery.listen, hass, SERVICE_HOMEKIT, discovery_dispatch) - return True diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index fe15cfe2eab..93279bd626e 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -28,13 +28,25 @@ TARGET_STATE_MAP = { } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit Alarm Control Panel support.""" - if discovery_info is None: - return - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitAlarmControlPanel(accessory, discovery_info)], - True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit alarm control panel.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'security-system': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitAlarmControlPanel(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index a5b70082002..b9922ea43bb 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -8,11 +8,25 @@ from . import KNOWN_DEVICES, HomeKitEntity _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit motion sensor support.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitMotionSensor(accessory, discovery_info)], True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lighting.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'motion': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitMotionSensor(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice): diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 4c299d1c7d0..c5a6ee0c3dc 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -26,11 +26,25 @@ MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} DEFAULT_VALID_MODES = list(MODE_HOMEKIT_TO_HASS) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Homekit climate.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitClimateDevice(accessory, discovery_info)], True) + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'thermostat': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitClimateDevice(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 197d15116b1..6c534bb0c64 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -78,9 +78,8 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): if user_input is not None: key = user_input['device'] - props = self.devices[key]['properties'] - self.hkid = props['id'] - self.model = props['md'] + self.hkid = self.devices[key]['id'] + self.model = self.devices[key]['md'] return await self.async_step_pair() controller = homekit.Controller() @@ -90,11 +89,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): self.devices = {} for host in all_hosts: - status_flags = int(host['properties']['sf']) + status_flags = int(host['sf']) paired = not status_flags & 0x01 if paired: continue - self.devices[host['properties']['id']] = host + self.devices[host['name']] = host if not self.devices: return self.async_abort( @@ -263,13 +262,26 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): async def _entry_from_accessory(self, pairing): """Return a config entry from an initialized bridge.""" - accessories = await self.hass.async_add_executor_job( - pairing.list_accessories_and_characteristics - ) + # The bulk of the pairing record is stored on the config entry. + # A specific exception is the 'accessories' key. This is more + # volatile. We do cache it, but not against the config entry. + # So copy the pairing data and mutate the copy. + pairing_data = pairing.pairing_data.copy() + + # Use the accessories data from the pairing operation if it is + # available. Otherwise request a fresh copy from the API. + # This removes the 'accessories' key from pairing_data at + # the same time. + accessories = pairing_data.pop('accessories', None) + if not accessories: + accessories = await self.hass.async_add_executor_job( + pairing.list_accessories_and_characteristics + ) + bridge_info = get_bridge_information(accessories) name = get_accessory_name(bridge_info) return self.async_create_entry( title=name, - data=pairing.pairing_data, + data=pairing_data, ) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index af438c68164..080d6034237 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -1,14 +1,8 @@ """Helpers for managing a pairing with a HomeKit accessory or bridge.""" import asyncio import logging -import os -from homeassistant.helpers import discovery - -from .const import ( - CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_DEVICES, - PAIRING_FILE, HOMEKIT_DIR, ENTITY_MAP -) +from .const import HOMEKIT_ACCESSORY_DISPATCH, ENTITY_MAP RETRY_INTERVAL = 60 # seconds @@ -53,75 +47,69 @@ def get_accessory_name(accessory_info): class HKDevice(): """HomeKit device.""" - def __init__(self, hass, host, port, model, hkid, config_num, config): + def __init__(self, hass, config_entry, pairing_data): """Initialise a generic HomeKit device.""" - _LOGGER.info("Setting up Homekit device %s", model) - self.hass = hass - self.controller = hass.data[CONTROLLER] + from homekit.controller.ip_implementation import IpPairing + + self.hass = hass + 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) - self.host = host - self.port = port - self.model = model - self.hkid = hkid - self.config_num = config_num - self.config = config - self.configurator = hass.components.configurator self.accessories = {} + 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() # This just tracks aid/iid pairs so we know if a HK service has been # mapped to a HA entity. self.entities = [] + # There are multiple entities sharing a single connection - only + # allow one entity to use pairing at once. self.pairing_lock = asyncio.Lock(loop=hass.loop) - self.pairing = self.controller.pairings.get(hkid) - - hass.data[KNOWN_DEVICES][hkid] = self - - def setup(self): + async def async_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) + if not cache: + return await self.async_refresh_entity_map(self.config_num) self.accessories = cache['accessories'] + self.config_num = cache['config_num'] # Ensure the Pairing object has access to the latest version of the # entity map. self.pairing.pairing_data['accessories'] = self.accessories + self.async_load_platforms() + self.add_entities() return True - def 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. - """ - self.hass.add_job( - self.async_refresh_entity_map, - config_num, - ) - async def async_refresh_entity_map(self, config_num): """Handle setup of a HomeKit accessory.""" # pylint: disable=import-error from homekit.exceptions import AccessoryDisconnectedError try: - self.accessories = await self.hass.async_add_executor_job( - self.pairing.list_accessories_and_characteristics, - ) + async with self.pairing_lock: + self.accessories = await self.hass.async_add_executor_job( + self.pairing.list_accessories_and_characteristics + ) except AccessoryDisconnectedError: # If we fail to refresh this data then we will naturally retry # later when Bonjour spots c# is still not up to date. @@ -139,94 +127,62 @@ class HKDevice(): # aid/iid to GATT characteristics. So push it to there as well. self.pairing.pairing_data['accessories'] = self.accessories - # Register add new entities that are available - await self.hass.async_add_executor_job(self.add_entities) + self.async_load_platforms() + + # Register and add new entities that are available + self.add_entities() return True + 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]) + def add_entities(self): """Process the entity map and create HA entities.""" - # pylint: disable=import-error + self._add_new_entities(self.listeners) + + def _add_new_entities(self, callbacks): from homekit.model.services import ServicesTypes for accessory in self.accessories: aid = accessory['aid'] for service in accessory['services']: iid = service['iid'] + stype = ServicesTypes.get_short(service['type'].upper()) + service['stype'] = stype + if (aid, iid) in self.entities: # Don't add the same entity again continue - devtype = ServicesTypes.get_short(service['type']) - _LOGGER.debug("Found %s", devtype) - service_info = {'serial': self.hkid, - 'aid': aid, - 'iid': service['iid'], - 'model': self.model, - 'device-type': devtype} - component = HOMEKIT_ACCESSORY_DISPATCH.get(devtype, None) - if component is not None: - discovery.load_platform(self.hass, component, DOMAIN, - service_info, self.config) - self.entities.append((aid, iid)) + for listener in callbacks: + if listener(aid, service): + self.entities.append((aid, iid)) + break - def device_config_callback(self, callback_data): - """Handle initial pairing.""" - import homekit # pylint: disable=import-error - code = callback_data.get('code').strip() - try: - self.controller.perform_pairing(self.hkid, self.hkid, code) - except homekit.UnavailableError: - error_msg = "This accessory is already paired to another device. \ - Please reset the accessory and try again." - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) - return - except homekit.AuthenticationError: - error_msg = "Incorrect HomeKit code for {}. Please check it and \ - try again.".format(self.model) - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) - return - except homekit.UnknownError: - error_msg = "Received an unknown error. Please file a bug." - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) - raise + def async_load_platforms(self): + """Load any platforms needed by this HomeKit device.""" + from homekit.model.services import ServicesTypes - self.pairing = self.controller.pairings.get(self.hkid) - if self.pairing is not None: - 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.setup() - else: - error_msg = "Unable to pair, please try again" - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) + 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 - def configure(self): - """Obtain the pairing code for a HomeKit device.""" - description = "Please enter the HomeKit code for your {}".format( - self.model) - self.hass.data[DOMAIN+self.hkid] = \ - self.configurator.request_config(self.model, - self.device_config_callback, - description=description, - submit_caption="submit", - fields=[{'id': 'code', - 'name': 'HomeKit code', - 'type': 'string'}]) + 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) async def get_characteristics(self, *args, **kwargs): """Read latest state from homekit accessory.""" @@ -261,4 +217,4 @@ class HKDevice(): This id is random and will change if a device undergoes a hard reset. """ - return self.hkid + return self.pairing_data['AccessoryPairingID'] diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index bd466d074d0..7f3761d33a4 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -35,18 +35,30 @@ CURRENT_WINDOW_STATE_MAP = { } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up HomeKit Cover support.""" - if discovery_info is None: - return - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass - if discovery_info['device-type'] == 'garage-door-opener': - add_entities([HomeKitGarageDoorCover(accessory, discovery_info)], - True) - else: - add_entities([HomeKitWindowCover(accessory, discovery_info)], - True) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit covers.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + info = {'aid': aid, 'iid': service['iid']} + if service['stype'] == 'garage-door-opener': + async_add_entities([HomeKitGarageDoorCover(conn, info)], True) + return True + + if service['stype'] in ('window-covering', 'window'): + async_add_entities([HomeKitWindowCover(conn, info)], True) + return True + + return False + + conn.add_listener(async_add_service) class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index a139b1f2932..248412c91a3 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -10,11 +10,25 @@ from . import KNOWN_DEVICES, HomeKitEntity _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit lighting.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitLight(accessory, discovery_info)], True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lightbulb.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'lightbulb': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitLight(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitLight(HomeKitEntity, Light): diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 67de2bfaf3f..1449f265245 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -24,12 +24,25 @@ TARGET_STATE_MAP = { } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit Lock support.""" - if discovery_info is None: - return - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitLock(accessory, discovery_info)], True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lock.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] != 'lock-mechanism': + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitLock(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitLock(HomeKitEntity, LockDevice): diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index c1b923a5677..53476b8ba6d 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -5,7 +5,7 @@ "requirements": [ "homekit[IP]==0.14.0" ], - "dependencies": ["configurator"], + "dependencies": [], "codeowners": [ "@Jc2k" ] diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index b377da80142..f6f450b2b01 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -11,21 +11,35 @@ UNIT_PERCENT = "%" UNIT_LUX = "lux" -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit sensor support.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - devtype = discovery_info['device-type'] +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit covers.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + devtype = service['stype'] + info = {'aid': aid, 'iid': service['iid']} if devtype == 'humidity': - add_entities( - [HomeKitHumiditySensor(accessory, discovery_info)], True) - elif devtype == 'temperature': - add_entities( - [HomeKitTemperatureSensor(accessory, discovery_info)], True) - elif devtype == 'light': - add_entities( - [HomeKitLightSensor(accessory, discovery_info)], True) + async_add_entities([HomeKitHumiditySensor(conn, info)], True) + return True + + if devtype == 'temperature': + async_add_entities([HomeKitTemperatureSensor(conn, info)], True) + return True + + if devtype == 'light': + async_add_entities([HomeKitLightSensor(conn, info)], True) + return True + + return False + + conn.add_listener(async_add_service) class HomeKitHumiditySensor(HomeKitEntity): diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index c09502373a6..670ddd4db5b 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -10,11 +10,25 @@ OUTLET_IN_USE = "outlet_in_use" _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit switch support.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitSwitch(accessory, discovery_info)], True) +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Legacy set up platform.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit lock.""" + hkid = config_entry.data['AccessoryPairingID'] + conn = hass.data[KNOWN_DEVICES][hkid] + + def async_add_service(aid, service): + if service['stype'] not in ('switch', 'outlet'): + return False + info = {'aid': aid, 'iid': service['iid']} + async_add_entities([HomeKitSwitch(conn, info)], True) + return True + + conn.add_listener(async_add_service) class HomeKitSwitch(HomeKitEntity, SwitchDevice): diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 593b402a3fd..6f4e57203f1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -155,6 +155,7 @@ FLOWS = [ 'gpslogger', 'hangouts', 'heos', + 'homekit_controller', 'homematicip_cloud', 'hue', 'ifttt', diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 8635e0b6d05..87482f8e92c 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -9,13 +9,15 @@ from homekit.model.characteristics import ( AbstractCharacteristic, CharacteristicPermissions, CharacteristicsTypes) from homekit.model import Accessory, get_id from homekit.exceptions import AccessoryNotFoundError -from homeassistant.components.homekit_controller import SERVICE_HOMEKIT + +from homeassistant import config_entries from homeassistant.components.homekit_controller.const import ( CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH) +from homeassistant.components.homekit_controller import ( + async_setup_entry, config_flow) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import ( - async_fire_time_changed, async_fire_service_discovered, load_fixture) +from tests.common import async_fire_time_changed, load_fixture class FakePairing: @@ -217,26 +219,36 @@ async def setup_platform(hass): return fake_controller -async def setup_test_accessories(hass, accessories, capitalize=False): - """Load a fake homekit accessory based on a homekit accessory model. - - If capitalize is True, property names will be in upper case. - """ +async def setup_test_accessories(hass, accessories): + """Load a fake homekit device based on captured JSON profile.""" fake_controller = await setup_platform(hass) pairing = fake_controller.add(accessories) discovery_info = { + 'name': 'TestDevice', 'host': '127.0.0.1', 'port': 8080, 'properties': { - ('MD' if capitalize else 'md'): 'TestDevice', - ('ID' if capitalize else 'id'): '00:00:00:00:00:00', - ('C#' if capitalize else 'c#'): 1, + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, } } - async_fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) - await hass.async_block_till_done() + pairing.pairing_data.update({ + 'AccessoryPairingID': discovery_info['properties']['id'], + }) + + config_entry = config_entries.ConfigEntry( + 1, 'homekit_controller', 'TestData', pairing.pairing_data, + 'test', config_entries.CONN_CLASS_LOCAL_PUSH + ) + + pairing_cls_loc = 'homekit.controller.ip_implementation.IpPairing' + with mock.patch(pairing_cls_loc) as pairing_cls: + pairing_cls.return_value = pairing + await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() return pairing @@ -249,6 +261,7 @@ async def device_config_changed(hass, accessories): pairing.accessories = accessories discovery_info = { + 'name': 'TestDevice', 'host': '127.0.0.1', 'port': 8080, 'properties': { @@ -259,7 +272,14 @@ async def device_config_changed(hass, accessories): } } - async_fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) + # Config Flow will abort and notify us if the discovery event is of + # interest - in this case c# has incremented + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + flow.context = {} + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' # Wait for services to reconfigure await hass.async_block_till_done() @@ -285,7 +305,6 @@ async def setup_test_component(hass, services, capitalize=False, suffix=None): accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') accessory.services.extend(services) - pairing = await setup_test_accessories(hass, [accessory], capitalize) - + pairing = await setup_test_accessories(hass, [accessory]) entity = 'testdevice' if suffix is None else 'testdevice_{}'.format(suffix) return Helper(hass, '.'.join((domain, entity)), pairing, accessory) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 23d0a32f7ad..166ef32784b 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -7,11 +7,15 @@ https://github.com/home-assistant/home-assistant/issues/15336 from unittest import mock from homekit import AccessoryDisconnectedError +import pytest +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_HUMIDITY, SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW, SUPPORT_OPERATION_MODE) + + from tests.components.homekit_controller.common import ( FakePairing, device_config_changed, setup_accessories_from_file, setup_test_accessories, Helper @@ -110,14 +114,19 @@ async def test_ecobee3_setup_connection_failure(hass): list_accessories = 'list_accessories_and_characteristics' with mock.patch.object(FakePairing, list_accessories) as laac: laac.side_effect = AccessoryDisconnectedError('Connection failed') - await setup_test_accessories(hass, accessories) + + # If there is no cached entity map and the accessory connection is + # failing then we have to fail the config entry setup. + with pytest.raises(ConfigEntryNotReady): + await setup_test_accessories(hass, accessories) climate = entity_registry.async_get('climate.homew') assert climate is None - # When a regular discovery event happens it should trigger another scan - # which should cause our entities to be added. - await device_config_changed(hass, accessories) + # When accessory raises ConfigEntryNoteReady HA will retry - lets make + # sure there is no cruft causing conflicts left behind by now doing + # a successful setup. + await setup_test_accessories(hass, accessories) climate = entity_registry.async_get('climate.homew') assert climate.unique_id == 'homekit-123456789012-16' diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index c8b81a88478..33160abaa55 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -627,12 +627,10 @@ async def test_user_works(hass): 'name': 'TestDevice', 'host': '127.0.0.1', 'port': 8080, - 'properties': { - 'md': 'TestDevice', - 'id': '00:00:00:00:00:00', - 'c#': 1, - 'sf': 1, - } + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, } pairing = mock.Mock(pairing_data={ @@ -666,7 +664,7 @@ async def test_user_works(hass): assert result['step_id'] == 'user' result = await flow.async_step_user({ - 'device': '00:00:00:00:00:00', + 'device': 'TestDevice', }) assert result['type'] == 'form' assert result['step_id'] == 'pair' @@ -701,12 +699,10 @@ async def test_user_no_unpaired_devices(hass): 'name': 'TestDevice', 'host': '127.0.0.1', 'port': 8080, - 'properties': { - 'md': 'TestDevice', - 'id': '00:00:00:00:00:00', - 'c#': 1, - 'sf': 0, - } + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, } with mock.patch('homekit.Controller') as controller_cls: diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 19ccc21b7e8..66d4505d6fb 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -71,20 +71,6 @@ def create_window_covering_service_with_v_tilt(): return service -async def test_accept_capitalized_property_names(hass, utcnow): - """Test that we can handle a device with capitalized property names.""" - window_cover = create_window_covering_service() - helper = await setup_test_component(hass, [window_cover], capitalize=True) - - # The specific interaction we do here doesn't matter; we just need - # to do *something* to ensure that discovery properly dealt with the - # capitalized property names. - await hass.services.async_call('cover', 'open_cover', { - 'entity_id': helper.entity_id, - }, blocking=True) - assert helper.characteristics[POSITION_TARGET].value == 100 - - async def test_change_window_cover_state(hass, utcnow): """Test that we can turn a HomeKit alarm on and off again.""" window_cover = create_window_covering_service()