From 5f9a1d105c32fd13d360e53de8e418b13c0c8f56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Jan 2021 09:57:13 -0600 Subject: [PATCH] Improve HomeKit Accessory Mode UX (#45402) --- homeassistant/components/homekit/__init__.py | 12 +- .../components/homekit/config_flow.py | 236 +++++++++++------- homeassistant/components/homekit/const.py | 1 + homeassistant/components/homekit/strings.json | 26 +- .../components/homekit/translations/en.json | 90 ++++--- tests/components/homekit/test_config_flow.py | 216 ++++++++++++++-- 6 files changed, 421 insertions(+), 160 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 53fbd7cf8f1..5cbc9bb6f18 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -575,13 +575,15 @@ class HomeKit: bridged_states = [] for state in self.hass.states.async_all(): - if not self._filter(state.entity_id): + entity_id = state.entity_id + + if not self._filter(entity_id): continue - ent_reg_ent = ent_reg.async_get(state.entity_id) + ent_reg_ent = ent_reg.async_get(entity_id) if ent_reg_ent: await self._async_set_device_info_attributes( - ent_reg_ent, dev_reg, state.entity_id + ent_reg_ent, dev_reg, entity_id ) self._async_configure_linked_sensors(ent_reg_ent, device_lookup, state) @@ -612,13 +614,15 @@ class HomeKit: connection = (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER) self._async_purge_old_bridges(dev_reg, identifier, connection) + is_accessory_mode = self._homekit_mode == HOMEKIT_MODE_ACCESSORY + hk_mode_name = "Accessory" if is_accessory_mode else "Bridge" dev_reg.async_get_or_create( config_entry_id=self._entry_id, identifiers={identifier}, connections={connection}, manufacturer=MANUFACTURER, name=self._name, - model="Home Assistant HomeKit Bridge", + model=f"Home Assistant HomeKit {hk_mode_name}", ) @callback diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 8d763581615..d8708168e12 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -1,4 +1,5 @@ """Config flow for HomeKit integration.""" +import logging import random import string @@ -6,7 +7,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_NAME, CONF_PORT +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + CONF_DOMAINS, + CONF_ENTITIES, + CONF_ENTITY_ID, + CONF_NAME, + CONF_PORT, +) from homeassistant.core import callback, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( @@ -27,6 +35,7 @@ from .const import ( DEFAULT_HOMEKIT_MODE, HOMEKIT_MODE_ACCESSORY, HOMEKIT_MODES, + SHORT_ACCESSORY_NAME, SHORT_BRIDGE_NAME, VIDEO_CODEC_COPY, ) @@ -80,6 +89,19 @@ DEFAULT_DOMAINS = [ "water_heater", ] +DOMAINS_PREFER_ACCESSORY_MODE = ["camera", "media_player"] + +CAMERA_ENTITY_PREFIX = "camera." + +_EMPTY_ENTITY_FILTER = { + CONF_INCLUDE_DOMAINS: [], + CONF_EXCLUDE_DOMAINS: [], + CONF_INCLUDE_ENTITIES: [], + CONF_EXCLUDE_ENTITIES: [], +} + +_LOGGER = logging.getLogger(__name__) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for HomeKit.""" @@ -89,51 +111,90 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize config flow.""" - self.homekit_data = {} + 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): + """Choose specific domains in bridge mode.""" + if user_input is not None: + entity_filter = _EMPTY_ENTITY_FILTER.copy() + entity_filter[CONF_INCLUDE_DOMAINS] = user_input[CONF_INCLUDE_DOMAINS] + self.hk_data[CONF_FILTER] = entity_filter + return await self.async_step_pairing() + + default_domains = [] if self._async_current_names() else DEFAULT_DOMAINS + return self.async_show_form( + step_id="bridge_mode", + data_schema=vol.Schema( + { + vol.Required( + CONF_INCLUDE_DOMAINS, default=default_domains + ): cv.multi_select(SUPPORTED_DOMAINS), + } + ), + ) + 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_create_entry(title=self.entry_title, data=self.hk_data) + + self.hk_data[CONF_PORT] = await self._async_available_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]}" return self.async_show_form( step_id="pairing", - description_placeholders={CONF_NAME: self.homekit_data[CONF_NAME]}, + description_placeholders={CONF_NAME: self.hk_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: [], + self.hk_data = { + CONF_HOMEKIT_MODE: user_input[CONF_HOMEKIT_MODE], } - del self.homekit_data[CONF_INCLUDE_DOMAINS] - self.entry_title = title - return await self.async_step_pairing() - - default_domains = [] if self._async_current_names() else DEFAULT_DOMAINS - setup_schema = vol.Schema( - { - vol.Required( - CONF_INCLUDE_DOMAINS, default=default_domains - ): cv.multi_select(SUPPORTED_DOMAINS), - } - ) + 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=setup_schema, errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOMEKIT_MODE, default=homekit_mode): vol.In( + HOMEKIT_MODES + ) + } + ), + errors=errors, ) async def async_step_import(self, user_input=None): @@ -153,28 +214,28 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @callback def _async_current_names(self): """Return a set of bridge names.""" - current_entries = self._async_current_entries() - return { entry.data[CONF_NAME] - for entry in current_entries + for entry in self._async_current_entries() if CONF_NAME in entry.data } @callback - def _async_available_name(self): + def _async_available_name(self, homekit_mode): """Return an available for the bridge.""" + base_name = SHORT_BRIDGE_NAME + if homekit_mode == HOMEKIT_MODE_ACCESSORY: + base_name = SHORT_ACCESSORY_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 - trailer = "".join(random.choices(acceptable_chars, k=4)) - all_names = self._async_current_names() - suggested_name = f"{SHORT_BRIDGE_NAME} {trailer}" - while suggested_name in all_names: + 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"{SHORT_BRIDGE_NAME} {trailer}" + suggested_name = f"{base_name} {trailer}" return suggested_name @@ -196,12 +257,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for tado.""" + """Handle a option flow for homekit.""" def __init__(self, config_entry: config_entries.ConfigEntry): """Initialize options flow.""" self.config_entry = config_entry - self.homekit_options = {} + self.hk_options = {} self.included_cameras = set() async def async_step_yaml(self, user_input=None): @@ -217,17 +278,17 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Choose advanced options.""" if not self.show_advanced_options or user_input is not None: if user_input: - self.homekit_options.update(user_input) + self.hk_options.update(user_input) - self.homekit_options[CONF_AUTO_START] = self.homekit_options.get( + self.hk_options[CONF_AUTO_START] = self.hk_options.get( CONF_AUTO_START, DEFAULT_AUTO_START ) for key in (CONF_DOMAINS, CONF_ENTITIES): - if key in self.homekit_options: - del self.homekit_options[key] + if key in self.hk_options: + del self.hk_options[key] - return self.async_create_entry(title="", data=self.homekit_options) + return self.async_create_entry(title="", data=self.hk_options) return self.async_show_form( step_id="advanced", @@ -235,7 +296,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): { vol.Optional( CONF_AUTO_START, - default=self.homekit_options.get( + default=self.hk_options.get( CONF_AUTO_START, DEFAULT_AUTO_START ), ): bool @@ -246,7 +307,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_cameras(self, user_input=None): """Choose camera config.""" if user_input is not None: - entity_config = self.homekit_options[CONF_ENTITY_CONFIG] + entity_config = self.hk_options[CONF_ENTITY_CONFIG] for entity_id in self.included_cameras: if entity_id in user_input[CONF_CAMERA_COPY]: entity_config.setdefault(entity_id, {})[ @@ -260,7 +321,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return await self.async_step_advanced() cameras_with_copy = [] - entity_config = self.homekit_options.setdefault(CONF_ENTITY_CONFIG, {}) + entity_config = self.hk_options.setdefault(CONF_ENTITY_CONFIG, {}) for entity in self.included_cameras: hk_entity_config = entity_config.get(entity, {}) if hk_entity_config.get(CONF_VIDEO_CODEC) == VIDEO_CODEC_COPY: @@ -279,19 +340,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_include_exclude(self, user_input=None): """Choose entities to include or exclude from the domain.""" if user_input is not None: - entity_filter = { - CONF_INCLUDE_DOMAINS: [], - CONF_EXCLUDE_DOMAINS: [], - CONF_INCLUDE_ENTITIES: [], - CONF_EXCLUDE_ENTITIES: [], - } + entity_filter = _EMPTY_ENTITY_FILTER.copy() if isinstance(user_input[CONF_ENTITIES], list): entities = user_input[CONF_ENTITIES] else: entities = [user_input[CONF_ENTITIES]] if ( - self.homekit_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY + self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY or user_input[CONF_INCLUDE_EXCLUDE_MODE] == MODE_INCLUDE ): entity_filter[CONF_INCLUDE_ENTITIES] = entities @@ -300,7 +356,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): domains_with_entities_selected = _domains_set_from_entities(entities) entity_filter[CONF_INCLUDE_DOMAINS] = [ domain - for domain in self.homekit_options[CONF_DOMAINS] + for domain in self.hk_options[CONF_DOMAINS] if domain not in domains_with_entities_selected ] @@ -308,34 +364,33 @@ class OptionsFlowHandler(config_entries.OptionsFlow): if entity_id not in entities: self.included_cameras.remove(entity_id) else: - entity_filter[CONF_INCLUDE_DOMAINS] = self.homekit_options[CONF_DOMAINS] + entity_filter[CONF_INCLUDE_DOMAINS] = self.hk_options[CONF_DOMAINS] entity_filter[CONF_EXCLUDE_ENTITIES] = entities for entity_id in entities: if entity_id in self.included_cameras: self.included_cameras.remove(entity_id) - self.homekit_options[CONF_FILTER] = entity_filter + self.hk_options[CONF_FILTER] = entity_filter if self.included_cameras: return await self.async_step_cameras() 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, + entity_filter = self.hk_options.get(CONF_FILTER, {}) + all_supported_entities = _async_get_matching_entities( self.hass, - self.homekit_options[CONF_DOMAINS], + domains=self.hk_options[CONF_DOMAINS], ) self.included_cameras = { entity_id for entity_id in all_supported_entities - if entity_id.startswith("camera.") + if entity_id.startswith(CAMERA_ENTITY_PREFIX) } data_schema = {} entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) - if self.homekit_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY: + if self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY: entity_schema = vol.In else: if entities: @@ -362,42 +417,43 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return await self.async_step_yaml(user_input) if user_input is not None: - self.homekit_options.update(user_input) + self.hk_options.update(user_input) return await self.async_step_include_exclude() - self.homekit_options = dict(self.config_entry.options) - entity_filter = self.homekit_options.get(CONF_FILTER, {}) + hk_options = dict(self.config_entry.options) + entity_filter = hk_options.get(CONF_FILTER, {}) - homekit_mode = self.homekit_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) + homekit_mode = hk_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) domains = entity_filter.get(CONF_INCLUDE_DOMAINS, []) include_entities = entity_filter.get(CONF_INCLUDE_ENTITIES) if include_entities: domains.extend(_domains_set_from_entities(include_entities)) - data_schema = vol.Schema( - { - vol.Optional(CONF_HOMEKIT_MODE, default=homekit_mode): vol.In( - HOMEKIT_MODES - ), - vol.Optional( - CONF_DOMAINS, - default=domains, - ): cv.multi_select(SUPPORTED_DOMAINS), - } + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_HOMEKIT_MODE, default=homekit_mode): vol.In( + HOMEKIT_MODES + ), + vol.Required( + CONF_DOMAINS, + default=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 +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)})" + for state in sorted( + hass.states.async_all(domains and set(domains)), + key=lambda item: item.entity_id, + ) + } def _domains_set_from_entities(entity_ids): diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 77c5dbff0f9..fac4168a79b 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -104,6 +104,7 @@ SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory" BRIDGE_MODEL = "Bridge" BRIDGE_NAME = "Home Assistant Bridge" SHORT_BRIDGE_NAME = "HASS Bridge" +SHORT_ACCESSORY_NAME = "HASS Accessory" BRIDGE_SERIAL_NUMBER = "homekit.bridge" MANUFACTURER = "Home Assistant" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 5ba578f38c3..ed825ada23c 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -8,18 +8,18 @@ "init": { "data": { "mode": "[%key:common::config_flow::data::mode%]", - "include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]" + "include_domains": "[%key:component::homekit::config::step::bridge_mode::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 exposed 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 expose." + "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." }, "include_exclude": { "data": { "mode": "[%key:common::config_flow::data::mode%]", "entities": "Entities" }, - "description": "Choose the entities to be exposed. In accessory mode, only a single entity is exposed. In bridge include mode, all entities in the domain will be exposed unless specific entities are selected. In bridge exclude mode, all entities in the domain will be exposed 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.", - "title": "Select entities to be exposed" + "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.", + "title": "Select entities to be included" }, "cameras": { "data": { @@ -41,11 +41,25 @@ "step": { "user": { "data": { - "include_domains": "Domains to include" + "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.", + "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." diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index 5ba578f38c3..cc6c8f8dc31 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -1,25 +1,44 @@ { + "config": { + "abort": { + "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.", + "title": "Pair HomeKit" + }, + "user": { + "data": { + "mode": "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" + } + } + }, "options": { "step": { - "yaml": { - "title": "Adjust HomeKit Options", - "description": "This entry is controlled via YAML" - }, - "init": { + "advanced": { "data": { - "mode": "[%key:common::config_flow::data::mode%]", - "include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]" + "auto_start": "Autostart (disable if you are calling the homekit.start service manually)" }, - "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 exposed 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 expose." - }, - "include_exclude": { - "data": { - "mode": "[%key:common::config_flow::data::mode%]", - "entities": "Entities" - }, - "description": "Choose the entities to be exposed. In accessory mode, only a single entity is exposed. In bridge include mode, all entities in the domain will be exposed unless specific entities are selected. In bridge exclude mode, all entities in the domain will be exposed 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.", - "title": "Select entities to be exposed" + "description": "These settings only need to be adjusted if HomeKit is not functional.", + "title": "Advanced Configuration" }, "cameras": { "data": { @@ -28,31 +47,26 @@ "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", "title": "Select camera video codec." }, - "advanced": { + "include_exclude": { "data": { - "auto_start": "Autostart (disable if you are calling the homekit.start service manually)" + "entities": "Entities", + "mode": "Mode" }, - "description": "These settings only need to be adjusted if HomeKit is not functional.", - "title": "Advanced Configuration" - } - } - }, - "config": { - "step": { - "user": { - "data": { - "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 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.", + "title": "Select entities 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." + "init": { + "data": { + "include_domains": "Domains to include", + "mode": "Mode" + }, + "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." + }, + "yaml": { + "description": "This entry is controlled via YAML", + "title": "Adjust HomeKit Options" } - }, - "abort": { - "port_name_in_use": "An accessory or bridge with the same name or port is already configured." } } } diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 4438404af2e..1dd628af18d 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -32,8 +32,8 @@ def _mock_config_entry_with_options_populated(): ) -async def test_user_form(hass): - """Test we can setup a new instance.""" +async def test_setup_in_bridge_mode(hass): + """Test we can setup a new instance in bridge mode.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -41,17 +41,23 @@ async def test_user_form(hass): assert result["type"] == "form" assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"mode": "bridge"}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "bridge_mode" + 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"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"include_domains": ["light"]}, ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["step_id"] == "pairing" + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["step_id"] == "pairing" with patch( "homeassistant.components.homekit.async_setup", return_value=True @@ -59,22 +65,23 @@ async def test_user_form(hass): "homeassistant.components.homekit.async_setup_entry", return_value=True, ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result4 = await hass.config_entries.flow.async_configure( + result3["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"] == { + 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"] == { "filter": { "exclude_domains": [], "exclude_entities": [], "include_domains": ["light"], "include_entities": [], }, + "mode": "bridge", "name": bridge_name, "port": 12345, } @@ -82,6 +89,66 @@ async def test_user_form(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.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + hass.states.async_set("camera.mine", "off") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"mode": "accessory"}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "accessory_mode" + + with patch( + "homeassistant.components.homekit.config_flow.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( + "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"], + {}, + ) + 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"] == { + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": [], + "include_entities": ["camera.mine"], + }, + "mode": "accessory", + "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 + + async def test_import(hass): """Test we can import instance.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -343,10 +410,11 @@ async def test_options_flow_exclude_mode_with_cameras(hass): result3 = await hass.config_entries.options.async_configure( result2["flow_id"], - user_input={"camera_copy": []}, + user_input={"camera_copy": ["camera.native_h264"]}, ) assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { "auto_start": True, "mode": "bridge", @@ -356,7 +424,7 @@ async def test_options_flow_exclude_mode_with_cameras(hass): "include_domains": ["fan", "vacuum", "climate", "camera"], "include_entities": [], }, - "entity_config": {"camera.native_h264": {}}, + "entity_config": {"camera.native_h264": {"video_codec": "copy"}}, } @@ -458,7 +526,7 @@ async def test_options_flow_include_mode_with_cameras(hass): "include_domains": ["fan", "vacuum", "climate", "camera"], "include_entities": [], }, - "entity_config": {"camera.native_h264": {}}, + "entity_config": {}, } @@ -519,19 +587,19 @@ async def test_options_flow_include_mode_basic_accessory(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( + result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"domains": ["media_player"], "mode": "accessory"}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "include_exclude" - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={"entities": "media_player.tv"}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { "auto_start": True, "mode": "accessory", @@ -542,3 +610,107 @@ async def test_options_flow_include_mode_basic_accessory(hass): "include_entities": ["media_player.tv"], }, } + + +async def test_converting_bridge_to_accessory_mode(hass): + """Test we can convert a bridge to accessory mode.""" + 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"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"mode": "bridge"}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "bridge_mode" + + with patch( + "homeassistant.components.homekit.config_flow.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( + "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"], + {}, + ) + 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"] == { + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["light"], + "include_entities": [], + }, + "mode": "bridge", + "name": bridge_name, + "port": 12345, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = result4["result"] + + hass.states.async_set("camera.tv", "off") + hass.states.async_set("camera.sonos", "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={"domains": ["camera"], "mode": "accessory"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "include_exclude" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": "camera.tv"}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "cameras" + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"camera_copy": ["camera.tv"]}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "auto_start": True, + "entity_config": {"camera.tv": {"video_codec": "copy"}}, + "mode": "accessory", + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": [], + "include_entities": ["camera.tv"], + }, + }