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:
parent
80c535f02e
commit
009f34bfed
4 changed files with 193 additions and 3 deletions
|
@ -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."""
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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", {})
|
||||
|
|
Loading…
Add table
Reference in a new issue