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

View File

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

View File

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