Add support for restoring HomeKit IIDs (#79913)

This commit is contained in:
J. Nick Koston 2022-10-14 09:58:09 -10:00 committed by GitHub
parent 20f1f8aabb
commit 3b33e0d832
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 527 additions and 103 deletions

View File

@ -74,7 +74,13 @@ from . import ( # noqa: F401
type_switches,
type_thermostats,
)
from .accessories import HomeAccessory, HomeBridge, HomeDriver, get_accessory
from .accessories import (
HomeAccessory,
HomeBridge,
HomeDriver,
HomeIIDManager,
get_accessory,
)
from .aidmanager import AccessoryAidStorage
from .const import (
ATTR_INTEGRATION,
@ -107,6 +113,7 @@ from .const import (
SERVICE_HOMEKIT_UNPAIR,
SHUTDOWN_TIMEOUT,
)
from .iidmanager import AccessoryIIDStorage
from .type_triggers import DeviceTriggerAccessory
from .util import (
accessory_friendly_name,
@ -489,8 +496,6 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None:
class HomeKit:
"""Class to handle all actions between HomeKit and Home Assistant."""
driver: HomeDriver
def __init__(
self,
hass: HomeAssistant,
@ -520,12 +525,14 @@ class HomeKit:
self._homekit_mode = homekit_mode
self._devices = devices or []
self.aid_storage: AccessoryAidStorage | None = None
self.iid_storage: AccessoryIIDStorage | None = None
self.status = STATUS_READY
self.driver: HomeDriver | None = None
self.bridge: HomeBridge | None = None
def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None:
"""Set up bridge and accessory driver."""
assert self.iid_storage is not None
persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id)
self.driver = HomeDriver(
@ -541,6 +548,7 @@ class HomeKit:
async_zeroconf_instance=async_zeroconf_instance,
zeroconf_server=f"{uuid}-hap.local.",
loader=get_loader(),
iid_manager=HomeIIDManager(self.iid_storage),
)
# If we do not load the mac address will be wrong
@ -555,14 +563,27 @@ class HomeKit:
return
await self.async_reset_accessories_in_bridge_mode(entity_ids)
async def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None:
"""Shutdown an accessory."""
assert self.driver is not None
await accessory.stop()
# Deallocate the IIDs for the accessory
iid_manager = self.driver.iid_manager
for service in accessory.services:
iid_manager.remove_iid(iid_manager.remove_obj(service))
for char in service.characteristics:
iid_manager.remove_iid(iid_manager.remove_obj(char))
async def async_reset_accessories_in_accessory_mode(
self, entity_ids: Iterable[str]
) -> None:
"""Reset accessories in accessory mode."""
assert self.driver is not None
acc = cast(HomeAccessory, self.driver.accessory)
await self._async_shutdown_accessory(acc)
if acc.entity_id not in entity_ids:
return
await acc.stop()
if not (state := self.hass.states.get(acc.entity_id)):
_LOGGER.warning(
"The underlying entity %s disappeared during reset", acc.entity_id
@ -579,6 +600,8 @@ class HomeKit:
"""Reset accessories in bridge mode."""
assert self.aid_storage is not None
assert self.bridge is not None
assert self.driver is not None
new = []
acc: HomeAccessory | None
for entity_id in entity_ids:
@ -590,9 +613,10 @@ class HomeKit:
self._name,
entity_id,
)
if (acc := await self.async_remove_bridge_accessory(aid)) and (
state := self.hass.states.get(acc.entity_id)
):
acc = await self.async_remove_bridge_accessory(aid)
if acc:
await self._async_shutdown_accessory(acc)
if acc and (state := self.hass.states.get(acc.entity_id)):
new.append(state)
else:
_LOGGER.warning(
@ -612,10 +636,13 @@ class HomeKit:
async def async_config_changed(self) -> None:
"""Call config changed which writes out the new config to disk."""
assert self.driver is not None
await self.hass.async_add_executor_job(self.driver.config_changed)
def add_bridge_accessory(self, state: State) -> HomeAccessory | None:
"""Try adding accessory to bridge if configured beforehand."""
assert self.driver is not None
if self._would_exceed_max_devices(state.entity_id):
return None
@ -694,7 +721,6 @@ class HomeKit:
"""Try adding accessory to bridge if configured beforehand."""
assert self.bridge is not None
if acc := self.bridge.accessories.pop(aid, None):
await acc.stop()
return cast(HomeAccessory, acc)
return None
@ -741,9 +767,14 @@ class HomeKit:
self.status = STATUS_WAIT
async_zc_instance = await zeroconf.async_get_async_instance(self.hass)
uuid = await instance_id.async_get(self.hass)
await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid)
self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id)
self.iid_storage = AccessoryIIDStorage(self.hass, self._entry_id)
# Avoid gather here since it will be I/O bound anyways
await self.aid_storage.async_initialize()
await self.iid_storage.async_initialize()
await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid)
assert self.driver is not None
if not await self._async_create_accessories():
return
self._async_register_bridge()
@ -760,6 +791,8 @@ class HomeKit:
@callback
def _async_show_setup_message(self) -> None:
"""Show the pairing setup message."""
assert self.driver is not None
async_show_setup_message(
self.hass,
self._entry_id,
@ -771,6 +804,8 @@ class HomeKit:
@callback
def async_unpair(self) -> None:
"""Remove all pairings for an accessory so it can be repaired."""
assert self.driver is not None
state = self.driver.state
for client_uuid in list(state.paired_clients):
# We need to check again since removing a single client
@ -842,6 +877,8 @@ class HomeKit:
self, entity_states: list[State]
) -> HomeAccessory | None:
"""Create a single HomeKit accessory (accessory mode)."""
assert self.driver is not None
if not entity_states:
_LOGGER.error(
"HomeKit %s cannot startup: entity not available: %s",
@ -864,6 +901,8 @@ class HomeKit:
self, entity_states: Iterable[State]
) -> HomeAccessory:
"""Create a HomeKit bridge with accessories. (bridge mode)."""
assert self.driver is not None
self.bridge = HomeBridge(self.hass, self.driver, self._name)
for state in entity_states:
self.add_bridge_accessory(state)
@ -892,6 +931,8 @@ class HomeKit:
async def _async_create_accessories(self) -> bool:
"""Create the accessories."""
assert self.driver is not None
entity_states = await self.async_configure_accessories()
if self._homekit_mode == HOMEKIT_MODE_ACCESSORY:
acc = self._async_create_single_accessory(entity_states)
@ -910,7 +951,8 @@ class HomeKit:
return
self.status = STATUS_STOPPED
_LOGGER.debug("Driver stop for %s", self._name)
await self.driver.async_stop()
if self.driver:
await self.driver.async_stop()
@callback
def _async_configure_linked_sensors(

View File

@ -7,7 +7,10 @@ from uuid import UUID
from pyhap.accessory import Accessory, Bridge
from pyhap.accessory_driver import AccessoryDriver
from pyhap.characteristic import Characteristic
from pyhap.const import CATEGORY_OTHER
from pyhap.iid_manager import IIDManager
from pyhap.service import Service
from pyhap.util import callback as pyhap_callback
from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature
@ -83,6 +86,7 @@ from .const import (
TYPE_SWITCH,
TYPE_VALVE,
)
from .iidmanager import AccessoryIIDStorage
from .util import (
accessory_friendly_name,
async_dismiss_setup_message,
@ -266,6 +270,7 @@ class HomeAccessory(Accessory): # type: ignore[misc]
driver=driver,
display_name=cleanup_name_for_homekit(name),
aid=aid,
iid_manager=driver.iid_manager,
*args,
**kwargs,
)
@ -316,8 +321,8 @@ class HomeAccessory(Accessory): # type: ignore[misc]
serv_info.configure_char(
CHAR_HARDWARE_REVISION, value=hw_version[:MAX_VERSION_LENGTH]
)
self.iid_manager.assign(char)
char.broker = self
self.iid_manager.assign(char)
self.category = category
self.entity_id = entity_id
@ -565,7 +570,7 @@ class HomeBridge(Bridge): # type: ignore[misc]
def __init__(self, hass: HomeAssistant, driver: HomeDriver, name: str) -> None:
"""Initialize a Bridge object."""
super().__init__(driver, name)
super().__init__(driver, name, iid_manager=driver.iid_manager)
self.set_info_service(
firmware_revision=format_version(__version__),
manufacturer=MANUFACTURER,
@ -598,6 +603,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
entry_id: str,
bridge_name: str,
entry_title: str,
iid_manager: HomeIIDManager,
**kwargs: Any,
) -> None:
"""Initialize a AccessoryDriver object."""
@ -606,6 +612,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
self._entry_id = entry_id
self._bridge_name = bridge_name
self._entry_title = entry_title
self.iid_manager = iid_manager
@pyhap_callback # type: ignore[misc]
def pair(
@ -632,3 +639,30 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
self.state.pincode,
self.accessory.xhm_uri(),
)
class HomeIIDManager(IIDManager): # type: ignore[misc]
"""IID Manager that remembers IIDs between restarts."""
def __init__(self, iid_storage: AccessoryIIDStorage) -> None:
"""Initialize a IIDManager object."""
super().__init__()
self._iid_storage = iid_storage
def get_iid_for_obj(self, obj: Characteristic | Service) -> int:
"""Get IID for object."""
aid = obj.broker.aid
if isinstance(obj, Characteristic):
service = obj.service
iid = self._iid_storage.get_or_allocate_iid(
aid, service.type_id, service.unique_id, obj.type_id, obj.unique_id
)
else:
iid = self._iid_storage.get_or_allocate_iid(
aid, obj.type_id, obj.unique_id, None, None
)
if iid in self.objs:
raise RuntimeError(
f"Cannot assign IID {iid} to {obj} as it is already in use by: {self.objs[iid]}"
)
return iid

View File

@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics(
"options": dict(entry.options),
},
}
if not hasattr(homekit, "driver"):
if not homekit.driver: # not started yet or startup failed
return data
driver: AccessoryDriver = homekit.driver
data.update(driver.get_accessories())

View File

@ -0,0 +1,96 @@
"""
Manage allocation of instance ID's.
HomeKit needs to allocate unique numbers to each accessory. These need to
be stable between reboots and upgrades.
This module generates and stores them in a HA storage.
"""
from __future__ import annotations
from uuid import UUID
from pyhap.util import uuid_to_hap_type
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from .util import get_iid_storage_filename_for_entry_id
IID_MANAGER_STORAGE_VERSION = 1
IID_MANAGER_SAVE_DELAY = 2
ALLOCATIONS_KEY = "allocations"
IID_MIN = 1
IID_MAX = 18446744073709551615
class AccessoryIIDStorage:
"""
Provide stable allocation of IIDs for the lifetime of an accessory.
Will generate new ID's, ensure they are unique and store them to make sure they
persist over reboots.
"""
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
"""Create a new iid store."""
self.hass = hass
self.allocations: dict[str, int] = {}
self.allocated_iids: list[int] = []
self.entry_id = entry_id
self.store: Store | None = None
async def async_initialize(self) -> None:
"""Load the latest IID data."""
iid_store = get_iid_storage_filename_for_entry_id(self.entry_id)
self.store = Store(self.hass, IID_MANAGER_STORAGE_VERSION, iid_store)
if not (raw_storage := await self.store.async_load()):
# There is no data about iid allocations yet
return
assert isinstance(raw_storage, dict)
self.allocations = raw_storage.get(ALLOCATIONS_KEY, {})
self.allocated_iids = sorted(self.allocations.values())
def get_or_allocate_iid(
self,
aid: int,
service_uuid: UUID,
service_unique_id: str | None,
char_uuid: UUID | None,
char_unique_id: str | None,
) -> int:
"""Generate a stable iid."""
service_hap_type: str = uuid_to_hap_type(service_uuid)
char_hap_type: str | None = uuid_to_hap_type(char_uuid) if char_uuid else None
# Allocation key must be a string since we are saving it to JSON
allocation_key = (
f'{aid}_{service_hap_type}_{service_unique_id or ""}_'
f'{char_hap_type or ""}_{char_unique_id or ""}'
)
if allocation_key in self.allocations:
return self.allocations[allocation_key]
next_iid = self.allocated_iids[-1] + 1 if self.allocated_iids else 1
self.allocations[allocation_key] = next_iid
self.allocated_iids.append(next_iid)
self._async_schedule_save()
return next_iid
@callback
def _async_schedule_save(self) -> None:
"""Schedule saving the iid allocations."""
assert self.store is not None
self.store.async_delay_save(self._data_to_save, IID_MANAGER_SAVE_DELAY)
async def async_save(self) -> None:
"""Save the iid allocations."""
assert self.store is not None
return await self.store.async_save(self._data_to_save())
@callback
def _data_to_save(self) -> dict[str, dict[str, int]]:
"""Return data of entity map to store in a file."""
return {ALLOCATIONS_KEY: self.allocations}

View File

@ -105,7 +105,9 @@ class Fan(HomeAccessory):
)
elif self.preset_modes:
for preset_mode in self.preset_modes:
preset_serv = self.add_preload_service(SERV_SWITCH, CHAR_NAME)
preset_serv = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=preset_mode
)
serv_fan.add_linked_service(preset_serv)
preset_serv.configure_char(
CHAR_NAME,

View File

@ -96,7 +96,9 @@ class MediaPlayer(HomeAccessory):
if FEATURE_ON_OFF in feature_list:
name = self.generate_service_name(FEATURE_ON_OFF)
serv_on_off = self.add_preload_service(SERV_SWITCH, CHAR_NAME)
serv_on_off = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_ON_OFF
)
serv_on_off.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char(
CHAR_ON, value=False, setter_callback=self.set_on_off
@ -104,7 +106,9 @@ class MediaPlayer(HomeAccessory):
if FEATURE_PLAY_PAUSE in feature_list:
name = self.generate_service_name(FEATURE_PLAY_PAUSE)
serv_play_pause = self.add_preload_service(SERV_SWITCH, CHAR_NAME)
serv_play_pause = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_PAUSE
)
serv_play_pause.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char(
CHAR_ON, value=False, setter_callback=self.set_play_pause
@ -112,7 +116,9 @@ class MediaPlayer(HomeAccessory):
if FEATURE_PLAY_STOP in feature_list:
name = self.generate_service_name(FEATURE_PLAY_STOP)
serv_play_stop = self.add_preload_service(SERV_SWITCH, CHAR_NAME)
serv_play_stop = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_STOP
)
serv_play_stop.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char(
CHAR_ON, value=False, setter_callback=self.set_play_stop
@ -120,7 +126,9 @@ class MediaPlayer(HomeAccessory):
if FEATURE_TOGGLE_MUTE in feature_list:
name = self.generate_service_name(FEATURE_TOGGLE_MUTE)
serv_toggle_mute = self.add_preload_service(SERV_SWITCH, CHAR_NAME)
serv_toggle_mute = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_TOGGLE_MUTE
)
serv_toggle_mute.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char(
CHAR_ON, value=False, setter_callback=self.set_toggle_mute

View File

@ -131,7 +131,7 @@ class RemoteInputSelectAccessory(HomeAccessory):
)
for index, source in enumerate(self.sources):
serv_input = self.add_preload_service(
SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME]
SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME], unique_id=source
)
serv_tv.add_linked_service(serv_input)
serv_input.configure_char(CHAR_CONFIGURED_NAME, value=source)

