diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 396f36f7c03..534ea3c6f95 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -5,7 +5,7 @@ import logging import os from aiohttp import web -from pyhap.const import CATEGORY_CAMERA, CATEGORY_TELEVISION, STANDALONE_AID +from pyhap.const import STANDALONE_AID import voluptuous as vol from homeassistant.components import zeroconf @@ -70,6 +70,7 @@ from .const import ( CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_ENTRY_INDEX, + CONF_EXCLUDE_ACCESSORY_MODE, CONF_FILTER, CONF_HOMEKIT_MODE, CONF_LINKED_BATTERY_CHARGING_SENSOR, @@ -81,6 +82,7 @@ from .const import ( CONF_ZEROCONF_DEFAULT_INTERFACE, CONFIG_OPTIONS, DEFAULT_AUTO_START, + DEFAULT_EXCLUDE_ACCESSORY_MODE, DEFAULT_HOMEKIT_MODE, DEFAULT_PORT, DEFAULT_SAFE_MODE, @@ -97,11 +99,13 @@ from .const import ( UNDO_UPDATE_LISTENER, ) from .util import ( + accessory_friendly_name, dismiss_setup_message, get_persist_fullpath_for_entry_id, port_is_available, remove_state_files_for_entry_id, show_setup_message, + state_needs_accessory_mode, validate_entity_config, ) @@ -243,6 +247,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # ip_address and advertise_ip are yaml only ip_address = conf.get(CONF_IP_ADDRESS) advertise_ip = conf.get(CONF_ADVERTISE_IP) + # exclude_accessory_mode is only used for config flow + # to indicate that the config entry was setup after + # we started creating config entries for entities that + # to run in accessory mode and that we should never include + # these entities on the bridge. For backwards compatibility + # with users who have not migrated yet we do not do exclude + # these entities by default as we cannot migrate automatically + # since it requires a re-pairing. + exclude_accessory_mode = conf.get( + CONF_EXCLUDE_ACCESSORY_MODE, DEFAULT_EXCLUDE_ACCESSORY_MODE + ) homekit_mode = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy() auto_start = options.get(CONF_AUTO_START, DEFAULT_AUTO_START) @@ -254,10 +269,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): port, ip_address, entity_filter, + exclude_accessory_mode, entity_config, homekit_mode, advertise_ip, entry.entry_id, + entry.title, ) zeroconf_instance = await zeroconf.async_get_instance(hass) @@ -427,10 +444,12 @@ class HomeKit: port, ip_address, entity_filter, + exclude_accessory_mode, entity_config, homekit_mode, advertise_ip=None, entry_id=None, + entry_title=None, ): """Initialize a HomeKit object.""" self.hass = hass @@ -439,8 +458,10 @@ class HomeKit: self._ip_address = ip_address self._filter = entity_filter self._config = entity_config + self._exclude_accessory_mode = exclude_accessory_mode self._advertise_ip = advertise_ip self._entry_id = entry_id + self._entry_title = entry_title self._homekit_mode = homekit_mode self.status = STATUS_READY @@ -457,6 +478,7 @@ class HomeKit: self.hass, self._entry_id, self._name, + self._entry_title, loop=self.hass.loop, address=ip_addr, port=self._port, @@ -518,6 +540,18 @@ class HomeKit: ) return + if state_needs_accessory_mode(state): + if self._exclude_accessory_mode: + return + _LOGGER.warning( + "The bridge %s has entity %s. For best performance, " + "and to prevent unexpected unavailability, create and " + "pair a separate HomeKit instance in accessory mode for " + "this entity.", + self._name, + state.entity_id, + ) + aid = self.hass.data[DOMAIN][self._entry_id][ AID_STORAGE ].get_or_allocate_aid_for_entity_id(state.entity_id) @@ -528,24 +562,6 @@ class HomeKit: try: acc = get_accessory(self.hass, self.driver, state, aid, conf) if acc is not None: - if acc.category == CATEGORY_CAMERA: - _LOGGER.warning( - "The bridge %s has camera %s. For best performance, " - "and to prevent unexpected unavailability, create and " - "pair a separate HomeKit instance in accessory mode for " - "each camera.", - self._name, - acc.entity_id, - ) - elif acc.category == CATEGORY_TELEVISION: - _LOGGER.warning( - "The bridge %s has tv %s. For best performance, " - "and to prevent unexpected unavailability, create and " - "pair a separate HomeKit instance in accessory mode for " - "each tv media player.", - self._name, - acc.entity_id, - ) self.bridge.add_accessory(acc) except Exception: # pylint: disable=broad-except _LOGGER.exception( @@ -650,6 +666,7 @@ class HomeKit: state = entity_states[0] conf = self._config.pop(state.entity_id, {}) acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) + self.driver.add_accessory(acc) else: self.bridge = HomeBridge(self.hass, self.driver, self._name) @@ -663,7 +680,7 @@ class HomeKit: show_setup_message( self.hass, self._entry_id, - self._name, + accessory_friendly_name(self._entry_title, self.driver.accessory), self.driver.state.pincode, self.driver.accessory.xhm_uri(), ) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index e31b9ec842e..7e68daf4b62 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -71,6 +71,7 @@ from .const import ( TYPE_VALVE, ) from .util import ( + accessory_friendly_name, convert_to_float, dismiss_setup_message, format_sw_version, @@ -489,12 +490,13 @@ class HomeBridge(Bridge): class HomeDriver(AccessoryDriver): """Adapter class for AccessoryDriver.""" - def __init__(self, hass, entry_id, bridge_name, **kwargs): + def __init__(self, hass, entry_id, bridge_name, entry_title, **kwargs): """Initialize a AccessoryDriver object.""" super().__init__(**kwargs) self.hass = hass self._entry_id = entry_id self._bridge_name = bridge_name + self._entry_title = entry_title def pair(self, client_uuid, client_public): """Override super function to dismiss setup message if paired.""" @@ -506,10 +508,14 @@ class HomeDriver(AccessoryDriver): def unpair(self, client_uuid): """Override super function to show setup message if unpaired.""" super().unpair(client_uuid) + + if self.state.paired: + return + show_setup_message( self.hass, self._entry_id, - self._bridge_name, + accessory_friendly_name(self._entry_title, self.accessory), self.state.pincode, self.accessory.xhm_uri(), ) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index d6278c0ca94..c21c27fba83 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -1,10 +1,13 @@ """Config flow for HomeKit integration.""" import random +import re import string import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_FRIENDLY_NAME, @@ -26,6 +29,7 @@ from homeassistant.helpers.entityfilter import ( from .const import ( CONF_AUTO_START, CONF_ENTITY_CONFIG, + CONF_EXCLUDE_ACCESSORY_MODE, CONF_FILTER, CONF_HOMEKIT_MODE, CONF_VIDEO_CODEC, @@ -33,13 +37,13 @@ from .const import ( DEFAULT_CONFIG_FLOW_PORT, DEFAULT_HOMEKIT_MODE, HOMEKIT_MODE_ACCESSORY, + HOMEKIT_MODE_BRIDGE, HOMEKIT_MODES, - SHORT_ACCESSORY_NAME, SHORT_BRIDGE_NAME, VIDEO_CODEC_COPY, ) from .const import DOMAIN # pylint:disable=unused-import -from .util import async_find_next_available_port +from .util import async_find_next_available_port, state_needs_accessory_mode CONF_CAMERA_COPY = "camera_copy" CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode" @@ -49,11 +53,16 @@ MODE_EXCLUDE = "exclude" INCLUDE_EXCLUDE_MODES = [MODE_EXCLUDE, MODE_INCLUDE] +DOMAINS_NEED_ACCESSORY_MODE = [CAMERA_DOMAIN, MEDIA_PLAYER_DOMAIN] +NEVER_BRIDGED_DOMAINS = [CAMERA_DOMAIN] + +CAMERA_ENTITY_PREFIX = f"{CAMERA_DOMAIN}." + SUPPORTED_DOMAINS = [ "alarm_control_panel", "automation", "binary_sensor", - "camera", + CAMERA_DOMAIN, "climate", "cover", "demo", @@ -63,7 +72,7 @@ SUPPORTED_DOMAINS = [ "input_boolean", "light", "lock", - "media_player", + MEDIA_PLAYER_DOMAIN, "person", "remote", "scene", @@ -77,22 +86,18 @@ SUPPORTED_DOMAINS = [ DEFAULT_DOMAINS = [ "alarm_control_panel", "climate", + CAMERA_DOMAIN, "cover", "humidifier", "fan", "light", "lock", - "media_player", + MEDIA_PLAYER_DOMAIN, "switch", "vacuum", "water_heater", ] -DOMAINS_PREFER_ACCESSORY_MODE = ["camera", "media_player"] - -CAMERA_DOMAIN = "camera" -CAMERA_ENTITY_PREFIX = f"{CAMERA_DOMAIN}." - _EMPTY_ENTITY_FILTER = { CONF_INCLUDE_DOMAINS: [], CONF_EXCLUDE_DOMAINS: [], @@ -110,32 +115,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize config flow.""" self.hk_data = {} - self.entry_title = None - async def async_step_accessory_mode(self, user_input=None): - """Choose specific entity in accessory mode.""" - if user_input is not None: - entity_id = user_input[CONF_ENTITY_ID] - entity_filter = _EMPTY_ENTITY_FILTER.copy() - entity_filter[CONF_INCLUDE_ENTITIES] = [entity_id] - self.hk_data[CONF_FILTER] = entity_filter - if entity_id.startswith(CAMERA_ENTITY_PREFIX): - self.hk_data[CONF_ENTITY_CONFIG] = { - entity_id: {CONF_VIDEO_CODEC: VIDEO_CODEC_COPY} - } - return await self.async_step_pairing() - - all_supported_entities = _async_get_matching_entities( - self.hass, domains=DOMAINS_PREFER_ACCESSORY_MODE - ) - return self.async_show_form( - step_id="accessory_mode", - data_schema=vol.Schema( - {vol.Required(CONF_ENTITY_ID): vol.In(all_supported_entities)} - ), - ) - - async def async_step_bridge_mode(self, user_input=None): + async def async_step_user(self, user_input=None): """Choose specific domains in bridge mode.""" if user_input is not None: entity_filter = _EMPTY_ENTITY_FILTER.copy() @@ -143,9 +124,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.hk_data[CONF_FILTER] = entity_filter return await self.async_step_pairing() + self.hk_data[CONF_HOMEKIT_MODE] = HOMEKIT_MODE_BRIDGE default_domains = [] if self._async_current_names() else DEFAULT_DOMAINS return self.async_show_form( - step_id="bridge_mode", + step_id="user", data_schema=vol.Schema( { vol.Required( @@ -158,43 +140,72 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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.hk_data) + port = await async_find_next_available_port( + self.hass, DEFAULT_CONFIG_FLOW_PORT + ) + await self._async_add_entries_for_accessory_mode_entities(port) + self.hk_data[CONF_PORT] = port + include_domains_filter = self.hk_data[CONF_FILTER][CONF_INCLUDE_DOMAINS] + for domain in NEVER_BRIDGED_DOMAINS: + if domain in include_domains_filter: + include_domains_filter.remove(domain) + return self.async_create_entry( + title=f"{self.hk_data[CONF_NAME]}:{self.hk_data[CONF_PORT]}", + data=self.hk_data, + ) - self.hk_data[CONF_PORT] = await async_find_next_available_port( - self.hass, DEFAULT_CONFIG_FLOW_PORT - ) - self.hk_data[CONF_NAME] = self._async_available_name( - self.hk_data[CONF_HOMEKIT_MODE] - ) - self.entry_title = f"{self.hk_data[CONF_NAME]}:{self.hk_data[CONF_PORT]}" + self.hk_data[CONF_NAME] = self._async_available_name(SHORT_BRIDGE_NAME) + self.hk_data[CONF_EXCLUDE_ACCESSORY_MODE] = True return self.async_show_form( step_id="pairing", description_placeholders={CONF_NAME: self.hk_data[CONF_NAME]}, ) - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - errors = {} + async def _async_add_entries_for_accessory_mode_entities(self, last_assigned_port): + """Generate new flows for entities that need their own instances.""" + accessory_mode_entity_ids = _async_get_entity_ids_for_accessory_mode( + self.hass, self.hk_data[CONF_FILTER][CONF_INCLUDE_DOMAINS] + ) + exiting_entity_ids_accessory_mode = _async_entity_ids_with_accessory_mode( + self.hass + ) + next_port_to_check = last_assigned_port + 1 + for entity_id in accessory_mode_entity_ids: + if entity_id in exiting_entity_ids_accessory_mode: + continue + port = await async_find_next_available_port(self.hass, next_port_to_check) + next_port_to_check = port + 1 + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "accessory"}, + data={CONF_ENTITY_ID: entity_id, CONF_PORT: port}, + ) + ) - if user_input is not None: - self.hk_data = { - CONF_HOMEKIT_MODE: user_input[CONF_HOMEKIT_MODE], + async def async_step_accessory(self, accessory_input): + """Handle creation a single accessory in accessory mode.""" + entity_id = accessory_input[CONF_ENTITY_ID] + port = accessory_input[CONF_PORT] + + state = self.hass.states.get(entity_id) + name = state.attributes.get(ATTR_FRIENDLY_NAME) or state.entity_id + entity_filter = _EMPTY_ENTITY_FILTER.copy() + entity_filter[CONF_INCLUDE_ENTITIES] = [entity_id] + + entry_data = { + CONF_PORT: port, + CONF_NAME: self._async_available_name(name), + CONF_HOMEKIT_MODE: HOMEKIT_MODE_ACCESSORY, + CONF_FILTER: entity_filter, + } + if entity_id.startswith(CAMERA_ENTITY_PREFIX): + entry_data[CONF_ENTITY_CONFIG] = { + entity_id: {CONF_VIDEO_CODEC: VIDEO_CODEC_COPY} } - if user_input[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY: - return await self.async_step_accessory_mode() - return await self.async_step_bridge_mode() - homekit_mode = self.hk_data.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOMEKIT_MODE, default=homekit_mode): vol.In( - HOMEKIT_MODES - ) - } - ), - errors=errors, + return self.async_create_entry( + title=f"{name}:{entry_data[CONF_PORT]}", data=entry_data ) async def async_step_import(self, user_input=None): @@ -215,21 +226,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } @callback - def _async_available_name(self, homekit_mode): + def _async_available_name(self, requested_name): """Return an available for the bridge.""" + current_names = self._async_current_names() + valid_mdns_name = re.sub("[^A-Za-z0-9 ]+", " ", requested_name) - base_name = SHORT_BRIDGE_NAME - if homekit_mode == HOMEKIT_MODE_ACCESSORY: - base_name = SHORT_ACCESSORY_NAME + if valid_mdns_name not in current_names: + return valid_mdns_name - # 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 + acceptable_mdns_chars = string.ascii_uppercase + string.digits suggested_name = None - while not suggested_name or suggested_name in self._async_current_names(): - trailer = "".join(random.choices(acceptable_chars, k=4)) - suggested_name = f"{base_name} {trailer}" + while not suggested_name or suggested_name in current_names: + trailer = "".join(random.choices(acceptable_mdns_chars, k=2)) + suggested_name = f"{valid_mdns_name} {trailer}" return suggested_name @@ -447,7 +456,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): def _async_get_matching_entities(hass, domains=None): """Fetch all entities or entities in the given domains.""" return { - state.entity_id: f"{state.entity_id} ({state.attributes.get(ATTR_FRIENDLY_NAME, state.entity_id)})" + state.entity_id: f"{state.attributes.get(ATTR_FRIENDLY_NAME, state.entity_id)} ({state.entity_id})" for state in sorted( hass.states.async_all(domains and set(domains)), key=lambda item: item.entity_id, @@ -457,7 +466,41 @@ def _async_get_matching_entities(hass, domains=None): def _domains_set_from_entities(entity_ids): """Build a set of domains for the given entity ids.""" - domains = set() - for entity_id in entity_ids: - domains.add(split_entity_id(entity_id)[0]) - return domains + return {split_entity_id(entity_id)[0] for entity_id in entity_ids} + + +@callback +def _async_get_entity_ids_for_accessory_mode(hass, include_domains): + """Build a list of entities that should be paired in accessory mode.""" + accessory_mode_domains = { + domain for domain in include_domains if domain in DOMAINS_NEED_ACCESSORY_MODE + } + + if not accessory_mode_domains: + return [] + + return [ + state.entity_id + for state in hass.states.async_all(accessory_mode_domains) + if state_needs_accessory_mode(state) + ] + + +@callback +def _async_entity_ids_with_accessory_mode(hass): + """Return a set of entity ids that have config entries in accessory mode.""" + + entity_ids = set() + + current_entries = hass.config_entries.async_entries(DOMAIN) + for entry in current_entries: + # We have to handle the case where the data has not yet + # been migrated to options because the data was just + # imported and the entry was never started + target = entry.options if CONF_HOMEKIT_MODE in entry.options else entry.data + if target.get(CONF_HOMEKIT_MODE) != HOMEKIT_MODE_ACCESSORY: + continue + + entity_ids.add(target[CONF_FILTER][CONF_INCLUDE_ENTITIES][0]) + + return entity_ids diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index fac4168a79b..67312903b50 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -42,6 +42,7 @@ CONF_ENTITY_CONFIG = "entity_config" CONF_FEATURE = "feature" CONF_FEATURE_LIST = "feature_list" CONF_FILTER = "filter" +CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor" @@ -68,6 +69,7 @@ DEFAULT_AUDIO_CODEC = AUDIO_CODEC_OPUS DEFAULT_AUDIO_MAP = "0:a:0" DEFAULT_AUDIO_PACKET_SIZE = 188 DEFAULT_AUTO_START = True +DEFAULT_EXCLUDE_ACCESSORY_MODE = False DEFAULT_LOW_BATTERY_THRESHOLD = 20 DEFAULT_MAX_FPS = 30 DEFAULT_MAX_HEIGHT = 1080 diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index ed825ada23c..a9b7c1c6cc1 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -8,7 +8,7 @@ "init": { "data": { "mode": "[%key:common::config_flow::data::mode%]", - "include_domains": "[%key:component::homekit::config::step::bridge_mode::data::include_domains%]" + "include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]" }, "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", "title": "Select domains to be included." @@ -18,7 +18,7 @@ "mode": "[%key:common::config_flow::data::mode%]", "entities": "Entities" }, - "description": "Choose the entities to be included. In accessory mode, only a single entity is included. In bridge include mode, all entities in the domain will be included unless specific entities are selected. In bridge exclude mode, all entities in the domain will be included except for the excluded entities. For best performance, and to prevent unexpected unavailability, create and pair a separate HomeKit instance in accessory mode for each tv media player and camera.", + "description": "Choose the entities to be included. In accessory mode, only a single entity is included. In bridge include mode, all entities in the domain will be included unless specific entities are selected. In bridge exclude mode, all entities in the domain will be included except for the excluded entities. For best performance, a seperate HomeKit accessory will beeach tv media player and camera.", "title": "Select entities to be included" }, "cameras": { @@ -40,29 +40,15 @@ "config": { "step": { "user": { - "data": { - "mode": "[%key:common::config_flow::data::mode%]" - }, - "description": "The HomeKit integration will allow you to access your Home Assistant entities in HomeKit. In bridge mode, 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 best performance, and to prevent unexpected unavailability, create and pair a separate HomeKit instance in accessory mode for each tv media player and camera.", - "title": "Activate HomeKit" - }, - "accessory_mode": { - "data": { - "entity_id": "Entity" - }, - "description": "Choose the entity to be included. In accessory mode, only a single entity is included.", - "title": "Select entity to be included" - }, - "bridge_mode": { "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included.", + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", "title": "Select domains to be included" }, "pairing": { "title": "Pair HomeKit", - "description": "As soon as the {name} is ready, pairing will be available in \u201cNotifications\u201d as \u201cHomeKit Bridge Setup\u201d." + "description": "To complete pairing following the instructions in \u201cNotifications\u201d under \u201cHomeKit Pairing\u201d." } }, "abort": { diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index db0656c0450..e9aaeb60df8 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -4,32 +4,16 @@ "port_name_in_use": "An accessory or bridge with the same name or port is already configured." }, "step": { - "accessory_mode": { - "data": { - "entity_id": "Entity" - }, - "description": "Choose the entity to be included. In accessory mode, only a single entity is included.", - "title": "Select entity to be included" - }, - "bridge_mode": { - "data": { - "include_domains": "Domains to include" - }, - "description": "Choose the domains to be included. All supported entities in the domain will be included.", - "title": "Select domains to be included" - }, "pairing": { - "description": "As soon as the {name} is ready, pairing will be available in \u201cNotifications\u201d as \u201cHomeKit Bridge Setup\u201d.", + "description": "To complete pairing following the instructions in \u201cNotifications\u201d under \u201cHomeKit Pairing\u201d.", "title": "Pair HomeKit" }, "user": { "data": { - "auto_start": "Autostart (disable if using Z-Wave or other delayed start system)", - "include_domains": "Domains to include", - "mode": "Mode" + "include_domains": "Domains to include" }, - "description": "The HomeKit integration will allow you to access your Home Assistant entities in HomeKit. In bridge mode, 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 best performance, and to prevent unexpected unavailability, create and pair a separate HomeKit instance in accessory mode for each tv media player and camera.", - "title": "Activate HomeKit" + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", + "title": "Select domains to be included" } } }, @@ -37,8 +21,7 @@ "step": { "advanced": { "data": { - "auto_start": "Autostart (disable if you are calling the homekit.start service manually)", - "safe_mode": "Safe Mode (enable only if pairing fails)" + "auto_start": "Autostart (disable if you are calling the homekit.start service manually)" }, "description": "These settings only need to be adjusted if HomeKit is not functional.", "title": "Advanced Configuration" @@ -55,7 +38,7 @@ "entities": "Entities", "mode": "Mode" }, - "description": "Choose the entities to be included. In accessory mode, only a single entity is included. In bridge include mode, all entities in the domain will be included unless specific entities are selected. In bridge exclude mode, all entities in the domain will be included except for the excluded entities. For best performance, and to prevent unexpected unavailability, create and pair a separate HomeKit instance in accessory mode for each tv media player and camera.", + "description": "Choose the entities to be included. In accessory mode, only a single entity is included. In bridge include mode, all entities in the domain will be included unless specific entities are selected. In bridge exclude mode, all entities in the domain will be included except for the excluded entities. For best performance, a seperate HomeKit accessory will beeach tv media player and camera.", "title": "Select entities to be included" }, "init": { diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index c23b8c1baaf..46b893bb96d 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -11,8 +11,14 @@ import pyqrcode import voluptuous as vol from homeassistant.components import binary_sensor, media_player, sensor +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.media_player import ( + DEVICE_CLASS_TV, + DOMAIN as MEDIA_PLAYER_DOMAIN, +) from homeassistant.const import ( ATTR_CODE, + ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_PORT, @@ -328,9 +334,7 @@ def show_setup_message(hass, entry_id, bridge_name, pincode, uri): f"### {pin}\n" f"![image](/api/homekit/pairingqr?{entry_id}-{pairing_secret})" ) - hass.components.persistent_notification.create( - message, "HomeKit Bridge Setup", entry_id - ) + hass.components.persistent_notification.create(message, "HomeKit Pairing", entry_id) def dismiss_setup_message(hass, entry_id): @@ -473,3 +477,30 @@ def pid_is_alive(pid) -> bool: except OSError: pass return False + + +def accessory_friendly_name(hass_name, accessory): + """Return the combined name for the accessory. + + The mDNS name and the Home Assistant config entry + name are usually different which means they need to + see both to identify the accessory. + """ + accessory_mdns_name = accessory.display_name + if hass_name.startswith(accessory_mdns_name): + return hass_name + return f"{hass_name} ({accessory_mdns_name})" + + +def state_needs_accessory_mode(state): + """Return if the entity represented by the state must be paired in accessory mode.""" + if state.domain == CAMERA_DOMAIN: + return True + + if ( + state.domain == MEDIA_PLAYER_DOMAIN + and state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TV + ): + return True + + return False diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index e308ed21537..afaa9ea0892 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -603,13 +603,19 @@ def test_home_driver(): with patch("pyhap.accessory_driver.AccessoryDriver.__init__") as mock_driver: driver = HomeDriver( - "hass", "entry_id", "name", address=ip_address, port=port, persist_file=path + "hass", + "entry_id", + "name", + "title", + 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) + driver.state = Mock(pincode=pin, paired=False) xhm_uri_mock = Mock(return_value="X-HM://0") - driver.accessory = Mock(xhm_uri=xhm_uri_mock) + driver.accessory = Mock(display_name="any", xhm_uri=xhm_uri_mock) # pair with patch("pyhap.accessory_driver.AccessoryDriver.pair") as mock_pair, patch( @@ -627,4 +633,4 @@ def test_home_driver(): driver.unpair("client_uuid") mock_unpair.assert_called_with("client_uuid") - mock_show_msg.assert_called_with("hass", "entry_id", "name", pin, "X-HM://0") + mock_show_msg.assert_called_with("hass", "entry_id", "title (any)", pin, "X-HM://0") diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 34c7ab2ecc0..3d94672cd8a 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.homekit.const import DOMAIN +from homeassistant.components.homekit.const import DOMAIN, SHORT_BRIDGE_NAME from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT @@ -39,48 +39,41 @@ async def test_setup_in_bridge_mode(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"mode": "bridge"}, + {"include_domains": ["light"]}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["step_id"] == "bridge_mode" + assert result2["step_id"] == "pairing" with patch( "homeassistant.components.homekit.config_flow.async_find_next_available_port", return_value=12345, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {"include_domains": ["light"]}, - ) - assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result3["step_id"] == "pairing" - - with patch( + ), 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: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {}, ) await hass.async_block_till_done() - assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"][:11] == "HASS Bridge" - bridge_name = (result4["title"].split(":"))[0] - assert result4["data"] == { + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + bridge_name = (result3["title"].split(":"))[0] + assert bridge_name == SHORT_BRIDGE_NAME + assert result3["data"] == { "filter": { "exclude_domains": [], "exclude_entities": [], "include_domains": ["light"], "include_entities": [], }, + "exclude_accessory_mode": True, "mode": "bridge", "name": bridge_name, "port": 12345, @@ -89,64 +82,147 @@ async def test_setup_in_bridge_mode(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_setup_in_accessory_mode(hass): - """Test we can setup a new instance in accessory.""" +async def test_setup_in_bridge_mode_name_taken(hass): + """Test we can setup a new instance in bridge mode when the name is taken.""" await setup.async_setup_component(hass, "persistent_notification", {}) - hass.states.async_set("camera.mine", "off") + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: SHORT_BRIDGE_NAME, CONF_PORT: 8000}, + ) + entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"mode": "accessory"}, + {"include_domains": ["light"]}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["step_id"] == "accessory_mode" + assert result2["step_id"] == "pairing" with patch( "homeassistant.components.homekit.config_flow.async_find_next_available_port", return_value=12345, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {"entity_id": "camera.mine"}, - ) - assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result3["step_id"] == "pairing" - - with patch( + ), 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: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {}, ) await hass.async_block_till_done() - assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"][:14] == "HASS Accessory" - bridge_name = (result4["title"].split(":"))[0] - assert result4["data"] == { + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] != SHORT_BRIDGE_NAME + assert result3["title"].startswith(SHORT_BRIDGE_NAME) + bridge_name = (result3["title"].split(":"))[0] + assert result3["data"] == { "filter": { "exclude_domains": [], "exclude_entities": [], - "include_domains": [], - "include_entities": ["camera.mine"], + "include_domains": ["light"], + "include_entities": [], }, - "mode": "accessory", + "exclude_accessory_mode": True, + "mode": "bridge", "name": bridge_name, - "entity_config": {"camera.mine": {"video_codec": "copy"}}, "port": 12345, } assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 2 + + +async def test_setup_creates_entries_for_accessory_mode_devices(hass): + """Test we can setup a new instance and we create entries for accessory mode devices.""" + hass.states.async_set("camera.one", "on") + hass.states.async_set("camera.existing", "on") + hass.states.async_set("media_player.two", "on", {"device_class": "tv"}) + + bridge_mode_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "bridge", CONF_PORT: 8001}, + options={ + "mode": "bridge", + "filter": { + "include_entities": ["camera.existing"], + }, + }, + ) + bridge_mode_entry.add_to_hass(hass) + accessory_mode_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "accessory", CONF_PORT: 8000}, + options={ + "mode": "accessory", + "filter": { + "include_entities": ["camera.existing"], + }, + }, + ) + accessory_mode_entry.add_to_hass(hass) + + 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"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"include_domains": ["camera", "media_player", "light"]}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "pairing" + + with patch( + "homeassistant.components.homekit.config_flow.async_find_next_available_port", + return_value=12345, + ), 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( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + 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"] == { + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["media_player", "light"], + "include_entities": [], + }, + "exclude_accessory_mode": True, + "mode": "bridge", + "name": bridge_name, + "port": 12345, + } + assert len(mock_setup.mock_calls) == 1 + # + # Existing accessory mode entries should get setup but not duplicated + # + # 1 - existing accessory for camera.existing + # 2 - existing bridge for camera.one + # 3 - new bridge + # 4 - camera.one in accessory mode + # 5 - media_player.two in accessory mode + assert len(mock_setup_entry.mock_calls) == 5 async def test_import(hass): @@ -656,55 +732,48 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver): DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"mode": "bridge"}, + {"include_domains": ["light"]}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["step_id"] == "bridge_mode" - - with patch( - "homeassistant.components.homekit.config_flow.async_find_next_available_port", - return_value=12345, - ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {"include_domains": ["light"]}, - ) - assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result3["step_id"] == "pairing" + assert result2["step_id"] == "pairing" # We need to actually setup the config entry or the data # will not get migrated to options with patch( + "homeassistant.components.homekit.config_flow.async_find_next_available_port", + return_value=12345, + ), patch( "homeassistant.components.homekit.HomeKit.async_start", return_value=True, ) as mock_async_start: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {}, ) await hass.async_block_till_done() - assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"][:11] == "HASS Bridge" - bridge_name = (result4["title"].split(":"))[0] - assert result4["data"] == { + 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"] == { "filter": { "exclude_domains": [], "exclude_entities": [], "include_domains": ["light"], "include_entities": [], }, + "exclude_accessory_mode": True, "mode": "bridge", "name": bridge_name, "port": 12345, } assert len(mock_async_start.mock_calls) == 1 - config_entry = result4["result"] + config_entry = result3["result"] hass.states.async_set("camera.tv", "off") hass.states.async_set("camera.sonos", "off") diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index b0213ee7e8b..ec324602684 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -82,6 +82,22 @@ def entity_reg_fixture(hass): return mock_registry(hass) +def _mock_homekit(hass, entry, homekit_mode, entity_filter=None): + return HomeKit( + hass=hass, + name=BRIDGE_NAME, + port=DEFAULT_PORT, + ip_address=None, + entity_filter=entity_filter or generate_filter([], [], [], []), + exclude_accessory_mode=False, + entity_config={}, + homekit_mode=homekit_mode, + advertise_ip=None, + entry_id=entry.entry_id, + entry_title=entry.title, + ) + + async def test_setup_min(hass, mock_zeroconf): """Test async_setup with min config options.""" entry = MockConfigEntry( @@ -103,10 +119,12 @@ async def test_setup_min(hass, mock_zeroconf): DEFAULT_PORT, None, ANY, + ANY, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit().setup.called is True @@ -139,10 +157,12 @@ async def test_setup_auto_start_disabled(hass, mock_zeroconf): 11111, "172.0.0.0", ANY, + ANY, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit().setup.called is True @@ -184,11 +204,13 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): BRIDGE_NAME, DEFAULT_PORT, None, + True, {}, {}, HOMEKIT_MODE_BRIDGE, advertise_ip=None, entry_id=entry.entry_id, + entry_title=entry.title, ) hass.states.async_set("light.demo", "on") @@ -205,6 +227,7 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): hass, entry.entry_id, BRIDGE_NAME, + entry.title, loop=hass.loop, address=IP_ADDRESS, port=DEFAULT_PORT, @@ -230,11 +253,13 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): BRIDGE_NAME, DEFAULT_PORT, "172.0.0.0", + True, {}, {}, HOMEKIT_MODE_BRIDGE, None, entry_id=entry.entry_id, + entry_title=entry.title, ) mock_zeroconf = MagicMock() @@ -245,6 +270,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_zeroconf): hass, entry.entry_id, BRIDGE_NAME, + entry.title, loop=hass.loop, address="172.0.0.0", port=DEFAULT_PORT, @@ -266,11 +292,13 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): BRIDGE_NAME, DEFAULT_PORT, "0.0.0.0", + True, {}, {}, HOMEKIT_MODE_BRIDGE, "192.168.1.100", entry_id=entry.entry_id, + entry_title=entry.title, ) zeroconf_instance = MagicMock() @@ -281,6 +309,7 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): hass, entry.entry_id, BRIDGE_NAME, + entry.title, loop=hass.loop, address="0.0.0.0", port=DEFAULT_PORT, @@ -292,40 +321,40 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_zeroconf): async def test_homekit_add_accessory(hass, mock_zeroconf): """Add accessory if config exists and get_acc returns an accessory.""" - entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - lambda entity_id: True, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) + entry.add_to_hass(hass) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) homekit.driver = "driver" homekit.bridge = mock_bridge = Mock() homekit.bridge.accessories = range(10) + homekit.async_start = AsyncMock() + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() mock_acc = Mock(category="any") - await async_init_integration(hass) - with patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc: mock_get_acc.side_effect = [None, mock_acc, None] - homekit.add_bridge_accessory(State("light.demo", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 1403373688, {}) + state = State("light.demo", "on") + homekit.add_bridge_accessory(state) + mock_get_acc.assert_called_with(hass, ANY, ANY, 1403373688, {}) assert not mock_bridge.add_accessory.called - homekit.add_bridge_accessory(State("demo.test", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 600325356, {}) + state = State("demo.test", "on") + homekit.add_bridge_accessory(state) + mock_get_acc.assert_called_with(hass, ANY, ANY, 600325356, {}) assert mock_bridge.add_accessory.called - homekit.add_bridge_accessory(State("demo.test_2", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 1467253281, {}) - mock_bridge.add_accessory.assert_called_with(mock_acc) + state = State("demo.test_2", "on") + homekit.add_bridge_accessory(state) + mock_get_acc.assert_called_with(hass, ANY, ANY, 1467253281, {}) + assert mock_bridge.add_accessory.called @pytest.mark.parametrize("acc_category", [CATEGORY_TELEVISION, CATEGORY_CAMERA]) @@ -333,37 +362,30 @@ async def test_homekit_warn_add_accessory_bridge( hass, acc_category, mock_zeroconf, caplog ): """Test we warn when adding cameras or tvs to a bridge.""" - entry = await async_init_integration(hass) - - homekit = HomeKit( - hass, - None, - None, - None, - lambda entity_id: True, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) + entry.add_to_hass(hass) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) homekit.driver = "driver" homekit.bridge = mock_bridge = Mock() homekit.bridge.accessories = range(10) + homekit.async_start = AsyncMock() + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() mock_camera_acc = Mock(category=acc_category) - await async_init_integration(hass) - with patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc: mock_get_acc.side_effect = [None, mock_camera_acc, None] - homekit.add_bridge_accessory(State("light.demo", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 1403373688, {}) + state = State("camera.test", "on") + homekit.add_bridge_accessory(state) + mock_get_acc.assert_called_with(hass, ANY, ANY, 1508819236, {}) assert not mock_bridge.add_accessory.called - homekit.add_bridge_accessory(State("camera.test", "on")) - mock_get_acc.assert_called_with(hass, "driver", ANY, 1508819236, {}) - assert mock_bridge.add_accessory.called - assert "accessory mode" in caplog.text @@ -371,17 +393,8 @@ async def test_homekit_remove_accessory(hass, mock_zeroconf): """Remove accessory from bridge.""" entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - lambda entity_id: True, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.driver = "driver" homekit.bridge = mock_bridge = Mock() mock_bridge.accessories = {"light.demo": "acc"} @@ -396,17 +409,8 @@ async def test_homekit_entity_filter(hass, mock_zeroconf): entry = await async_init_integration(hass) entity_filter = generate_filter(["cover"], ["demo.test"], [], []) - homekit = HomeKit( - hass, - None, - None, - None, - entity_filter, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter) + homekit.bridge = Mock() homekit.bridge.accessories = {} @@ -432,17 +436,8 @@ async def test_homekit_entity_glob_filter(hass, mock_zeroconf): entity_filter = generate_filter( ["cover"], ["demo.test"], [], [], ["*.included_*"], ["*.excluded_*"] ) - homekit = HomeKit( - hass, - None, - None, - None, - entity_filter, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter) + homekit.bridge = Mock() homekit.bridge.accessories = {} @@ -471,17 +466,8 @@ async def test_homekit_start(hass, hk_driver, device_reg): entry = await async_init_integration(hass) pin = b"123-45-678" - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.bridge = Mock() homekit.bridge.accessories = [] homekit.driver = hk_driver @@ -513,7 +499,9 @@ async def test_homekit_start(hass, hk_driver, device_reg): await hass.async_block_till_done() mock_add_acc.assert_any_call(state) - mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY) + mock_setup_msg.assert_called_with( + hass, entry.entry_id, "Mock Title (any)", pin, ANY + ) hk_driver_add_acc.assert_called_with(homekit.bridge) assert hk_driver_start.called assert homekit.status == STATUS_RUNNING @@ -563,17 +551,7 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc entity_filter = generate_filter(["cover", "light"], ["demo.test"], [], []) await async_init_entry(hass, entry) - homekit = HomeKit( - hass, - None, - None, - None, - entity_filter, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter) homekit.bridge = Mock() homekit.bridge.accessories = [] @@ -593,7 +571,9 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc await homekit.async_start() await hass.async_block_till_done() - mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY) + mock_setup_msg.assert_called_with( + hass, entry.entry_id, "Mock Title (any)", pin, ANY + ) hk_driver_add_acc.assert_called_with(homekit.bridge) assert hk_driver_start.called assert homekit.status == STATUS_RUNNING @@ -608,18 +588,8 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc async def test_homekit_stop(hass): """Test HomeKit stop method.""" entry = await async_init_integration(hass) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) homekit.driver = Mock() homekit.driver.async_stop = AsyncMock() homekit.bridge = Mock() @@ -649,17 +619,8 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) entity_id = "light.demo" - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {entity_id: {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.bridge = Mock() homekit.bridge.accessories = {} @@ -697,17 +658,7 @@ async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroco entity_filter = generate_filter(["cover", "light"], ["demo.test"], [], []) - homekit = HomeKit( - hass, - None, - None, - None, - entity_filter, - {}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter) def _mock_bridge(*_): mock_bridge = HomeBridge(hass, hk_driver, "mock_bridge") @@ -738,17 +689,8 @@ async def test_homekit_finds_linked_batteries( """Test HomeKit start method.""" entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"light.demo": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.driver = hk_driver # pylint: disable=protected-access homekit._filter = Mock(return_value=True) @@ -792,9 +734,6 @@ async def test_homekit_finds_linked_batteries( ) hass.states.async_set(light.entity_id, STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -823,18 +762,8 @@ async def test_homekit_async_get_integration_fails( ): """Test that we continue if async_get_integration fails.""" entry = await async_init_integration(hass) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"light.demo": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) homekit.driver = hk_driver # pylint: disable=protected-access homekit._filter = Mock(return_value=True) @@ -877,9 +806,6 @@ async def test_homekit_async_get_integration_fails( ) hass.states.async_set(light.entity_id, STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -927,10 +853,12 @@ async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf): 12345, None, ANY, + ANY, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit().setup.called is True @@ -989,18 +917,8 @@ async def test_homekit_ignored_missing_devices( ): """Test HomeKit handles a device in the entity registry but missing from the device registry.""" entry = await async_init_integration(hass) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"light.demo": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) homekit.driver = hk_driver # pylint: disable=protected-access homekit._filter = Mock(return_value=True) @@ -1041,9 +959,6 @@ async def test_homekit_ignored_missing_devices( hass.states.async_set(light.entity_id, STATE_ON) hass.states.async_set("light.two", STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -1071,17 +986,8 @@ async def test_homekit_finds_linked_motion_sensors( """Test HomeKit start method.""" entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"camera.camera_demo": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.driver = hk_driver # pylint: disable=protected-access homekit._filter = Mock(return_value=True) @@ -1115,9 +1021,6 @@ async def test_homekit_finds_linked_motion_sensors( ) hass.states.async_set(camera.entity_id, STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -1146,17 +1049,8 @@ async def test_homekit_finds_linked_humidity_sensors( """Test HomeKit start method.""" entry = await async_init_integration(hass) - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {"humidifier.humidifier": {}}, - HOMEKIT_MODE_BRIDGE, - advertise_ip=None, - entry_id=entry.entry_id, - ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + homekit.driver = hk_driver homekit._filter = Mock(return_value=True) homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge") @@ -1192,9 +1086,6 @@ async def test_homekit_finds_linked_humidity_sensors( ) hass.states.async_set(humidifier.entity_id, STATE_ON) - def _mock_get_accessory(*args, **kwargs): - return [None, "acc", None] - with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( @@ -1241,10 +1132,12 @@ async def test_reload(hass, mock_zeroconf): 12345, None, ANY, + False, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit().setup.called is True yaml_path = os.path.join( @@ -1277,10 +1170,12 @@ async def test_reload(hass, mock_zeroconf): 45678, None, ANY, + False, {}, HOMEKIT_MODE_BRIDGE, None, entry.entry_id, + entry.title, ) assert mock_homekit2().setup.called is True @@ -1294,17 +1189,9 @@ async def test_homekit_start_in_accessory_mode(hass, hk_driver, device_reg): entry = await async_init_integration(hass) pin = b"123-45-678" - homekit = HomeKit( - hass, - None, - None, - None, - {}, - {}, - HOMEKIT_MODE_ACCESSORY, - advertise_ip=None, - entry_id=entry.entry_id, - ) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + homekit.bridge = Mock() homekit.bridge.accessories = [] homekit.driver = hk_driver @@ -1323,6 +1210,8 @@ async def test_homekit_start_in_accessory_mode(hass, hk_driver, device_reg): await hass.async_block_till_done() mock_add_acc.assert_not_called() - mock_setup_msg.assert_called_with(hass, entry.entry_id, None, pin, ANY) + mock_setup_msg.assert_called_with( + hass, entry.entry_id, "Mock Title (any)", pin, ANY + ) assert hk_driver_start.called assert homekit.status == STATUS_RUNNING diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index afa1408a06b..9b03d616002 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -1,4 +1,6 @@ """Test HomeKit util module.""" +from unittest.mock import Mock + import pytest import voluptuous as vol @@ -22,6 +24,7 @@ from homeassistant.components.homekit.const import ( TYPE_VALVE, ) from homeassistant.components.homekit.util import ( + accessory_friendly_name, async_find_next_available_port, cleanup_name_for_homekit, convert_to_float, @@ -284,3 +287,12 @@ async def test_format_sw_version(): assert format_sw_version("56.0-76060") == "56.0.76060" assert format_sw_version(3.6) == "3.6" assert format_sw_version("unknown") is None + + +async def test_accessory_friendly_name(): + """Test we provide a helpful friendly name.""" + + accessory = Mock() + accessory.display_name = "same" + assert accessory_friendly_name("same", accessory) == "same" + assert accessory_friendly_name("hass title", accessory) == "hass title (same)"