Allow HomeKit to be configured in include mode from the UI (#41662)

This commit is contained in:
J. Nick Koston 2020-10-15 08:33:01 -05:00 committed by GitHub
parent f2a2cfc52e
commit c5ae801bcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 321 additions and 88 deletions

View File

@ -6,7 +6,7 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_NAME, CONF_PORT
from homeassistant.core import callback, split_entity_id
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import (
@ -32,7 +32,12 @@ from .const import DOMAIN # pylint:disable=unused-import
from .util import find_next_available_port
CONF_CAMERA_COPY = "camera_copy"
CONF_DOMAINS = "domains"
CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode"
MODE_INCLUDE = "include"
MODE_EXCLUDE = "exclude"
INCLUDE_EXCLUDE_MODES = [MODE_EXCLUDE, MODE_INCLUDE]
SUPPORTED_DOMAINS = [
"alarm_control_panel",
@ -64,6 +69,7 @@ DEFAULT_DOMAINS = [
"climate",
"cover",
"humidifier",
"fan",
"light",
"lock",
"media_player",
@ -210,7 +216,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
"""Choose advanced options."""
if user_input is not None:
self.homekit_options.update(user_input)
del self.homekit_options[CONF_INCLUDE_DOMAINS]
for key in (CONF_DOMAINS, CONF_ENTITIES):
if key in self.homekit_options:
del self.homekit_options[key]
return self.async_create_entry(title="", data=self.homekit_options)
schema_base = {}
@ -275,46 +283,78 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
)
return self.async_show_form(step_id="cameras", data_schema=data_schema)
async def async_step_exclude(self, user_input=None):
"""Choose entities to exclude from the domain."""
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:
self.homekit_options[CONF_FILTER] = {
CONF_INCLUDE_DOMAINS: self.homekit_options[CONF_INCLUDE_DOMAINS],
CONF_EXCLUDE_DOMAINS: self.homekit_options.get(
CONF_EXCLUDE_DOMAINS, []
),
CONF_INCLUDE_ENTITIES: self.homekit_options.get(
CONF_INCLUDE_ENTITIES, []
),
CONF_EXCLUDE_ENTITIES: user_input[CONF_EXCLUDE_ENTITIES],
entity_filter = {
CONF_INCLUDE_DOMAINS: [],
CONF_EXCLUDE_DOMAINS: [],
CONF_INCLUDE_ENTITIES: [],
CONF_EXCLUDE_ENTITIES: [],
}
for entity_id in user_input[CONF_EXCLUDE_ENTITIES]:
if entity_id in self.included_cameras:
self.included_cameras.remove(entity_id)
if user_input[CONF_INCLUDE_EXCLUDE_MODE] == MODE_INCLUDE:
entity_filter[CONF_INCLUDE_ENTITIES] = user_input[CONF_ENTITIES]
# Include all of the domain if there are no entities
# explicitly included as the user selected the domain
domains_with_entities_selected = _domains_set_from_entities(
user_input[CONF_ENTITIES]
)
entity_filter[CONF_INCLUDE_DOMAINS] = [
domain
for domain in self.homekit_options[CONF_DOMAINS]
if domain not in domains_with_entities_selected
]
for entity_id in list(self.included_cameras):
if entity_id not in user_input[CONF_ENTITIES]:
self.included_cameras.remove(entity_id)
else:
entity_filter[CONF_INCLUDE_DOMAINS] = self.homekit_options[CONF_DOMAINS]
entity_filter[CONF_EXCLUDE_ENTITIES] = user_input[CONF_ENTITIES]
for entity_id in user_input[CONF_ENTITIES]:
if entity_id in self.included_cameras:
self.included_cameras.remove(entity_id)
self.homekit_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,
self.hass,
self.homekit_options[CONF_INCLUDE_DOMAINS],
self.homekit_options[CONF_DOMAINS],
)
self.included_cameras = {
entity_id
for entity_id in all_supported_entities
if entity_id.startswith("camera.")
}
entities = entity_filter.get(CONF_INCLUDE_ENTITIES, [])
if entities:
include_exclude_mode = MODE_INCLUDE
else:
include_exclude_mode = MODE_EXCLUDE
entities = entity_filter.get(CONF_EXCLUDE_ENTITIES, [])
data_schema = vol.Schema(
{
vol.Required(
CONF_INCLUDE_EXCLUDE_MODE,
default=include_exclude_mode,
): vol.In(INCLUDE_EXCLUDE_MODES),
vol.Optional(
CONF_EXCLUDE_ENTITIES,
default=entity_filter.get(CONF_EXCLUDE_ENTITIES, []),
CONF_ENTITIES,
default=entities,
): cv.multi_select(all_supported_entities),
}
)
return self.async_show_form(step_id="exclude", data_schema=data_schema)
return self.async_show_form(step_id="include_exclude", data_schema=data_schema)
async def async_step_init(self, user_input=None):
"""Handle options flow."""
@ -323,16 +363,21 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if user_input is not None:
self.homekit_options.update(user_input)
return await self.async_step_exclude()
return await self.async_step_include_exclude()
self.homekit_options = dict(self.config_entry.options)
entity_filter = self.homekit_options.get(CONF_FILTER, {})
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_INCLUDE_DOMAINS,
default=entity_filter.get(CONF_INCLUDE_DOMAINS, []),
CONF_DOMAINS,
default=domains,
): cv.multi_select(SUPPORTED_DOMAINS)
}
)
@ -349,3 +394,11 @@ def _get_entities_matching_domains(hass, domains):
]
entity_ids.sort()
return entity_ids
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

