From 793592b2b841de658c75147b65d44f1e13546867 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2020 23:05:06 -0500 Subject: [PATCH] Config flow for homekit (#34560) * Config flow for homekit Allows multiple homekit bridges to run HAP-python state is now stored at .storage/homekit.{entry_id}.state aids is now stored at .storage/homekit.{entry_id}.aids Overcomes 150 device limit by supporting multiple bridges. Name and port are now automatically allocated to avoid conflicts which was one of the main reasons pairing failed. YAML configuration remains available in order to offer entity specific configuration. Entries created by config flow can add and remove included domains and entities without having to restart * Fix services as there are multiple now * migrate in executor * drop title from strings * Update homeassistant/components/homekit/strings.json Co-authored-by: Paulus Schoutsen * Make auto_start advanced mode only, add coverage * put back title * more references * delete port since manual config is no longer needed Co-authored-by: Paulus Schoutsen --- homeassistant/components/homekit/__init__.py | 488 ++++++++++-------- .../components/homekit/accessories.py | 153 +++++- .../components/homekit/aidmanager.py | 12 +- .../components/homekit/config_flow.py | 301 +++++++++++ homeassistant/components/homekit/const.py | 16 +- .../components/homekit/manifest.json | 3 +- homeassistant/components/homekit/strings.json | 54 ++ .../components/homekit/translations/en.json | 54 ++ .../components/homekit/type_covers.py | 3 +- homeassistant/components/homekit/type_fans.py | 3 +- .../components/homekit/type_lights.py | 3 +- .../components/homekit/type_locks.py | 3 +- .../components/homekit/type_media_players.py | 3 +- .../homekit/type_security_systems.py | 3 +- .../components/homekit/type_sensors.py | 3 +- .../components/homekit/type_switches.py | 3 +- .../components/homekit/type_thermostats.py | 3 +- homeassistant/components/homekit/util.py | 107 +++- homeassistant/generated/config_flows.py | 1 + homeassistant/helpers/entityfilter.py | 30 +- tests/components/homekit/test_accessories.py | 8 +- tests/components/homekit/test_aidmanager.py | 15 +- tests/components/homekit/test_config_flow.py | 259 ++++++++++ .../homekit/test_get_accessories.py | 27 +- tests/components/homekit/test_homekit.py | 441 ++++++++++++++-- .../homekit/test_type_media_players.py | 1 + .../homekit/test_type_security_systems.py | 1 + tests/components/homekit/test_util.py | 44 +- tests/components/homekit/util.py | 34 ++ 29 files changed, 1754 insertions(+), 322 deletions(-) create mode 100644 homeassistant/components/homekit/config_flow.py create mode 100644 homeassistant/components/homekit/strings.json create mode 100644 homeassistant/components/homekit/translations/en.json create mode 100644 tests/components/homekit/test_config_flow.py create mode 100644 tests/components/homekit/util.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index c77ec36ccf3..184fce2309b 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1,4 +1,5 @@ """Support for Apple HomeKit.""" +import asyncio import ipaddress import logging @@ -6,41 +7,36 @@ from aiohttp import web import voluptuous as vol from zeroconf import InterfaceChoice -from homeassistant.components import cover, vacuum from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING -from homeassistant.components.cover import DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE from homeassistant.components.http import HomeAssistantView -from homeassistant.components.media_player import DEVICE_CLASS_TV +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SERVICE, - ATTR_SUPPORTED_FEATURES, - ATTR_UNIT_OF_MEASUREMENT, CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, - CONF_TYPE, DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, - EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - UNIT_PERCENTAGE, ) -from homeassistant.core import callback -from homeassistant.exceptions import Unauthorized -from homeassistant.helpers import entity_registry +from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady, Unauthorized +from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entityfilter import FILTER_SCHEMA +from homeassistant.helpers.entityfilter import ( + BASE_FILTER_SCHEMA, + CONF_EXCLUDE_DOMAINS, + CONF_EXCLUDE_ENTITIES, + CONF_INCLUDE_DOMAINS, + CONF_INCLUDE_ENTITIES, + convert_filter, +) from homeassistant.util import get_local_ip -from homeassistant.util.decorator import Registry +from .accessories import get_accessory from .aidmanager import AccessoryAidStorage from .const import ( AID_STORAGE, @@ -50,43 +46,41 @@ from .const import ( CONF_ADVERTISE_IP, CONF_AUTO_START, CONF_ENTITY_CONFIG, - CONF_FEATURE_LIST, + CONF_ENTRY_INDEX, CONF_FILTER, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, CONF_SAFE_MODE, CONF_ZEROCONF_DEFAULT_INTERFACE, + CONFIG_OPTIONS, DEFAULT_AUTO_START, DEFAULT_PORT, DEFAULT_SAFE_MODE, DEFAULT_ZEROCONF_DEFAULT_INTERFACE, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_PM25, DOMAIN, EVENT_HOMEKIT_CHANGED, - HOMEKIT_FILE, + HOMEKIT, HOMEKIT_PAIRING_QR, HOMEKIT_PAIRING_QR_SECRET, + MANUFACTURER, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, - TYPE_FAUCET, - TYPE_OUTLET, - TYPE_SHOWER, - TYPE_SPRINKLER, - TYPE_SWITCH, - TYPE_VALVE, + SHUTDOWN_TIMEOUT, + UNDO_UPDATE_LISTENER, ) from .util import ( + dismiss_setup_message, + get_persist_fullpath_for_entry_id, + migrate_filesystem_state_data_for_primary_imported_entry_id, + port_is_available, + remove_state_files_for_entry_id, show_setup_message, validate_entity_config, - validate_media_player_features, ) _LOGGER = logging.getLogger(__name__) MAX_DEVICES = 150 -TYPES = Registry() # #### Driver Status #### STATUS_READY = 0 @@ -94,66 +88,139 @@ STATUS_RUNNING = 1 STATUS_STOPPED = 2 STATUS_WAIT = 3 -SWITCH_TYPES = { - TYPE_FAUCET: "Valve", - TYPE_OUTLET: "Outlet", - TYPE_SHOWER: "Valve", - TYPE_SPRINKLER: "Valve", - TYPE_SWITCH: "Switch", - TYPE_VALVE: "Valve", -} -CONFIG_SCHEMA = vol.Schema( +def _has_all_unique_names_and_ports(bridges): + """Validate that each homekit bridge configured has a unique name.""" + names = [bridge[CONF_NAME] for bridge in bridges] + ports = [bridge[CONF_PORT] for bridge in bridges] + vol.Schema(vol.Unique())(names) + vol.Schema(vol.Unique())(ports) + return bridges + + +BRIDGE_SCHEMA = vol.Schema( { - DOMAIN: vol.All( - { - vol.Optional(CONF_NAME, default=BRIDGE_NAME): vol.All( - cv.string, vol.Length(min=3, max=25) - ), - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), - vol.Optional(CONF_ADVERTISE_IP): vol.All( - ipaddress.ip_address, cv.string - ), - vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean, - vol.Optional(CONF_SAFE_MODE, default=DEFAULT_SAFE_MODE): cv.boolean, - vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, - vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, - vol.Optional( - CONF_ZEROCONF_DEFAULT_INTERFACE, - default=DEFAULT_ZEROCONF_DEFAULT_INTERFACE, - ): cv.boolean, - } - ) + vol.Optional(CONF_NAME, default=BRIDGE_NAME): vol.All( + cv.string, vol.Length(min=3, max=25) + ), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_ADVERTISE_IP): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean, + vol.Optional(CONF_SAFE_MODE, default=DEFAULT_SAFE_MODE): cv.boolean, + vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA, + vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, + vol.Optional( + CONF_ZEROCONF_DEFAULT_INTERFACE, default=DEFAULT_ZEROCONF_DEFAULT_INTERFACE, + ): cv.boolean, }, extra=vol.ALLOW_EXTRA, ) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [BRIDGE_SCHEMA], _has_all_unique_names_and_ports)}, + extra=vol.ALLOW_EXTRA, +) + + RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema( {vol.Required(ATTR_ENTITY_ID): cv.entity_ids} ) -async def async_setup(hass, config): - """Set up the HomeKit component.""" - _LOGGER.debug("Begin setup HomeKit") +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the HomeKit from yaml.""" - aid_storage = hass.data[AID_STORAGE] = AccessoryAidStorage(hass) - await aid_storage.async_initialize() + hass.data.setdefault(DOMAIN, {}) - hass.http.register_view(HomeKitPairingQRView) + _async_register_events_and_services(hass) + + if DOMAIN not in config: + return True + + current_entries = hass.config_entries.async_entries(DOMAIN) + + entries_by_name = {entry.data[CONF_NAME]: entry for entry in current_entries} + + for index, conf in enumerate(config[DOMAIN]): + bridge_name = conf[CONF_NAME] + + if ( + bridge_name in entries_by_name + and entries_by_name[bridge_name].source == SOURCE_IMPORT + ): + entry = entries_by_name[bridge_name] + # If they alter the yaml config we import the changes + # since there currently is no practical way to support + # all the options in the UI at this time. + data = conf.copy() + options = {} + for key in CONFIG_OPTIONS: + options[key] = data[key] + del data[key] + + hass.config_entries.async_update_entry(entry, data=data, options=options) + continue + + conf[CONF_ENTRY_INDEX] = index + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up HomeKit from a config entry.""" + _async_import_options_from_data_if_missing(hass, entry) + + conf = entry.data + options = entry.options - conf = config[DOMAIN] name = conf[CONF_NAME] port = conf[CONF_PORT] + _LOGGER.debug("Begin setup HomeKit for %s", name) + + # If the previous instance hasn't cleaned up yet + # we need to wait a bit + if not await hass.async_add_executor_job(port_is_available, port): + raise ConfigEntryNotReady + + if CONF_ENTRY_INDEX in conf and conf[CONF_ENTRY_INDEX] == 0: + _LOGGER.debug("Migrating legacy HomeKit data for %s", name) + hass.async_add_executor_job( + migrate_filesystem_state_data_for_primary_imported_entry_id, + hass, + entry.entry_id, + ) + + aid_storage = AccessoryAidStorage(hass, entry.entry_id) + + await aid_storage.async_initialize() + # These are yaml only ip_address = conf.get(CONF_IP_ADDRESS) advertise_ip = conf.get(CONF_ADVERTISE_IP) - auto_start = conf[CONF_AUTO_START] - safe_mode = conf[CONF_SAFE_MODE] - entity_filter = conf[CONF_FILTER] - entity_config = conf[CONF_ENTITY_CONFIG] + entity_config = conf.get(CONF_ENTITY_CONFIG, {}) + + auto_start = options.get(CONF_AUTO_START, DEFAULT_AUTO_START) + safe_mode = options.get(CONF_SAFE_MODE, DEFAULT_SAFE_MODE) + entity_filter = convert_filter( + options.get( + CONF_FILTER, + { + CONF_INCLUDE_DOMAINS: [], + CONF_EXCLUDE_DOMAINS: [], + CONF_INCLUDE_ENTITIES: [], + CONF_EXCLUDE_ENTITIES: [], + }, + ) + ) interface_choice = ( - InterfaceChoice.Default if conf.get(CONF_ZEROCONF_DEFAULT_INTERFACE) else None + InterfaceChoice.Default + if options.get(CONF_ZEROCONF_DEFAULT_INTERFACE) + else None ) homekit = HomeKit( @@ -166,20 +233,100 @@ async def async_setup(hass, config): safe_mode, advertise_ip, interface_choice, + entry.entry_id, ) await hass.async_add_executor_job(homekit.setup) + undo_listener = entry.add_update_listener(_async_update_listener) + + hass.data[DOMAIN][entry.entry_id] = { + AID_STORAGE: aid_storage, + HOMEKIT: homekit, + UNDO_UPDATE_LISTENER: undo_listener, + } + + if hass.state == CoreState.running: + await homekit.async_start() + elif auto_start: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, homekit.async_start) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + if entry.source == SOURCE_IMPORT: + return + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + + dismiss_setup_message(hass, entry.entry_id) + + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + + homekit = hass.data[DOMAIN][entry.entry_id][HOMEKIT] + + if homekit.status == STATUS_RUNNING: + await homekit.async_stop() + + for _ in range(0, SHUTDOWN_TIMEOUT): + if not await hass.async_add_executor_job( + port_is_available, entry.data[CONF_PORT] + ): + _LOGGER.info("Waiting for the HomeKit server to shutdown.") + await asyncio.sleep(1) + + hass.data[DOMAIN].pop(entry.entry_id) + + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry): + """Remove a config entry.""" + return await hass.async_add_executor_job( + remove_state_files_for_entry_id, hass, entry.entry_id + ) + + +@callback +def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): + options = dict(entry.options) + data = dict(entry.data) + modified = False + for importable_option in CONFIG_OPTIONS: + if importable_option not in entry.options and importable_option in entry.data: + options[importable_option] = entry.data[importable_option] + del data[importable_option] + modified = True + + if modified: + hass.config_entries.async_update_entry(entry, data=data, options=options) + + +@callback +def _async_register_events_and_services(hass: HomeAssistant): + """Register events and services for HomeKit.""" + + hass.http.register_view(HomeKitPairingQRView) + def handle_homekit_reset_accessory(service): """Handle start HomeKit service call.""" - if homekit.status != STATUS_RUNNING: - _LOGGER.warning( - "HomeKit is not running. Either it is waiting to be " - "started or has been stopped." - ) - return + for entry_id in hass.data[DOMAIN]: + if HOMEKIT not in hass.data[DOMAIN][entry_id]: + continue + homekit = hass.data[DOMAIN][entry_id][HOMEKIT] + if homekit.status != STATUS_RUNNING: + _LOGGER.warning( + "HomeKit is not running. Either it is waiting to be " + "started or has been stopped." + ) + continue - entity_ids = service.data.get("entity_id") - homekit.reset_accessories(entity_ids) + entity_ids = service.data.get("entity_id") + homekit.reset_accessories(entity_ids) hass.services.async_register( DOMAIN, @@ -208,124 +355,24 @@ async def async_setup(hass, config): DOMAIN, EVENT_HOMEKIT_CHANGED, async_describe_logbook_event ) - if auto_start: - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, homekit.async_start) - return True - async def async_handle_homekit_service_start(service): """Handle start HomeKit service call.""" - if homekit.status != STATUS_READY: - _LOGGER.warning( - "HomeKit is not ready. Either it is already running or has " - "been stopped." - ) - return - await homekit.async_start() + for entry_id in hass.data[DOMAIN]: + if HOMEKIT not in hass.data[DOMAIN][entry_id]: + continue + homekit = hass.data[DOMAIN][entry_id][HOMEKIT] + if homekit.status != STATUS_READY: + _LOGGER.warning( + "HomeKit is not ready. Either it is already running or has " + "been stopped." + ) + continue + await homekit.async_start() hass.services.async_register( DOMAIN, SERVICE_HOMEKIT_START, async_handle_homekit_service_start ) - return True - - -def get_accessory(hass, driver, state, aid, config): - """Take state and return an accessory object if supported.""" - if not aid: - _LOGGER.warning( - 'The entity "%s" is not supported, since it ' - "generates an invalid aid, please change it.", - state.entity_id, - ) - return None - - a_type = None - name = config.get(CONF_NAME, state.name) - - if state.domain == "alarm_control_panel": - a_type = "SecuritySystem" - - elif state.domain in ("binary_sensor", "device_tracker", "person"): - a_type = "BinarySensor" - - elif state.domain == "climate": - a_type = "Thermostat" - - elif state.domain == "cover": - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - - if device_class in (DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE) and features & ( - cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE - ): - a_type = "GarageDoorOpener" - elif features & cover.SUPPORT_SET_POSITION: - a_type = "WindowCovering" - elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): - a_type = "WindowCoveringBasic" - - elif state.domain == "fan": - a_type = "Fan" - - elif state.domain == "light": - a_type = "Light" - - elif state.domain == "lock": - a_type = "Lock" - - elif state.domain == "media_player": - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - feature_list = config.get(CONF_FEATURE_LIST) - - if device_class == DEVICE_CLASS_TV: - a_type = "TelevisionMediaPlayer" - else: - if feature_list and validate_media_player_features(state, feature_list): - a_type = "MediaPlayer" - - elif state.domain == "sensor": - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - if device_class == DEVICE_CLASS_TEMPERATURE or unit in ( - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - ): - a_type = "TemperatureSensor" - elif device_class == DEVICE_CLASS_HUMIDITY and unit == UNIT_PERCENTAGE: - a_type = "HumiditySensor" - elif device_class == DEVICE_CLASS_PM25 or DEVICE_CLASS_PM25 in state.entity_id: - a_type = "AirQualitySensor" - elif device_class == DEVICE_CLASS_CO: - a_type = "CarbonMonoxideSensor" - elif device_class == DEVICE_CLASS_CO2 or DEVICE_CLASS_CO2 in state.entity_id: - a_type = "CarbonDioxideSensor" - elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ("lm", "lx"): - a_type = "LightSensor" - - elif state.domain == "switch": - switch_type = config.get(CONF_TYPE, TYPE_SWITCH) - a_type = SWITCH_TYPES[switch_type] - - elif state.domain == "vacuum": - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if features & (vacuum.SUPPORT_START | vacuum.SUPPORT_RETURN_HOME): - a_type = "DockVacuum" - else: - a_type = "Switch" - - elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"): - a_type = "Switch" - - elif state.domain == "water_heater": - a_type = "WaterHeater" - - if a_type is None: - return None - - _LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type) - return TYPES[a_type](hass, driver, name, state.entity_id, aid, config) - class HomeKit: """Class to handle all actions between HomeKit and Home Assistant.""" @@ -341,6 +388,7 @@ class HomeKit: safe_mode, advertise_ip=None, interface_choice=None, + entry_id=None, ): """Initialize a HomeKit object.""" self.hass = hass @@ -352,6 +400,7 @@ class HomeKit: self._safe_mode = safe_mode self._advertise_ip = advertise_ip self._interface_choice = interface_choice + self._entry_id = entry_id self.status = STATUS_READY self.bridge = None @@ -363,25 +412,26 @@ class HomeKit: from .accessories import HomeBridge, HomeDriver self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) - ip_addr = self._ip_address or get_local_ip() - path = self.hass.config.path(HOMEKIT_FILE) + persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) self.driver = HomeDriver( self.hass, + self._entry_id, + self._name, address=ip_addr, port=self._port, - persist_file=path, + persist_file=persist_file, advertised_address=self._advertise_ip, interface_choice=self._interface_choice, ) self.bridge = HomeBridge(self.hass, self.driver, self._name) if self._safe_mode: - _LOGGER.debug("Safe_mode selected") + _LOGGER.debug("Safe_mode selected for %s", self._name) self.driver.safe_mode = True def reset_accessories(self, entity_ids): """Reset the accessory to load the latest configuration.""" - aid_storage = self.hass.data[AID_STORAGE] + aid_storage = self.hass.data[DOMAIN][self._entry_id][AID_STORAGE] removed = [] for entity_id in entity_ids: aid = aid_storage.get_or_allocate_aid_for_entity_id(entity_id) @@ -412,9 +462,9 @@ class HomeKit: ) return - aid = self.hass.data[AID_STORAGE].get_or_allocate_aid_for_entity_id( - state.entity_id - ) + aid = self.hass.data[DOMAIN][self._entry_id][ + AID_STORAGE + ].get_or_allocate_aid_for_entity_id(state.entity_id) conf = self._config.pop(state.entity_id, {}) # If an accessory cannot be created or added due to an exception # of any kind (usually in pyhap) it should not prevent @@ -437,6 +487,7 @@ class HomeKit: async def async_start(self, *args): """Start the accessory driver.""" + if self.status != STATUS_READY: return self.status = STATUS_WAIT @@ -459,6 +510,20 @@ class HomeKit: bridged_states.append(state) await self.hass.async_add_executor_job(self._start, bridged_states) + await self._async_register_bridge() + + async def _async_register_bridge(self): + """Register the bridge as a device so homekit_controller and exclude it from discovery.""" + registry = await device_registry.async_get_registry(self.hass) + registry.async_get_or_create( + config_entry_id=self._entry_id, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, self.driver.state.mac) + }, + manufacturer=MANUFACTURER, + name=self._name, + model="Home Assistant HomeKit Bridge", + ) def _start(self, bridged_states): from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel @@ -480,10 +545,14 @@ class HomeKit: if not self.driver.state.paired: show_setup_message( - self.hass, self.driver.state.pincode, self.bridge.xhm_uri() + self.hass, + self._entry_id, + self._name, + self.driver.state.pincode, + self.bridge.xhm_uri(), ) - _LOGGER.debug("Driver start") + _LOGGER.debug("Driver start for %s", self._name) self.hass.add_job(self.driver.start) self.status = STATUS_RUNNING @@ -492,8 +561,7 @@ class HomeKit: if self.status != STATUS_RUNNING: return self.status = STATUS_STOPPED - - _LOGGER.debug("Driver stop") + _LOGGER.debug("Driver stop for %s", self._name) self.hass.add_job(self.driver.stop) @callback @@ -539,9 +607,17 @@ class HomeKitPairingQRView(HomeAssistantView): # pylint: disable=no-self-use async def get(self, request): """Retrieve the pairing QRCode image.""" - if request.query_string != request.app["hass"].data[HOMEKIT_PAIRING_QR_SECRET]: + if not request.query_string: + raise Unauthorized() + entry_id, secret = request.query_string.split("-") + + if ( + entry_id not in request.app["hass"].data[DOMAIN] + or secret + != request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET] + ): raise Unauthorized() return web.Response( - body=request.app["hass"].data[HOMEKIT_PAIRING_QR], + body=request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR], content_type="image/svg+xml", ) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index c3b42f0b6dc..ddafbd8fa66 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -8,12 +8,26 @@ from pyhap.accessory import Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER +from homeassistant.components import cover, vacuum +from homeassistant.components.cover import DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE +from homeassistant.components.media_player import DEVICE_CLASS_TV from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SERVICE, + ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, + CONF_NAME, + CONF_TYPE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, STATE_ON, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, __version__, ) from homeassistant.core import callback as ha_callback, split_entity_id @@ -22,6 +36,7 @@ from homeassistant.helpers.event import ( track_point_in_utc_time, ) from homeassistant.util import dt as dt_util +from homeassistant.util.decorator import Registry from .const import ( ATTR_DISPLAY_NAME, @@ -31,21 +46,45 @@ from .const import ( CHAR_BATTERY_LEVEL, CHAR_CHARGING_STATE, CHAR_STATUS_LOW_BATTERY, + CONF_FEATURE_LIST, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, DEBOUNCE_TIMEOUT, DEFAULT_LOW_BATTERY_THRESHOLD, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_PM25, EVENT_HOMEKIT_CHANGED, HK_CHARGING, HK_NOT_CHARGABLE, HK_NOT_CHARGING, MANUFACTURER, SERV_BATTERY_SERVICE, + TYPE_FAUCET, + TYPE_OUTLET, + TYPE_SHOWER, + TYPE_SPRINKLER, + TYPE_SWITCH, + TYPE_VALVE, +) +from .util import ( + convert_to_float, + dismiss_setup_message, + show_setup_message, + validate_media_player_features, ) -from .util import convert_to_float, dismiss_setup_message, show_setup_message _LOGGER = logging.getLogger(__name__) +SWITCH_TYPES = { + TYPE_FAUCET: "Valve", + TYPE_OUTLET: "Outlet", + TYPE_SHOWER: "Valve", + TYPE_SPRINKLER: "Valve", + TYPE_SWITCH: "Switch", + TYPE_VALVE: "Valve", +} +TYPES = Registry() def debounce(func): @@ -79,6 +118,104 @@ def debounce(func): return wrapper +def get_accessory(hass, driver, state, aid, config): + """Take state and return an accessory object if supported.""" + if not aid: + _LOGGER.warning( + 'The entity "%s" is not supported, since it ' + "generates an invalid aid, please change it.", + state.entity_id, + ) + return None + + a_type = None + name = config.get(CONF_NAME, state.name) + + if state.domain == "alarm_control_panel": + a_type = "SecuritySystem" + + elif state.domain in ("binary_sensor", "device_tracker", "person"): + a_type = "BinarySensor" + + elif state.domain == "climate": + a_type = "Thermostat" + + elif state.domain == "cover": + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if device_class in (DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE) and features & ( + cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE + ): + a_type = "GarageDoorOpener" + elif features & cover.SUPPORT_SET_POSITION: + a_type = "WindowCovering" + elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): + a_type = "WindowCoveringBasic" + + elif state.domain == "fan": + a_type = "Fan" + + elif state.domain == "light": + a_type = "Light" + + elif state.domain == "lock": + a_type = "Lock" + + elif state.domain == "media_player": + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + feature_list = config.get(CONF_FEATURE_LIST) + + if device_class == DEVICE_CLASS_TV: + a_type = "TelevisionMediaPlayer" + else: + if feature_list and validate_media_player_features(state, feature_list): + a_type = "MediaPlayer" + + elif state.domain == "sensor": + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + if device_class == DEVICE_CLASS_TEMPERATURE or unit in ( + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + ): + a_type = "TemperatureSensor" + elif device_class == DEVICE_CLASS_HUMIDITY and unit == UNIT_PERCENTAGE: + a_type = "HumiditySensor" + elif device_class == DEVICE_CLASS_PM25 or DEVICE_CLASS_PM25 in state.entity_id: + a_type = "AirQualitySensor" + elif device_class == DEVICE_CLASS_CO: + a_type = "CarbonMonoxideSensor" + elif device_class == DEVICE_CLASS_CO2 or DEVICE_CLASS_CO2 in state.entity_id: + a_type = "CarbonDioxideSensor" + elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ("lm", "lx"): + a_type = "LightSensor" + + elif state.domain == "switch": + switch_type = config.get(CONF_TYPE, TYPE_SWITCH) + a_type = SWITCH_TYPES[switch_type] + + elif state.domain == "vacuum": + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if features & (vacuum.SUPPORT_START | vacuum.SUPPORT_RETURN_HOME): + a_type = "DockVacuum" + else: + a_type = "Switch" + + elif state.domain in ("automation", "input_boolean", "remote", "scene", "script"): + a_type = "Switch" + + elif state.domain == "water_heater": + a_type = "WaterHeater" + + if a_type is None: + return None + + _LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type) + return TYPES[a_type](hass, driver, name, state.entity_id, aid, config) + + class HomeAccessory(Accessory): """Adapter class for Accessory.""" @@ -327,19 +464,27 @@ class HomeBridge(Bridge): class HomeDriver(AccessoryDriver): """Adapter class for AccessoryDriver.""" - def __init__(self, hass, **kwargs): + def __init__(self, hass, entry_id, bridge_name, **kwargs): """Initialize a AccessoryDriver object.""" super().__init__(**kwargs) self.hass = hass + self._entry_id = entry_id + self._bridge_name = bridge_name def pair(self, client_uuid, client_public): """Override super function to dismiss setup message if paired.""" success = super().pair(client_uuid, client_public) if success: - dismiss_setup_message(self.hass) + dismiss_setup_message(self.hass, self._entry_id) return success def unpair(self, client_uuid): """Override super function to show setup message if unpaired.""" super().unpair(client_uuid) - show_setup_message(self.hass, self.state.pincode, self.accessory.xhm_uri()) + show_setup_message( + self.hass, + self._entry_id, + self._bridge_name, + self.state.pincode, + self.accessory.xhm_uri(), + ) diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 95181114e79..487865f22ab 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -15,13 +15,13 @@ from zlib import adler32 from fnvhash import fnv1a_32 +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.storage import Store -from .const import DOMAIN +from .util import get_aid_storage_filename_for_entry_id -AID_MANAGER_STORAGE_KEY = f"{DOMAIN}.aids" AID_MANAGER_STORAGE_VERSION = 1 AID_MANAGER_SAVE_DELAY = 2 @@ -74,13 +74,13 @@ class AccessoryAidStorage: persist over reboots. """ - def __init__(self, hass: HomeAssistant): + def __init__(self, hass: HomeAssistant, entry: ConfigEntry): """Create a new entity map store.""" self.hass = hass - self.store = Store(hass, AID_MANAGER_STORAGE_VERSION, AID_MANAGER_STORAGE_KEY) self.allocations = {} self.allocated_aids = set() - + self._entry = entry + self.store = None self._entity_registry = None async def async_initialize(self): @@ -88,6 +88,8 @@ class AccessoryAidStorage: self._entity_registry = ( await self.hass.helpers.entity_registry.async_get_registry() ) + aidstore = get_aid_storage_filename_for_entry_id(self._entry) + self.store = Store(self.hass, AID_MANAGER_STORAGE_VERSION, aidstore) raw_storage = await self.store.async_load() if not raw_storage: diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py new file mode 100644 index 00000000000..0f83b7a3c24 --- /dev/null +++ b/homeassistant/components/homekit/config_flow.py @@ -0,0 +1,301 @@ +"""Config flow for HomeKit integration.""" +import logging +import random +import string + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.core import callback, split_entity_id +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entityfilter import ( + CONF_EXCLUDE_DOMAINS, + CONF_EXCLUDE_ENTITIES, + CONF_INCLUDE_DOMAINS, + CONF_INCLUDE_ENTITIES, +) + +from .const import ( + CONF_AUTO_START, + CONF_FILTER, + CONF_SAFE_MODE, + CONF_ZEROCONF_DEFAULT_INTERFACE, + DEFAULT_AUTO_START, + DEFAULT_CONFIG_FLOW_PORT, + DEFAULT_SAFE_MODE, + DEFAULT_ZEROCONF_DEFAULT_INTERFACE, + SHORT_BRIDGE_NAME, +) +from .const import DOMAIN # pylint:disable=unused-import +from .util import find_next_available_port + +_LOGGER = logging.getLogger(__name__) + +CONF_DOMAINS = "domains" +SUPPORTED_DOMAINS = [ + "alarm_control_panel", + "automation", + "binary_sensor", + "climate", + "cover", + "demo", + "device_tracker", + "fan", + "input_boolean", + "light", + "lock", + "media_player", + "person", + "remote", + "scene", + "script", + "sensor", + "switch", + "vacuum", + "water_heater", +] + +DEFAULT_DOMAINS = [ + "alarm_control_panel", + "climate", + "cover", + "light", + "lock", + "media_player", + "switch", + "vacuum", + "water_heater", +] + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for HomeKit.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize config flow.""" + self.homekit_data = {} + self.entry_title = None + + async def async_step_pairing(self, user_input=None): + """Pairing instructions.""" + if user_input is not None: + return self.async_create_entry( + title=self.entry_title, data=self.homekit_data + ) + return self.async_show_form( + step_id="pairing", + description_placeholders={CONF_NAME: self.homekit_data[CONF_NAME]}, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + port = await self._async_available_port() + name = self._async_available_name() + title = f"{name}:{port}" + self.homekit_data = user_input.copy() + self.homekit_data[CONF_NAME] = name + self.homekit_data[CONF_PORT] = port + self.homekit_data[CONF_FILTER] = { + CONF_INCLUDE_DOMAINS: user_input[CONF_INCLUDE_DOMAINS], + CONF_INCLUDE_ENTITIES: [], + CONF_EXCLUDE_DOMAINS: [], + CONF_EXCLUDE_ENTITIES: [], + } + del self.homekit_data[CONF_INCLUDE_DOMAINS] + self.entry_title = title + return await self.async_step_pairing() + + default_domains = [] if self._async_current_entries() else DEFAULT_DOMAINS + setup_schema = vol.Schema( + { + vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): bool, + vol.Required( + CONF_INCLUDE_DOMAINS, default=default_domains + ): cv.multi_select(SUPPORTED_DOMAINS), + } + ) + + return self.async_show_form( + step_id="user", data_schema=setup_schema, errors=errors + ) + + async def async_step_import(self, user_input=None): + """Handle import from yaml.""" + if not self._async_is_unique_name_port(user_input): + return self.async_abort(reason="port_name_in_use") + return self.async_create_entry( + title=f"{user_input[CONF_NAME]}:{user_input[CONF_PORT]}", data=user_input + ) + + async def _async_available_port(self): + """Return an available port the bridge.""" + return await self.hass.async_add_executor_job( + find_next_available_port, DEFAULT_CONFIG_FLOW_PORT + ) + + @callback + def _async_available_name(self): + """Return an available for the bridge.""" + current_entries = self._async_current_entries() + + # We always pick a RANDOM name to avoid Zeroconf + # name collisions. If the name has been seen before + # pairing will probably fail. + acceptable_chars = string.ascii_uppercase + string.digits + trailer = "".join(random.choices(acceptable_chars, k=4)) + all_names = {entry.data[CONF_NAME] for entry in current_entries} + suggested_name = f"{SHORT_BRIDGE_NAME} {trailer}" + while suggested_name in all_names: + trailer = "".join(random.choices(acceptable_chars, k=4)) + suggested_name = f"{SHORT_BRIDGE_NAME} {trailer}" + + return suggested_name + + @callback + def _async_is_unique_name_port(self, user_input): + """Determine is a name or port is already used.""" + name = user_input[CONF_NAME] + port = user_input[CONF_PORT] + for entry in self._async_current_entries(): + if entry.data[CONF_NAME] == name or entry.data[CONF_PORT] == port: + return False + return True + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for tado.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + self.homekit_options = {} + + async def async_step_yaml(self, user_input=None): + """No options for yaml managed entries.""" + if user_input is not None: + # Apparently not possible to abort an options flow + # at the moment + return self.async_create_entry(title="", data=self.config_entry.options) + + return self.async_show_form(step_id="yaml") + + async def async_step_advanced(self, user_input=None): + """Choose advanced options.""" + if user_input is not None: + self.homekit_options.update(user_input) + del self.homekit_options[CONF_INCLUDE_DOMAINS] + return self.async_create_entry(title="", data=self.homekit_options) + + schema_base = {} + + if self.show_advanced_options: + schema_base[ + vol.Optional( + CONF_AUTO_START, + default=self.homekit_options.get( + CONF_AUTO_START, DEFAULT_AUTO_START + ), + ) + ] = bool + else: + self.homekit_options[CONF_AUTO_START] = self.homekit_options.get( + CONF_AUTO_START, DEFAULT_AUTO_START + ) + + schema_base.update( + { + vol.Optional( + CONF_SAFE_MODE, + default=self.homekit_options.get(CONF_SAFE_MODE, DEFAULT_SAFE_MODE), + ): bool, + vol.Optional( + CONF_ZEROCONF_DEFAULT_INTERFACE, + default=self.homekit_options.get( + CONF_ZEROCONF_DEFAULT_INTERFACE, + DEFAULT_ZEROCONF_DEFAULT_INTERFACE, + ), + ): bool, + } + ) + + return self.async_show_form( + step_id="advanced", data_schema=vol.Schema(schema_base) + ) + + async def async_step_exclude(self, user_input=None): + """Choose entities to exclude from the domain.""" + if user_input is not None: + self.homekit_options[CONF_FILTER] = { + CONF_INCLUDE_DOMAINS: self.homekit_options[CONF_INCLUDE_DOMAINS], + CONF_EXCLUDE_DOMAINS: self.homekit_options.get( + CONF_EXCLUDE_DOMAINS, [] + ), + CONF_INCLUDE_ENTITIES: self.homekit_options.get( + CONF_INCLUDE_ENTITIES, [] + ), + CONF_EXCLUDE_ENTITIES: user_input[CONF_EXCLUDE_ENTITIES], + } + return await self.async_step_advanced() + + entity_filter = self.homekit_options.get(CONF_FILTER, {}) + all_supported_entities = await self.hass.async_add_executor_job( + _get_entities_matching_domains, + self.hass, + self.homekit_options[CONF_INCLUDE_DOMAINS], + ) + data_schema = vol.Schema( + { + vol.Optional( + CONF_EXCLUDE_ENTITIES, + default=entity_filter.get(CONF_EXCLUDE_ENTITIES, []), + ): cv.multi_select(all_supported_entities), + } + ) + return self.async_show_form(step_id="exclude", data_schema=data_schema) + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if self.config_entry.source == SOURCE_IMPORT: + return await self.async_step_yaml(user_input) + + if user_input is not None: + self.homekit_options.update(user_input) + return await self.async_step_exclude() + + self.homekit_options = dict(self.config_entry.options) + entity_filter = self.homekit_options.get(CONF_FILTER, {}) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_INCLUDE_DOMAINS, + default=entity_filter.get(CONF_INCLUDE_DOMAINS, []), + ): cv.multi_select(SUPPORTED_DOMAINS) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +def _get_entities_matching_domains(hass, domains): + """List entities in the given domains.""" + included_domains = set(domains) + entity_ids = [ + state.entity_id + for state in hass.states.all() + if (split_entity_id(state.entity_id))[0] in included_domains + ] + entity_ids.sort() + return entity_ids diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index f0224ce71f4..ab0c15ee9a7 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,13 +1,17 @@ """Constants used be the HomeKit component.""" + # #### Misc #### DEBOUNCE_TIMEOUT = 0.5 DEVICE_PRECISION_LEEWAY = 6 DOMAIN = "homekit" HOMEKIT_FILE = ".homekit.state" -HOMEKIT_NOTIFY_ID = 4663548 AID_STORAGE = "homekit-aid-allocations" HOMEKIT_PAIRING_QR = "homekit-pairing-qr" HOMEKIT_PAIRING_QR_SECRET = "homekit-pairing-qr-secret" +HOMEKIT = "homekit" +UNDO_UPDATE_LISTENER = "undo_update_listener" +SHUTDOWN_TIMEOUT = 30 +CONF_ENTRY_INDEX = "index" # #### Attributes #### ATTR_DISPLAY_NAME = "display_name" @@ -30,6 +34,7 @@ CONF_ZEROCONF_DEFAULT_INTERFACE = "zeroconf_default_interface" DEFAULT_AUTO_START = True DEFAULT_LOW_BATTERY_THRESHOLD = 20 DEFAULT_PORT = 51827 +DEFAULT_CONFIG_FLOW_PORT = 51828 DEFAULT_SAFE_MODE = False DEFAULT_ZEROCONF_DEFAULT_INTERFACE = False @@ -49,6 +54,7 @@ SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory" # #### String Constants #### BRIDGE_MODEL = "Bridge" BRIDGE_NAME = "Home Assistant Bridge" +SHORT_BRIDGE_NAME = "HASS Bridge" BRIDGE_SERIAL_NUMBER = "homekit.bridge" MANUFACTURER = "Home Assistant" @@ -203,3 +209,11 @@ HK_POSITION_STOPPED = 2 HK_NOT_CHARGING = 0 HK_CHARGING = 1 HK_NOT_CHARGABLE = 2 + +# ### Config Options ### +CONFIG_OPTIONS = [ + CONF_FILTER, + CONF_AUTO_START, + CONF_ZEROCONF_DEFAULT_INTERFACE, + CONF_SAFE_MODE, +] diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 482cef57ca7..27f83d996ad 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -5,5 +5,6 @@ "requirements": ["HAP-python==2.8.2","fnvhash==0.1.0","PyQRCode==1.2.1","base36==0.1.1"], "dependencies": ["http"], "after_dependencies": ["logbook"], - "codeowners": ["@bdraco"] + "codeowners": ["@bdraco"], + "config_flow": true } diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json new file mode 100644 index 00000000000..ca5a67f5363 --- /dev/null +++ b/homeassistant/components/homekit/strings.json @@ -0,0 +1,54 @@ +{ + "title" : "HomeKit Bridge", + "options" : { + "step" : { + "yaml" : { + "title" : "Adjust HomeKit Bridge Options", + "description" : "This entry is controlled via YAML" + }, + "init" : { + "data" : { + "include_domains" : "[%key:component::homekit::config::step::user::data::include_domains%]" + }, + "description" : "Entities in the “Domains to include” will be bridged to HomeKit. You will be able to select which entities to exclude from this list on the next screen.", + "title" : "Select domains to bridge." + }, + "exclude" : { + "data" : { + "exclude_entities" : "Entities to exclude" + }, + "description" : "Choose the entities that you do NOT want to be bridged.", + "title" : "Exclude entities in selected domains from bridge" + }, + "advanced" : { + "data" : { + "auto_start" : "[%key:component::homekit::config::step::user::data::auto_start%]", + "safe_mode" : "Safe Mode (enable only if pairing fails)", + "zeroconf_default_interface" : "Use default zeroconf interface (enable if the bridge cannot be found in the Home app)" + }, + "description" : "These settings only need to be adjusted if the HomeKit bridge is not functional.", + "title" : "Advanced Configuration" + } + } + }, + "config" : { + "step" : { + "user" : { + "data" : { + "auto_start" : "Autostart (disable if using Z-Wave or other delayed start system)", + "include_domains" : "Domains to include" + }, + "description" : "A HomeKit Bridge will allow you to access your Home Assistant entities in HomeKit. HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML for the primary bridge.", + "title" : "Activate HomeKit Bridge" + }, + "pairing": { + "title": "Pair HomeKit Bridge", + "description": "As soon as the {name} bridge is ready, pairing will be available in “Notifications” as “HomeKit Bridge Setup”." + } + }, + "abort" : { + "port_name_in_use" : "A bridge with the same name or port is already configured." + } + } + } + \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json new file mode 100644 index 00000000000..ca5a67f5363 --- /dev/null +++ b/homeassistant/components/homekit/translations/en.json @@ -0,0 +1,54 @@ +{ + "title" : "HomeKit Bridge", + "options" : { + "step" : { + "yaml" : { + "title" : "Adjust HomeKit Bridge Options", + "description" : "This entry is controlled via YAML" + }, + "init" : { + "data" : { + "include_domains" : "[%key:component::homekit::config::step::user::data::include_domains%]" + }, + "description" : "Entities in the “Domains to include” will be bridged to HomeKit. You will be able to select which entities to exclude from this list on the next screen.", + "title" : "Select domains to bridge." + }, + "exclude" : { + "data" : { + "exclude_entities" : "Entities to exclude" + }, + "description" : "Choose the entities that you do NOT want to be bridged.", + "title" : "Exclude entities in selected domains from bridge" + }, + "advanced" : { + "data" : { + "auto_start" : "[%key:component::homekit::config::step::user::data::auto_start%]", + "safe_mode" : "Safe Mode (enable only if pairing fails)", + "zeroconf_default_interface" : "Use default zeroconf interface (enable if the bridge cannot be found in the Home app)" + }, + "description" : "These settings only need to be adjusted if the HomeKit bridge is not functional.", + "title" : "Advanced Configuration" + } + } + }, + "config" : { + "step" : { + "user" : { + "data" : { + "auto_start" : "Autostart (disable if using Z-Wave or other delayed start system)", + "include_domains" : "Domains to include" + }, + "description" : "A HomeKit Bridge will allow you to access your Home Assistant entities in HomeKit. HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML for the primary bridge.", + "title" : "Activate HomeKit Bridge" + }, + "pairing": { + "title": "Pair HomeKit Bridge", + "description": "As soon as the {name} bridge is ready, pairing will be available in “Notifications” as “HomeKit Bridge Setup”." + } + }, + "abort" : { + "port_name_in_use" : "A bridge with the same name or port is already configured." + } + } + } + \ No newline at end of file diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 987ba900bc8..25d1782b392 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -26,8 +26,7 @@ from homeassistant.const import ( STATE_OPENING, ) -from . import TYPES -from .accessories import HomeAccessory, debounce +from .accessories import TYPES, HomeAccessory, debounce from .const import ( CHAR_CURRENT_DOOR_STATE, CHAR_CURRENT_POSITION, diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 291b3ffed90..b80a65eede1 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -27,8 +27,7 @@ from homeassistant.const import ( STATE_ON, ) -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 8458c8351da..3f4c2518202 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -24,8 +24,7 @@ from homeassistant.const import ( STATE_ON, ) -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import ( CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 0d2a19ef089..5697306bf32 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -6,8 +6,7 @@ from pyhap.const import CATEGORY_DOOR_LOCK from homeassistant.components.lock import DOMAIN, STATE_LOCKED, STATE_UNLOCKED from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 78c11fc41f9..154355a0da3 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -37,8 +37,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, CHAR_ACTIVE_IDENTIFIER, diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 59e10a42c29..8a2bb971cf1 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -18,8 +18,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import ( CHAR_CURRENT_SECURITY_STATE, CHAR_TARGET_SECURITY_STATE, diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 5bdf9a07f04..c37755bc1c5 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -11,8 +11,7 @@ from homeassistant.const import ( TEMP_CELSIUS, ) -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import ( CHAR_AIR_PARTICULATE_DENSITY, CHAR_AIR_QUALITY, diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index a1088f110e5..072c8681a50 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -27,8 +27,7 @@ from homeassistant.const import ( from homeassistant.core import split_entity_id from homeassistant.helpers.event import call_later -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, CHAR_IN_USE, diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 0da92ef3dba..84cefced602 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -52,8 +52,7 @@ from homeassistant.const import ( UNIT_PERCENTAGE, ) -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import ( CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_HEATING_COOLING, diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index b8d98ad2304..3ccf73d3925 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -2,7 +2,9 @@ from collections import OrderedDict, namedtuple import io import logging +import os import secrets +import socket import pyqrcode import voluptuous as vol @@ -15,8 +17,9 @@ from homeassistant.const import ( CONF_TYPE, TEMP_CELSIUS, ) -from homeassistant.core import split_entity_id +from homeassistant.core import HomeAssistant, split_entity_id import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.storage import STORAGE_DIR import homeassistant.util.temperature as temp_util from .const import ( @@ -25,11 +28,12 @@ from .const import ( CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD, + DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, - HOMEKIT_NOTIFY_ID, + HOMEKIT_FILE, HOMEKIT_PAIRING_QR, HOMEKIT_PAIRING_QR_SECRET, TYPE_FAUCET, @@ -42,6 +46,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +MAX_PORT = 65535 BASIC_INFO_SCHEMA = vol.Schema( { @@ -210,7 +215,7 @@ class HomeKitSpeedMapping: return list(self.speed_ranges.keys())[0] -def show_setup_message(hass, pincode, uri): +def show_setup_message(hass, entry_id, bridge_name, pincode, uri): """Display persistent notification with setup information.""" pin = pincode.decode() _LOGGER.info("Pincode: %s", pin) @@ -220,23 +225,23 @@ def show_setup_message(hass, pincode, uri): url.svg(buffer, scale=5) pairing_secret = secrets.token_hex(32) - hass.data[HOMEKIT_PAIRING_QR] = buffer.getvalue() - hass.data[HOMEKIT_PAIRING_QR_SECRET] = pairing_secret + hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR] = buffer.getvalue() + hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET] = pairing_secret message = ( - f"To set up Home Assistant in the Home App, " + f"To set up {bridge_name} in the Home App, " f"scan the QR code or enter the following code:\n" f"### {pin}\n" - f"![image](/api/homekit/pairingqr?{pairing_secret})" + f"![image](/api/homekit/pairingqr?{entry_id}-{pairing_secret})" ) hass.components.persistent_notification.create( - message, "HomeKit Setup", HOMEKIT_NOTIFY_ID + message, "HomeKit Bridge Setup", entry_id ) -def dismiss_setup_message(hass): +def dismiss_setup_message(hass, entry_id): """Dismiss persistent notification and remove QR code.""" - hass.components.persistent_notification.dismiss(HOMEKIT_NOTIFY_ID) + hass.components.persistent_notification.dismiss(entry_id) def convert_to_float(state): @@ -268,3 +273,85 @@ def density_to_air_quality(density): if density <= 150: return 4 return 5 + + +def get_persist_filename_for_entry_id(entry_id: str): + """Determine the filename of the homekit state file.""" + return f"{DOMAIN}.{entry_id}.state" + + +def get_aid_storage_filename_for_entry_id(entry_id: str): + """Determine the ilename of homekit aid storage file.""" + return f"{DOMAIN}.{entry_id}.aids" + + +def get_persist_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str): + """Determine the path to the homekit state file.""" + return hass.config.path(STORAGE_DIR, get_persist_filename_for_entry_id(entry_id)) + + +def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str): + """Determine the path to the homekit aid storage file.""" + return hass.config.path( + STORAGE_DIR, get_aid_storage_filename_for_entry_id(entry_id) + ) + + +def migrate_filesystem_state_data_for_primary_imported_entry_id( + hass: HomeAssistant, entry_id: str +): + """Migrate the old paths to the storage directory.""" + legacy_persist_file_path = hass.config.path(HOMEKIT_FILE) + if os.path.exists(legacy_persist_file_path): + os.rename( + legacy_persist_file_path, get_persist_fullpath_for_entry_id(hass, entry_id) + ) + + legacy_aid_storage_path = hass.config.path(STORAGE_DIR, "homekit.aids") + if os.path.exists(legacy_aid_storage_path): + os.rename( + legacy_aid_storage_path, + get_aid_storage_fullpath_for_entry_id(hass, entry_id), + ) + + +def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str): + """Remove the state files from disk.""" + persist_file_path = get_persist_fullpath_for_entry_id(hass, entry_id) + aid_storage_path = get_aid_storage_fullpath_for_entry_id(hass, entry_id) + os.unlink(persist_file_path) + if os.path.exists(aid_storage_path): + os.unlink(aid_storage_path) + return True + + +def _get_test_socket(): + """Create a socket to test binding ports.""" + test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + test_socket.setblocking(False) + test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return test_socket + + +def port_is_available(port: int): + """Check to see if a port is available.""" + test_socket = _get_test_socket() + try: + test_socket.bind(("", port)) + except OSError: + return False + + return True + + +def find_next_available_port(start_port: int): + """Find the next available port starting with the given port.""" + test_socket = _get_test_socket() + for port in range(start_port, MAX_PORT): + try: + test_socket.bind(("", port)) + return port + except OSError: + if port == MAX_PORT: + raise + continue diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6e259c2bf84..a65d1e2b52a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -50,6 +50,7 @@ FLOWS = [ "harmony", "heos", "hisense_aehw4a1", + "homekit", "homekit_controller", "homematicip_cloud", "huawei_lte", diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index a9c3ab27ead..f8dd83ccfcc 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -12,7 +12,8 @@ CONF_EXCLUDE_DOMAINS = "exclude_domains" CONF_EXCLUDE_ENTITIES = "exclude_entities" -def _convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]: +def convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]: + """Convert the filter schema into a filter.""" filt = generate_filter( config[CONF_INCLUDE_DOMAINS], config[CONF_INCLUDE_ENTITIES], @@ -24,22 +25,21 @@ def _convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]: return filt -FILTER_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_EXCLUDE_DOMAINS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_EXCLUDE_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_INCLUDE_DOMAINS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_INCLUDE_ENTITIES, default=[]): cv.entity_ids, - } - ), - _convert_filter, +BASE_FILTER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_EXCLUDE_DOMAINS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_EXCLUDE_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_INCLUDE_DOMAINS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_INCLUDE_ENTITIES, default=[]): cv.entity_ids, + } ) +FILTER_SCHEMA = vol.All(BASE_FILTER_SCHEMA, convert_filter) + def generate_filter( include_domains: List[str], diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index c4b61f68833..e2fb79f56ce 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -453,7 +453,9 @@ def test_home_driver(): pin = b"123-45-678" with patch("pyhap.accessory_driver.AccessoryDriver.__init__") as mock_driver: - driver = HomeDriver("hass", address=ip_address, port=port, persist_file=path) + driver = HomeDriver( + "hass", "entry_id", "name", address=ip_address, port=port, persist_file=path + ) mock_driver.assert_called_with(address=ip_address, port=port, persist_file=path) driver.state = Mock(pincode=pin) @@ -467,7 +469,7 @@ def test_home_driver(): driver.pair("client_uuid", "client_public") mock_pair.assert_called_with("client_uuid", "client_public") - mock_dissmiss_msg.assert_called_with("hass") + mock_dissmiss_msg.assert_called_with("hass", "entry_id") # unpair with patch("pyhap.accessory_driver.AccessoryDriver.unpair") as mock_unpair, patch( @@ -476,4 +478,4 @@ def test_home_driver(): driver.unpair("client_uuid") mock_unpair.assert_called_with("client_uuid") - mock_show_msg.assert_called_with("hass", pin, "X-HM://0") + mock_show_msg.assert_called_with("hass", "entry_id", "name", pin, "X-HM://0") diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 258f26e78a6..ff55ba9afa4 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -5,8 +5,8 @@ from zlib import adler32 import pytest from homeassistant.components.homekit.aidmanager import ( - AID_MANAGER_STORAGE_KEY, AccessoryAidStorage, + get_aid_storage_filename_for_entry_id, get_system_unique_id, ) from homeassistant.helpers import device_registry @@ -53,7 +53,7 @@ async def test_aid_generation(hass, device_reg, entity_reg): with patch( "homeassistant.components.homekit.aidmanager.AccessoryAidStorage.async_schedule_save" ): - aid_storage = AccessoryAidStorage(hass) + aid_storage = AccessoryAidStorage(hass, config_entry) await aid_storage.async_initialize() for _ in range(0, 2): @@ -110,7 +110,7 @@ async def test_aid_adler32_collision(hass, device_reg, entity_reg): with patch( "homeassistant.components.homekit.aidmanager.AccessoryAidStorage.async_schedule_save" ): - aid_storage = AccessoryAidStorage(hass) + aid_storage = AccessoryAidStorage(hass, config_entry) await aid_storage.async_initialize() seen_aids = set() @@ -129,8 +129,8 @@ async def test_aid_generation_no_unique_ids_handles_collision( hass, device_reg, entity_reg ): """Test colliding aids is stable.""" - - aid_storage = AccessoryAidStorage(hass) + config_entry = MockConfigEntry(domain="test", data={}) + aid_storage = AccessoryAidStorage(hass, config_entry) await aid_storage.async_initialize() seen_aids = set() @@ -394,7 +394,7 @@ async def test_aid_generation_no_unique_ids_handles_collision( await aid_storage.async_save() await hass.async_block_till_done() - aid_storage = AccessoryAidStorage(hass) + aid_storage = AccessoryAidStorage(hass, config_entry) await aid_storage.async_initialize() assert aid_storage.allocations == { @@ -620,6 +620,7 @@ async def test_aid_generation_no_unique_ids_handles_collision( "light.light99": 596247761, } - aid_storage_path = hass.config.path(STORAGE_DIR, AID_MANAGER_STORAGE_KEY) + aidstore = get_aid_storage_filename_for_entry_id(config_entry.entry_id) + aid_storage_path = hass.config.path(STORAGE_DIR, aidstore) if await hass.async_add_executor_job(os.path.exists, aid_storage_path): await hass.async_add_executor_job(os.unlink, aid_storage_path) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py new file mode 100644 index 00000000000..0fd0a51fd51 --- /dev/null +++ b/tests/components/homekit/test_config_flow.py @@ -0,0 +1,259 @@ +"""Test the HomeKit config flow.""" +from asynctest import patch + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.homekit.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_NAME, CONF_PORT + +from tests.common import MockConfigEntry + + +def _mock_config_entry_with_options_populated(): + """Create a mock config entry with options populated.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + options={ + "filter": { + "include_domains": [ + "fan", + "vacuum", + "media_player", + "climate", + "alarm_control_panel", + ], + "exclude_entities": ["climate.front_gate"], + }, + "auto_start": False, + "safe_mode": False, + "zeroconf_default_interface": True, + }, + ) + + +async def test_user_form(hass): + """Test we can setup a new instance.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.homekit.config_flow.find_next_available_port", + return_value=12345, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"auto_start": True, "include_domains": ["light"]}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "pairing" + + with patch( + "homeassistant.components.homekit.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.homekit.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {},) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"][:11] == "HASS Bridge" + bridge_name = (result3["title"].split(":"))[0] + assert result3["data"] == { + "auto_start": True, + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["light"], + "include_entities": [], + }, + "name": bridge_name, + "port": 12345, + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass): + """Test we can import instance.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "port_name_in_use" + + with patch( + "homeassistant.components.homekit.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.homekit.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_NAME: "othername", CONF_PORT: 56789}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "othername:56789" + assert result2["data"] == { + "name": "othername", + "port": 56789, + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 2 + + +async def test_options_flow_advanced(hass): + """Test config flow options.""" + + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.old", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": True} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"include_domains": ["fan", "vacuum", "climate"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "exclude" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"exclude_entities": ["climate.old"]}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "advanced" + + with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={ + "auto_start": True, + "safe_mode": True, + "zeroconf_default_interface": False, + }, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "filter": { + "exclude_domains": [], + "exclude_entities": ["climate.old"], + "include_domains": ["fan", "vacuum", "climate"], + "include_entities": [], + }, + "safe_mode": True, + "zeroconf_default_interface": False, + } + + +async def test_options_flow_basic(hass): + """Test config flow options.""" + + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.old", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": False} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"include_domains": ["fan", "vacuum", "climate"]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "exclude" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"exclude_entities": ["climate.old"]}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "advanced" + + with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"safe_mode": True, "zeroconf_default_interface": False}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": False, + "filter": { + "exclude_domains": [], + "exclude_entities": ["climate.old"], + "include_domains": ["fan", "vacuum", "climate"], + "include_entities": [], + }, + "safe_mode": True, + "zeroconf_default_interface": False, + } + + +async def test_options_flow_blocked_when_from_yaml(hass): + """Test config flow options.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + options={ + "auto_start": True, + "filter": { + "include_domains": [ + "fan", + "vacuum", + "media_player", + "climate", + "alarm_control_panel", + ], + "exclude_entities": ["climate.front_gate"], + }, + "safe_mode": False, + "zeroconf_default_interface": True, + }, + source=SOURCE_IMPORT, + ) + config_entry.add_to_hass(hass) + + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "yaml" + + with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 08a04d5b88e..286fe51535e 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -5,7 +5,7 @@ import pytest import homeassistant.components.climate as climate import homeassistant.components.cover as cover -from homeassistant.components.homekit import TYPES, get_accessory +from homeassistant.components.homekit.accessories import TYPES, get_accessory from homeassistant.components.homekit.const import ( CONF_FEATURE_LIST, FEATURE_ON_OFF, @@ -17,6 +17,7 @@ from homeassistant.components.homekit.const import ( TYPE_VALVE, ) import homeassistant.components.media_player.const as media_player_c +import homeassistant.components.vacuum as vacuum from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_CLASS, @@ -239,3 +240,27 @@ def test_type_switches(type_name, entity_id, state, attrs, config): entity_state = State(entity_id, state, attrs) get_accessory(None, None, entity_state, 2, config) assert mock_type.called + + +@pytest.mark.parametrize( + "type_name, entity_id, state, attrs", + [ + ( + "DockVacuum", + "vacuum.dock_vacuum", + "docked", + { + ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_START + | vacuum.SUPPORT_RETURN_HOME + }, + ), + ("Switch", "vacuum.basic_vacuum", "off", {},), + ], +) +def test_type_vacuum(type_name, entity_id, state, attrs): + """Test if vacuum types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, {}) + assert mock_type.called diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 8a1d911ef04..e5bee83a0eb 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1,10 +1,11 @@ """Tests for the HomeKit component.""" +import os +from typing import Dict from unittest.mock import ANY, Mock, patch import pytest from zeroconf import InterfaceChoice -from homeassistant import setup from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING from homeassistant.components.homekit import ( MAX_DEVICES, @@ -19,6 +20,7 @@ from homeassistant.components.homekit.const import ( AID_STORAGE, BRIDGE_NAME, CONF_AUTO_START, + CONF_ENTRY_INDEX, CONF_SAFE_MODE, CONF_ZEROCONF_DEFAULT_INTERFACE, DEFAULT_PORT, @@ -28,6 +30,11 @@ from homeassistant.components.homekit.const import ( SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, ) +from homeassistant.components.homekit.util import ( + get_aid_storage_fullpath_for_entry_id, + get_persist_fullpath_for_entry_id, +) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -42,13 +49,17 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.helpers import device_registry from homeassistant.helpers.entityfilter import generate_filter +from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.setup import async_setup_component +from homeassistant.util import json as json_util + +from .util import PATH_HOMEKIT, async_init_entry, async_init_integration from tests.async_mock import AsyncMock from tests.common import MockConfigEntry, mock_device_registry, mock_registry from tests.components.homekit.common import patch_debounce IP_ADDRESS = "127.0.0.1" -PATH_HOMEKIT = "homeassistant.components.homekit" @pytest.fixture @@ -73,11 +84,31 @@ def debounce_patcher(): async def test_setup_min(hass): """Test async_setup with min config options.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT}, + options={}, + ) + entry.add_to_hass(hass) + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit: - assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + mock_homekit.return_value = homekit = Mock() + type(homekit).async_start = AsyncMock() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() mock_homekit.assert_any_call( - hass, BRIDGE_NAME, DEFAULT_PORT, None, ANY, {}, DEFAULT_SAFE_MODE, None, None + hass, + BRIDGE_NAME, + DEFAULT_PORT, + None, + ANY, + {}, + DEFAULT_SAFE_MODE, + None, + None, + entry.entry_id, ) assert mock_homekit().setup.called is True @@ -86,26 +117,27 @@ async def test_setup_min(hass): hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - mock_homekit().async_start.assert_called_with(ANY) + mock_homekit().async_start.assert_called() async def test_setup_auto_start_disabled(hass): """Test async_setup with auto start disabled and test service calls.""" - config = { - DOMAIN: { + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "Test Name", CONF_PORT: 11111, CONF_IP_ADDRESS: "172.0.0.0"}, + options={ CONF_AUTO_START: False, - CONF_NAME: "Test Name", - CONF_PORT: 11111, - CONF_IP_ADDRESS: "172.0.0.0", CONF_SAFE_MODE: DEFAULT_SAFE_MODE, CONF_ZEROCONF_DEFAULT_INTERFACE: True, - } - } + }, + ) + entry.add_to_hass(hass) with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit: mock_homekit.return_value = homekit = Mock() type(homekit).async_start = AsyncMock() - assert await setup.async_setup_component(hass, DOMAIN, config) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() mock_homekit.assert_any_call( hass, @@ -117,6 +149,7 @@ async def test_setup_auto_start_disabled(hass): DEFAULT_SAFE_MODE, None, InterfaceChoice.Default, + entry.entry_id, ) assert mock_homekit().setup.called is True @@ -148,7 +181,23 @@ async def test_setup_auto_start_disabled(hass): async def test_homekit_setup(hass, hk_driver): """Test setup of bridge and driver.""" - homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}, DEFAULT_SAFE_MODE) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + source=SOURCE_IMPORT, + ) + homekit = HomeKit( + hass, + BRIDGE_NAME, + DEFAULT_PORT, + None, + {}, + {}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) with patch( f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver @@ -156,10 +205,12 @@ async def test_homekit_setup(hass, hk_driver): mock_ip.return_value = IP_ADDRESS await hass.async_add_executor_job(homekit.setup) - path = hass.config.path(HOMEKIT_FILE) + path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) assert isinstance(homekit.bridge, HomeBridge) mock_driver.assert_called_with( hass, + entry.entry_id, + BRIDGE_NAME, address=IP_ADDRESS, port=DEFAULT_PORT, persist_file=path, @@ -174,17 +225,36 @@ async def test_homekit_setup(hass, hk_driver): async def test_homekit_setup_ip_address(hass, hk_driver): """Test setup with given IP address.""" - homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, "172.0.0.0", {}, {}, None) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + source=SOURCE_IMPORT, + ) + homekit = HomeKit( + hass, + BRIDGE_NAME, + DEFAULT_PORT, + "172.0.0.0", + {}, + {}, + None, + None, + interface_choice=None, + entry_id=entry.entry_id, + ) + path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) with patch( f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver ) as mock_driver: await hass.async_add_executor_job(homekit.setup) mock_driver.assert_called_with( hass, + entry.entry_id, + BRIDGE_NAME, address="172.0.0.0", port=DEFAULT_PORT, - persist_file=ANY, + persist_file=path, advertised_address=None, interface_choice=None, ) @@ -192,19 +262,36 @@ async def test_homekit_setup_ip_address(hass, hk_driver): async def test_homekit_setup_advertise_ip(hass, hk_driver): """Test setup with given IP address to advertise.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + source=SOURCE_IMPORT, + ) homekit = HomeKit( - hass, BRIDGE_NAME, DEFAULT_PORT, "0.0.0.0", {}, {}, None, "192.168.1.100" + hass, + BRIDGE_NAME, + DEFAULT_PORT, + "0.0.0.0", + {}, + {}, + None, + "192.168.1.100", + interface_choice=None, + entry_id=entry.entry_id, ) + path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) with patch( f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver ) as mock_driver: await hass.async_add_executor_job(homekit.setup) mock_driver.assert_called_with( hass, + entry.entry_id, + BRIDGE_NAME, address="0.0.0.0", port=DEFAULT_PORT, - persist_file=ANY, + persist_file=path, advertised_address="192.168.1.100", interface_choice=None, ) @@ -212,6 +299,11 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver): async def test_homekit_setup_interface_choice(hass, hk_driver): """Test setup with interface choice of Default.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + source=SOURCE_IMPORT, + ) homekit = HomeKit( hass, BRIDGE_NAME, @@ -222,17 +314,21 @@ async def test_homekit_setup_interface_choice(hass, hk_driver): None, None, InterfaceChoice.Default, + entry_id=entry.entry_id, ) + path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) with patch( f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver ) as mock_driver: await hass.async_add_executor_job(homekit.setup) mock_driver.assert_called_with( hass, + entry.entry_id, + BRIDGE_NAME, address="0.0.0.0", port=DEFAULT_PORT, - persist_file=ANY, + persist_file=path, advertised_address=None, interface_choice=InterfaceChoice.Default, ) @@ -240,7 +336,23 @@ async def test_homekit_setup_interface_choice(hass, hk_driver): async def test_homekit_setup_safe_mode(hass, hk_driver): """Test if safe_mode flag is set.""" - homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}, True, None) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + source=SOURCE_IMPORT, + ) + homekit = HomeKit( + hass, + BRIDGE_NAME, + DEFAULT_PORT, + None, + {}, + {}, + True, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) with patch(f"{PATH_HOMEKIT}.accessories.HomeDriver", return_value=hk_driver): await hass.async_add_executor_job(homekit.setup) @@ -249,12 +361,25 @@ async def test_homekit_setup_safe_mode(hass, hk_driver): async def test_homekit_add_accessory(hass): """Add accessory if config exists and get_acc returns an accessory.""" - homekit = HomeKit(hass, None, None, None, lambda entity_id: True, {}, None, None) + entry = await async_init_integration(hass) + + homekit = HomeKit( + hass, + None, + None, + None, + lambda entity_id: True, + {}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) homekit.driver = "driver" homekit.bridge = mock_bridge = Mock() homekit.bridge.accessories = range(10) - assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await async_init_integration(hass) with patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc: mock_get_acc.side_effect = [None, "acc", None] @@ -273,7 +398,20 @@ async def test_homekit_add_accessory(hass): async def test_homekit_remove_accessory(hass): """Remove accessory from bridge.""" - homekit = HomeKit("hass", None, None, None, lambda entity_id: True, {}, None, None) + entry = await async_init_integration(hass) + + homekit = HomeKit( + hass, + None, + None, + None, + lambda entity_id: True, + {}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) homekit.driver = "driver" homekit.bridge = mock_bridge = Mock() mock_bridge.accessories = {"light.demo": "acc"} @@ -285,10 +423,21 @@ async def test_homekit_remove_accessory(hass): async def test_homekit_entity_filter(hass): """Test the entity filter.""" - assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + entry = await async_init_integration(hass) entity_filter = generate_filter(["cover"], ["demo.test"], [], []) - homekit = HomeKit(hass, None, None, None, entity_filter, {}, None, None) + homekit = HomeKit( + hass, + None, + None, + None, + entity_filter, + {}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) homekit.bridge = Mock() homekit.bridge.accessories = {} @@ -309,8 +458,21 @@ async def test_homekit_entity_filter(hass): async def test_homekit_start(hass, hk_driver, debounce_patcher): """Test HomeKit start method.""" + entry = await async_init_integration(hass) + pin = b"123-45-678" - homekit = HomeKit(hass, None, None, None, {}, {"cover.demo": {}}, None, None) + homekit = HomeKit( + hass, + None, + None, + None, + {}, + {}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) homekit.bridge = Mock() homekit.bridge.accessories = [] homekit.driver = hk_driver @@ -330,7 +492,7 @@ async def test_homekit_start(hass, hk_driver, debounce_patcher): await hass.async_block_till_done() mock_add_acc.assert_called_with(state) - mock_setup_msg.assert_called_with(hass, pin, ANY) + mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY) hk_driver_add_acc.assert_called_with(homekit.bridge) assert hk_driver_start.called assert homekit.status == STATUS_RUNNING @@ -345,11 +507,25 @@ async def test_homekit_start(hass, hk_driver, debounce_patcher): async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, debounce_patcher): """Test HomeKit start method.""" pin = b"123-45-678" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) entity_filter = generate_filter(["cover", "light"], ["demo.test"], [], []) - assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await async_init_entry(hass, entry) + homekit = HomeKit( + hass, + None, + None, + None, + entity_filter, + {}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) - homekit = HomeKit(hass, None, None, None, entity_filter, {}, None, None) homekit.bridge = Mock() homekit.bridge.accessories = [] homekit.driver = hk_driver @@ -367,7 +543,7 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, debounce_p await homekit.async_start() await hass.async_block_till_done() - mock_setup_msg.assert_called_with(hass, pin, ANY) + mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY) hk_driver_add_acc.assert_called_with(homekit.bridge) assert hk_driver_start.called assert homekit.status == STATUS_RUNNING @@ -381,10 +557,23 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, debounce_p async def test_homekit_stop(hass): """Test HomeKit stop method.""" - homekit = HomeKit(hass, None, None, None, None, None, None) + entry = await async_init_integration(hass) + + homekit = HomeKit( + hass, + None, + None, + None, + {}, + {}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) homekit.driver = Mock() - assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await async_init_integration(hass) assert homekit.status == STATUS_READY await homekit.async_stop() @@ -406,8 +595,23 @@ async def test_homekit_stop(hass): async def test_homekit_reset_accessories(hass): """Test adding too many accessories to HomeKit.""" + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) entity_id = "light.demo" - homekit = HomeKit(hass, None, None, None, {}, {entity_id: {}}, None) + homekit = HomeKit( + hass, + None, + None, + None, + {}, + {entity_id: {}}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) homekit.bridge = Mock() homekit.bridge.accessories = {} @@ -415,11 +619,14 @@ async def test_homekit_reset_accessories(hass): f"{PATH_HOMEKIT}.HomeKit.setup" ), patch("pyhap.accessory.Bridge.add_accessory") as mock_add_accessory, patch( "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed: + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.start" + ): + await async_init_entry(hass, entry) - assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - aid = hass.data[AID_STORAGE].get_or_allocate_aid_for_entity_id(entity_id) + aid = hass.data[DOMAIN][entry.entry_id][ + AID_STORAGE + ].get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: "acc"} homekit.status = STATUS_RUNNING @@ -438,10 +645,22 @@ async def test_homekit_reset_accessories(hass): async def test_homekit_too_many_accessories(hass, hk_driver): """Test adding too many accessories to HomeKit.""" + entry = await async_init_integration(hass) entity_filter = generate_filter(["cover", "light"], ["demo.test"], [], []) - homekit = HomeKit(hass, None, None, None, entity_filter, {}, None, None) + homekit = HomeKit( + hass, + None, + None, + None, + entity_filter, + {}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) homekit.bridge = Mock() # The bridge itself counts as an accessory homekit.bridge.accessories = range(MAX_DEVICES) @@ -463,9 +682,20 @@ async def test_homekit_finds_linked_batteries( hass, hk_driver, debounce_patcher, device_reg, entity_reg ): """Test HomeKit start method.""" - assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + entry = await async_init_integration(hass) - homekit = HomeKit(hass, None, None, None, {}, {"light.demo": {}}, None, None) + homekit = HomeKit( + hass, + None, + None, + None, + {}, + {"light.demo": {}}, + DEFAULT_SAFE_MODE, + advertise_ip=None, + interface_choice=None, + entry_id=entry.entry_id, + ) homekit.driver = hk_driver homekit._filter = Mock(return_value=True) homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge") @@ -526,3 +756,132 @@ async def test_homekit_finds_linked_batteries( "linked_battery_sensor": "sensor.light_battery", }, ) + + +async def test_setup_imported(hass): + """Test async_setup with imported config options.""" + legacy_persist_file_path = hass.config.path(HOMEKIT_FILE) + legacy_aid_storage_path = hass.config.path(STORAGE_DIR, "homekit.aids") + legacy_homekit_state_contents = {"homekit.state": 1} + legacy_homekit_aids_contents = {"homekit.aids": 1} + await hass.async_add_executor_job( + _write_data, legacy_persist_file_path, legacy_homekit_state_contents + ) + await hass.async_add_executor_job( + _write_data, legacy_aid_storage_path, legacy_homekit_aids_contents + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IMPORT, + data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT, CONF_ENTRY_INDEX: 0}, + options={}, + ) + entry.add_to_hass(hass) + + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit: + mock_homekit.return_value = homekit = Mock() + type(homekit).async_start = AsyncMock() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_homekit.assert_any_call( + hass, + BRIDGE_NAME, + DEFAULT_PORT, + None, + ANY, + {}, + DEFAULT_SAFE_MODE, + None, + None, + entry.entry_id, + ) + assert mock_homekit().setup.called is True + + # Test auto start enabled + mock_homekit.reset_mock() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + mock_homekit().async_start.assert_called() + + migrated_persist_file_path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) + assert ( + await hass.async_add_executor_job( + json_util.load_json, migrated_persist_file_path + ) + == legacy_homekit_state_contents + ) + os.unlink(migrated_persist_file_path) + migrated_aid_file_path = get_aid_storage_fullpath_for_entry_id(hass, entry.entry_id) + assert ( + await hass.async_add_executor_job(json_util.load_json, migrated_aid_file_path) + == legacy_homekit_aids_contents + ) + os.unlink(migrated_aid_file_path) + + +async def test_yaml_updates_update_config_entry_for_name(hass): + """Test async_setup with imported config.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IMPORT, + data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT}, + options={}, + ) + entry.add_to_hass(hass) + + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit: + mock_homekit.return_value = homekit = Mock() + type(homekit).async_start = AsyncMock() + assert await async_setup_component( + hass, "homekit", {"homekit": {CONF_NAME: BRIDGE_NAME, CONF_PORT: 12345}} + ) + await hass.async_block_till_done() + + mock_homekit.assert_any_call( + hass, + BRIDGE_NAME, + 12345, + None, + ANY, + {}, + DEFAULT_SAFE_MODE, + None, + None, + entry.entry_id, + ) + assert mock_homekit().setup.called is True + + # Test auto start enabled + mock_homekit.reset_mock() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + mock_homekit().async_start.assert_called() + + +async def test_raise_config_entry_not_ready(hass): + """Test async_setup when the port is not available.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT}, + options={}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homekit.port_is_available", return_value=False, + ): + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +def _write_data(path: str, data: Dict) -> None: + """Write the data.""" + if not os.path.isdir(os.path.dirname(path)): + os.makedirs(os.path.dirname(path)) + json_util.save_json(path, data) diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 5fe8c438ca1..cb2de7264a8 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -348,6 +348,7 @@ async def test_media_player_television_basic(hass, hk_driver, events, caplog): await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) await acc.run_handler() + await hass.async_block_till_done() assert acc.chars_tv == [] assert acc.chars_speaker == [] diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 690cd8f318f..b139fac3657 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -28,6 +28,7 @@ async def test_switch_set_state(hass, hk_driver, events): await hass.async_block_till_done() acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config) await acc.run_handler() + await hass.async_block_till_done() assert acc.aid == 2 assert acc.category == 11 # AlarmSystem diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 41b134c10a5..2c8c93cee4c 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -7,9 +7,10 @@ from homeassistant.components.homekit.const import ( CONF_FEATURE_LIST, CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, + DEFAULT_CONFIG_FLOW_PORT, + DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, - HOMEKIT_NOTIFY_ID, HOMEKIT_PAIRING_QR, HOMEKIT_PAIRING_QR_SECRET, TYPE_FAUCET, @@ -25,6 +26,8 @@ from homeassistant.components.homekit.util import ( convert_to_float, density_to_air_quality, dismiss_setup_message, + find_next_available_port, + port_is_available, show_setup_message, temperature_to_homekit, temperature_to_states, @@ -34,7 +37,7 @@ from homeassistant.components.homekit.util import ( from homeassistant.components.persistent_notification import ( ATTR_MESSAGE, ATTR_NOTIFICATION_ID, - DOMAIN, + DOMAIN as PERSISTENT_NOTIFICATION_DOMAIN, ) from homeassistant.const import ( ATTR_CODE, @@ -47,6 +50,8 @@ from homeassistant.const import ( ) from homeassistant.core import State +from .util import async_init_integration + from tests.common import async_mock_service @@ -199,27 +204,36 @@ async def test_show_setup_msg(hass): """Test show setup message as persistence notification.""" pincode = b"123-45-678" - call_create_notification = async_mock_service(hass, DOMAIN, "create") + entry = await async_init_integration(hass) + assert entry - await hass.async_add_executor_job(show_setup_message, hass, pincode, "X-HM://0") + call_create_notification = async_mock_service( + hass, PERSISTENT_NOTIFICATION_DOMAIN, "create" + ) + + await hass.async_add_executor_job( + show_setup_message, hass, entry.entry_id, "bridge_name", pincode, "X-HM://0" + ) await hass.async_block_till_done() - assert hass.data[HOMEKIT_PAIRING_QR_SECRET] - assert hass.data[HOMEKIT_PAIRING_QR] + assert hass.data[DOMAIN][entry.entry_id][HOMEKIT_PAIRING_QR_SECRET] + assert hass.data[DOMAIN][entry.entry_id][HOMEKIT_PAIRING_QR] assert call_create_notification - assert call_create_notification[0].data[ATTR_NOTIFICATION_ID] == HOMEKIT_NOTIFY_ID + assert call_create_notification[0].data[ATTR_NOTIFICATION_ID] == entry.entry_id assert pincode.decode() in call_create_notification[0].data[ATTR_MESSAGE] async def test_dismiss_setup_msg(hass): """Test dismiss setup message.""" - call_dismiss_notification = async_mock_service(hass, DOMAIN, "dismiss") + call_dismiss_notification = async_mock_service( + hass, PERSISTENT_NOTIFICATION_DOMAIN, "dismiss" + ) - await hass.async_add_executor_job(dismiss_setup_message, hass) + await hass.async_add_executor_job(dismiss_setup_message, hass, "entry_id") await hass.async_block_till_done() assert call_dismiss_notification - assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == HOMEKIT_NOTIFY_ID + assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == "entry_id" def test_homekit_speed_mapping(): @@ -277,3 +291,13 @@ def test_speed_to_states(): assert speed_mapping.speed_to_states(66) == "low" assert speed_mapping.speed_to_states(67) == "high" assert speed_mapping.speed_to_states(100) == "high" + + +async def test_port_is_available(hass): + """Test we can get an available port and it is actually available.""" + next_port = await hass.async_add_executor_job( + find_next_available_port, DEFAULT_CONFIG_FLOW_PORT + ) + assert next_port + + assert await hass.async_add_executor_job(port_is_available, next_port) diff --git a/tests/components/homekit/util.py b/tests/components/homekit/util.py new file mode 100644 index 00000000000..0abf3007c04 --- /dev/null +++ b/tests/components/homekit/util.py @@ -0,0 +1,34 @@ +"""Test util for the homekit integration.""" + +from asynctest import patch + +from homeassistant.components.homekit.const import DOMAIN +from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +PATH_HOMEKIT = "homeassistant.components.homekit" + + +async def async_init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the homekit integration in Home Assistant.""" + + with patch(f"{PATH_HOMEKIT}.HomeKit.async_start"): + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +async def async_init_entry(hass: HomeAssistant, entry: MockConfigEntry): + """Set up the homekit integration in Home Assistant.""" + + with patch(f"{PATH_HOMEKIT}.HomeKit.async_start"): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry