Only compute homekit_controller accessory_info when entity is added or config changes (#102145)

pull/102179/head
J. Nick Koston 2023-10-17 10:53:17 -10:00 committed by GitHub
parent e6895b5738
commit d8e541a284
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 642 additions and 33 deletions

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Iterable
from datetime import datetime, timedelta
from functools import partial
import logging
from operator import attrgetter
from types import MappingProxyType
@ -144,6 +145,7 @@ class HKDevice:
)
self._availability_callbacks: set[CALLBACK_TYPE] = set()
self._config_changed_callbacks: set[CALLBACK_TYPE] = set()
self._subscriptions: dict[tuple[int, int], set[CALLBACK_TYPE]] = {}
@property
@ -605,6 +607,8 @@ class HKDevice:
await self.async_process_entity_map()
if self.watchable_characteristics:
await self.pairing.subscribe(self.watchable_characteristics)
for callback_ in self._config_changed_callbacks:
callback_()
await self.async_update()
await self.async_add_new_entities()
@ -805,6 +809,16 @@ class HKDevice:
for callback_ in to_callback:
callback_()
@callback
def _remove_characteristics_callback(
self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE
) -> None:
"""Remove a characteristics callback."""
for aid_iid in characteristics:
self._subscriptions[aid_iid].remove(callback_)
if not self._subscriptions[aid_iid]:
del self._subscriptions[aid_iid]
@callback
def async_subscribe(
self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE
@ -812,24 +826,31 @@ class HKDevice:
"""Add characteristics to the watch list."""
for aid_iid in characteristics:
self._subscriptions.setdefault(aid_iid, set()).add(callback_)
return partial(
self._remove_characteristics_callback, characteristics, callback_
)
def _unsub():
for aid_iid in characteristics:
self._subscriptions[aid_iid].remove(callback_)
if not self._subscriptions[aid_iid]:
del self._subscriptions[aid_iid]
return _unsub
@callback
def _remove_availability_callback(self, callback_: CALLBACK_TYPE) -> None:
"""Remove an availability callback."""
self._availability_callbacks.remove(callback_)
@callback
def async_subscribe_availability(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE:
"""Add characteristics to the watch list."""
self._availability_callbacks.add(callback_)
return partial(self._remove_availability_callback, callback_)
def _unsub():
self._availability_callbacks.remove(callback_)
@callback
def _remove_config_changed_callback(self, callback_: CALLBACK_TYPE) -> None:
"""Remove an availability callback."""
self._config_changed_callbacks.remove(callback_)
return _unsub
@callback
def async_subscribe_config_changed(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE:
"""Subscribe to config of the accessory being changed aka c# changes."""
self._config_changed_callbacks.add(callback_)
return partial(self._remove_config_changed_callback, callback_)
async def get_characteristics(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
"""Read latest state from homekit accessory."""

View File

@ -3,15 +3,15 @@ from __future__ import annotations
from typing import Any
from aiohomekit.model import Accessory
from aiohomekit.model.characteristics import (
EVENT_CHARACTERISTICS,
Characteristic,
CharacteristicPermissions,
CharacteristicsTypes,
)
from aiohomekit.model.services import Service, ServicesTypes
from aiohomekit.model.services import ServicesTypes
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
@ -32,41 +32,43 @@ class HomeKitEntity(Entity):
self._iid = devinfo["iid"]
self._char_name: str | None = None
self.all_characteristics: set[tuple[int, int]] = set()
self._async_set_accessory_and_service()
self.setup()
super().__init__()
@property
def accessory(self) -> Accessory:
"""Return an Accessory model that this entity is attached to."""
return self._accessory.entity_map.aid(self._aid)
@property
def accessory_info(self) -> Service:
"""Information about the make and model of an accessory."""
return self.accessory.services.first(
@callback
def _async_set_accessory_and_service(self) -> None:
"""Set the accessory and service for this entity."""
accessory = self._accessory
self.accessory = accessory.entity_map.aid(self._aid)
self.service = self.accessory.services.iid(self._iid)
self.accessory_info = self.accessory.services.first(
service_type=ServicesTypes.ACCESSORY_INFORMATION
)
@property
def service(self) -> Service:
"""Return a Service model that this entity is attached to."""
return self.accessory.services.iid(self._iid)
@callback
def _async_config_changed(self) -> None:
"""Handle accessory discovery changes."""
self._async_set_accessory_and_service()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Entity added to hass."""
accessory = self._accessory
self.async_on_remove(
self._accessory.async_subscribe(
accessory.async_subscribe(
self.all_characteristics, self._async_write_ha_state
)
)
self.async_on_remove(
self._accessory.async_subscribe_availability(self._async_write_ha_state)
accessory.async_subscribe_availability(self._async_write_ha_state)
)
self._accessory.add_pollable_characteristics(self.pollable_characteristics)
await self._accessory.add_watchable_characteristics(
self.watchable_characteristics
self.async_on_remove(
accessory.async_subscribe_config_changed(self._async_config_changed)
)
accessory.add_pollable_characteristics(self.pollable_characteristics)
await accessory.add_watchable_characteristics(self.watchable_characteristics)
async def async_will_remove_from_hass(self) -> None:
"""Prepare to be removed from hass."""

View File

@ -8,6 +8,7 @@ import os
from typing import Any, Final
from unittest import mock
from aiohomekit.controller.abstract import AbstractPairing
from aiohomekit.hkjson import loads as hkloads
from aiohomekit.model import (
Accessories,
@ -180,7 +181,7 @@ async def time_changed(hass, seconds):
await hass.async_block_till_done()
async def setup_accessories_from_file(hass, path):
async def setup_accessories_from_file(hass: HomeAssistant, path: str) -> Accessories:
"""Load an collection of accessory defs from JSON data."""
accessories_fixture = await hass.async_add_executor_job(
load_fixture, os.path.join("homekit_controller", path)
@ -242,11 +243,11 @@ async def setup_test_accessories_with_controller(
return config_entry, pairing
async def device_config_changed(hass, accessories):
async def device_config_changed(hass: HomeAssistant, accessories: Accessories):
"""Discover new devices added to Home Assistant at runtime."""
# Update the accessories our FakePairing knows about
controller = hass.data[CONTROLLER]
pairing = controller.pairings["00:00:00:00:00:00"]
pairing: AbstractPairing = controller.pairings["00:00:00:00:00:00"]
accessories_obj = Accessories()
for accessory in accessories:

View File

@ -0,0 +1,244 @@
[
{
"aid": 1,
"services": [
{
"characteristics": [
{
"description": "Identify",
"format": "bool",
"iid": 2,
"perms": ["pw"],
"type": "00000014-0000-1000-8000-0026BB765291"
},
{
"description": "Manufacturer",
"format": "string",
"iid": 3,
"perms": ["pr"],
"type": "00000020-0000-1000-8000-0026BB765291",
"value": "Home Assistant"
},
{
"description": "Model",
"format": "string",
"iid": 4,
"perms": ["pr"],
"type": "00000021-0000-1000-8000-0026BB765291",
"value": "Bridge"
},
{
"description": "Name",
"format": "string",
"iid": 5,
"perms": ["pr"],
"type": "00000023-0000-1000-8000-0026BB765291",
"value": "Home Assistant Bridge"
},
{
"description": "SerialNumber",
"format": "string",
"iid": 6,
"perms": ["pr"],
"type": "00000030-0000-1000-8000-0026BB765291",
"value": "homekit.bridge"
},
{
"description": "FirmwareRevision",
"format": "string",
"iid": 7,
"perms": ["pr"],
"type": "00000052-0000-1000-8000-0026BB765291",
"value": "0.104.0.dev0"
}
],
"iid": 1,
"stype": "accessory-information",
"type": "0000003E-0000-1000-8000-0026BB765291"
}
]
},
{
"aid": 1256851357,
"services": [
{
"characteristics": [
{
"description": "Identify",
"format": "bool",
"iid": 2,
"perms": ["pw"],
"type": "00000014-0000-1000-8000-0026BB765291"
},
{
"description": "Manufacturer",
"format": "string",
"iid": 3,
"perms": ["pr"],
"type": "00000020-0000-1000-8000-0026BB765291",
"value": "Home Assistant"
},
{
"description": "Model",
"format": "string",
"iid": 4,
"perms": ["pr"],
"type": "00000021-0000-1000-8000-0026BB765291",
"value": "Fan"
},
{
"description": "Name",
"format": "string",
"iid": 5,
"perms": ["pr"],
"type": "00000023-0000-1000-8000-0026BB765291",
"value": "Living Room Fan"
},
{
"description": "SerialNumber",
"format": "string",
"iid": 6,
"perms": ["pr"],
"type": "00000030-0000-1000-8000-0026BB765291",
"value": "fan.living_room_fan"
},
{
"description": "FirmwareRevision",
"format": "string",
"iid": 7,
"perms": ["pr"],
"type": "00000052-0000-1000-8000-0026BB765291",
"value": "0.104.0.dev0"
}
],
"iid": 1,
"stype": "accessory-information",
"type": "0000003E-0000-1000-8000-0026BB765291"
},
{
"characteristics": [
{
"description": "Active",
"format": "uint8",
"iid": 9,
"perms": ["pr", "pw", "ev"],
"type": "000000B0-0000-1000-8000-0026BB765291",
"valid-values": [0, 1],
"value": 0
},
{
"description": "RotationDirection",
"format": "int",
"iid": 10,
"perms": ["pr", "pw", "ev"],
"type": "00000028-0000-1000-8000-0026BB765291",
"valid-values": [0, 1],
"value": 0
},
{
"description": "RotationSpeed",
"format": "float",
"iid": 12,
"maxValue": 100,
"minStep": 1,
"minValue": 0,
"perms": ["pr", "pw", "ev"],
"type": "00000029-0000-1000-8000-0026BB765291",
"unit": "percentage",
"value": 100
}
],
"iid": 8,
"stype": "fanv2",
"type": "000000B7-0000-1000-8000-0026BB765291"
}
]
},
{
"aid": 766313939,
"services": [
{
"characteristics": [
{
"description": "Identify",
"format": "bool",
"iid": 2,
"perms": ["pw"],
"type": "00000014-0000-1000-8000-0026BB765291"
},
{
"description": "Manufacturer",
"format": "string",
"iid": 3,
"perms": ["pr"],
"type": "00000020-0000-1000-8000-0026BB765291",
"value": "Home Assistant"
},
{
"description": "Model",
"format": "string",
"iid": 4,
"perms": ["pr"],
"type": "00000021-0000-1000-8000-0026BB765291",
"value": "Fan"
},
{
"description": "Name",
"format": "string",
"iid": 5,
"perms": ["pr"],
"type": "00000023-0000-1000-8000-0026BB765291",
"value": "Ceiling Fan"
},
{
"description": "SerialNumber",
"format": "string",
"iid": 6,
"perms": ["pr"],
"type": "00000030-0000-1000-8000-0026BB765291",
"value": "fan.ceiling_fan"
},
{
"description": "FirmwareRevision",
"format": "string",
"iid": 7,
"perms": ["pr"],
"type": "00000052-0000-1000-8000-0026BB765291",
"value": "0.104.0.dev0"
}
],
"iid": 1,
"stype": "accessory-information",
"type": "0000003E-0000-1000-8000-0026BB765291"
},
{
"characteristics": [
{
"description": "Active",
"format": "uint8",
"iid": 9,
"perms": ["pr", "pw", "ev"],
"type": "000000B0-0000-1000-8000-0026BB765291",
"valid-values": [0, 1],
"value": 0
},
{
"description": "RotationSpeed",
"format": "float",
"iid": 10,
"maxValue": 100,
"minStep": 1,
"minValue": 0,
"perms": ["pr", "pw", "ev"],
"type": "00000029-0000-1000-8000-0026BB765291",
"unit": "percentage",
"value": 100
}
],
"iid": 8,
"stype": "fanv2",
"type": "000000B7-0000-1000-8000-0026BB765291"
}
]
}
]

View File

@ -5544,6 +5544,292 @@
}),
])
# ---
# name: test_snapshots[home_assistant_bridge_basic_fan]
list([
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'TestData',
]),
'configuration_url': None,
'connections': list([
]),
'disabled_by': None,
'entry_type': None,
'hw_version': '',
'identifiers': list([
list([
'homekit_controller:accessory-id',
'00:00:00:00:00:00:aid:766313939',
]),
]),
'is_new': False,
'manufacturer': 'Home Assistant',
'model': 'Fan',
'name': 'Ceiling Fan',
'name_by_user': None,
'suggested_area': None,
'sw_version': '0.104.0.dev0',
}),
'entities': list([
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': None,
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.ceiling_fan_identify',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Ceiling Fan Identify',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_766313939_1_2',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'friendly_name': 'Ceiling Fan Identify',
}),
'entity_id': 'button.ceiling_fan_identify',
'state': 'unknown',
}),
}),
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': dict({
'preset_modes': None,
}),
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'fan',
'entity_category': None,
'entity_id': 'fan.ceiling_fan',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Ceiling Fan',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': <FanEntityFeature: 1>,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_766313939_8',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'friendly_name': 'Ceiling Fan',
'percentage': 0,
'percentage_step': 1.0,
'preset_mode': None,
'preset_modes': None,
'supported_features': <FanEntityFeature: 1>,
}),
'entity_id': 'fan.ceiling_fan',
'state': 'off',
}),
}),
]),
}),
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'TestData',
]),
'configuration_url': None,
'connections': list([
]),
'disabled_by': None,
'entry_type': None,
'hw_version': '',
'identifiers': list([
list([
'homekit_controller:accessory-id',
'00:00:00:00:00:00:aid:1',
]),
]),
'is_new': False,
'manufacturer': 'Home Assistant',
'model': 'Bridge',
'name': 'Home Assistant Bridge',
'name_by_user': None,
'suggested_area': None,
'sw_version': '0.104.0.dev0',
}),
'entities': list([
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': None,
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.home_assistant_bridge_identify',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Home Assistant Bridge Identify',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_1_1_2',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'friendly_name': 'Home Assistant Bridge Identify',
}),
'entity_id': 'button.home_assistant_bridge_identify',
'state': 'unknown',
}),
}),
]),
}),
dict({
'device': dict({
'area_id': None,
'config_entries': list([
'TestData',
]),
'configuration_url': None,
'connections': list([
]),
'disabled_by': None,
'entry_type': None,
'hw_version': '',
'identifiers': list([
list([
'homekit_controller:accessory-id',
'00:00:00:00:00:00:aid:1256851357',
]),
]),
'is_new': False,
'manufacturer': 'Home Assistant',
'model': 'Fan',
'name': 'Living Room Fan',
'name_by_user': None,
'suggested_area': None,
'sw_version': '0.104.0.dev0',
}),
'entities': list([
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': None,
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.living_room_fan_identify',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Living Room Fan Identify',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_1256851357_1_2',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'friendly_name': 'Living Room Fan Identify',
}),
'entity_id': 'button.living_room_fan_identify',
'state': 'unknown',
}),
}),
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': dict({
'preset_modes': None,
}),
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'fan',
'entity_category': None,
'entity_id': 'fan.living_room_fan',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Living Room Fan',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': <FanEntityFeature: 5>,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_1256851357_8',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'direction': 'forward',
'friendly_name': 'Living Room Fan',
'percentage': 0,
'percentage_step': 1.0,
'preset_mode': None,
'preset_modes': None,
'supported_features': <FanEntityFeature: 5>,
}),
'entity_id': 'fan.living_room_fan',
'state': 'off',
}),
}),
]),
}),
])
# ---
# name: test_snapshots[home_assistant_bridge_fan]
list([
dict({

View File

@ -0,0 +1,55 @@
"""Test for a Home Assistant bridge that changes fan features at runtime."""
from homeassistant.components.fan import FanEntityFeature
from homeassistant.const import ATTR_SUPPORTED_FEATURES
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from ..common import (
device_config_changed,
setup_accessories_from_file,
setup_test_accessories,
)
async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None:
"""Test that new features can be added at runtime."""
entity_registry = er.async_get(hass)
# Set up a basic fan that does not support oscillation
accessories = await setup_accessories_from_file(
hass, "home_assistant_bridge_basic_fan.json"
)
await setup_test_accessories(hass, accessories)
fan = entity_registry.async_get("fan.living_room_fan")
assert fan.unique_id == "00:00:00:00:00:00_1256851357_8"
fan_state = hass.states.get("fan.living_room_fan")
assert (
fan_state.attributes[ATTR_SUPPORTED_FEATURES]
is FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION
)
fan = entity_registry.async_get("fan.ceiling_fan")
assert fan.unique_id == "00:00:00:00:00:00_766313939_8"
fan_state = hass.states.get("fan.ceiling_fan")
assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED
# Now change the config to add oscillation
accessories = await setup_accessories_from_file(
hass, "home_assistant_bridge_fan.json"
)
await device_config_changed(hass, accessories)
fan_state = hass.states.get("fan.living_room_fan")
assert (
fan_state.attributes[ATTR_SUPPORTED_FEATURES]
is FanEntityFeature.SET_SPEED
| FanEntityFeature.DIRECTION
| FanEntityFeature.OSCILLATE
)
fan_state = hass.states.get("fan.ceiling_fan")
assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED