diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json new file mode 100644 index 00000000000..6cbd172085e --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "HomeKit Accessory", + "step": { + "user": { + "title": "Pair with HomeKit Accessory", + "description": "Select the device you want to pair with", + "data": { + "device": "Device" + } + }, + "pair": { + "title": "Pair with {{ model }}", + "description": "Enter your HomeKit pairing code to use this accessory", + "data": { + "pairing_code": "Pairing Code" + } + } + }, + "error": { + "unable_to_pair": "Unable to pair, please try again.", + "unknown_error": "Device reported an unknown error. Pairing failed.", + "authentication_error": "Incorrect HomeKit code. Please check it and try again." + }, + "abort": { + "no_devices": "No unpaired devices could be found", + "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", + "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", + "already_configured": "Accessory is already configured with this controller.", + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed." + } + } +} diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index e4bfdc24ffb..3e481db96da 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -8,38 +8,22 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import call_later +from .const import ( + CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_ACCESSORIES, + KNOWN_DEVICES +) + + REQUIREMENTS = ['homekit==0.12.2'] -DOMAIN = 'homekit_controller' HOMEKIT_DIR = '.homekit' -# Mapping from Homekit type to component. -HOMEKIT_ACCESSORY_DISPATCH = { - 'lightbulb': 'light', - 'outlet': 'switch', - 'switch': 'switch', - 'thermostat': 'climate', - 'security-system': 'alarm_control_panel', - 'garage-door-opener': 'cover', - 'window': 'cover', - 'window-covering': 'cover', - 'lock-mechanism': 'lock', - 'motion': 'binary_sensor', - 'humidity': 'sensor', - 'light': 'sensor', - 'temperature': 'sensor' -} - HOMEKIT_IGNORE = [ 'BSB002', 'Home Assistant Bridge', 'TRADFRI gateway', ] -KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) -KNOWN_DEVICES = "{}-devices".format(DOMAIN) -CONTROLLER = "{}-controller".format(DOMAIN) - _LOGGER = logging.getLogger(__name__) REQUEST_TIMEOUT = 5 # seconds diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py new file mode 100644 index 00000000000..5b4d5e81e29 --- /dev/null +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -0,0 +1,260 @@ +"""Config flow to configure homekit_controller.""" +import os +import json +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback + +from .const import DOMAIN, KNOWN_DEVICES +from .connection import get_bridge_information, get_accessory_name + + +HOMEKIT_IGNORE = [ + 'BSB002', + 'Home Assistant Bridge', + 'TRADFRI gateway', +] +HOMEKIT_DIR = '.homekit' +PAIRING_FILE = 'pairing.json' + +_LOGGER = logging.getLogger(__name__) + + +def load_old_pairings(hass): + """Load any old pairings from on-disk json fragments.""" + old_pairings = {} + + data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR) + pairing_file = os.path.join(data_dir, PAIRING_FILE) + + # Find any pairings created with in HA 0.85 / 0.86 + if os.path.exists(pairing_file): + with open(pairing_file) as pairing_file: + old_pairings.update(json.load(pairing_file)) + + # Find any pairings created in HA <= 0.84 + if os.path.exists(data_dir): + for device in os.listdir(data_dir): + if not device.startswith('hk-'): + continue + alias = device[3:] + if alias in old_pairings: + continue + with open(os.path.join(data_dir, device)) as pairing_data_fp: + old_pairings[alias] = json.load(pairing_data_fp) + + return old_pairings + + +@callback +def find_existing_host(hass, serial): + """Return a set of the configured hosts.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data['AccessoryPairingID'] == serial: + return entry + + +@config_entries.HANDLERS.register(DOMAIN) +class HomekitControllerFlowHandler(config_entries.ConfigFlow): + """Handle a HomeKit config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the homekit_controller flow.""" + self.model = None + self.hkid = None + self.devices = {} + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + import homekit + + errors = {} + + if user_input is not None: + key = user_input['device'] + props = self.devices[key]['properties'] + self.hkid = props['id'] + self.model = props['md'] + return await self.async_step_pair() + + controller = homekit.Controller() + all_hosts = await self.hass.async_add_executor_job( + controller.discover, 5 + ) + + self.devices = {} + for host in all_hosts: + status_flags = int(host['properties']['sf']) + paired = not status_flags & 0x01 + if paired: + continue + self.devices[host['properties']['id']] = host + + if not self.devices: + return self.async_abort( + reason='no_devices' + ) + + return self.async_show_form( + step_id='user', + errors=errors, + data_schema=vol.Schema({ + vol.Required('device'): vol.In(self.devices.keys()), + }) + ) + + async def async_step_discovery(self, discovery_info): + """Handle a discovered HomeKit accessory. + + This flow is triggered by the discovery component. + """ + # Normalize properties from discovery + # homekit_python has code to do this, but not in a form we can + # easily use, so do the bare minimum ourselves here instead. + properties = { + key.lower(): value + for (key, value) in discovery_info['properties'].items() + } + + # The hkid is a unique random number that looks like a pairing code. + # It changes if a device is factory reset. + hkid = properties['id'] + model = properties['md'] + + status_flags = int(properties['sf']) + paired = not status_flags & 0x01 + + # The configuration number increases every time the characteristic map + # needs updating. Some devices use a slightly off-spec name so handle + # both cases. + try: + config_num = int(properties['c#']) + except KeyError: + _LOGGER.warning( + "HomeKit device %s: c# not exposed, in violation of spec", + hkid) + config_num = None + + if paired: + if hkid in self.hass.data.get(KNOWN_DEVICES, {}): + # The device is already paired and known to us + # According to spec we should monitor c# (config_num) for + # changes. If it changes, we check for new entities + conn = self.hass.data[KNOWN_DEVICES][hkid] + if conn.config_num != config_num: + _LOGGER.debug( + "HomeKit info %s: c# incremented, refreshing entities", + hkid) + self.hass.async_create_task( + conn.async_config_num_changed(config_num)) + return self.async_abort(reason='already_configured') + + old_pairings = await self.hass.async_add_executor_job( + load_old_pairings, + self.hass + ) + + if hkid in old_pairings: + return await self.async_import_legacy_pairing( + properties, + old_pairings[hkid] + ) + + # Device is paired but not to us - ignore it + _LOGGER.debug("HomeKit device %s ignored as already paired", hkid) + return self.async_abort(reason='already_paired') + + # Devices in HOMEKIT_IGNORE have native local integrations - users + # should be encouraged to use native integration and not confused + # by alternative HK API. + if model in HOMEKIT_IGNORE: + return self.async_abort(reason='ignored_model') + + # Device isn't paired with us or anyone else. + # But we have a 'complete' config entry for it - that is probably + # invalid. Remove it automatically. + existing = find_existing_host(self.hass, hkid) + if existing: + await self.hass.config_entries.async_remove(existing.entry_id) + + self.model = model + self.hkid = hkid + return await self.async_step_pair() + + async def async_import_legacy_pairing(self, discovery_props, pairing_data): + """Migrate a legacy pairing to config entries.""" + from homekit.controller import Pairing + + hkid = discovery_props['id'] + + existing = find_existing_host(self.hass, hkid) + if existing: + _LOGGER.info( + ("Legacy configuration for homekit accessory %s" + "not loaded as already migrated"), hkid) + return self.async_abort(reason='already_configured') + + _LOGGER.info( + ("Legacy configuration %s for homekit" + "accessory migrated to config entries"), hkid) + + pairing = Pairing(pairing_data) + + return await self._entry_from_accessory(pairing) + + async def async_step_pair(self, pair_info=None): + """Pair with a new HomeKit accessory.""" + import homekit # pylint: disable=import-error + + errors = {} + + if pair_info: + code = pair_info['pairing_code'] + controller = homekit.Controller() + try: + await self.hass.async_add_executor_job( + controller.perform_pairing, self.hkid, self.hkid, code + ) + + pairing = controller.pairings.get(self.hkid) + if pairing: + return await self._entry_from_accessory( + pairing) + + errors['pairing_code'] = 'unable_to_pair' + except homekit.AuthenticationError: + errors['pairing_code'] = 'authentication_error' + except homekit.UnknownError: + errors['pairing_code'] = 'unknown_error' + except homekit.UnavailableError: + return self.async_abort(reason='already_paired') + + return self.async_show_form( + step_id='pair', + description_placeholders={ + 'model': self.model, + }, + errors=errors, + data_schema=vol.Schema({ + vol.Required('pairing_code'): vol.All(str, vol.Strip), + }) + ) + + 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 + ) + bridge_info = get_bridge_information(accessories) + name = get_accessory_name(bridge_info) + + return self.async_create_entry( + title=name, + data=pairing.pairing_data, + ) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py new file mode 100644 index 00000000000..5550846120b --- /dev/null +++ b/homeassistant/components/homekit_controller/connection.py @@ -0,0 +1,35 @@ +"""Helpers for managing a pairing with a HomeKit accessory or bridge.""" + + +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 diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py new file mode 100644 index 00000000000..873f6b343d2 --- /dev/null +++ b/homeassistant/components/homekit_controller/const.py @@ -0,0 +1,23 @@ +"""Constants for the homekit_controller component.""" +DOMAIN = 'homekit_controller' + +KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) +KNOWN_DEVICES = "{}-devices".format(DOMAIN) +CONTROLLER = "{}-controller".format(DOMAIN) + +# Mapping from Homekit type to component. +HOMEKIT_ACCESSORY_DISPATCH = { + 'lightbulb': 'light', + 'outlet': 'switch', + 'switch': 'switch', + 'thermostat': 'climate', + 'security-system': 'alarm_control_panel', + 'garage-door-opener': 'cover', + 'window': 'cover', + 'window-covering': 'cover', + 'lock-mechanism': 'lock', + 'motion': 'binary_sensor', + 'humidity': 'sensor', + 'light': 'sensor', + 'temperature': 'sensor' +} diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json new file mode 100644 index 00000000000..6cbd172085e --- /dev/null +++ b/homeassistant/components/homekit_controller/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "HomeKit Accessory", + "step": { + "user": { + "title": "Pair with HomeKit Accessory", + "description": "Select the device you want to pair with", + "data": { + "device": "Device" + } + }, + "pair": { + "title": "Pair with {{ model }}", + "description": "Enter your HomeKit pairing code to use this accessory", + "data": { + "pairing_code": "Pairing Code" + } + } + }, + "error": { + "unable_to_pair": "Unable to pair, please try again.", + "unknown_error": "Device reported an unknown error. Pairing failed.", + "authentication_error": "Incorrect HomeKit code. Please check it and try again." + }, + "abort": { + "no_devices": "No unpaired devices could be found", + "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", + "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", + "already_configured": "Accessory is already configured with this controller.", + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed." + } + } +} diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 9409e3affad..29e7f4e986e 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -22,38 +22,47 @@ class FakePairing: class. """ - def __init__(self, accessory): + def __init__(self, accessories): """Create a fake pairing from an accessory model.""" - self.accessory = accessory - self.pairing_data = { - 'accessories': self.list_accessories_and_characteristics() - } + self.accessories = accessories + self.pairing_data = {} def list_accessories_and_characteristics(self): """Fake implementation of list_accessories_and_characteristics.""" - return [self.accessory.to_accessory_and_service_list()] + accessories = [ + a.to_accessory_and_service_list() for a in self.accessories + ] + # replicate what happens upstream right now + self.pairing_data['accessories'] = accessories + return accessories def get_characteristics(self, characteristics): """Fake implementation of get_characteristics.""" results = {} for aid, cid in characteristics: - for service in self.accessory.services: - for char in service.characteristics: - if char.iid != cid: - continue - results[(aid, cid)] = { - 'value': char.get_value() - } + for accessory in self.accessories: + if aid != accessory.aid: + continue + for service in accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + results[(aid, cid)] = { + 'value': char.get_value() + } return results def put_characteristics(self, characteristics): """Fake implementation of put_characteristics.""" - for _, cid, new_val in characteristics: - for service in self.accessory.services: - for char in service.characteristics: - if char.iid != cid: - continue - char.set_value(new_val) + for aid, cid, new_val in characteristics: + for accessory in self.accessories: + if aid != accessory.aid: + continue + for service in accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + char.set_value(new_val) class FakeController: @@ -68,9 +77,9 @@ class FakeController: """Create a Fake controller with no pairings.""" self.pairings = {} - def add(self, accessory): + def add(self, accessories): """Create and register a fake pairing for a simulated accessory.""" - pairing = FakePairing(accessory) + pairing = FakePairing(accessories) self.pairings['00:00:00:00:00:00'] = pairing return pairing @@ -134,6 +143,20 @@ class FakeService(AbstractService): return char +async def setup_platform(hass): + """Load the platform but with a fake Controller API.""" + config = { + 'discovery': { + } + } + + with mock.patch('homekit.Controller') as controller: + fake_controller = controller.return_value = FakeController() + await async_setup_component(hass, DOMAIN, config) + + return fake_controller + + async def setup_test_component(hass, services, capitalize=False, suffix=None): """Load a fake homekit accessory based on a homekit accessory model. @@ -150,18 +173,11 @@ async def setup_test_component(hass, services, capitalize=False, suffix=None): assert domain, 'Cannot map test homekit services to homeassistant domain' - config = { - 'discovery': { - } - } - - with mock.patch('homekit.Controller') as controller: - fake_controller = controller.return_value = FakeController() - await async_setup_component(hass, DOMAIN, config) + fake_controller = await setup_platform(hass) accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') accessory.services.extend(services) - pairing = fake_controller.add(accessory) + pairing = fake_controller.add([accessory]) discovery_info = { 'host': '127.0.0.1', diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py new file mode 100644 index 00000000000..cf4da597b12 --- /dev/null +++ b/tests/components/homekit_controller/test_config_flow.py @@ -0,0 +1,783 @@ +"""Tests for homekit_controller config flow.""" +import json +from unittest import mock + +import homekit + +from homeassistant.components.homekit_controller import config_flow +from homeassistant.components.homekit_controller.const import KNOWN_DEVICES +from tests.common import MockConfigEntry +from tests.components.homekit_controller.common import ( + Accessory, FakeService, setup_platform +) + + +async def test_discovery_works(hass): + """Test a device being discovered.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_discovery_works_upper_case(hass): + """Test a device being discovered.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'MD': 'TestDevice', + 'ID': '00:00:00:00:00:00', + 'C#': 1, + 'SF': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_discovery_works_missing_csharp(hass): + """Test a device being discovered that has missing mdns attrs.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_pair_already_paired_1(hass): + """Already paired.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_paired' + + +async def test_discovery_ignored_model(hass): + """Already paired.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'BSB002', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'ignored_model' + + +async def test_discovery_invalid_config_entry(hass): + """There is already a config entry for the pairing id but its invalid.""" + MockConfigEntry(domain='homekit_controller', data={ + 'AccessoryPairingID': '00:00:00:00:00:00' + }).add_to_hass(hass) + + # We just added a mock config entry so it must be visible in hass + assert len(hass.config_entries.async_entries()) == 1 + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + # Discovery of a HKID that is in a pairable state but for which there is + # already a config entry - in that case the stale config entry is + # automatically removed. + config_entry_count = len(hass.config_entries.async_entries()) + assert config_entry_count == 0 + + +async def test_discovery_already_configured(hass): + """Already configured.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + await setup_platform(hass) + + conn = mock.Mock() + conn.config_num = 1 + hass.data[KNOWN_DEVICES]['00:00:00:00:00:00'] = conn + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + assert conn.async_config_num_changed.call_count == 0 + + +async def test_discovery_already_configured_config_change(hass): + """Already configured.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 2, + 'sf': 0, + } + } + + await setup_platform(hass) + + conn = mock.Mock() + conn.config_num = 1 + hass.data[KNOWN_DEVICES]['00:00:00:00:00:00'] = conn + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + assert conn.async_config_num_changed.call_args == mock.call(2) + + +async def test_pair_unable_to_pair(hass): + """Pairing completed without exception, but didn't create a pairing.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == 'unable_to_pair' + + +async def test_pair_authentication_error(hass): + """Pairing code is incorrect.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + exc = homekit.AuthenticationError('Invalid pairing code') + controller.perform_pairing.side_effect = exc + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == 'authentication_error' + + +async def test_pair_unknown_error(hass): + """Pairing failed for an unknown rason.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + exc = homekit.UnknownError('Unknown error') + controller.perform_pairing.side_effect = exc + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == 'unknown_error' + + +async def test_pair_already_paired(hass): + """Device is already paired.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + exc = homekit.UnavailableError('Unavailable error') + controller.perform_pairing.side_effect = exc + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'abort' + assert result['reason'] == 'already_paired' + + +async def test_import_works(hass): + """Test a device being discovered.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + import_info = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls.return_value = pairing + result = await flow.async_import_legacy_pairing( + discovery_info['properties'], import_info) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_import_already_configured(hass): + """Test importing a device from .homekit that is already a ConfigEntry.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + import_info = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + config_entry = MockConfigEntry( + domain='homekit_controller', + data=import_info + ) + config_entry.add_to_hass(hass) + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_import_legacy_pairing( + discovery_info['properties'], import_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + +async def test_user_works(hass): + """Test user initiated disovers devices.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + controller.discover.return_value = [ + discovery_info, + ] + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_user() + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + result = await flow.async_step_user({ + 'device': '00:00:00:00:00:00', + }) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_user_no_devices(hass): + """Test user initiated pairing where no devices discovered.""" + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value.discover.return_value = [] + result = await flow.async_step_user() + + assert result['type'] == 'abort' + assert result['reason'] == 'no_devices' + + +async def test_user_no_unpaired_devices(hass): + """Test user initiated pairing where no unpaired devices discovered.""" + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value.discover.return_value = [ + discovery_info, + ] + result = await flow.async_step_user() + + assert result['type'] == 'abort' + assert result['reason'] == 'no_devices' + + +async def test_parse_new_homekit_json(hass): + """Test migrating recent .homekit/pairings.json files.""" + service = FakeService('public.hap.service.lightbulb') + on_char = service.add_characteristic('on') + on_char.value = 1 + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.append(service) + + fake_controller = await setup_platform(hass) + pairing = fake_controller.add([accessory]) + pairing.pairing_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + mock_path = mock.Mock() + mock_path.exists.side_effect = [True, False] + + read_data = { + '00:00:00:00:00:00': pairing.pairing_data, + } + mock_open = mock.mock_open(read_data=json.dumps(read_data)) + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls.return_value = pairing + with mock.patch('builtins.open', mock_open): + with mock.patch('os.path', mock_path): + result = await flow.async_step_discovery(discovery_info) + + assert result['type'] == 'create_entry' + assert result['title'] == 'TestDevice' + assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' + + +async def test_parse_old_homekit_json(hass): + """Test migrating original .homekit/hk-00:00:00:00:00:00 files.""" + service = FakeService('public.hap.service.lightbulb') + on_char = service.add_characteristic('on') + on_char.value = 1 + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.append(service) + + fake_controller = await setup_platform(hass) + pairing = fake_controller.add([accessory]) + pairing.pairing_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + mock_path = mock.Mock() + mock_path.exists.side_effect = [False, True] + + mock_listdir = mock.Mock() + mock_listdir.return_value = [ + 'hk-00:00:00:00:00:00', + 'pairings.json' + ] + + read_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + mock_open = mock.mock_open(read_data=json.dumps(read_data)) + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls.return_value = pairing + with mock.patch('builtins.open', mock_open): + with mock.patch('os.path', mock_path): + with mock.patch('os.listdir', mock_listdir): + result = await flow.async_step_discovery(discovery_info) + + assert result['type'] == 'create_entry' + assert result['title'] == 'TestDevice' + assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' + + +async def test_parse_overlapping_homekit_json(hass): + """Test migrating .homekit/pairings.json files when hk- exists too.""" + service = FakeService('public.hap.service.lightbulb') + on_char = service.add_characteristic('on') + on_char.value = 1 + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.append(service) + + fake_controller = await setup_platform(hass) + pairing = fake_controller.add([accessory]) + pairing.pairing_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + mock_listdir = mock.Mock() + mock_listdir.return_value = [ + 'hk-00:00:00:00:00:00', + 'pairings.json' + ] + + mock_path = mock.Mock() + mock_path.exists.side_effect = [True, True] + + # First file to get loaded is .homekit/pairing.json + read_data_1 = { + '00:00:00:00:00:00': { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + } + mock_open_1 = mock.mock_open(read_data=json.dumps(read_data_1)) + + # Second file to get loaded is .homekit/hk-00:00:00:00:00:00 + read_data_2 = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + mock_open_2 = mock.mock_open(read_data=json.dumps(read_data_2)) + + side_effects = [mock_open_1.return_value, mock_open_2.return_value] + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls.return_value = pairing + with mock.patch('builtins.open', side_effect=side_effects): + with mock.patch('os.path', mock_path): + with mock.patch('os.listdir', mock_listdir): + result = await flow.async_step_discovery(discovery_info) + + await hass.async_block_till_done() + + assert result['type'] == 'create_entry' + assert result['title'] == 'TestDevice' + assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00'