View File

@ -10,15 +10,16 @@
"data": {
"include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]"
},
"description": "Entities in the \u201cDomains to include\u201d will be bridged to HomeKit. You will be able to select which entities to exclude from this list on the next screen.",
"description": "Entities in the \u201cDomains to include\u201d will be bridged 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 bridge."
},
"exclude": {
"include_exclude": {
"data": {
"exclude_entities": "Entities to exclude"
"mode": "Mode",
"entities": "Entities"
},
"description": "Choose the entities that you do NOT want to be bridged.",
"title": "Exclude entities in selected domains from bridge"
"description": "Choose the entities to be bridged. In include mode, all entities in the domain will be bridged unless specific entities are selected. In exclude mode, all entities in the domain will be bridged except for the excluded entities.",
"title": "Select entities to be bridged"
},
"cameras": {
"data": {

View File

@ -1,32 +1,25 @@
{
"config": {
"abort": {
"port_name_in_use": "A bridge with the same name or port is already configured."
},
"step": {
"pairing": {
"description": "As soon as the {name} bridge is ready, pairing will be available in \u201cNotifications\u201d as \u201cHomeKit Bridge Setup\u201d.",
"title": "Pair HomeKit Bridge"
},
"user": {
"data": {
"auto_start": "Autostart (disable if using Z-Wave or other delayed start system)",
"include_domains": "Domains to include"
},
"description": "A HomeKit Bridge will allow you to access your Home Assistant entities in HomeKit. HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML for the primary bridge.",
"title": "Activate HomeKit Bridge"
}
}
},
"title": "HomeKit Bridge",
"options": {
"step": {
"advanced": {
"yaml": {
"title": "Adjust HomeKit Bridge Options",
"description": "This entry is controlled via YAML"
},
"init": {
"data": {
"auto_start": "Autostart (disable if using Z-Wave or other delayed start system)",
"safe_mode": "Safe Mode (enable only if pairing fails)"
"include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]"
},
"description": "These settings only need to be adjusted if the HomeKit bridge is not functional.",
"title": "Advanced Configuration"
"description": "Entities in the \u201cDomains to include\u201d will be bridged 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 bridge."
},
"include_exclude": {
"data": {
"mode": "Mode",
"entities": "Entities"
},
"description": "Choose the entities to be bridged. In include mode, all entities in the domain will be bridged unless specific entities are selected. In exclude mode, all entities in the domain will be bridged except for the excluded entities.",
"title": "Select entities to be bridged"
},
"cameras": {
"data": {
@ -35,25 +28,33 @@
"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."
},
"exclude": {
"advanced": {
"data": {
"exclude_entities": "Entities to exclude"
"auto_start": "[%key:component::homekit::config::step::user::data::auto_start%]",
"safe_mode": "Safe Mode (enable only if pairing fails)"
},
"description": "Choose the entities that you do NOT want to be bridged.",
"title": "Exclude entities in selected domains from bridge"
},
"init": {
"data": {
"include_domains": "Domains to include"
},
"description": "Entities in the \u201cDomains to include\u201d will be bridged to HomeKit. You will be able to select which entities to exclude from this list on the next screen.",
"title": "Select domains to bridge."
},
"yaml": {
"description": "This entry is controlled via YAML",
"title": "Adjust HomeKit Bridge Options"
"description": "These settings only need to be adjusted if the HomeKit bridge is not functional.",
"title": "Advanced Configuration"
}
}
},
"title": "HomeKit Bridge"
}
"config": {
"step": {
"user": {
"data": {
"auto_start": "Autostart (disable if using Z-Wave or other delayed start system)",
"include_domains": "Domains to include"
},
"description": "A HomeKit Bridge will allow you to access your Home Assistant entities in HomeKit. HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML for the primary bridge.",
"title": "Activate HomeKit Bridge"
},
"pairing": {
"title": "Pair HomeKit Bridge",
"description": "As soon as the {name} bridge is ready, pairing will be available in \u201cNotifications\u201d as \u201cHomeKit Bridge Setup\u201d."
}
},
"abort": {
"port_name_in_use": "A bridge with the same name or port is already configured."
}
}
}

View File

@ -123,8 +123,8 @@ async def test_import(hass):
assert len(mock_setup_entry.mock_calls) == 2
async def test_options_flow_advanced(hass):
"""Test config flow options."""
async def test_options_flow_exclude_mode_advanced(hass):
"""Test config flow options in exclude mode with advanced options."""
config_entry = _mock_config_entry_with_options_populated()
config_entry.add_to_hass(hass)
@ -141,15 +141,15 @@ async def test_options_flow_advanced(hass):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"include_domains": ["fan", "vacuum", "climate", "humidifier"]},
user_input={"domains": ["fan", "vacuum", "climate", "humidifier"]},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "exclude"
assert result["step_id"] == "include_exclude"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"exclude_entities": ["climate.old"]},
user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "advanced"
@ -173,8 +173,8 @@ async def test_options_flow_advanced(hass):
}
async def test_options_flow_basic(hass):
"""Test config flow options."""
async def test_options_flow_exclude_mode_basic(hass):
"""Test config flow options in exclude mode."""
config_entry = _mock_config_entry_with_options_populated()
config_entry.add_to_hass(hass)
@ -191,15 +191,15 @@ async def test_options_flow_basic(hass):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"include_domains": ["fan", "vacuum", "climate"]},
user_input={"domains": ["fan", "vacuum", "climate"]},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "exclude"
assert result["step_id"] == "include_exclude"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"exclude_entities": ["climate.old"]},
user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "advanced"
@ -223,8 +223,60 @@ async def test_options_flow_basic(hass):
}
async def test_options_flow_with_cameras(hass):
"""Test config flow options."""
async def test_options_flow_include_mode_basic(hass):
"""Test config flow options in include mode."""
config_entry = _mock_config_entry_with_options_populated()
config_entry.add_to_hass(hass)
hass.states.async_set("climate.old", "off")
hass.states.async_set("climate.new", "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": ["fan", "vacuum", "climate"]},
)
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": ["climate.new"], "include_exclude_mode": "include"},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "advanced"
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result3 = await hass.config_entries.options.async_configure(
result2["flow_id"],
user_input={"safe_mode": True},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"auto_start": False,
"filter": {
"exclude_domains": [],
"exclude_entities": [],
"include_domains": ["fan", "vacuum"],
"include_entities": ["climate.new"],
},
"safe_mode": True,
}
async def test_options_flow_exclude_mode_with_cameras(hass):
"""Test config flow options in exclude mode with cameras."""
config_entry = _mock_config_entry_with_options_populated()
config_entry.add_to_hass(hass)
@ -245,15 +297,18 @@ async def test_options_flow_with_cameras(hass):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"include_domains": ["fan", "vacuum", "climate", "camera"]},
user_input={"domains": ["fan", "vacuum", "climate", "camera"]},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "exclude"
assert result["step_id"] == "include_exclude"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"exclude_entities": ["climate.old", "camera.excluded"]},
user_input={
"entities": ["climate.old", "camera.excluded"],
"include_exclude_mode": "exclude",
},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "cameras"
@ -296,15 +351,138 @@ async def test_options_flow_with_cameras(hass):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"include_domains": ["fan", "vacuum", "climate", "camera"]},
user_input={"domains": ["fan", "vacuum", "climate", "camera"]},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "exclude"
assert result["step_id"] == "include_exclude"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"exclude_entities": ["climate.old", "camera.excluded"]},
user_input={
"entities": ["climate.old", "camera.excluded"],
"include_exclude_mode": "exclude",
},
)
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": []},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result3["step_id"] == "advanced"
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result4 = await hass.config_entries.options.async_configure(
result3["flow_id"],
user_input={"safe_mode": True},
)
assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"auto_start": False,
"filter": {
"exclude_domains": [],
"exclude_entities": ["climate.old", "camera.excluded"],
"include_domains": ["fan", "vacuum", "climate", "camera"],
"include_entities": [],
},
"entity_config": {"camera.native_h264": {}},
"safe_mode": True,
}
async def test_options_flow_include_mode_with_cameras(hass):
"""Test config flow options in include mode with cameras."""
config_entry = _mock_config_entry_with_options_populated()
config_entry.add_to_hass(hass)
hass.states.async_set("climate.old", "off")
hass.states.async_set("camera.native_h264", "off")
hass.states.async_set("camera.transcode_h264", "off")
hass.states.async_set("camera.excluded", "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": ["fan", "vacuum", "climate", "camera"]},
)
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.native_h264", "camera.transcode_h264"],
"include_exclude_mode": "include",
},
)
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.native_h264"]},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result3["step_id"] == "advanced"
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result4 = await hass.config_entries.options.async_configure(
result3["flow_id"],
user_input={"safe_mode": True},
)
assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"auto_start": False,
"filter": {
"exclude_domains": [],
"exclude_entities": [],
"include_domains": ["fan", "vacuum", "climate"],
"include_entities": ["camera.native_h264", "camera.transcode_h264"],
},
"entity_config": {"camera.native_h264": {"video_codec": "copy"}},
"safe_mode": True,
}
# Now run though again and verify we can turn off copy
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": ["fan", "vacuum", "climate", "camera"]},
)
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": ["climate.old", "camera.excluded"],
"include_exclude_mode": "exclude",
},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "cameras"