View File

@ -259,7 +259,7 @@ class SelectSwitch(HomeAccessory):
options = state.attributes[ATTR_OPTIONS]
for option in options:
serv_option = self.add_preload_service(
SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE]
SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE], unique_id=option
)
serv_option.configure_char(
CHAR_NAME, value=cleanup_name_for_homekit(option)

View File

@ -42,12 +42,14 @@ class DeviceTriggerAccessory(HomeAccessory):
for idx, trigger in enumerate(device_triggers):
type_ = trigger["type"]
subtype = trigger.get("subtype")
unique_id = f'{type_}-{subtype or ""}'
trigger_name = (
f"{type_.title()} {subtype.title()}" if subtype else type_.title()
)
serv_stateless_switch = self.add_preload_service(
SERV_STATELESS_PROGRAMMABLE_SWITCH,
[CHAR_NAME, CHAR_SERVICE_LABEL_INDEX],
unique_id=unique_id,
)
self.triggers.append(
serv_stateless_switch.configure_char(
@ -60,7 +62,9 @@ class DeviceTriggerAccessory(HomeAccessory):
serv_stateless_switch.configure_char(
CHAR_SERVICE_LABEL_INDEX, value=idx + 1
)
serv_service_label = self.add_preload_service(SERV_SERVICE_LABEL)
serv_service_label = self.add_preload_service(
SERV_SERVICE_LABEL, unique_id=unique_id
)
serv_service_label.configure_char(CHAR_SERVICE_LABEL_NAMESPACE, value=1)
serv_stateless_switch.add_linked_service(serv_service_label)

View File

@ -431,10 +431,15 @@ def get_persist_filename_for_entry_id(entry_id: str) -> str:
def get_aid_storage_filename_for_entry_id(entry_id: str) -> str:
"""Determine the ilename of homekit aid storage file."""
"""Determine the filename of homekit aid storage file."""
return f"{DOMAIN}.{entry_id}.aids"
def get_iid_storage_filename_for_entry_id(entry_id: str) -> str:
"""Determine the filename of homekit iid storage file."""
return f"{DOMAIN}.{entry_id}.iids"
def get_persist_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str:
"""Determine the path to the homekit state file."""
return hass.config.path(STORAGE_DIR, get_persist_filename_for_entry_id(entry_id))
@ -447,6 +452,13 @@ def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) ->
)
def get_iid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str) -> str:
"""Determine the path to the homekit iid storage file."""
return hass.config.path(
STORAGE_DIR, get_iid_storage_filename_for_entry_id(entry_id)
)
def _format_version_part(version_part: str) -> str:
return str(max(0, min(MAX_VERSION_PART, coerce_int(version_part))))
@ -466,14 +478,15 @@ def _is_zero_but_true(value: Any) -> bool:
return convert_to_float(value) == 0
def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str) -> bool:
def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str) -> None:
"""Remove the state files from disk."""
persist_file_path = get_persist_fullpath_for_entry_id(hass, entry_id)
aid_storage_path = get_aid_storage_fullpath_for_entry_id(hass, entry_id)
os.unlink(persist_file_path)
if os.path.exists(aid_storage_path):
os.unlink(aid_storage_path)
return True
for path in (
get_persist_fullpath_for_entry_id(hass, entry_id),
get_aid_storage_fullpath_for_entry_id(hass, entry_id),
get_iid_storage_fullpath_for_entry_id(hass, entry_id),
):
if os.path.exists(path):
os.unlink(path)
def _get_test_socket() -> socket.socket:

