From bd0af57ef2c2ce4646556482b8adf9a4878285a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Aug 2021 09:47:39 -0500 Subject: [PATCH] Support device triggers in HomeKit (#53869) --- homeassistant/components/homekit/__init__.py | 107 +++++++++++--- .../components/homekit/accessories.py | 24 +++- .../components/homekit/aidmanager.py | 6 +- .../components/homekit/config_flow.py | 43 ++++-- homeassistant/components/homekit/const.py | 6 + homeassistant/components/homekit/strings.json | 3 +- .../components/homekit/translations/en.json | 5 +- .../components/homekit/type_triggers.py | 89 ++++++++++++ tests/components/homekit/conftest.py | 44 +++++- tests/components/homekit/test_config_flow.py | 135 ++++++++++++++++++ tests/components/homekit/test_homekit.py | 61 ++++++-- .../components/homekit/test_type_triggers.py | 57 ++++++++ 12 files changed, 522 insertions(+), 58 deletions(-) create mode 100644 homeassistant/components/homekit/type_triggers.py create mode 100644 tests/components/homekit/test_type_triggers.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 58f3ab14ca9..19298a9f814 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -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.""" diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 2cd63facf24..8298cdd9c83 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -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 diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 5af5559b2ef..ddf3c7c564e 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -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] diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 1ec53079179..fdad10f873f 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -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 { diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 30b2a1c2597..4638c9f3b62 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -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 ### diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 3c9671c93e2..69cff3bfcc3 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -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" } } diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index cee1e64ad56..564709cb9c1 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -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": { diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py new file mode 100644 index 00000000000..6d5f67f9915 --- /dev/null +++ b/homeassistant/components/homekit/type_triggers.py @@ -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 diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index 5441bcc195c..5e2acbcd9db 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -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)) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index f3707f9f71e..af803d50cf4 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -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", diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 17c03ba1dcd..4976985fa15 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -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=[], ) diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py new file mode 100644 index 00000000000..4a265858cb3 --- /dev/null +++ b/tests/components/homekit/test_type_triggers.py @@ -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()