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:
parent
72410044cd
commit
bd0af57ef2
@ -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."""
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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 {
|
||||
|
@ -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 ###
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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": {
|
||||
|
89
homeassistant/components/homekit/type_triggers.py
Normal file
89
homeassistant/components/homekit/type_triggers.py
Normal 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
|
@ -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))
|
||||
|
@ -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",
|
||||
|
@ -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=[],
|
||||
)
|
||||
|
||||
|
||||
|
57
tests/components/homekit/test_type_triggers.py
Normal file
57
tests/components/homekit/test_type_triggers.py
Normal 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()
|
Loading…
Reference in New Issue
Block a user