mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add support for restoring HomeKit IIDs (#79913)
This commit is contained in:
parent
20f1f8aabb
commit
3b33e0d832
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
|
96
homeassistant/components/homekit/iidmanager.py
Normal file
96
homeassistant/components/homekit/iidmanager.py
Normal 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}
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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, {})
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
):
|
||||
|
101
tests/components/homekit/test_iidmanager.py
Normal file
101
tests/components/homekit/test_iidmanager.py
Normal 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)
|
||||
)
|
@ -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."""
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user