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

Add a homekit.unpair service to forcefully remove pairings (#53303)

- Sometimes homekit will go unresponsive because a pairing for a specific
  device is missing. To avoid deleting the config entry and recreating
  it, which can be a painful process if there are many bridged entities,
  the homekit.unpair service allows forceful removal of the pairings so
  the accessory can be paired again.
This commit is contained in:
J. Nick Koston 2021-07-22 00:44:36 -10:00 committed by GitHub
parent 80c535f02e
commit 009f34bfed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 193 additions and 3 deletions

View File

@ -23,6 +23,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_CHARGING,
ATTR_BATTERY_LEVEL,
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
CONF_IP_ADDRESS,
CONF_NAME,
@ -34,11 +35,12 @@ from homeassistant.const import (
SERVICE_RELOAD,
)
from homeassistant.core import CoreState, HomeAssistant, callback
from homeassistant.exceptions import Unauthorized
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers import device_registry, entity_registry
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_extract_referenced_entity_ids
from homeassistant.loader import IntegrationNotFound, async_get_integration
from . import ( # noqa: F401
@ -93,6 +95,7 @@ from .const import (
MANUFACTURER,
SERVICE_HOMEKIT_RESET_ACCESSORY,
SERVICE_HOMEKIT_START,
SERVICE_HOMEKIT_UNPAIR,
SHUTDOWN_TIMEOUT,
)
from .util import (
@ -170,6 +173,12 @@ RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema(
)
UNPAIR_SERVICE_SCHEMA = vol.All(
vol.Schema(cv.ENTITY_SERVICE_FIELDS),
cv.has_at_least_one_key(ATTR_DEVICE_ID),
)
def _async_get_entries_by_name(current_entries):
"""Return a dict of the entries by name."""
@ -356,7 +365,7 @@ def _async_register_events_and_services(hass: HomeAssistant):
hass.http.register_view(HomeKitPairingQRView)
async def async_handle_homekit_reset_accessory(service):
"""Handle start HomeKit service call."""
"""Handle reset accessory HomeKit service call."""
for entry_id in hass.data[DOMAIN]:
if HOMEKIT not in hass.data[DOMAIN][entry_id]:
continue
@ -378,6 +387,44 @@ def _async_register_events_and_services(hass: HomeAssistant):
schema=RESET_ACCESSORY_SERVICE_SCHEMA,
)
async def async_handle_homekit_unpair(service):
"""Handle unpair HomeKit service call."""
referenced = await async_extract_referenced_entity_ids(hass, service)
dev_reg = device_registry.async_get(hass)
for device_id in referenced.referenced_devices:
dev_reg_ent = dev_reg.async_get(device_id)
if not dev_reg_ent:
raise HomeAssistantError(f"No device found for device id: {device_id}")
macs = [
cval
for ctype, cval in dev_reg_ent.connections
if ctype == device_registry.CONNECTION_NETWORK_MAC
]
domain_data = hass.data[DOMAIN]
matching_instances = [
domain_data[entry_id][HOMEKIT]
for entry_id in domain_data
if HOMEKIT in domain_data[entry_id]
and domain_data[entry_id][HOMEKIT].driver
and device_registry.format_mac(
domain_data[entry_id][HOMEKIT].driver.state.mac
)
in macs
]
if not matching_instances:
raise HomeAssistantError(
f"No homekit accessory found for device id: {device_id}"
)
for homekit in matching_instances:
homekit.async_unpair()
hass.services.async_register(
DOMAIN,
SERVICE_HOMEKIT_UNPAIR,
async_handle_homekit_unpair,
schema=UNPAIR_SERVICE_SCHEMA,
)
async def async_handle_homekit_service_start(service):
"""Handle start HomeKit service call."""
tasks = []
@ -639,7 +686,11 @@ class HomeKit:
if self.driver.state.paired:
return
self._async_show_setup_message()
@callback
def _async_show_setup_message(self):
"""Show the pairing setup message."""
show_setup_message(
self.hass,
self._entry_id,
@ -648,6 +699,16 @@ class HomeKit:
self.driver.accessory.xhm_uri(),
)
@callback
def async_unpair(self):
"""Remove all pairings for an accessory so it can be repaired."""
state = self.driver.state
for client_uuid in list(state.paired_clients):
state.remove_paired_client(client_uuid)
self.driver.async_persist()
self.driver.async_update_advertisement()
self._async_show_setup_message()
@callback
def _async_register_bridge(self):
"""Register the bridge as a device so homekit_controller and exclude it from discovery."""

View File

@ -99,6 +99,7 @@ HOMEKIT_MODES = [HOMEKIT_MODE_BRIDGE, HOMEKIT_MODE_ACCESSORY]
# #### HomeKit Component Services ####
SERVICE_HOMEKIT_START = "start"
SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory"
SERVICE_HOMEKIT_UNPAIR = "unpair"
# #### String Constants ####
BRIDGE_MODEL = "Bridge"

View File

@ -14,3 +14,9 @@ reset_accessory:
target:
entity: {}
unpair:
name: Unpair an accessory or bridge
description: Forcefully remove all pairings from an accessory to allow re-pairing. Use this service if the accessory is no longer responsive, and you want to avoid deleting and re-adding the entry. Room locations, and accessory preferences will be lost.
target:
device:
integration: homekit

View File

@ -35,11 +35,13 @@ from homeassistant.components.homekit.const import (
HOMEKIT_MODE_BRIDGE,
SERVICE_HOMEKIT_RESET_ACCESSORY,
SERVICE_HOMEKIT_START,
SERVICE_HOMEKIT_UNPAIR,
)
from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
CONF_IP_ADDRESS,
@ -52,7 +54,7 @@ from homeassistant.const import (
SERVICE_RELOAD,
STATE_ON,
)
from homeassistant.core import State
from homeassistant.core import HomeAssistantError, State
from homeassistant.helpers import device_registry
from homeassistant.helpers.entityfilter import (
CONF_EXCLUDE_DOMAINS,
@ -668,6 +670,126 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf):
homekit.status = STATUS_READY
async def test_homekit_unpair(hass, device_reg, mock_zeroconf):
"""Test unpairing HomeKit accessories."""
await async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
entity_id = "light.demo"
hass.states.async_set("light.demo", "on")
homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE)
with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch(
"pyhap.accessory_driver.AccessoryDriver.async_start"
):
await async_init_entry(hass, entry)
acc_mock = MagicMock()
acc_mock.entity_id = entity_id
aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id)
homekit.bridge.accessories = {aid: acc_mock}
homekit.status = STATUS_RUNNING
state = homekit.driver.state
state.paired_clients = {"client1": "any"}
formatted_mac = device_registry.format_mac(state.mac)
hk_bridge_dev = device_reg.async_get_device(
{}, {(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)}
)
await hass.services.async_call(
DOMAIN,
SERVICE_HOMEKIT_UNPAIR,
{ATTR_DEVICE_ID: hk_bridge_dev.id},
blocking=True,
)
await hass.async_block_till_done()
assert state.paired_clients == {}
homekit.status = STATUS_STOPPED
async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf):
"""Test unpairing HomeKit accessories with invalid device id."""
await async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
entity_id = "light.demo"
hass.states.async_set("light.demo", "on")
homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE)
with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch(
"pyhap.accessory_driver.AccessoryDriver.async_start"
):
await async_init_entry(hass, entry)
acc_mock = MagicMock()
acc_mock.entity_id = entity_id
aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id)
homekit.bridge.accessories = {aid: acc_mock}
homekit.status = STATUS_RUNNING
state = homekit.driver.state
state.paired_clients = {"client1": "any"}
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
DOMAIN,
SERVICE_HOMEKIT_UNPAIR,
{ATTR_DEVICE_ID: "notvalid"},
blocking=True,
)
await hass.async_block_till_done()
state.paired_clients = {"client1": "any"}
homekit.status = STATUS_STOPPED
async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf):
"""Test unpairing HomeKit accessories with a non-homekit device id."""
await async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
not_homekit_entry = MockConfigEntry(
domain="not_homekit", data={CONF_NAME: "mock_name", CONF_PORT: 12345}
)
entity_id = "light.demo"
hass.states.async_set("light.demo", "on")
homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE)
with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch(
"pyhap.accessory_driver.AccessoryDriver.async_start"
):
await async_init_entry(hass, entry)
acc_mock = MagicMock()
acc_mock.entity_id = entity_id
aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id)
homekit.bridge.accessories = {aid: acc_mock}
homekit.status = STATUS_RUNNING
device_entry = device_reg.async_get_or_create(
config_entry_id=not_homekit_entry.entry_id,
sw_version="0.16.0",
model="Powerwall 2",
manufacturer="Tesla",
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
state = homekit.driver.state
state.paired_clients = {"client1": "any"}
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
DOMAIN,
SERVICE_HOMEKIT_UNPAIR,
{ATTR_DEVICE_ID: device_entry.id},
blocking=True,
)
await hass.async_block_till_done()
state.paired_clients = {"client1": "any"}
homekit.status = STATUS_STOPPED
async def test_homekit_reset_accessories_not_supported(hass, mock_zeroconf):
"""Test resetting HomeKit accessories with an unsupported entity."""
await async_setup_component(hass, "persistent_notification", {})