View File

@ -3,17 +3,50 @@ from contextlib import suppress
import os
from unittest.mock import patch
from pyhap.accessory_driver import AccessoryDriver
import pytest
from homeassistant.components.device_tracker.legacy import YAML_DEVICES
from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED
from homeassistant.components.homekit.accessories import HomeDriver, HomeIIDManager
from homeassistant.components.homekit.const import BRIDGE_NAME, EVENT_HOMEKIT_CHANGED
from homeassistant.components.homekit.iidmanager import AccessoryIIDStorage
from tests.common import async_capture_events, mock_device_registry, mock_registry
@pytest.fixture
def hk_driver(loop):
def iid_storage(hass):
"""Mock the iid storage."""
with patch.object(AccessoryIIDStorage, "_async_schedule_save"):
yield AccessoryIIDStorage(hass, "")
@pytest.fixture()
def run_driver(hass, loop, iid_storage):
"""Return a custom AccessoryDriver instance for HomeKit accessory init.
This mock does not mock async_stop, so the driver will not be stopped
"""
with patch("pyhap.accessory_driver.AsyncZeroconf"), patch(
"pyhap.accessory_driver.AccessoryEncoder"
), patch("pyhap.accessory_driver.HAPServer"), patch(
"pyhap.accessory_driver.AccessoryDriver.publish"
), patch(
"pyhap.accessory_driver.AccessoryDriver.persist"
):
yield HomeDriver(
hass,
pincode=b"123-45-678",
entry_id="",
entry_title="mock entry",
bridge_name=BRIDGE_NAME,
iid_manager=HomeIIDManager(iid_storage),
address="127.0.0.1",
loop=loop,
)
@pytest.fixture
def hk_driver(hass, loop, iid_storage):
"""Return a custom AccessoryDriver instance for HomeKit accessory init."""
with patch("pyhap.accessory_driver.AsyncZeroconf"), patch(
"pyhap.accessory_driver.AccessoryEncoder"
@ -24,11 +57,20 @@ def hk_driver(loop):
), patch(
"pyhap.accessory_driver.AccessoryDriver.persist"
):
yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop)
yield HomeDriver(
hass,
pincode=b"123-45-678",
entry_id="",
entry_title="mock entry",
bridge_name=BRIDGE_NAME,
iid_manager=HomeIIDManager(iid_storage),
address="127.0.0.1",
loop=loop,
)
@pytest.fixture
def mock_hap(loop, mock_zeroconf):
def mock_hap(hass, loop, iid_storage, mock_zeroconf):
"""Return a custom AccessoryDriver instance for HomeKit accessory init."""
with patch("pyhap.accessory_driver.AsyncZeroconf"), patch(
"pyhap.accessory_driver.AccessoryEncoder"
@ -43,7 +85,16 @@ def mock_hap(loop, mock_zeroconf):
), patch(
"pyhap.accessory_driver.AccessoryDriver.persist"
):
yield AccessoryDriver(pincode=b"123-45-678", address="127.0.0.1", loop=loop)
yield HomeDriver(
hass,
pincode=b"123-45-678",
entry_id="",
entry_title="mock entry",
bridge_name=BRIDGE_NAME,
iid_manager=HomeIIDManager(iid_storage),
address="127.0.0.1",
loop=loop,
)
@pytest.fixture

View File

@ -10,6 +10,7 @@ from homeassistant.components.homekit.accessories import (
HomeAccessory,
HomeBridge,
HomeDriver,
HomeIIDManager,
)
from homeassistant.components.homekit.const import (
ATTR_DISPLAY_NAME,
@ -107,7 +108,7 @@ async def test_home_accessory(hass, hk_driver):
hk_driver,
"Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length",
entity_id2,
3,
4,
{
ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length",
ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length",
@ -140,7 +141,7 @@ async def test_home_accessory(hass, hk_driver):
hk_driver,
"Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length",
entity_id2,
3,
5,
{
ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length",
ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length",
@ -191,7 +192,7 @@ async def test_home_accessory(hass, hk_driver):
entity_id = "test_model.demo"
hass.states.async_set(entity_id, None)
await hass.async_block_till_done()
acc = HomeAccessory(hass, hk_driver, "test_name", entity_id, 2, None)
acc = HomeAccessory(hass, hk_driver, "test_name", entity_id, 6, None)
serv = acc.services[0] # SERV_ACCESSORY_INFO
assert serv.get_characteristic(CHAR_MODEL).value == "Test Model"
@ -317,7 +318,7 @@ async def test_battery_service(hass, hk_driver, caplog):
with patch(
"homeassistant.components.homekit.accessories.HomeAccessory.async_update_state"
):
acc = HomeAccessory(hass, hk_driver, "Battery Service", entity_id, 2, None)
acc = HomeAccessory(hass, hk_driver, "Battery Service", entity_id, 3, None)
assert acc._char_battery.value == 0
assert acc._char_low_battery.value == 0
assert acc._char_charging.value == 2
@ -405,7 +406,7 @@ async def test_linked_battery_sensor(hass, hk_driver, caplog):
hk_driver,
"Battery Service",
entity_id,
2,
3,
{CONF_LINKED_BATTERY_SENSOR: linked_battery, CONF_LOW_BATTERY_THRESHOLD: 50},
)
with patch(
@ -700,16 +701,17 @@ def test_home_bridge(hk_driver):
assert serv.get_characteristic(CHAR_MODEL).value == BRIDGE_MODEL
assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == BRIDGE_SERIAL_NUMBER
def test_home_bridge_setup_message(hk_driver):
"""Test HomeBridge setup message."""
bridge = HomeBridge("hass", hk_driver, "test_name")
assert bridge.display_name == "test_name"
assert len(bridge.services) == 2
serv = bridge.services[0] # SERV_ACCESSORY_INFO
# setup_message
bridge.setup_message()
def test_home_driver():
def test_home_driver(iid_storage):
"""Test HomeDriver class."""
ip_address = "127.0.0.1"
port = 51826
@ -722,6 +724,7 @@ def test_home_driver():
"entry_id",
"name",
"title",
iid_manager=HomeIIDManager(iid_storage),
address=ip_address,
port=port,
persist_file=path,
@ -749,3 +752,22 @@ def test_home_driver():
mock_unpair.assert_called_with("client_uuid")
mock_show_msg.assert_called_with("hass", "entry_id", "title (any)", pin, "X-HM://0")
async def test_iid_collision_raises(hass, hk_driver):
"""Test iid collision raises.
If we try to allocate the same IID to the an accessory twice, we should
raise an exception.
"""
entity_id = "light.accessory"
entity_id2 = "light.accessory2"
hass.states.async_set(entity_id, STATE_OFF)
hass.states.async_set(entity_id2, STATE_OFF)
HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {})
with pytest.raises(RuntimeError):
HomeAccessory(hass, hk_driver, "Home Accessory", entity_id2, 2, {})

View File

@ -387,8 +387,9 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip):
}
@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True)
async def test_options_flow_devices(
mock_hap,
port_mock,
hass,
demo_cleanup,
device_reg,
@ -473,9 +474,13 @@ async def test_options_flow_devices(
},
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True)
async def test_options_flow_devices_preserved_when_advanced_off(
mock_hap, hass, mock_get_source_ip, mock_async_zeroconf
port_mock, hass, mock_get_source_ip, mock_async_zeroconf
):
"""Test devices are preserved if they were added in advanced mode but it was turned off."""
config_entry = MockConfigEntry(
@ -542,6 +547,8 @@ async def test_options_flow_devices_preserved_when_advanced_off(
"include_entities": [],
},
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
async def test_options_flow_include_mode_with_non_existant_entity(
@ -600,6 +607,8 @@ async def test_options_flow_include_mode_with_non_existant_entity(
"include_entities": ["climate.new", "climate.front_gate"],
},
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
async def test_options_flow_exclude_mode_with_non_existant_entity(
@ -659,6 +668,8 @@ async def test_options_flow_exclude_mode_with_non_existant_entity(
"include_entities": [],
},
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
async def test_options_flow_include_mode_basic(hass, mock_get_source_ip):
@ -704,6 +715,7 @@ async def test_options_flow_include_mode_basic(hass, mock_get_source_ip):
"include_entities": ["climate.new"],
},
}
await hass.config_entries.async_unload(config_entry.entry_id)
async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip):
@ -809,6 +821,8 @@ async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip):
},
"entity_config": {"camera.native_h264": {"video_codec": "copy"}},
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip):
@ -941,6 +955,8 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip):
},
"mode": "bridge",
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
async def test_options_flow_with_camera_audio(hass, mock_get_source_ip):
@ -1073,6 +1089,8 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip):
},
"mode": "bridge",
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip):
@ -1112,6 +1130,7 @@ async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip):
user_input={},
)
assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
await hass.config_entries.async_unload(config_entry.entry_id)
@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True)
@ -1211,6 +1230,8 @@ async def test_options_flow_include_mode_basic_accessory(
"include_entities": ["media_player.tv"],
},
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_source_ip):
@ -1317,6 +1338,8 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_sou
},
}
assert len(mock_setup_entry.mock_calls) == 1
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
def _get_schema_default(schema, key_name):
@ -1423,6 +1446,8 @@ async def test_options_flow_exclude_mode_skips_category_entities(
"include_entities": [],
},
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True)
@ -1501,6 +1526,8 @@ async def test_options_flow_exclude_mode_skips_hidden_entities(
"include_entities": [],
},
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)
@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True)
@ -1583,3 +1610,5 @@ async def test_options_flow_include_mode_allows_hidden_entities(
],
},
}
await hass.async_block_till_done()
await hass.config_entries.async_unload(config_entry.entry_id)

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import os
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
from pyhap.accessory import Accessory
@ -60,7 +59,6 @@ from homeassistant.helpers.entityfilter import (
convert_filter,
)
from homeassistant.setup import async_setup_component
from homeassistant.util import json as json_util
from .util import PATH_HOMEKIT, async_init_entry, async_init_integration
@ -122,6 +120,7 @@ def _mock_homekit(hass, entry, homekit_mode, entity_filter=None, devices=None):
def _mock_homekit_bridge(hass, entry):
homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE)
homekit.driver = MagicMock()
homekit.iid_storage = MagicMock()
return homekit
@ -177,6 +176,49 @@ async def test_setup_min(hass, mock_async_zeroconf):
assert mock_homekit().async_start.called is True
@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True)
async def test_removing_entry(port_mock, hass, mock_async_zeroconf):
"""Test removing a config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: BRIDGE_NAME, CONF_PORT: DEFAULT_PORT},
options={},
)
entry.add_to_hass(hass)
with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit, patch(
"homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4"
):
mock_homekit.return_value = homekit = Mock()
type(homekit).async_start = AsyncMock()
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
mock_homekit.assert_any_call(
hass,
BRIDGE_NAME,
DEFAULT_PORT,
"1.2.3.4",
ANY,
ANY,
{},
HOMEKIT_MODE_BRIDGE,
None,
entry.entry_id,
entry.title,
devices=[],
)
# Test auto start enabled
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert mock_homekit().async_start.called is True
await hass.config_entries.async_remove(entry.entry_id)
await hass.async_block_till_done()
async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf):
"""Test setup of bridge and driver."""
entry = MockConfigEntry(
@ -203,6 +245,7 @@ async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf):
zeroconf_mock = MagicMock()
uuid = await instance_id.async_get(hass)
with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver:
homekit.iid_storage = MagicMock()
await hass.async_add_executor_job(homekit.setup, zeroconf_mock, uuid)
path = get_persist_fullpath_for_entry_id(hass, entry.entry_id)
@ -219,6 +262,7 @@ async def test_homekit_setup(hass, hk_driver, mock_async_zeroconf):
async_zeroconf_instance=zeroconf_mock,
zeroconf_server=f"{uuid}-hap.local.",
loader=ANY,
iid_manager=ANY,
)
assert homekit.driver.safe_mode is False
@ -247,6 +291,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_async_zeroconf):
path = get_persist_fullpath_for_entry_id(hass, entry.entry_id)
uuid = await instance_id.async_get(hass)
with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver:
homekit.iid_storage = MagicMock()
await hass.async_add_executor_job(homekit.setup, mock_async_zeroconf, uuid)
mock_driver.assert_called_with(
hass,
@ -261,6 +306,7 @@ async def test_homekit_setup_ip_address(hass, hk_driver, mock_async_zeroconf):
async_zeroconf_instance=mock_async_zeroconf,
zeroconf_server=f"{uuid}-hap.local.",
loader=ANY,
iid_manager=ANY,
)
@ -289,6 +335,7 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_async_zeroconf):
path = get_persist_fullpath_for_entry_id(hass, entry.entry_id)
uuid = await instance_id.async_get(hass)
with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver:
homekit.iid_storage = MagicMock()
await hass.async_add_executor_job(homekit.setup, async_zeroconf_instance, uuid)
mock_driver.assert_called_with(
hass,
@ -303,10 +350,11 @@ async def test_homekit_setup_advertise_ip(hass, hk_driver, mock_async_zeroconf):
async_zeroconf_instance=async_zeroconf_instance,
zeroconf_server=f"{uuid}-hap.local.",
loader=ANY,
iid_manager=ANY,
)
async def test_homekit_add_accessory(hass, mock_async_zeroconf):
async def test_homekit_add_accessory(hass, mock_async_zeroconf, mock_hap):
"""Add accessory if config exists and get_acc returns an accessory."""
entry = MockConfigEntry(
@ -340,10 +388,12 @@ async def test_homekit_add_accessory(hass, mock_async_zeroconf):
mock_get_acc.assert_called_with(hass, ANY, ANY, 1467253281, {})
assert homekit.bridge.add_accessory.called
await homekit.async_stop()
@pytest.mark.parametrize("acc_category", [CATEGORY_TELEVISION, CATEGORY_CAMERA])
async def test_homekit_warn_add_accessory_bridge(
hass, acc_category, mock_async_zeroconf, caplog
hass, acc_category, mock_async_zeroconf, mock_hap, caplog
):
"""Test we warn when adding cameras or tvs to a bridge."""
@ -367,6 +417,7 @@ async def test_homekit_warn_add_accessory_bridge(
homekit.add_bridge_accessory(state)
mock_get_acc.assert_called_with(hass, ANY, ANY, 1508819236, {})
assert not homekit.bridge.add_accessory.called
await homekit.async_stop()
assert "accessory mode" in caplog.text
@ -385,7 +436,6 @@ async def test_homekit_remove_accessory(hass, mock_async_zeroconf):
acc = await homekit.async_remove_bridge_accessory(6)
assert acc is acc_mock
assert acc_mock.stop.called
assert len(homekit.bridge.accessories) == 0
@ -722,7 +772,7 @@ async def test_homekit_stop(hass):
assert homekit.driver.async_stop.called is True
async def test_homekit_reset_accessories(hass, mock_async_zeroconf):
async def test_homekit_reset_accessories(hass, mock_async_zeroconf, mock_hap):
"""Test resetting HomeKit accessories."""
entry = MockConfigEntry(
@ -736,20 +786,15 @@ async def test_homekit_reset_accessories(hass, mock_async_zeroconf):
"pyhap.accessory.Bridge.add_accessory"
) as mock_add_accessory, patch(
"pyhap.accessory_driver.AccessoryDriver.config_changed"
) as hk_driver_config_changed, patch(
), patch(
"pyhap.accessory_driver.AccessoryDriver.async_start"
), patch(
f"{PATH_HOMEKIT}.accessories.HomeAccessory.run"
) as mock_run, patch.object(
), patch.object(
homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0
):
await async_init_entry(hass, entry)
acc_mock = MagicMock()
acc_mock.entity_id = entity_id
acc_mock.stop = AsyncMock()
aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id)
homekit.bridge.accessories = {aid: acc_mock}
homekit.status = STATUS_RUNNING
homekit.driver.aio_stop_event = MagicMock()
@ -761,10 +806,9 @@ async def test_homekit_reset_accessories(hass, mock_async_zeroconf):
)
await hass.async_block_till_done()
assert hk_driver_config_changed.call_count == 2
assert mock_add_accessory.called
assert mock_run.called
homekit.status = STATUS_READY
await homekit.async_stop()
async def test_homekit_unpair(hass, device_reg, mock_async_zeroconf):
@ -1028,7 +1072,7 @@ async def test_homekit_reset_accessories_not_bridged(hass, mock_async_zeroconf):
homekit.status = STATUS_STOPPED
async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf):
async def test_homekit_reset_single_accessory(hass, mock_hap, mock_async_zeroconf):
"""Test resetting HomeKit single accessory."""
entry = MockConfigEntry(
@ -1046,13 +1090,7 @@ async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf):
f"{PATH_HOMEKIT}.accessories.HomeAccessory.run"
) as mock_run:
await async_init_entry(hass, entry)
homekit.status = STATUS_RUNNING
acc_mock = MagicMock()
acc_mock.entity_id = entity_id
acc_mock.stop = AsyncMock()
homekit.driver.accessory = acc_mock
homekit.driver.aio_stop_event = MagicMock()
await hass.services.async_call(
@ -1065,6 +1103,7 @@ async def test_homekit_reset_single_accessory(hass, mock_async_zeroconf):
assert mock_run.called
assert hk_driver_config_changed.call_count == 1
homekit.status = STATUS_READY
await homekit.async_stop()
async def test_homekit_reset_single_accessory_unsupported(hass, mock_async_zeroconf):
@ -1494,12 +1533,6 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_async_zeroconf
await hass.async_block_till_done()
def _write_data(path: str, data: dict) -> None:
"""Write the data."""
os.makedirs(os.path.dirname(path), exist_ok=True)
json_util.save_json(path, data)
async def test_homekit_ignored_missing_devices(
hass, hk_driver, device_reg, entity_reg, mock_async_zeroconf
):

View File

@ -0,0 +1,101 @@
"""Tests for the HomeKit IID manager."""
from uuid import UUID
from homeassistant.components.homekit.const import DOMAIN
from homeassistant.components.homekit.iidmanager import (
AccessoryIIDStorage,
get_iid_storage_filename_for_entry_id,
)
from homeassistant.util.uuid import random_uuid_hex
from tests.common import MockConfigEntry
async def test_iid_generation_and_restore(hass, iid_storage, hass_storage):
"""Test generating iids and restoring them from storage."""
entry = MockConfigEntry(domain=DOMAIN)
iid_storage = AccessoryIIDStorage(hass, entry.entry_id)
await iid_storage.async_initialize()
random_service_uuid = UUID(random_uuid_hex())
random_characteristic_uuid = UUID(random_uuid_hex())
iid1 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, None, random_characteristic_uuid, None
)
iid2 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, None, random_characteristic_uuid, None
)
assert iid1 == iid2
service_only_iid1 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, None, None, None
)
service_only_iid2 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, None, None, None
)
assert service_only_iid1 == service_only_iid2
assert service_only_iid1 != iid1
service_only_iid_with_unique_id1 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, "any", None, None
)
service_only_iid_with_unique_id2 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, "any", None, None
)
assert service_only_iid_with_unique_id1 == service_only_iid_with_unique_id2
assert service_only_iid_with_unique_id1 != service_only_iid1
unique_char_iid1 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, None, random_characteristic_uuid, "any"
)
unique_char_iid2 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, None, random_characteristic_uuid, "any"
)
assert unique_char_iid1 == unique_char_iid2
assert unique_char_iid1 != iid1
unique_service_unique_char_iid1 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, "any", random_characteristic_uuid, "any"
)
unique_service_unique_char_iid2 = iid_storage.get_or_allocate_iid(
1, random_service_uuid, "any", random_characteristic_uuid, "any"
)
assert unique_service_unique_char_iid1 == unique_service_unique_char_iid2
assert unique_service_unique_char_iid1 != iid1
unique_service_unique_char_new_aid_iid1 = iid_storage.get_or_allocate_iid(
2, random_service_uuid, "any", random_characteristic_uuid, "any"
)
unique_service_unique_char_new_aid_iid2 = iid_storage.get_or_allocate_iid(
2, random_service_uuid, "any", random_characteristic_uuid, "any"
)
assert (
unique_service_unique_char_new_aid_iid1
== unique_service_unique_char_new_aid_iid2
)
assert unique_service_unique_char_new_aid_iid1 != iid1
assert unique_service_unique_char_new_aid_iid1 != unique_service_unique_char_iid1
await iid_storage.async_save()
iid_storage2 = AccessoryIIDStorage(hass, entry.entry_id)
await iid_storage2.async_initialize()
iid3 = iid_storage2.get_or_allocate_iid(
1, random_service_uuid, None, random_characteristic_uuid, None
)
assert iid3 == iid1
async def test_iid_storage_filename(hass, iid_storage, hass_storage):
"""Test iid storage uses the expected filename."""
entry = MockConfigEntry(domain=DOMAIN)
iid_storage = AccessoryIIDStorage(hass, entry.entry_id)
await iid_storage.async_initialize()
assert iid_storage.store.path.endswith(
get_iid_storage_filename_for_entry_id(entry.entry_id)
)

View File

@ -4,7 +4,6 @@ import asyncio
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from uuid import UUID
from pyhap.accessory_driver import AccessoryDriver
import pytest
from homeassistant.components import camera, ffmpeg
@ -78,21 +77,6 @@ async def _async_stop_stream(hass, acc, session_info):
await hass.async_block_till_done()
@pytest.fixture()
def run_driver(hass):
"""Return a custom AccessoryDriver instance for HomeKit accessory init."""
with patch("pyhap.accessory_driver.AsyncZeroconf"), patch(
"pyhap.accessory_driver.AccessoryEncoder"
), patch("pyhap.accessory_driver.HAPServer"), patch(
"pyhap.accessory_driver.AccessoryDriver.publish"
), patch(
"pyhap.accessory_driver.AccessoryDriver.persist"
):
yield AccessoryDriver(
pincode=b"123-45-678", address="127.0.0.1", loop=hass.loop
)
def _mock_reader():
"""Mock ffmpeg reader."""

View File

@ -573,7 +573,7 @@ async def test_windowcovering_basic_restore(hass, hk_driver, events):
assert acc.char_target_position is not None
assert acc.char_position_state is not None
acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.all_info_set", 2, None)
acc = WindowCoveringBasic(hass, hk_driver, "Cover", "cover.all_info_set", 3, None)
assert acc.category == 14
assert acc.char_current_position is not None
assert acc.char_target_position is not None
@ -611,7 +611,7 @@ async def test_windowcovering_restore(hass, hk_driver, events):
assert acc.char_target_position is not None
assert acc.char_position_state is not None
acc = WindowCovering(hass, hk_driver, "Cover", "cover.all_info_set", 2, None)
acc = WindowCovering(hass, hk_driver, "Cover", "cover.all_info_set", 3, None)
assert acc.category == 14
assert acc.char_current_position is not None
assert acc.char_target_position is not None

View File

@ -561,7 +561,7 @@ async def test_fan_restore(hass, hk_driver, events):
assert acc.char_speed is None
assert acc.char_swing is None
acc = Fan(hass, hk_driver, "Fan", "fan.all_info_set", 2, None)
acc = Fan(hass, hk_driver, "Fan", "fan.all_info_set", 3, None)
assert acc.category == 3
assert acc.char_active is not None
assert acc.char_direction is not None

View File

@ -442,7 +442,7 @@ async def test_tv_restore(hass, hk_driver, events):
assert not hasattr(acc, "char_input_source")
acc = TelevisionMediaPlayer(
hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 2, None
hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 3, None
)
assert acc.category == 31
assert acc.chars_tv == [CHAR_REMOTE_KEY]

View File

@ -282,13 +282,16 @@ async def test_supported_states(hass, hk_driver, events):
},
]
aid = 1
for test_config in test_configs:
attrs = {"supported_features": test_config.get("features")}
hass.states.async_set(entity_id, None, attributes=attrs)
await hass.async_block_till_done()
acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config)
aid += 1
acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, aid, config)
await acc.run()
await hass.async_block_till_done()

View File

@ -423,12 +423,14 @@ async def test_motion_uses_bool(hass, hk_driver):
async def test_binary_device_classes(hass, hk_driver):
"""Test if services and characteristics are assigned correctly."""
entity_id = "binary_sensor.demo"
aid = 1
for device_class, (service, char, _) in BINARY_SENSOR_SERVICE_MAP.items():
hass.states.async_set(entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: device_class})
await hass.async_block_till_done()
acc = BinarySensor(hass, hk_driver, "Binary Sensor", entity_id, 2, None)
aid += 1
acc = BinarySensor(hass, hk_driver, "Binary Sensor", entity_id, aid, None)
assert acc.get_service(service).display_name == service
assert acc.char_detected.display_name == char
@ -460,7 +462,7 @@ async def test_sensor_restore(hass, hk_driver, events):
acc = get_accessory(hass, hk_driver, hass.states.get("sensor.temperature"), 2, {})
assert acc.category == 10
acc = get_accessory(hass, hk_driver, hass.states.get("sensor.humidity"), 2, {})
acc = get_accessory(hass, hk_driver, hass.states.get("sensor.humidity"), 3, {})
assert acc.category == 10

View File

@ -150,23 +150,23 @@ async def test_valve_set_state(hass, hk_driver, events):
assert acc.category == 29 # Faucet
assert acc.char_valve_type.value == 3 # Water faucet
acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SHOWER})
acc = Valve(hass, hk_driver, "Valve", entity_id, 3, {CONF_TYPE: TYPE_SHOWER})
await acc.run()
await hass.async_block_till_done()
assert acc.category == 30 # Shower
assert acc.char_valve_type.value == 2 # Shower head
acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_SPRINKLER})
acc = Valve(hass, hk_driver, "Valve", entity_id, 4, {CONF_TYPE: TYPE_SPRINKLER})
await acc.run()
await hass.async_block_till_done()
assert acc.category == 28 # Sprinkler
assert acc.char_valve_type.value == 1 # Irrigation
acc = Valve(hass, hk_driver, "Valve", entity_id, 2, {CONF_TYPE: TYPE_VALVE})
acc = Valve(hass, hk_driver, "Valve", entity_id, 5, {CONF_TYPE: TYPE_VALVE})
await acc.run()
await hass.async_block_till_done()
assert acc.aid == 2
assert acc.aid == 5
assert acc.category == 29 # Faucet
assert acc.char_active.value == 0

View File

@ -1045,7 +1045,7 @@ async def test_thermostat_restore(hass, hk_driver, events):
"off",
}
acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 2, None)
acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 3, None)
assert acc.category == 9
assert acc.get_temperature_range() == (60.0, 70.0)
assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == {
@ -1859,7 +1859,7 @@ async def test_water_heater_restore(hass, hk_driver, events):
}
acc = WaterHeater(
hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 2, None
hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 3, None
)
assert acc.category == 9
assert acc.get_temperature_range() == (60.0, 70.0)