Automatically create HomeKit accessory mode entries (#46473)
When we set up HomeKit, we asked users if they wanted to create an entry in bridge or accessory mode. This approach required the user to understand how HomeKit works and choose which type to create. When the user includes the media player or camera domains, we exclude them from the bridge and create the additional entries for each entity in accessory mode.
This commit is contained in:
parent
9159f54900
commit
87cbbcb014
11 changed files with 472 additions and 428 deletions
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue