1
mirror of https://github.com/home-assistant/core synced 2024-08-02 23:40:32 +02:00

Support device triggers in HomeKit (#53869)

This commit is contained in:
J. Nick Koston 2021-08-25 09:47:39 -05:00 committed by GitHub
parent 72410044cd
commit bd0af57ef2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 522 additions and 58 deletions

View File

@ -8,7 +8,7 @@ from aiohttp import web
from pyhap.const import STANDALONE_AID
import voluptuous as vol
from homeassistant.components import network, zeroconf
from homeassistant.components import device_automation, network, zeroconf
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_MOTION,
@ -28,6 +28,7 @@ from homeassistant.const import (
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_SW_VERSION,
CONF_DEVICES,
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PORT,
@ -99,6 +100,7 @@ from .const import (
SERVICE_HOMEKIT_UNPAIR,
SHUTDOWN_TIMEOUT,
)
from .type_triggers import DeviceTriggerAccessory
from .util import (
accessory_friendly_name,
dismiss_setup_message,
@ -158,6 +160,7 @@ BRIDGE_SCHEMA = vol.All(
vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA,
vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config,
vol.Optional(CONF_ZEROCONF_DEFAULT_INTERFACE): cv.boolean,
vol.Optional(CONF_DEVICES): cv.ensure_list,
},
extra=vol.ALLOW_EXTRA,
),
@ -237,8 +240,9 @@ def _async_update_config_entry_if_from_yaml(hass, entries_by_name, conf):
data = conf.copy()
options = {}
for key in CONFIG_OPTIONS:
options[key] = data[key]
del data[key]
if key in data:
options[key] = data[key]
del data[key]
hass.config_entries.async_update_entry(entry, data=data, options=options)
return True
@ -277,6 +281,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy()
auto_start = options.get(CONF_AUTO_START, DEFAULT_AUTO_START)
entity_filter = FILTER_SCHEMA(options.get(CONF_FILTER, {}))
devices = options.get(CONF_DEVICES, [])
homekit = HomeKit(
hass,
@ -290,6 +295,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
advertise_ip,
entry.entry_id,
entry.title,
devices=devices,
)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
@ -492,6 +498,7 @@ class HomeKit:
advertise_ip=None,
entry_id=None,
entry_title=None,
devices=None,
):
"""Initialize a HomeKit object."""
self.hass = hass
@ -505,6 +512,7 @@ class HomeKit:
self._entry_id = entry_id
self._entry_title = entry_title
self._homekit_mode = homekit_mode
self._devices = devices or []
self.aid_storage = None
self.status = STATUS_READY
@ -594,13 +602,7 @@ class HomeKit:
def add_bridge_accessory(self, state):
"""Try adding accessory to bridge if configured beforehand."""
# The bridge itself counts as an accessory
if len(self.bridge.accessories) + 1 >= MAX_DEVICES:
_LOGGER.warning(
"Cannot add %s as this would exceed the %d device limit. Consider using the filter option",
state.entity_id,
MAX_DEVICES,
)
if self._would_exceed_max_devices(state.entity_id):
return
if state_needs_accessory_mode(state):
@ -631,6 +633,42 @@ class HomeKit:
)
return None
def _would_exceed_max_devices(self, name):
"""Check if adding another devices would reach the limit and log."""
# The bridge itself counts as an accessory
if len(self.bridge.accessories) + 1 >= MAX_DEVICES:
_LOGGER.warning(
"Cannot add %s as this would exceed the %d device limit. Consider using the filter option",
name,
MAX_DEVICES,
)
return True
return False
def add_bridge_triggers_accessory(self, device, device_triggers):
"""Add device automation triggers to the bridge."""
if self._would_exceed_max_devices(device.name):
return
aid = self.aid_storage.get_or_allocate_aid(device.id, device.id)
# If an accessory cannot be created or added due to an exception
# of any kind (usually in pyhap) it should not prevent
# the rest of the accessories from being created
config = {}
self._fill_config_from_device_registry_entry(device, config)
self.bridge.add_accessory(
DeviceTriggerAccessory(
self.hass,
self.driver,
device.name,
None,
aid,
config,
device_id=device.id,
device_triggers=device_triggers,
)
)
def remove_bridge_accessory(self, aid):
"""Try adding accessory to bridge if configured beforehand."""
acc = self.bridge.accessories.pop(aid, None)
@ -778,12 +816,31 @@ class HomeKit:
)
return acc
@callback
def _async_create_bridge_accessory(self, entity_states):
async def _async_create_bridge_accessory(self, entity_states):
"""Create a HomeKit bridge with accessories. (bridge mode)."""
self.bridge = HomeBridge(self.hass, self.driver, self._name)
for state in entity_states:
self.add_bridge_accessory(state)
dev_reg = device_registry.async_get(self.hass)
if self._devices:
valid_device_ids = []
for device_id in self._devices:
if not dev_reg.async_get(device_id):
_LOGGER.warning(
"HomeKit %s cannot add device %s because it is missing from the device registry",
self._name,
device_id,
)
else:
valid_device_ids.append(device_id)
for device_id, device_triggers in (
await device_automation.async_get_device_automations(
self.hass, "trigger", valid_device_ids
)
).items():
self.add_bridge_triggers_accessory(
dev_reg.async_get(device_id), device_triggers
)
return self.bridge
async def _async_create_accessories(self):
@ -792,7 +849,7 @@ class HomeKit:
if self._homekit_mode == HOMEKIT_MODE_ACCESSORY:
acc = self._async_create_single_accessory(entity_states)
else:
acc = self._async_create_bridge_accessory(entity_states)
acc = await self._async_create_bridge_accessory(entity_states)
if acc is None:
return False
@ -875,15 +932,8 @@ class HomeKit:
"""Set attributes that will be used for homekit device info."""
ent_cfg = self._config.setdefault(entity_id, {})
if ent_reg_ent.device_id:
dev_reg_ent = dev_reg.async_get(ent_reg_ent.device_id)
if dev_reg_ent is not None:
# Handle missing devices
if dev_reg_ent.manufacturer:
ent_cfg[ATTR_MANUFACTURER] = dev_reg_ent.manufacturer
if dev_reg_ent.model:
ent_cfg[ATTR_MODEL] = dev_reg_ent.model
if dev_reg_ent.sw_version:
ent_cfg[ATTR_SW_VERSION] = dev_reg_ent.sw_version
if dev_reg_ent := dev_reg.async_get(ent_reg_ent.device_id):
self._fill_config_from_device_registry_entry(dev_reg_ent, ent_cfg)
if ATTR_MANUFACTURER not in ent_cfg:
try:
integration = await async_get_integration(
@ -893,6 +943,19 @@ class HomeKit:
except IntegrationNotFound:
ent_cfg[ATTR_INTEGRATION] = ent_reg_ent.platform
def _fill_config_from_device_registry_entry(self, device_entry, config):
"""Populate a config dict from the registry."""
if device_entry.manufacturer:
config[ATTR_MANUFACTURER] = device_entry.manufacturer
if device_entry.model:
config[ATTR_MODEL] = device_entry.model
if device_entry.sw_version:
config[ATTR_SW_VERSION] = device_entry.sw_version
if device_entry.config_entries:
first_entry = list(device_entry.config_entries)[0]
if entry := self.hass.config_entries.async_get_entry(first_entry):
config[ATTR_INTEGRATION] = entry.domain
class HomeKitPairingQRView(HomeAssistantView):
"""Display the homekit pairing code at a protected url."""

View File

@ -224,6 +224,7 @@ class HomeAccessory(Accessory):
config,
*args,
category=CATEGORY_OTHER,
device_id=None,
**kwargs,
):
"""Initialize a Accessory object."""
@ -231,18 +232,29 @@ class HomeAccessory(Accessory):
driver=driver, display_name=name[:MAX_NAME_LENGTH], aid=aid, *args, **kwargs
)
self.config = config or {}
domain = split_entity_id(entity_id)[0].replace("_", " ")
if device_id:
self.device_id = device_id
serial_number = device_id
domain = None
else:
self.device_id = None
serial_number = entity_id
domain = split_entity_id(entity_id)[0].replace("_", " ")
if self.config.get(ATTR_MANUFACTURER) is not None:
manufacturer = self.config[ATTR_MANUFACTURER]
elif self.config.get(ATTR_INTEGRATION) is not None:
manufacturer = self.config[ATTR_INTEGRATION].replace("_", " ").title()
else:
elif domain:
manufacturer = f"{MANUFACTURER} {domain}".title()
else:
manufacturer = MANUFACTURER
if self.config.get(ATTR_MODEL) is not None:
model = self.config[ATTR_MODEL]
else:
elif domain:
model = domain.title()
else:
model = MANUFACTURER
sw_version = None
if self.config.get(ATTR_SW_VERSION) is not None:
sw_version = format_sw_version(self.config[ATTR_SW_VERSION])
@ -252,7 +264,7 @@ class HomeAccessory(Accessory):
self.set_info_service(
manufacturer=manufacturer[:MAX_MANUFACTURER_LENGTH],
model=model[:MAX_MODEL_LENGTH],
serial_number=entity_id[:MAX_SERIAL_LENGTH],
serial_number=serial_number[:MAX_SERIAL_LENGTH],
firmware_revision=sw_version[:MAX_VERSION_LENGTH],
)
@ -260,6 +272,10 @@ class HomeAccessory(Accessory):
self.entity_id = entity_id
self.hass = hass
self._subscriptions = []
if device_id:
return
self._char_battery = None
self._char_charging = None
self._char_low_battery = None

View File

@ -94,12 +94,12 @@ class AccessoryAidStorage:
"""Generate a stable aid for an entity id."""
entity = self._entity_registry.async_get(entity_id)
if not entity:
return self._get_or_allocate_aid(None, entity_id)
return self.get_or_allocate_aid(None, entity_id)
sys_unique_id = get_system_unique_id(entity)
return self._get_or_allocate_aid(sys_unique_id, entity_id)
return self.get_or_allocate_aid(sys_unique_id, entity_id)
def _get_or_allocate_aid(self, unique_id: str, entity_id: str):
def get_or_allocate_aid(self, unique_id: str, entity_id: str):
"""Allocate (and return) a new aid for an accessory."""
if unique_id and unique_id in self.allocations:
return self.allocations[unique_id]

View File

@ -9,6 +9,7 @@ import string
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import device_automation
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
@ -16,6 +17,7 @@ from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_DEVICES,
CONF_DOMAINS,
CONF_ENTITIES,
CONF_ENTITY_ID,
@ -23,6 +25,7 @@ from homeassistant.const import (
CONF_PORT,
)
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.helpers import device_registry
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import (
CONF_EXCLUDE_DOMAINS,
@ -318,20 +321,31 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
if key in self.hk_options:
del self.hk_options[key]
if (
self.show_advanced_options
and self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE
):
self.hk_options[CONF_DEVICES] = user_input[CONF_DEVICES]
return self.async_create_entry(title="", data=self.hk_options)
data_schema = {
vol.Optional(
CONF_AUTO_START,
default=self.hk_options.get(CONF_AUTO_START, DEFAULT_AUTO_START),
): bool
}
if self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE:
all_supported_devices = await _async_get_supported_devices(self.hass)
devices = self.hk_options.get(CONF_DEVICES, [])
data_schema[vol.Optional(CONF_DEVICES, default=devices)] = cv.multi_select(
all_supported_devices
)
return self.async_show_form(
step_id="advanced",
data_schema=vol.Schema(
{
vol.Optional(
CONF_AUTO_START,
default=self.hk_options.get(
CONF_AUTO_START, DEFAULT_AUTO_START
),
): bool
}
),
data_schema=vol.Schema(data_schema),
)
async def async_step_cameras(self, user_input=None):
@ -412,7 +426,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
self.included_cameras = set()
self.hk_options[CONF_FILTER] = entity_filter
if self.included_cameras:
return await self.async_step_cameras()
@ -481,6 +494,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
)
async def _async_get_supported_devices(hass):
"""Return all supported devices."""
results = await device_automation.async_get_device_automations(hass, "trigger")
dev_reg = device_registry.async_get(hass)
unsorted = {device_id: dev_reg.async_get(device_id).name for device_id in results}
return dict(sorted(unsorted.items(), key=lambda item: item[1]))
def _async_get_matching_entities(hass, domains=None):
"""Fetch all entities or entities in the given domains."""
return {

View File

@ -1,5 +1,7 @@
"""Constants used be the HomeKit component."""
from homeassistant.const import CONF_DEVICES
# #### Misc ####
DEBOUNCE_TIMEOUT = 0.5
DEVICE_PRECISION_LEEWAY = 6
@ -136,6 +138,7 @@ SERV_MOTION_SENSOR = "MotionSensor"
SERV_OCCUPANCY_SENSOR = "OccupancySensor"
SERV_OUTLET = "Outlet"
SERV_SECURITY_SYSTEM = "SecuritySystem"
SERV_SERVICE_LABEL = "ServiceLabel"
SERV_SMOKE_SENSOR = "SmokeSensor"
SERV_SPEAKER = "Speaker"
SERV_STATELESS_PROGRAMMABLE_SWITCH = "StatelessProgrammableSwitch"
@ -205,6 +208,8 @@ CHAR_ROTATION_DIRECTION = "RotationDirection"
CHAR_ROTATION_SPEED = "RotationSpeed"
CHAR_SATURATION = "Saturation"
CHAR_SERIAL_NUMBER = "SerialNumber"
CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex"
CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace"
CHAR_SLEEP_DISCOVER_MODE = "SleepDiscoveryMode"
CHAR_SMOKE_DETECTED = "SmokeDetected"
CHAR_STATUS_LOW_BATTERY = "StatusLowBattery"
@ -292,6 +297,7 @@ CONFIG_OPTIONS = [
CONF_SAFE_MODE,
CONF_ENTITY_CONFIG,
CONF_HOMEKIT_MODE,
CONF_DEVICES,
]
# ### Maximum Lengths ###

View File

@ -30,9 +30,10 @@
},
"advanced": {
"data": {
"devices": "Devices (Triggers)",
"auto_start": "Autostart (disable if you are calling the homekit.start service manually)"
},
"description": "These settings only need to be adjusted if HomeKit is not functional.",
"description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.",
"title": "Advanced Configuration"
}
}

View File

@ -21,9 +21,10 @@
"step": {
"advanced": {
"data": {
"auto_start": "Autostart (disable if you are calling the homekit.start service manually)"
"auto_start": "Autostart (disable if you are calling the homekit.start service manually)",
"devices": "Devices (Triggers)"
},
"description": "These settings only need to be adjusted if HomeKit is not functional.",
"description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.",
"title": "Advanced Configuration"
},
"cameras": {

View File

@ -0,0 +1,89 @@
"""Class to hold all sensor accessories."""
import logging
from pyhap.const import CATEGORY_SENSOR
from homeassistant.helpers.trigger import async_initialize_triggers
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_NAME,
CHAR_PROGRAMMABLE_SWITCH_EVENT,
CHAR_SERVICE_LABEL_INDEX,
CHAR_SERVICE_LABEL_NAMESPACE,
SERV_SERVICE_LABEL,
SERV_STATELESS_PROGRAMMABLE_SWITCH,
)
_LOGGER = logging.getLogger(__name__)
@TYPES.register("DeviceTriggerAccessory")
class DeviceTriggerAccessory(HomeAccessory):
"""Generate a Programmable switch."""
def __init__(self, *args, device_triggers=None, device_id=None):
"""Initialize a Programmable switch accessory object."""
super().__init__(*args, category=CATEGORY_SENSOR, device_id=device_id)
self._device_triggers = device_triggers
self._remove_triggers = None
self.triggers = []
for idx, trigger in enumerate(device_triggers):
type_ = trigger.get("type")
subtype = trigger.get("subtype")
trigger_name = (
f"{type_.title()} {subtype.title()}" if subtype else type_.title()
)
serv_stateless_switch = self.add_preload_service(
SERV_STATELESS_PROGRAMMABLE_SWITCH,
[CHAR_NAME, CHAR_SERVICE_LABEL_INDEX],
)
self.triggers.append(
serv_stateless_switch.configure_char(
CHAR_PROGRAMMABLE_SWITCH_EVENT,
value=0,
valid_values={"Trigger": 0},
)
)
serv_stateless_switch.configure_char(CHAR_NAME, value=trigger_name)
serv_stateless_switch.configure_char(
CHAR_SERVICE_LABEL_INDEX, value=idx + 1
)
serv_service_label = self.add_preload_service(SERV_SERVICE_LABEL)
serv_service_label.configure_char(CHAR_SERVICE_LABEL_NAMESPACE, value=1)
serv_stateless_switch.add_linked_service(serv_service_label)
async def async_trigger(self, run_variables, context=None, skip_condition=False):
"""Trigger button press.
This method is a coroutine.
"""
reason = ""
if "trigger" in run_variables and "description" in run_variables["trigger"]:
reason = f' by {run_variables["trigger"]["description"]}'
_LOGGER.debug("Button triggered%s - %s", reason, run_variables)
idx = int(run_variables["trigger"]["idx"])
self.triggers[idx].set_value(0)
# Attach the trigger using the helper in async run
# and detach it in async stop
async def run(self):
"""Handle accessory driver started event."""
self._remove_triggers = await async_initialize_triggers(
self.hass,
self._device_triggers,
self.async_trigger,
"homekit",
self.display_name,
_LOGGER.log,
)
async def stop(self):
"""Handle accessory driver stop event."""
if self._remove_triggers:
self._remove_triggers()
@property
def available(self):
"""Return available."""
return True

View File

@ -1,12 +1,15 @@
"""HomeKit session fixtures."""
from contextlib import suppress
import os
from unittest.mock import patch
from pyhap.accessory_driver import AccessoryDriver
import pytest
from homeassistant.components.device_tracker.legacy import YAML_DEVICES
from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED
from tests.common import async_capture_events
from tests.common import async_capture_events, mock_device_registry, mock_registry
@pytest.fixture
@ -24,7 +27,46 @@ def hk_driver(loop):
yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop)
@pytest.fixture
def mock_hap(loop, mock_zeroconf):
"""Return a custom AccessoryDriver instance for HomeKit accessory init."""
with patch("pyhap.accessory_driver.AsyncZeroconf"), patch(
"pyhap.accessory_driver.AccessoryEncoder"
), patch("pyhap.accessory_driver.HAPServer.async_stop"), patch(
"pyhap.accessory_driver.HAPServer.async_start"
), patch(
"pyhap.accessory_driver.AccessoryDriver.publish"
), patch(
"pyhap.accessory_driver.AccessoryDriver.async_start"
), patch(
"pyhap.accessory_driver.AccessoryDriver.async_stop"
), patch(
"pyhap.accessory_driver.AccessoryDriver.persist"
):
yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop)
@pytest.fixture
def events(hass):
"""Yield caught homekit_changed events."""
return async_capture_events(hass, EVENT_HOMEKIT_CHANGED)
@pytest.fixture(name="device_reg")
def device_reg_fixture(hass):
"""Return an empty, loaded, registry."""
return mock_device_registry(hass)
@pytest.fixture(name="entity_reg")
def entity_reg_fixture(hass):
"""Return an empty, loaded, registry."""
return mock_registry(hass)
@pytest.fixture
def demo_cleanup(hass):
"""Clean up device tracker demo file."""
yield
with suppress(FileNotFoundError):
os.remove(hass.config.path(YAML_DEVICES))

View File

@ -7,6 +7,7 @@ from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.homekit.const import DOMAIN, SHORT_BRIDGE_NAME
from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@ -314,6 +315,7 @@ async def test_options_flow_exclude_mode_advanced(auto_start, hass):
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"auto_start": auto_start,
"devices": [],
"mode": "bridge",
"filter": {
"exclude_domains": [],
@ -365,6 +367,138 @@ async def test_options_flow_exclude_mode_basic(hass):
}
async def test_options_flow_devices(
mock_hap, hass, demo_cleanup, device_reg, entity_reg
):
"""Test devices can be bridged."""
config_entry = _mock_config_entry_with_options_populated()
config_entry.add_to_hass(hass)
demo_config_entry = MockConfigEntry(domain="domain")
demo_config_entry.add_to_hass(hass)
assert await async_setup_component(hass, "persistent_notification", {})
assert await async_setup_component(hass, "demo", {"demo": {}})
assert await async_setup_component(hass, "homekit", {"homekit": {}})
hass.states.async_set("climate.old", "off")
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(
config_entry.entry_id, context={"show_advanced_options": True}
)
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"
entry = entity_reg.async_get("light.ceiling_lights")
assert entry is not None
device_id = entry.device_id
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
"entities": ["climate.old"],
"include_exclude_mode": "exclude",
},
)
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result3 = await hass.config_entries.options.async_configure(
result2["flow_id"],
user_input={"auto_start": True, "devices": [device_id]},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"auto_start": True,
"devices": [device_id],
"mode": "bridge",
"filter": {
"exclude_domains": [],
"exclude_entities": ["climate.old"],
"include_domains": ["fan", "vacuum", "climate"],
"include_entities": [],
},
}
async def test_options_flow_devices_preserved_when_advanced_off(mock_hap, hass):
"""Test devices are preserved if they were added in advanced mode but it was turned off."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
options={
"devices": ["1fabcabcabcabcabcabcabcabcabc"],
"filter": {
"include_domains": [
"fan",
"humidifier",
"vacuum",
"media_player",
"climate",
"alarm_control_panel",
],
"exclude_entities": ["climate.front_gate"],
},
},
)
config_entry.add_to_hass(hass)
demo_config_entry = MockConfigEntry(domain="domain")
demo_config_entry.add_to_hass(hass)
assert await async_setup_component(hass, "persistent_notification", {})
assert await async_setup_component(hass, "homekit", {"homekit": {}})
hass.states.async_set("climate.old", "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.old"],
"include_exclude_mode": "exclude",
},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"auto_start": True,
"devices": ["1fabcabcabcabcabcabcabcabcabc"],
"mode": "bridge",
"filter": {
"exclude_domains": [],
"exclude_entities": ["climate.old"],
"include_domains": ["fan", "vacuum", "climate"],
"include_entities": [],
},
}
async def test_options_flow_include_mode_basic(hass):
"""Test config flow options in include mode."""
@ -646,6 +780,7 @@ async def test_options_flow_blocked_when_from_yaml(hass):
data={CONF_NAME: "mock_name", CONF_PORT: 12345},
options={
"auto_start": True,
"devices": [],
"filter": {
"include_domains": [
"fan",

View File

@ -37,6 +37,7 @@ from homeassistant.components.homekit.const import (
SERVICE_HOMEKIT_START,
SERVICE_HOMEKIT_UNPAIR,
)
from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory
from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
@ -70,7 +71,7 @@ from homeassistant.util import json as json_util
from .util import PATH_HOMEKIT, async_init_entry, async_init_integration
from tests.common import MockConfigEntry, mock_device_registry, mock_registry
from tests.common import MockConfigEntry
IP_ADDRESS = "127.0.0.1"
@ -101,19 +102,7 @@ def always_patch_driver(hk_driver):
"""Load the hk_driver fixture."""
@pytest.fixture(name="device_reg")
def device_reg_fixture(hass):
"""Return an empty, loaded, registry."""
return mock_device_registry(hass)
@pytest.fixture(name="entity_reg")
def entity_reg_fixture(hass):
"""Return an empty, loaded, registry."""
return mock_registry(hass)
def _mock_homekit(hass, entry, homekit_mode, entity_filter=None):
def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None):
return HomeKit(
hass=hass,
name=BRIDGE_NAME,
@ -126,6 +115,7 @@ def _mock_homekit(hass, entry, homekit_mode, entity_filter=None):
advertise_ip=None,
entry_id=entry.entry_id,
entry_title=entry.title,
devices=devices,
)
@ -178,6 +168,7 @@ async def test_setup_min(hass, mock_zeroconf):
None,
entry.entry_id,
entry.title,
devices=[],
)
# Test auto start enabled
@ -214,6 +205,7 @@ async def test_setup_auto_start_disabled(hass, mock_zeroconf):
None,
entry.entry_id,
entry.title,
devices=[],
)
# Test auto_start disabled
@ -602,6 +594,41 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc
assert not hk_driver_start.called
async def test_homekit_start_with_a_device(
hass, hk_driver, mock_zeroconf, demo_cleanup, device_reg, entity_reg
):
"""Test HomeKit start method with a device."""
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
assert await async_setup_component(hass, "demo", {"demo": {}})
await hass.async_block_till_done()
reg_entry = entity_reg.async_get("light.ceiling_lights")
assert reg_entry is not None
device_id = reg_entry.device_id
await async_init_entry(hass, entry)
homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, None, devices=[device_id])
homekit.driver = hk_driver
with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=Exception), patch(
f"{PATH_HOMEKIT}.show_setup_message"
) as mock_setup_msg:
await homekit.async_start()
await hass.async_block_till_done()
mock_setup_msg.assert_called_with(
hass, entry.entry_id, "Mock Title (Home Assistant Bridge)", ANY, ANY
)
assert homekit.status == STATUS_RUNNING
assert isinstance(
list(homekit.driver.accessory.accessories.values())[0], DeviceTriggerAccessory
)
await homekit.async_stop()
async def test_homekit_stop(hass):
"""Test HomeKit stop method."""
entry = await async_init_integration(hass)
@ -1141,6 +1168,7 @@ async def test_homekit_finds_linked_batteries(
"manufacturer": "Tesla",
"model": "Powerwall 2",
"sw_version": "0.16.0",
"platform": "test",
"linked_battery_charging_sensor": "binary_sensor.powerwall_battery_charging",
"linked_battery_sensor": "sensor.powerwall_battery",
},
@ -1250,6 +1278,7 @@ async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf):
None,
entry.entry_id,
entry.title,
devices=[],
)
# Test auto start enabled
@ -1416,6 +1445,7 @@ async def test_homekit_finds_linked_motion_sensors(
{
"manufacturer": "Ubq",
"model": "Camera Server",
"platform": "test",
"sw_version": "0.16.0",
"linked_motion_sensor": "binary_sensor.camera_motion_sensor",
},
@ -1480,6 +1510,7 @@ async def test_homekit_finds_linked_humidity_sensors(
{
"manufacturer": "Home Assistant",
"model": "Smart Brainy Clever Humidifier",
"platform": "test",
"sw_version": "0.16.1",
"linked_humidity_sensor": "sensor.humidifier_humidity_sensor",
},
@ -1518,6 +1549,7 @@ async def test_reload(hass, mock_zeroconf):
None,
entry.entry_id,
entry.title,
devices=[],
)
yaml_path = os.path.join(
_get_fixtures_base_path(),
@ -1556,6 +1588,7 @@ async def test_reload(hass, mock_zeroconf):
None,
entry.entry_id,
entry.title,
devices=[],
)

View File

@ -0,0 +1,57 @@
"""Test different accessory types: Triggers (Programmable Switches)."""
from unittest.mock import MagicMock
from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, async_get_device_automations
async def test_programmable_switch_button_fires_on_trigger(
hass, hk_driver, events, demo_cleanup, device_reg, entity_reg
):
"""Test that DeviceTriggerAccessory fires the programmable switch event on trigger."""
hk_driver.publish = MagicMock()
demo_config_entry = MockConfigEntry(domain="domain")
demo_config_entry.add_to_hass(hass)
assert await async_setup_component(hass, "demo", {"demo": {}})
await hass.async_block_till_done()
hass.states.async_set("light.ceiling_lights", STATE_OFF)
await hass.async_block_till_done()
entry = entity_reg.async_get("light.ceiling_lights")
assert entry is not None
device_id = entry.device_id
device_triggers = await async_get_device_automations(hass, "trigger", device_id)
acc = DeviceTriggerAccessory(
hass,
hk_driver,
"DeviceTriggerAccessory",
None,
1,
None,
device_id=device_id,
device_triggers=device_triggers,
)
await acc.run()
await hass.async_block_till_done()
assert acc.entity_id is None
assert acc.device_id is device_id
assert acc.available is True
hk_driver.publish.reset_mock()
hass.states.async_set("light.ceiling_lights", STATE_ON)
await hass.async_block_till_done()
hk_driver.publish.assert_called_once()
hk_driver.publish.reset_mock()
hass.states.async_set("light.ceiling_lights", STATE_OFF)
await hass.async_block_till_done()
hk_driver.publish.assert_called_once()
await acc.stop()