"""Code to support homekit_controller tests.""" from datetime import timedelta import json import os from unittest import mock from homekit.exceptions import AccessoryNotFoundError from homekit.model import Accessory, get_id from homekit.model.characteristics import ( AbstractCharacteristic, CharacteristicPermissions, CharacteristicsTypes, ) from homekit.model.services import AbstractService, ServicesTypes from homeassistant import config_entries from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import ( CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, ) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture class FakePairing: """ A test fake that pretends to be a paired HomeKit accessory. This only contains methods and values that exist on the upstream Pairing class. """ def __init__(self, accessories): """Create a fake pairing from an accessory model.""" self.accessories = accessories self.pairing_data = {} self.available = True def list_accessories_and_characteristics(self): """Fake implementation of list_accessories_and_characteristics.""" accessories = [a.to_accessory_and_service_list() for a in self.accessories] # replicate what happens upstream right now self.pairing_data["accessories"] = accessories return accessories def get_characteristics(self, characteristics): """Fake implementation of get_characteristics.""" if not self.available: raise AccessoryNotFoundError("Accessory not found") results = {} for aid, cid in characteristics: for accessory in self.accessories: if aid != accessory.aid: continue for service in accessory.services: for char in service.characteristics: if char.iid != cid: continue results[(aid, cid)] = {"value": char.get_value()} return results def put_characteristics(self, characteristics): """Fake implementation of put_characteristics.""" for aid, cid, new_val in characteristics: for accessory in self.accessories: if aid != accessory.aid: continue for service in accessory.services: for char in service.characteristics: if char.iid != cid: continue char.set_value(new_val) return {} class FakeController: """ A test fake that pretends to be a paired HomeKit accessory. This only contains methods and values that exist on the upstream Controller class. """ def __init__(self): """Create a Fake controller with no pairings.""" self.pairings = {} def add(self, accessories): """Create and register a fake pairing for a simulated accessory.""" pairing = FakePairing(accessories) self.pairings["00:00:00:00:00:00"] = pairing return pairing class Helper: """Helper methods for interacting with HomeKit fakes.""" def __init__(self, hass, entity_id, pairing, accessory, config_entry): """Create a helper for a given accessory/entity.""" self.hass = hass self.entity_id = entity_id self.pairing = pairing self.accessory = accessory self.config_entry = config_entry self.characteristics = {} for service in self.accessory.services: service_name = ServicesTypes.get_short(service.type) for char in service.characteristics: char_name = CharacteristicsTypes.get_short(char.type) self.characteristics[(service_name, char_name)] = char async def poll_and_get_state(self): """Trigger a time based poll and return the current entity state.""" await time_changed(self.hass, 60) state = self.hass.states.get(self.entity_id) assert state is not None return state class FakeCharacteristic(AbstractCharacteristic): """ A model of a generic HomeKit characteristic. Base is abstract and can't be instanced directly so this subclass is needed even though it doesn't add any methods. """ def to_accessory_and_service_list(self): """Serialize the characteristic.""" # Upstream doesn't correctly serialize valid_values # This fix will be upstreamed and this function removed when it # is fixed. record = super().to_accessory_and_service_list() if self.valid_values: record["valid-values"] = self.valid_values return record class FakeService(AbstractService): """A model of a generic HomeKit service.""" def __init__(self, service_name): """Create a fake service by its short form HAP spec name.""" char_type = ServicesTypes.get_uuid(service_name) super().__init__(char_type, get_id()) def add_characteristic(self, name): """Add a characteristic to this service by name.""" full_name = "public.hap.characteristic." + name char = FakeCharacteristic(get_id(), full_name, None) char.perms = [ CharacteristicPermissions.paired_read, CharacteristicPermissions.paired_write, ] self.characteristics.append(char) return char async def time_changed(hass, seconds): """Trigger time changed.""" next_update = dt_util.utcnow() + timedelta(seconds) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() async def setup_accessories_from_file(hass, path): """Load an collection of accessory defs from JSON data.""" accessories_fixture = await hass.async_add_executor_job( load_fixture, os.path.join("homekit_controller", path) ) accessories_json = json.loads(accessories_fixture) accessories = [] for accessory_data in accessories_json: accessory = Accessory("Name", "Mfr", "Model", "0001", "0.1") accessory.services = [] accessory.aid = accessory_data["aid"] for service_data in accessory_data["services"]: service = FakeService("public.hap.service.accessory-information") service.type = service_data["type"] service.iid = service_data["iid"] for char_data in service_data["characteristics"]: char = FakeCharacteristic(1, "23", None) char.type = char_data["type"] char.iid = char_data["iid"] char.perms = char_data["perms"] char.format = char_data["format"] if "description" in char_data: char.description = char_data["description"] if "value" in char_data: char.value = char_data["value"] if "minValue" in char_data: char.minValue = char_data["minValue"] if "maxValue" in char_data: char.maxValue = char_data["maxValue"] if "valid-values" in char_data: char.valid_values = char_data["valid-values"] service.characteristics.append(char) accessory.services.append(service) accessories.append(accessory) return accessories async def setup_platform(hass): """Load the platform but with a fake Controller API.""" config = {"discovery": {}} with mock.patch("homekit.Controller") as controller: fake_controller = controller.return_value = FakeController() await async_setup_component(hass, DOMAIN, config) return fake_controller async def setup_test_accessories(hass, accessories): """Load a fake homekit device based on captured JSON profile.""" fake_controller = await setup_platform(hass) pairing = fake_controller.add(accessories) discovery_info = { "name": "TestDevice", "host": "127.0.0.1", "port": 8080, "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1}, } pairing.pairing_data.update( {"AccessoryPairingID": discovery_info["properties"]["id"]} ) config_entry = MockConfigEntry( version=1, domain="homekit_controller", entry_id="TestData", data=pairing.pairing_data, title="test", connection_class=config_entries.CONN_CLASS_LOCAL_PUSH, ) config_entry.add_to_hass(hass) pairing_cls_loc = "homeassistant.components.homekit_controller.connection.IpPairing" with mock.patch(pairing_cls_loc) as pairing_cls: pairing_cls.return_value = pairing await config_entry.async_setup(hass) await hass.async_block_till_done() return config_entry, pairing async def device_config_changed(hass, accessories): """Discover new devices added to Home Assistant at runtime.""" # Update the accessories our FakePairing knows about controller = hass.data[CONTROLLER] pairing = controller.pairings["00:00:00:00:00:00"] pairing.accessories = accessories discovery_info = { "name": "TestDevice", "host": "127.0.0.1", "port": 8080, "properties": { "md": "TestDevice", "id": "00:00:00:00:00:00", "c#": "2", "sf": "0", }, } # Config Flow will abort and notify us if the discovery event is of # interest - in this case c# has incremented flow = config_flow.HomekitControllerFlowHandler() flow.hass = hass flow.context = {} result = await flow.async_step_zeroconf(discovery_info) assert result["type"] == "abort" assert result["reason"] == "already_configured" # Wait for services to reconfigure await hass.async_block_till_done() await hass.async_block_till_done() async def setup_test_component(hass, services, capitalize=False, suffix=None): """Load a fake homekit accessory based on a homekit accessory model. If capitalize is True, property names will be in upper case. If suffix is set, entityId will include the suffix """ domain = None for service in services: service_name = ServicesTypes.get_short(service.type) if service_name in HOMEKIT_ACCESSORY_DISPATCH: domain = HOMEKIT_ACCESSORY_DISPATCH[service_name] break assert domain, "Cannot map test homekit services to Home Assistant domain" accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") accessory.services.extend(services) config_entry, pairing = await setup_test_accessories(hass, [accessory]) entity = "testdevice" if suffix is None else "testdevice_{}".format(suffix) return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry)