Handle removal of accessories/services/chars in homekit_controller (#102179)

This commit is contained in:
J. Nick Koston 2023-10-17 20:49:35 -10:00 committed by GitHub
parent e2e9c84c88
commit c3d1db5db6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1907 additions and 65 deletions

View file

@ -29,7 +29,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from .config_flow import normalize_hkid
from .const import (
@ -43,6 +43,7 @@ from .const import (
IDENTIFIER_LEGACY_SERIAL_NUMBER,
IDENTIFIER_SERIAL_NUMBER,
STARTUP_EXCEPTIONS,
SUBSCRIBE_COOLDOWN,
)
from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry
@ -116,7 +117,7 @@ class HKDevice:
# This just tracks aid/iid pairs so we know if a HK service has been
# mapped to a HA entity.
self.entities: list[tuple[int, int | None, int | None]] = []
self.entities: set[tuple[int, int | None, int | None]] = set()
# A map of aid -> device_id
# Useful when routing events to triggers
@ -124,7 +125,7 @@ class HKDevice:
self.available = False
self.pollable_characteristics: list[tuple[int, int]] = []
self.pollable_characteristics: set[tuple[int, int]] = set()
# Never allow concurrent polling of the same accessory or bridge
self._polling_lock = asyncio.Lock()
@ -134,7 +135,7 @@ class HKDevice:
# This is set to True if we can't rely on serial numbers to be unique
self.unreliable_serial_numbers = False
self.watchable_characteristics: list[tuple[int, int]] = []
self.watchable_characteristics: set[tuple[int, int]] = set()
self._debounced_update = Debouncer(
hass,
@ -147,6 +148,8 @@ 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]] = {}
self._pending_subscribes: set[tuple[int, int]] = set()
self._subscribe_timer: CALLBACK_TYPE | None = None
@property
def entity_map(self) -> Accessories:
@ -162,26 +165,51 @@ class HKDevice:
self, characteristics: list[tuple[int, int]]
) -> None:
"""Add (aid, iid) pairs that we need to poll."""
self.pollable_characteristics.extend(characteristics)
self.pollable_characteristics.update(characteristics)
def remove_pollable_characteristics(self, accessory_id: int) -> None:
def remove_pollable_characteristics(
self, characteristics: list[tuple[int, int]]
) -> None:
"""Remove all pollable characteristics by accessory id."""
self.pollable_characteristics = [
char for char in self.pollable_characteristics if char[0] != accessory_id
]
for aid_iid in characteristics:
self.pollable_characteristics.discard(aid_iid)
async def add_watchable_characteristics(
def add_watchable_characteristics(
self, characteristics: list[tuple[int, int]]
) -> None:
"""Add (aid, iid) pairs that we need to poll."""
self.watchable_characteristics.extend(characteristics)
await self.pairing.subscribe(characteristics)
self.watchable_characteristics.update(characteristics)
self._pending_subscribes.update(characteristics)
# Try to subscribe to the characteristics all at once
if not self._subscribe_timer:
self._subscribe_timer = async_call_later(
self.hass,
SUBSCRIBE_COOLDOWN,
self._async_subscribe,
)
def remove_watchable_characteristics(self, accessory_id: int) -> None:
@callback
def _async_cancel_subscription_timer(self) -> None:
"""Cancel the subscribe timer."""
if self._subscribe_timer:
self._subscribe_timer()
self._subscribe_timer = None
async def _async_subscribe(self, _now: datetime) -> None:
"""Subscribe to characteristics."""
self._subscribe_timer = None
if self._pending_subscribes:
subscribes = self._pending_subscribes.copy()
self._pending_subscribes.clear()
await self.pairing.subscribe(subscribes)
def remove_watchable_characteristics(
self, characteristics: list[tuple[int, int]]
) -> None:
"""Remove all pollable characteristics by accessory id."""
self.watchable_characteristics = [
char for char in self.watchable_characteristics if char[0] != accessory_id
]
for aid_iid in characteristics:
self.watchable_characteristics.discard(aid_iid)
self._pending_subscribes.discard(aid_iid)
@callback
def async_set_available_state(self, available: bool) -> None:
@ -264,6 +292,7 @@ class HKDevice:
entry.async_on_unload(
pairing.dispatcher_availability_changed(self.async_set_available_state)
)
entry.async_on_unload(self._async_cancel_subscription_timer)
await self.async_process_entity_map()
@ -605,8 +634,6 @@ class HKDevice:
async def async_update_new_accessories_state(self) -> None:
"""Process a change in the pairings accessories state."""
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()
@ -623,7 +650,7 @@ class HKDevice:
if (accessory.aid, None, None) in self.entities:
continue
if handler(accessory):
self.entities.append((accessory.aid, None, None))
self.entities.add((accessory.aid, None, None))
break
def add_char_factory(self, add_entities_cb: AddCharacteristicCb) -> None:
@ -639,7 +666,7 @@ class HKDevice:
if (accessory.aid, service.iid, char.iid) in self.entities:
continue
if handler(char):
self.entities.append((accessory.aid, service.iid, char.iid))
self.entities.add((accessory.aid, service.iid, char.iid))
break
def add_listener(self, add_entities_cb: AddServiceCb) -> None:
@ -687,7 +714,7 @@ class HKDevice:
for listener in callbacks:
if listener(service):
self.entities.append((aid, None, iid))
self.entities.add((aid, None, iid))
break
async def async_load_platform(self, platform: str) -> None:
@ -811,7 +838,7 @@ class HKDevice:
@callback
def _remove_characteristics_callback(
self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE
self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE
) -> None:
"""Remove a characteristics callback."""
for aid_iid in characteristics:
@ -821,7 +848,7 @@ class HKDevice:
@callback
def async_subscribe(
self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE
self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE
) -> CALLBACK_TYPE:
"""Add characteristics to the watch list."""
for aid_iid in characteristics:

View file

@ -120,3 +120,5 @@ STARTUP_EXCEPTIONS = (
# also happens to be the same value used by
# the update coordinator.
DEBOUNCE_COOLDOWN = 10 # seconds
SUBSCRIBE_COOLDOWN = 0.25 # seconds

View file

@ -64,7 +64,8 @@ class TriggerSource:
self._callbacks: dict[tuple[str, str], list[Callable[[Any], None]]] = {}
self._iid_trigger_keys: dict[int, set[tuple[str, str]]] = {}
async def async_setup(
@callback
def async_setup(
self, connection: HKDevice, aid: int, triggers: list[dict[str, Any]]
) -> None:
"""Set up a set of triggers for a device.
@ -78,7 +79,7 @@ class TriggerSource:
self._triggers[trigger_key] = trigger_data
iid = trigger_data["characteristic"]
self._iid_trigger_keys.setdefault(iid, set()).add(trigger_key)
await connection.add_watchable_characteristics([(aid, iid)])
connection.add_watchable_characteristics([(aid, iid)])
def fire(self, iid: int, ev: dict[str, Any]) -> None:
"""Process events that have been received from a HomeKit accessory."""
@ -237,7 +238,7 @@ async def async_setup_triggers_for_entry(
return False
trigger = async_get_or_create_trigger_source(conn.hass, device_id)
hass.async_create_task(trigger.async_setup(conn, aid, triggers))
trigger.async_setup(conn, aid, triggers)
return True

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any
from aiohomekit.model import Service, Services
from aiohomekit.model.characteristics import (
EVENT_CHARACTERISTICS,
Characteristic,
@ -11,7 +12,7 @@ from aiohomekit.model.characteristics import (
)
from aiohomekit.model.services import ServicesTypes
from homeassistant.core import callback
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
@ -20,10 +21,21 @@ from .connection import HKDevice, valid_serial_number
from .utils import folded_name
def _get_service_by_iid_or_none(services: Services, iid: int) -> Service | None:
"""Return a service by iid or None."""
try:
return services.iid(iid)
except KeyError:
return None
class HomeKitEntity(Entity):
"""Representation of a Home Assistant HomeKit device."""
_attr_should_poll = False
pollable_characteristics: list[tuple[int, int]]
watchable_characteristics: list[tuple[int, int]]
all_characteristics: set[tuple[int, int]]
def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None:
"""Initialise a generic HomeKit device."""
@ -31,49 +43,77 @@ class HomeKitEntity(Entity):
self._aid = devinfo["aid"]
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()
self._char_subscription: CALLBACK_TYPE | None = None
self.async_setup()
super().__init__()
@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
)
def _async_handle_entity_removed(self) -> None:
"""Handle entity removal."""
# We call _async_unsubscribe_chars as soon as we
# know the entity is about to be removed so we do not try to
# update characteristics that no longer exist. It will get
# called in async_will_remove_from_hass as well, but that is
# too late.
self._async_unsubscribe_chars()
self.hass.async_create_task(self.async_remove(force_remove=True))
@callback
def _async_remove_entity_if_accessory_or_service_disappeared(self) -> bool:
"""Handle accessory or service disappearance."""
entity_map = self._accessory.entity_map
if not entity_map.has_aid(self._aid) or not _get_service_by_iid_or_none(
entity_map.aid(self._aid).services, self._iid
):
self._async_handle_entity_removed()
return True
return False
@callback
def _async_config_changed(self) -> None:
"""Handle accessory discovery changes."""
self._async_set_accessory_and_service()
if not self._async_remove_entity_if_accessory_or_service_disappeared():
self._async_reconfigure()
@callback
def _async_reconfigure(self) -> None:
"""Reconfigure the entity."""
self._async_unsubscribe_chars()
self.async_setup()
self._async_subscribe_chars()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Entity added to hass."""
accessory = self._accessory
self._async_subscribe_chars()
self.async_on_remove(
accessory.async_subscribe(
self.all_characteristics, self._async_write_ha_state
)
self._accessory.async_subscribe_config_changed(self._async_config_changed)
)
self.async_on_remove(
accessory.async_subscribe_availability(self._async_write_ha_state)
self._accessory.async_subscribe_availability(self._async_write_ha_state)
)
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."""
self._accessory.remove_pollable_characteristics(self._aid)
self._accessory.remove_watchable_characteristics(self._aid)
self._async_unsubscribe_chars()
@callback
def _async_unsubscribe_chars(self):
"""Handle unsubscribing from characteristics."""
if self._char_subscription:
self._char_subscription()
self._char_subscription = None
self._accessory.remove_pollable_characteristics(self.pollable_characteristics)
self._accessory.remove_watchable_characteristics(self.watchable_characteristics)
@callback
def _async_subscribe_chars(self):
"""Handle registering characteristics to watch and subscribe."""
self._accessory.add_pollable_characteristics(self.pollable_characteristics)
self._accessory.add_watchable_characteristics(self.watchable_characteristics)
self._char_subscription = self._accessory.async_subscribe(
self.all_characteristics, self._async_write_ha_state
)
async def async_put_characteristics(self, characteristics: dict[str, Any]) -> None:
"""Write characteristics to the device.
@ -92,10 +132,22 @@ class HomeKitEntity(Entity):
payload = self.service.build_update(characteristics)
return await self._accessory.put_characteristics(payload)
def setup(self) -> None:
@callback
def async_setup(self) -> None:
"""Configure an entity based on its HomeKit characteristics metadata."""
self.pollable_characteristics: list[tuple[int, int]] = []
self.watchable_characteristics: list[tuple[int, int]] = []
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
)
# If we re-setup, we need to make sure we make new
# lists since we passed them to the connection before
# and we do not want to inadvertently modify the old
# ones.
self.pollable_characteristics = []
self.watchable_characteristics = []
self.all_characteristics = set()
char_types = self.get_characteristic_types()
@ -203,7 +255,7 @@ class AccessoryEntity(HomeKitEntity):
return f"{self._accessory.unique_id}_{self._aid}"
class CharacteristicEntity(HomeKitEntity):
class BaseCharacteristicEntity(HomeKitEntity):
"""A HomeKit entity that is related to an single characteristic rather than a whole service.
This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with
@ -217,6 +269,35 @@ class CharacteristicEntity(HomeKitEntity):
self._char = char
super().__init__(accessory, devinfo)
@callback
def _async_remove_entity_if_characteristics_disappeared(self) -> bool:
"""Handle characteristic disappearance."""
if (
not self._accessory.entity_map.aid(self._aid)
.services.iid(self._iid)
.get_char_by_iid(self._char.iid)
):
self._async_handle_entity_removed()
return True
return False
@callback
def _async_config_changed(self) -> None:
"""Handle accessory discovery changes."""
if (
not self._async_remove_entity_if_accessory_or_service_disappeared()
and not self._async_remove_entity_if_characteristics_disappeared()
):
super()._async_reconfigure()
class CharacteristicEntity(BaseCharacteristicEntity):
"""A HomeKit entity that is related to an single characteristic rather than a whole service.
This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with
the service entity.
"""
@property
def old_unique_id(self) -> str:
"""Return the old ID of this device."""

View file

@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import KNOWN_DEVICES
from .connection import HKDevice
from .entity import HomeKitEntity
from .entity import BaseCharacteristicEntity
INPUT_EVENT_VALUES = {
InputEventValues.SINGLE_PRESS: "single_press",
@ -26,7 +26,7 @@ INPUT_EVENT_VALUES = {
}
class HomeKitEventEntity(HomeKitEntity, EventEntity):
class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity):
"""Representation of a Homekit event entity."""
_attr_should_poll = False
@ -44,10 +44,8 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity):
"aid": service.accessory.aid,
"iid": service.iid,
},
service.characteristics_by_type[CharacteristicsTypes.INPUT_EVENT],
)
self._characteristic = service.characteristics_by_type[
CharacteristicsTypes.INPUT_EVENT
]
self.entity_description = entity_description
@ -55,7 +53,7 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity):
# clamp InputEventValues for this exact device
self._attr_event_types = [
INPUT_EVENT_VALUES[v]
for v in clamp_enum_to_char(InputEventValues, self._characteristic)
for v in clamp_enum_to_char(InputEventValues, self._char)
]
def get_characteristic_types(self) -> list[str]:
@ -68,19 +66,19 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity):
self.async_on_remove(
self._accessory.async_subscribe(
[(self._aid, self._characteristic.iid)],
{(self._aid, self._char.iid)},
self._handle_event,
)
)
@callback
def _handle_event(self):
if self._characteristic.value is None:
if self._char.value is None:
# For IP backed devices the characteristic is marked as
# pollable, but always returns None when polled
# Make sure we don't explode if we see that edge case.
return
self._trigger_event(INPUT_EVENT_VALUES[self._characteristic.value])
self._trigger_event(INPUT_EVENT_VALUES[self._char.value])
self.async_write_ha_state()

View file

@ -26,6 +26,7 @@ from homeassistant.components.homekit_controller.const import (
DOMAIN,
HOMEKIT_ACCESSORY_DISPATCH,
IDENTIFIER_ACCESSORY_ID,
SUBSCRIBE_COOLDOWN,
)
from homeassistant.components.homekit_controller.utils import async_get_controller
from homeassistant.config_entries import ConfigEntry
@ -238,6 +239,7 @@ async def setup_test_accessories_with_controller(
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await time_changed(hass, SUBSCRIBE_COOLDOWN)
await hass.async_block_till_done()
return config_entry, pairing

View file

@ -0,0 +1,561 @@
[
{
"aid": 1,
"services": [
{
"type": "3E",
"characteristics": [
{
"value": "HomeW",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 2
},
{
"value": "ecobee Inc.",
"perms": ["pr"],
"type": "20",
"format": "string",
"iid": 3
},
{
"value": "123456789012",
"perms": ["pr"],
"type": "30",
"format": "string",
"iid": 4
},
{
"value": "ecobee3",
"perms": ["pr"],
"type": "21",
"format": "string",
"iid": 5
},
{
"perms": ["pw"],
"type": "14",
"format": "bool",
"iid": 6
},
{
"value": "4.2.394",
"perms": ["pr"],
"type": "52",
"format": "string",
"iid": 8
},
{
"value": 0,
"perms": ["pr", "ev"],
"type": "A6",
"format": "uint32",
"iid": 9
}
],
"iid": 1
},
{
"type": "A2",
"characteristics": [
{
"value": "1.1.0",
"perms": ["pr"],
"maxLen": 64,
"type": "37",
"format": "string",
"iid": 31
}
],
"iid": 30
},
{
"primary": true,
"type": "4A",
"characteristics": [
{
"value": 1,
"maxValue": 2,
"minStep": 1,
"perms": ["pr", "ev"],
"type": "F",
"minValue": 0,
"format": "uint8",
"iid": 17
},
{
"value": 1,
"maxValue": 3,
"minStep": 1,
"perms": ["pr", "pw", "ev"],
"type": "33",
"minValue": 0,
"format": "uint8",
"iid": 18
},
{
"value": 21.8,
"maxValue": 100,
"minStep": 0.1,
"perms": ["pr", "ev"],
"unit": "celsius",
"type": "11",
"minValue": 0,
"format": "float",
"iid": 19
},
{
"value": 22.2,
"maxValue": 33.3,
"minStep": 0.1,
"perms": ["pr", "pw", "ev"],
"unit": "celsius",
"type": "35",
"minValue": 7.2,
"format": "float",
"iid": 20
},
{
"value": 1,
"maxValue": 1,
"minStep": 1,
"perms": ["pr", "pw", "ev"],
"type": "36",
"minValue": 0,
"format": "uint8",
"iid": 21
},
{
"value": 24.4,
"maxValue": 33.3,
"minStep": 0.1,
"perms": ["pr", "pw", "ev"],
"unit": "celsius",
"type": "D",
"minValue": 18.3,
"format": "float",
"iid": 22
},
{
"value": 22.2,
"maxValue": 26.1,
"minStep": 0.1,
"perms": ["pr", "pw", "ev"],
"unit": "celsius",
"type": "12",
"minValue": 7.2,
"format": "float",
"iid": 23
},
{
"value": 34,
"maxValue": 100,
"minStep": 1,
"perms": ["pr", "ev"],
"unit": "percentage",
"type": "10",
"minValue": 0,
"format": "float",
"iid": 24
},
{
"value": 36,
"maxValue": 50,
"minStep": 1,
"perms": ["pr", "pw", "ev"],
"unit": "percentage",
"type": "34",
"minValue": 20,
"format": "float",
"iid": 25
},
{
"value": "HomeW",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 27
}
],
"iid": 16
}
]
},
{
"aid": 2,
"services": [
{
"type": "3E",
"characteristics": [
{
"value": "Kitchen",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 2049
},
{
"value": "ecobee Inc.",
"perms": ["pr"],
"type": "20",
"format": "string",
"iid": 2050
},
{
"value": "AB1C",
"perms": ["pr"],
"type": "30",
"format": "string",
"iid": 2051
},
{
"value": "REMOTE SENSOR",
"perms": ["pr"],
"type": "21",
"format": "string",
"iid": 2052
},
{
"value": "1.0.0",
"perms": ["pr"],
"type": "52",
"format": "string",
"iid": 8
},
{
"perms": ["pw"],
"type": "14",
"format": "bool",
"iid": 2053
}
],
"iid": 1
},
{
"type": "8A",
"characteristics": [
{
"value": 21.5,
"maxValue": 100,
"minStep": 0.1,
"perms": ["pr", "ev"],
"unit": "celsius",
"type": "11",
"minValue": 0,
"format": "float",
"iid": 2064
},
{
"value": "Kitchen",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 2067
},
{
"value": true,
"perms": ["pr", "ev"],
"type": "75",
"format": "bool",
"iid": 2066
},
{
"value": 0,
"maxValue": 1,
"minStep": 1,
"perms": ["pr", "ev"],
"type": "79",
"minValue": 0,
"format": "uint8",
"iid": 2065
}
],
"iid": 55
},
{
"type": "85",
"characteristics": [
{
"value": false,
"perms": ["pr", "ev"],
"type": "22",
"format": "bool",
"iid": 2060
},
{
"value": "Kitchen",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 2063
},
{
"value": true,
"perms": ["pr", "ev"],
"type": "75",
"format": "bool",
"iid": 2062
},
{
"minValue": 0,
"maxValue": 1,
"minStep": 1,
"perms": ["pr", "ev"],
"type": "79",
"value": 0,
"format": "uint8",
"iid": 2061
},
{
"minValue": -1,
"maxValue": 86400,
"perms": ["pr", "ev"],
"type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66",
"value": 3620,
"format": "int",
"iid": 2059
}
],
"iid": 56
}
]
},
{
"aid": 3,
"services": [
{
"type": "3E",
"characteristics": [
{
"value": "Porch",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 3073
},
{
"value": "ecobee Inc.",
"perms": ["pr"],
"type": "20",
"format": "string",
"iid": 3074
},
{
"value": "AB2C",
"perms": ["pr"],
"type": "30",
"format": "string",
"iid": 3075
},
{
"value": "REMOTE SENSOR",
"perms": ["pr"],
"type": "21",
"format": "string",
"iid": 3076
},
{
"value": "1.0.0",
"perms": ["pr"],
"type": "52",
"format": "string",
"iid": 8
},
{
"perms": ["pw"],
"type": "14",
"format": "bool",
"iid": 3077
}
],
"iid": 1
},
{
"type": "8A",
"characteristics": [
{
"value": 21,
"maxValue": 100,
"minStep": 0.1,
"perms": ["pr", "ev"],
"unit": "celsius",
"type": "11",
"minValue": 0,
"format": "float",
"iid": 3088
},
{
"value": "Porch",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 3091
},
{
"value": true,
"perms": ["pr", "ev"],
"type": "75",
"format": "bool",
"iid": 3090
},
{
"value": 0,
"maxValue": 1,
"minStep": 1,
"perms": ["pr", "ev"],
"type": "79",
"minValue": 0,
"format": "uint8",
"iid": 3089
}
],
"iid": 55
},
{
"type": "85",
"characteristics": [
{
"value": false,
"perms": ["pr", "ev"],
"type": "22",
"format": "bool",
"iid": 3084
},
{
"value": "Porch",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 3087
},
{
"value": true,
"perms": ["pr", "ev"],
"type": "75",
"format": "bool",
"iid": 3086
},
{
"minValue": 0,
"maxValue": 1,
"minStep": 1,
"perms": ["pr", "ev"],
"type": "79",
"value": 0,
"format": "uint8",
"iid": 3085
},
{
"minValue": -1,
"maxValue": 86400,
"perms": ["pr", "ev"],
"type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66",
"value": 5766,
"format": "int",
"iid": 3083
}
],
"iid": 56
}
]
},
{
"aid": 4,
"services": [
{
"type": "3E",
"characteristics": [
{
"value": "Basement",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 4097
},
{
"value": "ecobee Inc.",
"perms": ["pr"],
"type": "20",
"format": "string",
"iid": 4098
},
{
"value": "AB3C",
"perms": ["pr"],
"type": "30",
"format": "string",
"iid": 4099
},
{
"value": "REMOTE SENSOR",
"perms": ["pr"],
"type": "21",
"format": "string",
"iid": 4100
},
{
"value": "1.0.0",
"perms": ["pr"],
"type": "52",
"format": "string",
"iid": 8
},
{
"perms": ["pw"],
"type": "14",
"format": "bool",
"iid": 4101
}
],
"iid": 1
},
{
"type": "85",
"characteristics": [
{
"value": false,
"perms": ["pr", "ev"],
"type": "22",
"format": "bool",
"iid": 4108
},
{
"value": "Basement",
"perms": ["pr"],
"type": "23",
"format": "string",
"iid": 4111
},
{
"value": true,
"perms": ["pr", "ev"],
"type": "75",
"format": "bool",
"iid": 4110
},
{
"minValue": 0,
"maxValue": 1,
"minStep": 1,
"perms": ["pr", "ev"],
"type": "79",
"value": 0,
"format": "uint8",
"iid": 4109
},
{
"minValue": -1,
"maxValue": 86400,
"perms": ["pr", "ev"],
"type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66",
"value": 5472,
"format": "int",
"iid": 4107
}
],
"iid": 56
}
]
}
]

View file

@ -0,0 +1,166 @@
[
{
"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": "SwingMode",
"format": "uint8",
"iid": 11,
"perms": ["pr", "pw", "ev"],
"type": "000000B6-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"
}
]
}
]

View file

@ -3935,6 +3935,656 @@
}),
])
# ---
# name: test_snapshots[ecobee3_service_removed]
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:4',
]),
]),
'is_new': False,
'manufacturer': 'ecobee Inc.',
'model': 'REMOTE SENSOR',
'name': 'Basement',
'name_by_user': None,
'suggested_area': None,
'sw_version': '1.0.0',
}),
'entities': list([
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': None,
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.basement',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.MOTION: 'motion'>,
'original_icon': None,
'original_name': 'Basement',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_4_56',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'device_class': 'motion',
'friendly_name': 'Basement',
}),
'entity_id': 'binary_sensor.basement',
'state': 'off',
}),
}),
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.basement_identify',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Basement Identify',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_4_1_4101',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'friendly_name': 'Basement Identify',
}),
'entity_id': 'button.basement_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:1',
]),
]),
'is_new': False,
'manufacturer': 'ecobee Inc.',
'model': 'ecobee3',
'name': 'HomeW',
'name_by_user': None,
'suggested_area': None,
'sw_version': '4.2.394',
}),
'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.homew_identify',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'HomeW Identify',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_1_1_6',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'friendly_name': 'HomeW Identify',
}),
'entity_id': 'button.homew_identify',
'state': 'unknown',
}),
}),
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_humidity': 50,
'max_temp': 33.3,
'min_humidity': 20,
'min_temp': 7.2,
}),
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.homew',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'HomeW',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 7>,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_1_16',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'current_humidity': 34,
'current_temperature': 21.8,
'friendly_name': 'HomeW',
'humidity': 36,
'hvac_action': <HVACAction.HEATING: 'heating'>,
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_humidity': 50,
'max_temp': 33.3,
'min_humidity': 20,
'min_temp': 7.2,
'supported_features': <ClimateEntityFeature: 7>,
'target_temp_high': None,
'target_temp_low': None,
'temperature': 22.2,
}),
'entity_id': 'climate.homew',
'state': 'heat',
}),
}),
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': dict({
'options': list([
'celsius',
'fahrenheit',
]),
}),
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.homew_temperature_display_units',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:thermometer',
'original_name': 'HomeW Temperature Display Units',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'temperature_display_units',
'unique_id': '00:00:00:00:00:00_1_16_21',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'friendly_name': 'HomeW Temperature Display Units',
'icon': 'mdi:thermometer',
'options': list([
'celsius',
'fahrenheit',
]),
}),
'entity_id': 'select.homew_temperature_display_units',
'state': 'fahrenheit',
}),
}),
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.homew_current_humidity',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'HomeW Current Humidity',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_1_16_24',
'unit_of_measurement': '%',
}),
'state': dict({
'attributes': dict({
'device_class': 'humidity',
'friendly_name': 'HomeW Current Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'entity_id': 'sensor.homew_current_humidity',
'state': '34',
}),
}),
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.homew_current_temperature',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'HomeW Current Temperature',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_1_16_19',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'state': dict({
'attributes': dict({
'device_class': 'temperature',
'friendly_name': 'HomeW Current Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'entity_id': 'sensor.homew_current_temperature',
'state': '21.8',
}),
}),
]),
}),
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:2',
]),
]),
'is_new': False,
'manufacturer': 'ecobee Inc.',
'model': 'REMOTE SENSOR',
'name': 'Kitchen',
'name_by_user': None,
'suggested_area': None,
'sw_version': '1.0.0',
}),
'entities': list([
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': None,
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.kitchen',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.MOTION: 'motion'>,
'original_icon': None,
'original_name': 'Kitchen',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_2_56',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'device_class': 'motion',
'friendly_name': 'Kitchen',
}),
'entity_id': 'binary_sensor.kitchen',
'state': 'off',
}),
}),
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.kitchen_identify',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Kitchen Identify',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_2_1_2053',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'friendly_name': 'Kitchen Identify',
}),
'entity_id': 'button.kitchen_identify',
'state': 'unknown',
}),
}),
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.kitchen_temperature',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Kitchen Temperature',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_2_55',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'state': dict({
'attributes': dict({
'device_class': 'temperature',
'friendly_name': 'Kitchen Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'entity_id': 'sensor.kitchen_temperature',
'state': '21.5',
}),
}),
]),
}),
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:3',
]),
]),
'is_new': False,
'manufacturer': 'ecobee Inc.',
'model': 'REMOTE SENSOR',
'name': 'Porch',
'name_by_user': None,
'suggested_area': None,
'sw_version': '1.0.0',
}),
'entities': list([
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': None,
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.porch',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.MOTION: 'motion'>,
'original_icon': None,
'original_name': 'Porch',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_3_56',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'device_class': 'motion',
'friendly_name': 'Porch',
}),
'entity_id': 'binary_sensor.porch',
'state': 'off',
}),
}),
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.porch_identify',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Porch Identify',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_3_1_3077',
'unit_of_measurement': None,
}),
'state': dict({
'attributes': dict({
'friendly_name': 'Porch Identify',
}),
'entity_id': 'button.porch_identify',
'state': 'unknown',
}),
}),
dict({
'entry': dict({
'aliases': list([
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': 'TestData',
'device_class': None,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.porch_temperature',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Porch Temperature',
'platform': 'homekit_controller',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00:00:00:00:00:00_3_55',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'state': dict({
'attributes': dict({
'device_class': 'temperature',
'friendly_name': 'Porch Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'entity_id': 'sensor.porch_temperature',
'state': '21',
}),
}),
]),
}),
])
# ---
# name: test_snapshots[ecobee_501]
list([
dict({
@ -6117,6 +6767,185 @@
}),
])
# ---
# name: test_snapshots[home_assistant_bridge_fan_one_removed]
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: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: 7>,
'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',
'oscillating': False,
'percentage': 0,
'percentage_step': 1.0,
'preset_mode': None,
'preset_modes': None,
'supported_features': <FanEntityFeature: 7>,
}),
'entity_id': 'fan.living_room_fan',
'state': 'off',
}),
}),
]),
}),
])
# ---
# name: test_snapshots[homespan_daikin_bridge]
list([
dict({

View file

@ -252,3 +252,91 @@ async def test_ecobee3_add_sensors_at_runtime(hass: HomeAssistant) -> None:
occ3 = entity_registry.async_get("binary_sensor.basement")
assert occ3.unique_id == "00:00:00:00:00:00_4_56"
async def test_ecobee3_remove_sensors_at_runtime(hass: HomeAssistant) -> None:
"""Test that sensors are automatically removed."""
entity_registry = er.async_get(hass)
# Set up a base Ecobee 3 with additional sensors.
accessories = await setup_accessories_from_file(hass, "ecobee3.json")
await setup_test_accessories(hass, accessories)
climate = entity_registry.async_get("climate.homew")
assert climate.unique_id == "00:00:00:00:00:00_1_16"
occ1 = entity_registry.async_get("binary_sensor.kitchen")
assert occ1.unique_id == "00:00:00:00:00:00_2_56"
occ2 = entity_registry.async_get("binary_sensor.porch")
assert occ2.unique_id == "00:00:00:00:00:00_3_56"
occ3 = entity_registry.async_get("binary_sensor.basement")
assert occ3.unique_id == "00:00:00:00:00:00_4_56"
assert hass.states.get("binary_sensor.kitchen") is not None
assert hass.states.get("binary_sensor.porch") is not None
assert hass.states.get("binary_sensor.basement") is not None
# Now remove 3 new sensors at runtime - sensors should disappear and climate
# shouldn't be duplicated.
accessories = await setup_accessories_from_file(hass, "ecobee3_no_sensors.json")
await device_config_changed(hass, accessories)
assert hass.states.get("binary_sensor.kitchen") is None
assert hass.states.get("binary_sensor.porch") is None
assert hass.states.get("binary_sensor.basement") is None
# Now add the sensors back
accessories = await setup_accessories_from_file(hass, "ecobee3.json")
await device_config_changed(hass, accessories)
occ1 = entity_registry.async_get("binary_sensor.kitchen")
assert occ1.unique_id == "00:00:00:00:00:00_2_56"
occ2 = entity_registry.async_get("binary_sensor.porch")
assert occ2.unique_id == "00:00:00:00:00:00_3_56"
occ3 = entity_registry.async_get("binary_sensor.basement")
assert occ3.unique_id == "00:00:00:00:00:00_4_56"
# Currently it is not possible to add the entities back once
# they are removed because _add_new_entities has a guard to prevent
# the same entity from being added twice.
async def test_ecobee3_services_and_chars_removed(
hass: HomeAssistant,
) -> None:
"""Test handling removal of some services and chars."""
entity_registry = er.async_get(hass)
# Set up a base Ecobee 3 with additional sensors.
accessories = await setup_accessories_from_file(hass, "ecobee3.json")
await setup_test_accessories(hass, accessories)
climate = entity_registry.async_get("climate.homew")
assert climate.unique_id == "00:00:00:00:00:00_1_16"
assert hass.states.get("sensor.basement_temperature") is not None
assert hass.states.get("sensor.kitchen_temperature") is not None
assert hass.states.get("sensor.porch_temperature") is not None
assert hass.states.get("select.homew_current_mode") is not None
assert hass.states.get("button.homew_clear_hold") is not None
# Reconfigure with some of the chars removed and the basement temperature sensor
accessories = await setup_accessories_from_file(
hass, "ecobee3_service_removed.json"
)
await device_config_changed(hass, accessories)
# Make sure the climate entity is still there
assert hass.states.get("climate.homew") is not None
# Make sure the basement temperature sensor is gone
assert hass.states.get("sensor.basement_temperature") is None
# Make sure the current mode select and clear hold button are gone
assert hass.states.get("select.homew_current_mode") is None
assert hass.states.get("button.homew_clear_hold") is None

View file

@ -53,3 +53,90 @@ async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None:
)
fan_state = hass.states.get("fan.ceiling_fan")
assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED
async def test_fan_remove_feature_at_runtime(hass: HomeAssistant) -> None:
"""Test that features can be removed 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_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
| FanEntityFeature.OSCILLATE
)
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_basic_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
)
fan_state = hass.states.get("fan.ceiling_fan")
assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED
async def test_bridge_with_two_fans_one_removed(hass: HomeAssistant) -> None:
"""Test a bridge with two fans and one gets removed."""
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_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
| FanEntityFeature.OSCILLATE
)
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 remove one of the fans
accessories = await setup_accessories_from_file(
hass, "home_assistant_bridge_fan_one_removed.json"
)
await device_config_changed(hass, accessories)
# Verify the first fan is still there
fan_state = hass.states.get("fan.living_room_fan")
assert (
fan_state.attributes[ATTR_SUPPORTED_FEATURES]
is FanEntityFeature.SET_SPEED
| FanEntityFeature.DIRECTION
| FanEntityFeature.OSCILLATE
)
# The second fan should have been removed
assert not hass.states.get("fan.ceiling_fan")