def get_service_info_mock_without_an_address( service_type: str, name: str ) -> AsyncServiceInfo: """Return service info for get_service_info without any addresses.""" return AsyncServiceInfo( service_type, name, addresses=[], port=80, weight=0, priority=0, server="name.local.", properties=PROPERTIES, )
def mock_homekit_info(service_type, name): return AsyncServiceInfo( service_type, name, addresses=[b"\n\x00\x00\x14"], port=80, weight=0, priority=0, server="name.local.", properties={ b"md": model.encode(), b"sf": pairing_status }, )
def get_service_info_mock( service_type: str, name: str, *args: Any, **kwargs: Any ) -> AsyncServiceInfo: """Return service info for get_service_info.""" return AsyncServiceInfo( service_type, name, addresses=[b"\n\x00\x00\x14"], port=80, weight=0, priority=0, server="name.local.", properties=PROPERTIES, )
async def test_find_with_device(mock_asynczeroconf): desc = {b"id": b"00:00:02:00:00:02", b"c#": b"1", b"md": b"any"} info = AsyncServiceInfo( "_hap._tcp.local.", "foo1._hap._tcp.local.", addresses=[socket.inet_aton("127.0.0.1")], port=1234, properties=desc, weight=0, priority=0, ) with patch("aiohomekit.zeroconf.AsyncServiceInfo", return_value=info): result = await async_find_device_ip_and_port("00:00:02:00:00:02", 1) assert result == ("127.0.0.1", 1234)
async def _get_service_info(self, zeroconf: Zeroconf, service_type: str, name: str) -> None: """Get service information, if IP matches.""" service_info = AsyncServiceInfo(service_type, name) question_type = DNSQuestionType.QM if self._multicast else DNSQuestionType.QU with suppress(RuntimeError): await service_info.async_request(zeroconf, timeout=1000, question_type=question_type) if ( service_info is None or not service_info.addresses or str(ipaddress.ip_address(service_info.addresses[0])) != self.ip ): return # No need to continue, if there are no relevant service information self._logger.debug("Adding service info of %s to %s", service_type, service_info.server_key) self._info[service_type] = self.info_from_service(service_info)
async def _process_service_update(self, zeroconf: HaZeroconf, service_type: str, name: str) -> None: """Process a zeroconf update.""" async_service_info = AsyncServiceInfo(service_type, name) await async_service_info.async_request(zeroconf, 3000) info = info_from_service(async_service_info) if not info: # Prevent the browser thread from collapsing _LOGGER.debug("Failed to get addresses for device %s", name) return _LOGGER.debug("Discovered new device %s %s", name, info) props: dict[str, str] = info.properties # If we can handle it as a HomeKit discovery, we do that here. if service_type in HOMEKIT_TYPES and ( domain := async_get_homekit_discovery_domain( self.homekit_models, props)): discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_HOMEKIT}, info) # Continue on here as homekit_controller # still needs to get updates on devices # so it can see when the 'c#' field is updated. # # We only send updates to homekit_controller # if the device is already paired in order to avoid # offering a second discovery for the same device if not is_homekit_paired(props): integration: Integration = await async_get_integration( self.hass, domain) # Since we prefer local control, if the integration that is being discovered # is cloud AND the homekit device is UNPAIRED we still want to discovery it. # # Additionally if the integration is polling, HKC offers a local push # experience for the user to control the device so we want to offer that # as well. # # As soon as the device becomes paired, the config flow will be dismissed # in the event the user does not want to pair with Home Assistant. # if not integration.iot_class or ( not integration.iot_class.startswith("cloud") and "polling" not in integration.iot_class): return
async def _async_homekit_devices_from_cache( aiozc: AsyncZeroconf, filter_func: Callable = None) -> list[dict[str, Any]]: """Return all homekit devices in the cache, updating any missing data as needed.""" infos = [ AsyncServiceInfo(HAP_TYPE, record.alias) for record in aiozc.zeroconf.cache.get_all_by_details( HAP_TYPE, TYPE_PTR, CLASS_IN) ] tasks = [info.async_request(aiozc.zeroconf, _TIMEOUT_MS) for info in infos] await asyncio.gather(*tasks) devices = [] for info in infos: if not _service_info_is_homekit_device(info): continue if filter_func and not filter_func(info): continue devices.append(_build_data_from_service_info(info)) return devices
def test_is_homekit_device_case_insensitive(): desc = { b"C#": b"1", b"id": b"00:00:01:00:00:02", b"md": b"unittest", b"s#": b"1", b"ci": b"5", b"sf": b"0", } info = AsyncServiceInfo( "_hap._tcp.local.", "foo2._hap._tcp.local.", addresses=[socket.inet_aton("127.0.0.1")], port=1234, properties=desc, weight=0, priority=0, ) assert _service_info_is_homekit_device(info)
async def test_async_find_data_for_upper_case_device_id_matches(mock_asynczeroconf): desc = { b"c#": b"1", b"id": b"AA:00:01:00:00:02", b"md": b"unittest", b"s#": b"1", b"ci": b"5", b"sf": b"0", } info = AsyncServiceInfo( "_hap._tcp.local.", "foo2._hap._tcp.local.", addresses=[socket.inet_aton("127.0.0.1")], port=1234, properties=desc, weight=0, priority=0, ) with patch("aiohomekit.zeroconf.AsyncServiceInfo", return_value=info): result = await async_find_data_for_device_id( device_id="aa:00:01:00:00:02", max_seconds=1, async_zeroconf_instance=mock_asynczeroconf, ) assert result == { "address": "127.0.0.1", "c#": "1", "category": "Lightbulb", "ci": "5", "ff": 0, "flags": FeatureFlags(0), "id": "AA:00:01:00:00:02", "md": "unittest", "name": "foo2._hap._tcp.local.", "port": 1234, "pv": "1.0", "s#": "1", "sf": "0", "statusflags": "Accessory has been paired.", }
async def test_async_discover_homekit_devices_missing_md(mock_asynczeroconf): desc = { b"c#": b"1", b"id": b"00:00:01:00:00:02", b"s#": b"1", b"ci": b"5", b"sf": b"0", } info = AsyncServiceInfo( "_hap._tcp.local.", "foo2._hap._tcp.local.", addresses=[socket.inet_aton("127.0.0.1")], port=1234, properties=desc, weight=0, priority=0, ) with patch("aiohomekit.zeroconf.AsyncServiceInfo", return_value=info): result = await async_discover_homekit_devices(max_seconds=1) assert result == []
def _build_service_info_queries( self, ) -> List[Union[AsyncServiceInfo, AsyncDeviceInfoServiceInfo]]: """Build AsyncServiceInfo queries from the requested types.""" infos: List[Union[AsyncServiceInfo, AsyncDeviceInfoServiceInfo]] = [] device_names = set() for type_ in (SLEEP_PROXY, *self._services): if type_ == DEVICE_INFO: continue zc_type = f"{type_}." for record in self.zeroconf.cache.async_all_by_details( zc_type, _TYPE_PTR, _CLASS_IN): ptr_name = cast(DNSPointer, record).alias service_info = AsyncServiceInfo(zc_type, ptr_name) infos.append(service_info) name = _name_without_type(ptr_name, zc_type) device_name = self._device_info_name[type_](name) if device_name is not None and device_name not in device_names: device_names.add(device_name) device_service_info = AsyncDeviceInfoServiceInfo( DEVICE_INFO_TYPE, f"{device_name}.{DEVICE_INFO_TYPE}") infos.append(device_service_info) return infos
def create_async_service_infos(self, interface: str, service_name: str, domain_name: str, ip: str) -> AsyncServiceInfo: """ Create A list of AsyncServiceInfo() for the given interface and service Each domain results in a new service """ service = self.service_types[service_name] try: return AsyncServiceInfo( f"{service.name}.{service.protocol}.local.", f"{domain_name}.{service.name}.{service.protocol}.local.", addresses=[socket.inet_aton(ip)], port=service.port, properties=service.get_properties(), server=f"{domain_name}.local.", ) except Exception as e: logger.warning( f"Error creating AsyncServiceInfo {service.name} at {interface}: {e}" ) raise e
async def _process_service_update(self, zeroconf: HaZeroconf, service_type: str, name: str) -> None: """Process a zeroconf update.""" async_service_info = AsyncServiceInfo(service_type, name) await async_service_info.async_request(zeroconf, 3000) info = info_from_service(async_service_info) if not info: # Prevent the browser thread from collapsing _LOGGER.debug("Failed to get addresses for device %s", name) return _LOGGER.debug("Discovered new device %s %s", name, info) # If we can handle it as a HomeKit discovery, we do that here. if service_type in HOMEKIT_TYPES: props = info[ATTR_PROPERTIES] if domain := async_get_homekit_discovery_domain( self.homekit_models, props): discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_HOMEKIT}, info) # Continue on here as homekit_controller # still needs to get updates on devices # so it can see when the 'c#' field is updated. # # We only send updates to homekit_controller # if the device is already paired in order to avoid # offering a second discovery for the same device if domain and HOMEKIT_PAIRED_STATUS_FLAG in props: try: # 0 means paired and not discoverable by iOS clients) if int(props[HOMEKIT_PAIRED_STATUS_FLAG]): return except ValueError: # HomeKit pairing status unknown # likely bad homekit data return
def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" properties: dict[str, Any] = {"_raw": {}} for key, value in service.properties.items(): # See https://ietf.org/rfc/rfc6763.html#section-6.4 and # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings # for property keys and values try: key = key.decode("ascii") except UnicodeDecodeError: _LOGGER.debug("Ignoring invalid key provided by [%s]: %s", service.name, key) continue properties["_raw"][key] = value with suppress(UnicodeDecodeError): if isinstance(value, bytes): properties[key] = value.decode("utf-8") if not (addresses := service.addresses or service.parsed_addresses()): return None
async def _process_service_update(self, zeroconf: HaZeroconf, service_type: str, name: str) -> None: """Process a zeroconf update.""" async_service_info = AsyncServiceInfo(service_type, name) await async_service_info.async_request(zeroconf, 3000) info = info_from_service(async_service_info) if not info: # Prevent the browser thread from collapsing _LOGGER.debug("Failed to get addresses for device %s", name) return _LOGGER.debug("Discovered new device %s %s", name, info) assert self.flow_dispatcher is not None # If we can handle it as a HomeKit discovery, we do that here. if service_type in HOMEKIT_TYPES: if pending_flow := handle_homekit(self.hass, self.homekit_models, info): self.flow_dispatcher.async_create(pending_flow) # Continue on here as homekit_controller # still needs to get updates on devices # so it can see when the 'c#' field is updated. # # We only send updates to homekit_controller # if the device is already paired in order to avoid # offering a second discovery for the same device if pending_flow and HOMEKIT_PAIRED_STATUS_FLAG in info[ "properties"]: try: # 0 means paired and not discoverable by iOS clients) if int(info["properties"][HOMEKIT_PAIRED_STATUS_FLAG]): return except ValueError: # HomeKit pairing status unknown # likely bad homekit data return
async def test_async_find_data_for_device_id_info_without_id(mock_asynczeroconf): desc = { b"c#": b"1", b"md": b"unittest", b"s#": b"1", b"ci": b"5", b"sf": b"0", } info = AsyncServiceInfo( "_hap._tcp.local.", "foo2._hap._tcp.local.", addresses=[socket.inet_aton("127.0.0.1")], port=1234, properties=desc, weight=0, priority=0, ) with patch("aiohomekit.zeroconf.AsyncServiceInfo", return_value=info): with pytest.raises(AccessoryNotFoundError): await async_find_data_for_device_id( device_id="00:00:01:00:00:02", max_seconds=1, async_zeroconf_instance=mock_asynczeroconf, )
async def test_async_discover_homekit_devices_with_service_browser_running( mock_asynczeroconf, ): desc = { b"c#": b"1", b"id": b"00:00:01:00:00:02", b"md": b"unittest", b"s#": b"1", b"ci": b"5", b"sf": b"0", } info = AsyncServiceInfo( "_hap._tcp.local.", "foo._hap._tcp.local.", addresses=[socket.inet_aton("127.0.0.1")], port=1234, properties=desc, weight=0, priority=0, ) info2 = AsyncServiceInfo( "_hap._tcp.local.", "Foo2._hap._tcp.local.", addresses=[socket.inet_aton("127.0.0.1")], port=1234, properties=desc, weight=0, priority=0, ) mock_asynczeroconf.zeroconf.cache = MagicMock( get_all_by_details=MagicMock( return_value=[ MagicMock(alias="foo._hap._tcp.local."), MagicMock(alias="Foo2._hap._tcp.local."), ] ) ) with patch( "aiohomekit.zeroconf.AsyncServiceInfo", side_effect=[info, info2] ) as asyncserviceinfo_mock, patch( "aiohomekit.zeroconf.async_zeroconf_has_hap_service_browser", return_value=True ): result = await async_discover_homekit_devices( max_seconds=1, async_zeroconf_instance=mock_asynczeroconf ) assert result == [ { "address": "127.0.0.1", "c#": "1", "category": "Lightbulb", "ci": "5", "ff": 0, "flags": FeatureFlags(0), "id": "00:00:01:00:00:02", "md": "unittest", "name": "foo._hap._tcp.local.", "port": 1234, "pv": "1.0", "s#": "1", "sf": "0", "statusflags": "Accessory has been paired.", }, { "address": "127.0.0.1", "c#": "1", "category": "Lightbulb", "ci": "5", "ff": 0, "flags": FeatureFlags(0), "id": "00:00:01:00:00:02", "md": "unittest", "name": "Foo2._hap._tcp.local.", "port": 1234, "pv": "1.0", "s#": "1", "sf": "0", "statusflags": "Accessory has been paired.", }, ] assert asyncserviceinfo_mock.mock_calls == [ call("_hap._tcp.local.", "foo._hap._tcp.local."), call("_hap._tcp.local.", "Foo2._hap._tcp.local."), ]