Add entity category support to HomeKit (#64492)

This commit is contained in:
J. Nick Koston 2022-01-19 21:48:50 -10:00 committed by GitHub
parent e248ef1dd7
commit d53124910f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 447 additions and 152 deletions

View File

@ -35,6 +35,7 @@ from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PORT,
ENTITY_CATEGORIES,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
@ -43,7 +44,11 @@ from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers import device_registry, entity_registry
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA
from homeassistant.helpers.entityfilter import (
BASE_FILTER_SCHEMA,
FILTER_SCHEMA,
EntityFilter,
)
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_extract_referenced_entity_ids
from homeassistant.helpers.typing import ConfigType
@ -469,7 +474,7 @@ class HomeKit:
self._name = name
self._port = port
self._ip_address = ip_address
self._filter = entity_filter
self._filter: EntityFilter = entity_filter
self._config = entity_config
self._exclude_accessory_mode = exclude_accessory_mode
self._advertise_ip = advertise_ip
@ -661,6 +666,12 @@ class HomeKit:
continue
if ent_reg_ent := ent_reg.async_get(entity_id):
if (
ent_reg_ent.entity_category in ENTITY_CATEGORIES
and not self._filter.explicitly_included(entity_id)
):
continue
await self._async_set_device_info_attributes(
ent_reg_ent, dev_reg, entity_id
)

View File

@ -6,7 +6,7 @@ from copy import deepcopy
import random
import re
import string
from typing import Final
from typing import Any, Final
import voluptuous as vol
@ -25,9 +25,10 @@ from homeassistant.const import (
CONF_ENTITY_ID,
CONF_NAME,
CONF_PORT,
ENTITY_CATEGORIES,
)
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.helpers import device_registry
from homeassistant.helpers import device_registry, entity_registry
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import (
CONF_EXCLUDE_DOMAINS,
@ -127,6 +128,38 @@ _EMPTY_ENTITY_FILTER: Final = {
}
async def _async_domain_names(hass: HomeAssistant, domains: list[str]) -> str:
"""Build a list of integration names from domains."""
name_to_type_map = await _async_name_to_type_map(hass)
return ", ".join(
[name for domain, name in name_to_type_map.items() if domain in domains]
)
@callback
def _async_build_entites_filter(
domains: list[str], entities: list[str]
) -> dict[str, Any]:
"""Build an entities filter from domains and entities."""
entity_filter = deepcopy(_EMPTY_ENTITY_FILTER)
entity_filter[CONF_INCLUDE_ENTITIES] = 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(entities)
entity_filter[CONF_INCLUDE_DOMAINS] = [
domain for domain in domains if domain not in domains_with_entities_selected
]
return entity_filter
def _async_cameras_from_entities(entities: list[str]) -> set[str]:
return {
entity_id
for entity_id in entities
if entity_id.startswith(CAMERA_ENTITY_PREFIX)
}
async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]:
"""Create a mapping of types of devices/entities HomeKit can support."""
integrations = await asyncio.gather(
@ -331,6 +364,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
):
self.hk_options[CONF_DEVICES] = user_input[CONF_DEVICES]
if CONF_INCLUDE_EXCLUDE_MODE in self.hk_options:
del self.hk_options[CONF_INCLUDE_EXCLUDE_MODE]
return self.async_create_entry(title="", data=self.hk_options)
all_supported_devices = await _async_get_supported_devices(self.hass)
@ -398,99 +434,139 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
)
return self.async_show_form(step_id="cameras", data_schema=data_schema)
async def async_step_include_exclude(self, user_input=None):
"""Choose entities to include or exclude from the domain."""
async def async_step_accessory(self, user_input=None):
"""Choose entity for the accessory."""
domains = self.hk_options[CONF_DOMAINS]
if user_input is not None:
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.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY
or user_input[CONF_INCLUDE_EXCLUDE_MODE] == MODE_INCLUDE
):
entity_filter[CONF_INCLUDE_ENTITIES] = 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(entities)
entity_filter[CONF_INCLUDE_DOMAINS] = [
domain
for domain in self.hk_options[CONF_DOMAINS]
if domain not in domains_with_entities_selected
]
self.included_cameras = {
entity_id
for entity_id in entities
if entity_id.startswith(CAMERA_ENTITY_PREFIX)
}
else:
entity_filter[CONF_INCLUDE_DOMAINS] = self.hk_options[CONF_DOMAINS]
entity_filter[CONF_EXCLUDE_ENTITIES] = entities
if CAMERA_DOMAIN in entity_filter[CONF_INCLUDE_DOMAINS]:
camera_entities = _async_get_matching_entities(
self.hass,
domains=[CAMERA_DOMAIN],
)
self.included_cameras = {
entity_id
for entity_id in camera_entities
if entity_id not in entities
}
else:
self.included_cameras = set()
entities = cv.ensure_list(user_input[CONF_ENTITIES])
entity_filter = _async_build_entites_filter(domains, entities)
self.included_cameras = _async_cameras_from_entities(entities)
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.hk_options.get(CONF_FILTER, {})
entities = entity_filter.get(CONF_INCLUDE_ENTITIES, [])
all_supported_entities = _async_get_matching_entities(self.hass, domains)
# In accessory mode we can only have one
default_value = next(
iter(
entity_id
for entity_id in entities
if entity_id in all_supported_entities
),
None,
)
return self.async_show_form(
step_id="accessory",
data_schema=vol.Schema(
{
vol.Required(CONF_ENTITIES, default=default_value): vol.In(
all_supported_entities
)
}
),
)
async def async_step_include(self, user_input=None):
"""Choose entities to include from the domain on the bridge."""
domains = self.hk_options[CONF_DOMAINS]
if user_input is not None:
entities = cv.ensure_list(user_input[CONF_ENTITIES])
entity_filter = _async_build_entites_filter(domains, entities)
self.included_cameras = _async_cameras_from_entities(entities)
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.hk_options.get(CONF_FILTER, {})
entities = entity_filter.get(CONF_INCLUDE_ENTITIES, [])
all_supported_entities = _async_get_matching_entities(
self.hass,
domains=self.hk_options[CONF_DOMAINS],
)
data_schema = {}
if self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_ACCESSORY:
# In accessory mode we can only have one
default_value = next(
iter(
entity_id
for entity_id in entities
if entity_id in all_supported_entities
),
None,
)
entity_schema = vol.In
entities_schema_required = vol.Required
else:
# Bridge mode
entities_schema_required = vol.Optional
include_exclude_mode = MODE_INCLUDE
if not entities:
include_exclude_mode = MODE_EXCLUDE
entities = entity_filter.get(CONF_EXCLUDE_ENTITIES, [])
data_schema[
vol.Required(CONF_INCLUDE_EXCLUDE_MODE, default=include_exclude_mode)
] = vol.In(INCLUDE_EXCLUDE_MODES)
entity_schema = cv.multi_select
# Strip out entities that no longer exist to prevent error in the UI
default_value = [
entity_id
for entity_id in entities
if entity_id in all_supported_entities
]
all_supported_entities = _async_get_matching_entities(self.hass, domains)
if not entities:
entities = entity_filter.get(CONF_EXCLUDE_ENTITIES, [])
# Strip out entities that no longer exist to prevent error in the UI
default_value = [
entity_id for entity_id in entities if entity_id in all_supported_entities
]
data_schema[
entities_schema_required(CONF_ENTITIES, default=default_value)
] = entity_schema(all_supported_entities)
return self.async_show_form(
step_id="include_exclude", data_schema=vol.Schema(data_schema)
step_id="include",
description_placeholders={
"domains": await _async_domain_names(self.hass, domains)
},
data_schema=vol.Schema(
{
vol.Optional(CONF_ENTITIES, default=default_value): cv.multi_select(
all_supported_entities
)
}
),
)
async def async_step_exclude(self, user_input=None):
"""Choose entities to exclude from the domain on the bridge."""
domains = self.hk_options[CONF_DOMAINS]
if user_input is not None:
entity_filter = deepcopy(_EMPTY_ENTITY_FILTER)
entities = cv.ensure_list(user_input[CONF_ENTITIES])
entity_filter[CONF_INCLUDE_DOMAINS] = domains
entity_filter[CONF_EXCLUDE_ENTITIES] = entities
self.included_cameras = set()
if CAMERA_DOMAIN in entity_filter[CONF_INCLUDE_DOMAINS]:
camera_entities = _async_get_matching_entities(
self.hass, [CAMERA_DOMAIN]
)
self.included_cameras = {
entity_id
for entity_id in camera_entities
if entity_id not in entities
}
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.hk_options.get(CONF_FILTER, {})
entities = entity_filter.get(CONF_INCLUDE_ENTITIES, [])
all_supported_entities = _async_get_matching_entities(self.hass, domains)
if not entities:
entities = entity_filter.get(CONF_EXCLUDE_ENTITIES, [])
ent_reg = entity_registry.async_get(self.hass)
entity_cat_entities = set()
for entity_id in all_supported_entities:
if ent_reg_ent := ent_reg.async_get(entity_id):
if ent_reg_ent.entity_category in ENTITY_CATEGORIES:
entity_cat_entities.add(entity_id)
# Remove entity category entities since we will exclude them anyways
all_supported_entities = {
k: v
for k, v in all_supported_entities.items()
if k not in entity_cat_entities
}
# Strip out entities that no longer exist to prevent error in the UI
default_value = [
entity_id for entity_id in entities if entity_id in all_supported_entities
]
return self.async_show_form(
step_id="exclude",
description_placeholders={
"domains": await _async_domain_names(self.hass, domains)
},
data_schema=vol.Schema(
{
vol.Optional(CONF_ENTITIES, default=default_value): cv.multi_select(
all_supported_entities
)
}
),
)
async def async_step_init(self, user_input=None):
@ -500,17 +576,24 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if user_input is not None:
self.hk_options.update(user_input)
return await self.async_step_include_exclude()
if self.hk_options.get(CONF_HOMEKIT_MODE) == HOMEKIT_MODE_ACCESSORY:
return await self.async_step_accessory()
if user_input[CONF_INCLUDE_EXCLUDE_MODE] == MODE_INCLUDE:
return await self.async_step_include()
return await self.async_step_exclude()
self.hk_options = deepcopy(dict(self.config_entry.options))
entity_filter = self.hk_options.get(CONF_FILTER, {})
homekit_mode = self.hk_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE)
entity_filter = self.hk_options.get(CONF_FILTER, {})
include_exclude_mode = MODE_INCLUDE
entities = entity_filter.get(CONF_INCLUDE_ENTITIES, [])
if homekit_mode != HOMEKIT_MODE_ACCESSORY:
include_exclude_mode = MODE_INCLUDE if entities else MODE_EXCLUDE
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))
name_to_type_map = await _async_name_to_type_map(self.hass)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
@ -518,6 +601,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
vol.Required(CONF_HOMEKIT_MODE, default=homekit_mode): vol.In(
HOMEKIT_MODES
),
vol.Required(
CONF_INCLUDE_EXCLUDE_MODE, default=include_exclude_mode
): vol.In(INCLUDE_EXCLUDE_MODES),
vol.Required(
CONF_DOMAINS,
default=domains,
@ -540,7 +626,9 @@ async def _async_get_supported_devices(hass):
return dict(sorted(unsorted.items(), key=lambda item: item[1]))
def _async_get_matching_entities(hass, domains=None):
def _async_get_matching_entities(
hass: HomeAssistant, domains: list[str] | None = None
) -> dict[str, str]:
"""Fetch all entities or entities in the given domains."""
return {
state.entity_id: f"{state.attributes.get(ATTR_FRIENDLY_NAME, state.entity_id)} ({state.entity_id})"

View File

@ -7,20 +7,33 @@
},
"init": {
"data": {
"mode": "[%key:common::config_flow::data::mode%]",
"include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]"
"mode": "HomeKit Mode",
"include_exclude_mode": "Inclusion Mode",
"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."
"title": "Select mode and domains."
},
"include_exclude": {
"accessory": {
"data": {
"entities": "Entity"
},
"title": "Select the entity for the accessory"
},
"include": {
"data": {
"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, a separate HomeKit accessory will be created for each tv media player, activity based remote, lock, and camera.",
"title": "Select entities to be included"
"description": "All “{domains}” entities will be included unless specific entities are selected.",
"title": "Select the entities to be included"
},
"exclude": {
"data": {
"entities": "[%key:component::homekit::options::step::include::data::entities%]"
},
"description": "All “{domains}” entities will be included except for the excluded entities and categorized entities.",
"title": "Select the entities to be excluded"
},
"cameras": {
"data": {
"camera_copy": "Cameras that support native H.264 streams",
@ -45,7 +58,7 @@
"data": {
"include_domains": "Domains to include"
},
"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, activity based remote, lock, and camera.",
"description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.",
"title": "Select domains to be included"
},
"pairing": {

View File

@ -12,13 +12,19 @@
"data": {
"include_domains": "Domains to include"
},
"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, activity based remote, lock, and camera.",
"description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.",
"title": "Select domains to be included"
}
}
},
"options": {
"step": {
"accessory": {
"data": {
"entities": "Entity"
},
"title": "Select the entity for the accessory"
},
"advanced": {
"data": {
"auto_start": "Autostart (disable if you are calling the homekit.start service manually)",
@ -35,21 +41,28 @@
"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": "Camera Configuration"
},
"include_exclude": {
"exclude": {
"data": {
"entities": "Entities",
"mode": "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, a separate HomeKit accessory will be created for each tv media player, activity based remote, lock, and camera.",
"title": "Select entities to be included"
"description": "All \u201c{domains}\u201d entities will be included except for the excluded entities and categorized entities.",
"title": "Select the entities to be excluded"
},
"include": {
"data": {
"entities": "Entities"
},
"description": "All \u201c{domains}\u201d entities will be included unless specific entities are selected.",
"title": "Select the entities to be included"
},
"init": {
"data": {
"include_domains": "Domains to include",
"mode": "Mode"
"domains": "Domains to include",
"include_exclude_mode": "Inclusion Mode",
"mode": "HomeKit 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."
"title": "Select mode and domains."
},
"yaml": {
"description": "This entry is controlled via YAML",

View File

@ -1,6 +1,9 @@
"""Test the HomeKit config flow."""
from unittest.mock import patch
import pytest
import voluptuous
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.homekit.const import (
CONF_FILTER,
@ -9,6 +12,8 @@ from homeassistant.components.homekit.const import (
)
from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.entityfilter import CONF_INCLUDE_DOMAINS
from homeassistant.setup import async_setup_component
@ -296,15 +301,18 @@ async def test_options_flow_exclude_mode_advanced(hass, mock_get_source_ip):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate", "humidifier"]},
user_input={
"domains": ["fan", "vacuum", "climate", "humidifier"],
"include_exclude_mode": "exclude",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "exclude"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"},
user_input={"entities": ["climate.old"]},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "advanced"
@ -348,11 +356,14 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate"]},
user_input={
"domains": ["fan", "vacuum", "climate"],
"include_exclude_mode": "exclude",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "exclude"
entities = result["data_schema"]({})["entities"]
assert entities == ["climate.front_gate"]
@ -362,7 +373,7 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip):
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"},
user_input={"entities": ["climate.old"]},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
@ -424,11 +435,14 @@ async def test_options_flow_devices(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate"]},
user_input={
"domains": ["fan", "vacuum", "climate"],
"include_exclude_mode": "exclude",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "exclude"
entry = entity_reg.async_get("light.ceiling_lights")
assert entry is not None
@ -438,7 +452,6 @@ async def test_options_flow_devices(
result["flow_id"],
user_input={
"entities": ["climate.old"],
"include_exclude_mode": "exclude",
},
)
@ -502,17 +515,19 @@ async def test_options_flow_devices_preserved_when_advanced_off(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate"]},
user_input={
"domains": ["fan", "vacuum", "climate"],
"include_exclude_mode": "exclude",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "exclude"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"entities": ["climate.old"],
"include_exclude_mode": "exclude",
},
)
@ -557,11 +572,14 @@ async def test_options_flow_include_mode_with_non_existant_entity(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate"]},
user_input={
"domains": ["fan", "vacuum", "climate"],
"include_exclude_mode": "include",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "include"
entities = result["data_schema"]({})["entities"]
assert "climate.not_exist" not in entities
@ -570,7 +588,6 @@ async def test_options_flow_include_mode_with_non_existant_entity(
result["flow_id"],
user_input={
"entities": ["climate.new", "climate.front_gate"],
"include_exclude_mode": "include",
},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@ -614,11 +631,14 @@ async def test_options_flow_exclude_mode_with_non_existant_entity(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["climate"]},
user_input={
"domains": ["climate"],
"include_exclude_mode": "exclude",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "exclude"
entities = result["data_schema"]({})["entities"]
assert "climate.not_exist" not in entities
@ -627,7 +647,6 @@ async def test_options_flow_exclude_mode_with_non_existant_entity(
result["flow_id"],
user_input={
"entities": ["climate.new", "climate.front_gate"],
"include_exclude_mode": "exclude",
},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@ -662,15 +681,18 @@ async def test_options_flow_include_mode_basic(hass, mock_get_source_ip):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate"]},
user_input={
"domains": ["fan", "vacuum", "climate"],
"include_exclude_mode": "include",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "include"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"entities": ["climate.new"], "include_exclude_mode": "include"},
user_input={"entities": ["climate.new"]},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
@ -706,17 +728,19 @@ async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate", "camera"]},
user_input={
"domains": ["fan", "vacuum", "climate", "camera"],
"include_exclude_mode": "exclude",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "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
@ -750,17 +774,19 @@ async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate", "camera"]},
user_input={
"domains": ["fan", "vacuum", "climate", "camera"],
"include_exclude_mode": "exclude",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "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
@ -807,17 +833,19 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate", "camera"]},
user_input={
"domains": ["fan", "vacuum", "climate", "camera"],
"include_exclude_mode": "include",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "include"
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
@ -851,6 +879,7 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip):
assert result["data_schema"]({}) == {
"domains": ["fan", "vacuum", "climate", "camera"],
"mode": "bridge",
"include_exclude_mode": "include",
}
schema = result["data_schema"].schema
assert _get_schema_default(schema, "domains") == [
@ -860,30 +889,31 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip):
"camera",
]
assert _get_schema_default(schema, "mode") == "bridge"
assert _get_schema_default(schema, "include_exclude_mode") == "include"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate", "camera"]},
user_input={
"domains": ["fan", "vacuum", "climate", "camera"],
"include_exclude_mode": "exclude",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "exclude"
assert result["data_schema"]({}) == {
"entities": ["camera.native_h264", "camera.transcode_h264"],
"include_exclude_mode": "include",
}
schema = result["data_schema"].schema
assert _get_schema_default(schema, "entities") == [
"camera.native_h264",
"camera.transcode_h264",
]
assert _get_schema_default(schema, "include_exclude_mode") == "include"
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
@ -935,17 +965,19 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate", "camera"]},
user_input={
"domains": ["fan", "vacuum", "climate", "camera"],
"include_exclude_mode": "include",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "include"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"entities": ["camera.audio", "camera.no_audio"],
"include_exclude_mode": "include",
},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@ -979,6 +1011,7 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip):
assert result["data_schema"]({}) == {
"domains": ["fan", "vacuum", "climate", "camera"],
"mode": "bridge",
"include_exclude_mode": "include",
}
schema = result["data_schema"].schema
assert _get_schema_default(schema, "domains") == [
@ -988,30 +1021,31 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip):
"camera",
]
assert _get_schema_default(schema, "mode") == "bridge"
assert _get_schema_default(schema, "include_exclude_mode") == "include"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"domains": ["fan", "vacuum", "climate", "camera"]},
user_input={
"include_exclude_mode": "exclude",
"domains": ["fan", "vacuum", "climate", "camera"],
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "exclude"
assert result["data_schema"]({}) == {
"entities": ["camera.audio", "camera.no_audio"],
"include_exclude_mode": "include",
}
schema = result["data_schema"].schema
assert _get_schema_default(schema, "entities") == [
"camera.audio",
"camera.no_audio",
]
assert _get_schema_default(schema, "include_exclude_mode") == "include"
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
@ -1109,6 +1143,7 @@ async def test_options_flow_include_mode_basic_accessory(
"alarm_control_panel",
],
"mode": "bridge",
"include_exclude_mode": "exclude",
}
result2 = await hass.config_entries.options.async_configure(
@ -1117,7 +1152,7 @@ async def test_options_flow_include_mode_basic_accessory(
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "include_exclude"
assert result2["step_id"] == "accessory"
assert _get_schema_default(result2["data_schema"].schema, "entities") is None
result3 = await hass.config_entries.options.async_configure(
@ -1147,6 +1182,7 @@ async def test_options_flow_include_mode_basic_accessory(
assert result["data_schema"]({}) == {
"domains": ["media_player"],
"mode": "accessory",
"include_exclude_mode": "include",
}
result2 = await hass.config_entries.options.async_configure(
@ -1155,7 +1191,7 @@ async def test_options_flow_include_mode_basic_accessory(
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "include_exclude"
assert result2["step_id"] == "accessory"
assert (
_get_schema_default(result2["data_schema"].schema, "entities")
== "media_player.tv"
@ -1248,7 +1284,7 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_sou
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude"
assert result["step_id"] == "accessory"
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
@ -1289,3 +1325,81 @@ def _get_schema_default(schema, key_name):
if schema_key == key_name:
return schema_key.default()
raise KeyError(f"{key_name} not found in schema")
@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True)
async def test_options_flow_exclude_mode_skips_category_entities(
port_mock, hass, mock_get_source_ip, hk_driver, mock_async_zeroconf, entity_reg
):
"""Ensure exclude mode does not offer category entities."""
config_entry = _mock_config_entry_with_options_populated()
await async_init_entry(hass, config_entry)
hass.states.async_set("media_player.tv", "off")
hass.states.async_set("media_player.sonos", "off")
hass.states.async_set("switch.other", "off")
sonos_config_switch: RegistryEntry = entity_reg.async_get_or_create(
"switch",
"sonos",
"config",
device_id="1234",
entity_category=EntityCategory.CONFIG,
)
hass.states.async_set(sonos_config_switch.entity_id, "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"
assert result["data_schema"]({}) == {
"domains": [
"fan",
"humidifier",
"vacuum",
"media_player",
"climate",
"alarm_control_panel",
],
"mode": "bridge",
"include_exclude_mode": "exclude",
}
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"domains": ["media_player", "switch"],
"mode": "bridge",
"include_exclude_mode": "exclude",
},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "exclude"
assert _get_schema_default(result2["data_schema"].schema, "entities") == []
# sonos_config_switch.entity_id is a config category entity
# so it should not be selectable since it will always be excluded
with pytest.raises(voluptuous.error.MultipleInvalid):
await hass.config_entries.options.async_configure(
result2["flow_id"],
user_input={"entities": [sonos_config_switch.entity_id]},
)
result4 = await hass.config_entries.options.async_configure(
result2["flow_id"],
user_input={"entities": ["media_player.tv", "switch.other"]},
)
assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"mode": "bridge",
"filter": {
"exclude_domains": [],
"exclude_entities": ["media_player.tv", "switch.other"],
"include_domains": ["media_player", "switch"],
"include_entities": [],
},
}

View File

@ -429,6 +429,62 @@ async def test_homekit_entity_glob_filter(hass, mock_async_zeroconf):
assert hass.states.get("light.included_test") in filtered_states
async def test_homekit_entity_glob_filter_with_config_entities(
hass, mock_async_zeroconf, entity_reg
):
"""Test the entity filter with configuration entities."""
entry = await async_init_integration(hass)
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_registry import RegistryEntry
select_config_entity: RegistryEntry = entity_reg.async_get_or_create(
"select",
"any",
"any",
device_id="1234",
entity_category=EntityCategory.CONFIG,
)
hass.states.async_set(select_config_entity.entity_id, "off")
switch_config_entity: RegistryEntry = entity_reg.async_get_or_create(
"switch",
"any",
"any",
device_id="1234",
entity_category=EntityCategory.CONFIG,
)
hass.states.async_set(switch_config_entity.entity_id, "off")
hass.states.async_set("select.keep", "open")
hass.states.async_set("cover.excluded_test", "open")
hass.states.async_set("light.included_test", "on")
entity_filter = generate_filter(
["select"],
["switch.test", switch_config_entity.entity_id],
[],
[],
["*.included_*"],
["*.excluded_*"],
)
homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter)
homekit.bridge = Mock()
homekit.bridge.accessories = {}
filtered_states = await homekit.async_configure_accessories()
assert (
hass.states.get(switch_config_entity.entity_id) in filtered_states
) # explicitly included
assert (
hass.states.get(select_config_entity.entity_id) not in filtered_states
) # not explicted included and its a config entity
assert hass.states.get("cover.excluded_test") not in filtered_states
assert hass.states.get("light.included_test") in filtered_states
assert hass.states.get("select.keep") in filtered_states
async def test_homekit_start(hass, hk_driver, mock_async_zeroconf, device_reg):
"""Test HomeKit start method."""
entry = await async_init_integration(hass)