Repository: Jc2k/aiohomekit

Issue:
Invalid HomeKit Zeroconf record
Getting the following in home assistant logs...

022-11-02 17:33:30.752 DEBUG (MainThread) [aiohomekit.protocol.tlv] sending [
1 (Identifier): (2 bytes/<class 'bytes'>) b'\x01\x00'
2 (Salt): (1 bytes/<class 'bytes'>) b'\x01'
]
2022-11-02 17:33:33.767 DEBUG (MainThread) [aiohomekit.zeroconf] _hap._tcp.local.: Not a valid homekit device: Invalid HomeKit Zeroconf record: Missing address
2022-11-02 17:33:33.767 DEBUG (MainThread) [aiohomekit.zeroconf] _hap._tcp.local.: Not a valid homekit device: Invalid HomeKit Zeroconf record: Missing address
2022-11-02 17:33:33.767 DEBUG (MainThread) [aiohomekit.zeroconf] _hap._tcp.local.: Not a valid homekit device: Invalid HomeKit Zeroconf record: Missing address
2022-11-02 17:33:33.768 DEBUG (MainThread) [aiohomekit.zeroconf] _hap._tcp.local.: Not a valid homekit device: Invalid HomeKit Zeroconf record: Missing address
2022-11-02 17:33:33.768 DEBUG (MainThread) [aiohomekit.zeroconf] _hap._tcp.local.: Not a valid homekit device: Invalid HomeKit Zeroconf record: Missing address
2022-11-02 17:33:33.768 DEBUG (MainThread) [aiohomekit.zeroconf] _hap._tcp.local.: Not a valid homekit device: Invalid HomeKit Zeroconf record: Missing address
2022-11-02 17:33:33.768 DEBUG (MainThread) [aiohomekit.zeroconf] _hap._tcp.local.: Not a valid homekit device: Invalid HomeKit Zeroconf record: Missing address
2022-11-02 17:33:33.769 DEBUG (MainThread) [aiohomekit.zeroconf] _hap._tcp.local.: Not a valid homekit device: Invalid HomeKit Zeroconf record: Missing address

Comments:

Patch:
diff --git a/aiohomekit/zeroconf.py b/aiohomekit/zeroconf.py
index 51b6b48..030b99c 100644
--- a/aiohomekit/zeroconf.py
+++ b/aiohomekit/zeroconf.py
@@ -25,7 +25,7 @@ from ipaddress import ip_address
 import logging
 
 import async_timeout
-from zeroconf import ServiceListener, ServiceStateChange, Zeroconf
+from zeroconf import DNSPointer, ServiceListener, ServiceStateChange, Zeroconf
 from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
 
 from aiohomekit.characteristic_cache import CharacteristicCacheType
@@ -216,12 +216,16 @@ class ZeroconfController(AbstractController):
             self._async_zeroconf_instance, self.hap_type
         )
         self._browser.service_state_changed.register_handler(self._handle_service)
+        await self._async_update_from_cache(zc)
 
+        return self
+
+    async def _async_update_from_cache(self, zc: Zeroconf) -> None:
+        """Load the records from the cache."""
         infos = [
-            AsyncServiceInfo(self.hap_type, record.name)
-            for record in zc.cache.get_all_by_details(self.hap_type, TYPE_PTR, CLASS_IN)
+            AsyncServiceInfo(self.hap_type, record.alias)
+            for record in self._async_get_ptr_records(zc)
         ]
-
         tasks = []
         for info in infos:
             if info.load_from_cache(self._async_zeroconf_instance.zeroconf):
@@ -232,7 +236,9 @@ class ZeroconfController(AbstractController):
         if tasks:
             await asyncio.gather(*tasks)
 
-        return self
+    def _async_get_ptr_records(self, zc: Zeroconf) -> list[DNSPointer]:
+        """Return all PTR records for the HAP type."""
+        return zc.cache.async_all_by_details(self.hap_type, TYPE_PTR, CLASS_IN)
 
     def _handle_service(
         self,


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 7472d30..2fbb52f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -10,7 +10,7 @@ from unittest import mock
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
-from zeroconf import SignalRegistrationInterface
+from zeroconf import DNSCache, SignalRegistrationInterface
 
 from aiohomekit import Controller
 from aiohomekit.controller.ip import IpPairing
@@ -70,6 +70,7 @@ def mock_asynczeroconf():
             zc.async_register_service = AsyncMock()
             zc.async_close = AsyncMock()
             zeroconf = MagicMock(name="zeroconf_mock")
+            zeroconf.cache = DNSCache()
             zeroconf.async_wait_for_start = AsyncMock()
             zeroconf.listeners = [AsyncServiceBrowserStub()]
             zc.zeroconf = zeroconf
diff --git a/tests/test_controller_ip_controller.py b/tests/test_controller_ip_controller.py
index ea8f477..385d397 100644
--- a/tests/test_controller_ip_controller.py
+++ b/tests/test_controller_ip_controller.py
@@ -1,10 +1,12 @@
 from collections.abc import Iterable
 import contextlib
 import socket
-from unittest.mock import MagicMock, patch
+from typing import Optional
+from unittest.mock import patch
 
 import pytest
-from zeroconf.asyncio import AsyncServiceInfo
+from zeroconf import DNSQuestionType, Zeroconf
+from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
 
 from aiohomekit.characteristic_cache import CharacteristicCacheMemory
 from aiohomekit.controller.ip.controller import IpController
@@ -12,9 +14,23 @@ from aiohomekit.exceptions import AccessoryNotFoundError
 from aiohomekit.model.categories import Categories
 from aiohomekit.model.status_flags import StatusFlags
 
+HAP_TYPE_TCP = "_hap._tcp.local."
+HAP_TYPE_UDP = "_hap._udp.local."
+CLASS_IN = 1
+TYPE_PTR = 12
 
-@contextlib.contextmanager
-def _install_mock_service_info(mock_asynczeroconf) -> Iterable[AsyncServiceInfo]:
+
+class MockedAsyncServiceInfo(AsyncServiceInfo):
+    async def async_request(
+        self,
+        zc: "Zeroconf",
+        timeout: float,
+        question_type: Optional[DNSQuestionType] = None,
+    ) -> bool:
+        return self.load_from_cache(zc)
+
+
+def _get_mock_service_info():
     desc = {
         b"c#": b"1",
         b"id": b"00:00:01:00:00:02",
@@ -23,10 +39,9 @@ def _install_mock_service_info(mock_asynczeroconf) -> Iterable[AsyncServiceInfo]
         b"ci": b"5",
         b"sf": b"0",
     }
-
-    info = AsyncServiceInfo(
-        "_hap._tcp.local.",
-        "foo._hap._tcp.local.",
+    return MockedAsyncServiceInfo(
+        HAP_TYPE_TCP,
+        f"foo.{HAP_TYPE_TCP}",
         addresses=[socket.inet_aton("127.0.0.1")],
         port=1234,
         properties=desc,
@@ -34,26 +49,33 @@ def _install_mock_service_info(mock_asynczeroconf) -> Iterable[AsyncServiceInfo]
         priority=0,
     )
 
-    mock_asynczeroconf.zeroconf.cache = MagicMock(
-        get_all_by_details=MagicMock(
-            return_value=[
-                MagicMock(alias="foo._hap._tcp.local."),
-            ]
-        )
+
+@contextlib.contextmanager
+def _install_mock_service_info(
+    mock_asynczeroconf: AsyncZeroconf, info: MockedAsyncServiceInfo
+) -> Iterable[AsyncServiceInfo]:
+    zeroconf: Zeroconf = mock_asynczeroconf.zeroconf
+    zeroconf.cache.async_add_records(
+        [*info.dns_addresses(), info.dns_pointer(), info.dns_service(), info.dns_text()]
+    )
+
+    assert (
+        zeroconf.cache.async_all_by_details(HAP_TYPE_TCP, TYPE_PTR, CLASS_IN)
+        is not None
     )
 
-    with patch("aiohomekit.zeroconf.AsyncServiceInfo", side_effect=[info]):
-        yield info
+    with patch("aiohomekit.zeroconf.AsyncServiceInfo", MockedAsyncServiceInfo):
+        yield
 
 
-async def test_discover_find_one(mock_asynczeroconf):
+async def test_discover_find_one(mock_asynczeroconf: AsyncZeroconf):
     controller = IpController(
         char_cache=CharacteristicCacheMemory(), zeroconf_instance=mock_asynczeroconf
     )
-
-    with _install_mock_service_info(mock_asynczeroconf):
+    with _install_mock_service_info(mock_asynczeroconf, _get_mock_service_info()):
         async with controller:
             result = await controller.async_find("00:00:01:00:00:02")
+        await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
 
     assert result.description.id == "00:00:01:00:00:02"
     assert result.description.category == Categories.LIGHTBULB
@@ -64,15 +86,18 @@ async def test_discover_find_one(mock_asynczeroconf):
     assert result.paired is True
 
 
-async def test_discover_find_one_unpaired(mock_asynczeroconf):
+async def test_discover_find_one_unpaired(mock_asynczeroconf: AsyncZeroconf):
     controller = IpController(
         char_cache=CharacteristicCacheMemory(), zeroconf_instance=mock_asynczeroconf
     )
 
-    with _install_mock_service_info(mock_asynczeroconf) as svc:
-        svc.properties[b"sf"] = b"1"
+    svc = _get_mock_service_info()
+    svc.properties[b"sf"] = b"1"
+    svc._set_properties(svc.properties)
+    with _install_mock_service_info(mock_asynczeroconf, svc):
         async with controller:
             result = await controller.async_find("00:00:01:00:00:02")
+        await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
 
     assert result.description.id == "00:00:01:00:00:02"
     assert result.description.status_flags == StatusFlags.UNPAIRED
@@ -86,56 +111,73 @@ async def test_discover_find_none(mock_asynczeroconf):
 
     async with controller:
         with pytest.raises(AccessoryNotFoundError):
-            await controller.async_find("00:00:00:00:00:00")
+            await controller.async_find("00:00:00:00:00:00", timeout=0.001)
 
 
-async def test_find_device_id_case_lower(mock_asynczeroconf):
+async def test_find_device_id_case_lower(mock_asynczeroconf: AsyncZeroconf):
     controller = IpController(
         char_cache=CharacteristicCacheMemory(), zeroconf_instance=mock_asynczeroconf
     )
 
-    with _install_mock_service_info(mock_asynczeroconf) as svc_info:
-        svc_info.properties[b"id"] = b"aa:aa:aa:aa:aa:aa"
+    svc_info_1 = _get_mock_service_info()
+    svc_info_1.properties[b"id"] = b"aa:aa:aa:aa:aa:aa"
+    svc_info_1._set_properties(svc_info_1.properties)
 
+    with _install_mock_service_info(mock_asynczeroconf, svc_info_1):
         async with controller:
+            await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
             res = await controller.async_find("AA:AA:AA:AA:AA:AA")
             assert res.description.id == "aa:aa:aa:aa:aa:aa"
 
-    with _install_mock_service_info(mock_asynczeroconf) as svc_info:
-        svc_info.properties[b"id"] = b"aa:aa:aa:aa:aa:aa"
+    svc_info_2 = _get_mock_service_info()
+    svc_info_2.properties[b"id"] = b"aa:aa:aa:aa:aa:aa"
+    svc_info_2._set_properties(svc_info_2.properties)
+
+    with _install_mock_service_info(mock_asynczeroconf, svc_info_2):
+        svc_info_2.properties[b"id"] = b"aa:aa:aa:aa:aa:aa"
 
         async with controller:
+            await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
             res = await controller.async_find("aa:aa:aa:aa:aa:aa")
             assert res.description.id == "aa:aa:aa:aa:aa:aa"
 
 
-async def test_find_device_id_case_upper(mock_asynczeroconf):
+async def test_find_device_id_case_upper(mock_asynczeroconf: AsyncZeroconf):
     controller = IpController(
         char_cache=CharacteristicCacheMemory(), zeroconf_instance=mock_asynczeroconf
     )
 
-    with _install_mock_service_info(mock_asynczeroconf) as svc_info:
-        svc_info.properties[b"id"] = b"AA:AA:aa:aa:AA:AA"
+    svc_info = _get_mock_service_info()
+    svc_info.properties[b"id"] = b"AA:AA:aa:aa:AA:AA"
+    svc_info._set_properties(svc_info.properties)
 
+    with _install_mock_service_info(mock_asynczeroconf, svc_info):
         async with controller:
+            await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
             res = await controller.async_find("AA:AA:AA:AA:AA:AA")
             assert res.description.id == "aa:aa:aa:aa:aa:aa"
 
-    with _install_mock_service_info(mock_asynczeroconf) as svc_info:
-        svc_info.properties[b"id"] = b"AA:AA:aa:aa:AA:AA"
+    svc_info = _get_mock_service_info()
+    svc_info.properties[b"id"] = b"AA:AA:aa:aa:AA:AA"
+    svc_info._set_properties(svc_info.properties)
+
+    with _install_mock_service_info(mock_asynczeroconf, svc_info):
 
         async with controller:
+            await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
             res = await controller.async_find("aa:aa:aa:aa:aa:aa")
             assert res.description.id == "aa:aa:aa:aa:aa:aa"
 
 
-async def test_discover_discover_one(mock_asynczeroconf):
+async def test_discover_discover_one(mock_asynczeroconf: AsyncZeroconf):
     controller = IpController(
         char_cache=CharacteristicCacheMemory(), zeroconf_instance=mock_asynczeroconf
     )
 
-    with _install_mock_service_info(mock_asynczeroconf):
+    srv_info = _get_mock_service_info()
+    with _install_mock_service_info(mock_asynczeroconf, srv_info):
         async with controller:
+            await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
             results = [d async for d in controller.async_discover()]
 
     assert results[0].description.id == "00:00:01:00:00:02"
@@ -156,58 +198,75 @@ async def test_discover_none(mock_asynczeroconf):
     assert results == []
 
 
-async def test_discover_missing_csharp(mock_asynczeroconf):
+async def test_discover_missing_csharp(mock_asynczeroconf: AsyncZeroconf):
     controller = IpController(
         char_cache=CharacteristicCacheMemory(), zeroconf_instance=mock_asynczeroconf
     )
 
-    with _install_mock_service_info(mock_asynczeroconf) as svc_info:
-        del svc_info.properties[b"c#"]
+    svc_info = _get_mock_service_info()
+    del svc_info.properties[b"c#"]
+    svc_info._set_properties(svc_info.properties)
+
+    with _install_mock_service_info(mock_asynczeroconf, svc_info):
         async with controller:
+            await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
             results = [d async for d in controller.async_discover()]
 
     assert results[0].description.id == "00:00:01:00:00:02"
     assert results[0].description.config_num == 0
 
 
-async def test_discover_csharp_case(mock_asynczeroconf):
+async def test_discover_csharp_case(mock_asynczeroconf: AsyncZeroconf):
     controller = IpController(
         char_cache=CharacteristicCacheMemory(), zeroconf_instance=mock_asynczeroconf
     )
 
-    with _install_mock_service_info(mock_asynczeroconf) as svc_info:
-        del svc_info.properties[b"c#"]
-        svc_info.properties[b"C#"] = b"1"
+    svc_info = _get_mock_service_info()
+    del svc_info.properties[b"c#"]
+    svc_info.properties[b"C#"] = b"1"
+    svc_info._set_properties(svc_info.properties)
 
+    with _install_mock_service_info(mock_asynczeroconf, svc_info):
         async with controller:
+            await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
             results = [d async for d in controller.async_discover()]
 
     assert results[0].description.config_num == 1
 
 
-async def test_discover_device_id_case_lower(mock_asynczeroconf):
+async def test_discover_device_id_case_lower(mock_asynczeroconf: AsyncZeroconf):
     controller = IpController(
         char_cache=CharacteristicCacheMemory(), zeroconf_instance=mock_asynczeroconf
     )
 
-    with _install_mock_service_info(mock_asynczeroconf) as svc_info:
-        svc_info.properties[b"id"] = b"aa:aa:aa:aa:aa:aa"
+    svc_info = _get_mock_service_info()
+    svc_info.properties[b"id"] = b"aa:aa:aa:aa:aa:aa"
+    svc_info._set_properties(svc_info.properties)
+
+    with _install_mock_service_info(mock_asynczeroconf, svc_info):
 
         async with controller:
+            await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
+
             results = [d async for d in controller.async_discover()]
 
     assert results[0].description.id == "aa:aa:aa:aa:aa:aa"
 
 
-async def test_discover_device_id_case_upper(mock_asynczeroconf):
+async def test_discover_device_id_case_upper(mock_asynczeroconf: AsyncZeroconf):
     controller = IpController(
         char_cache=CharacteristicCacheMemory(), zeroconf_instance=mock_asynczeroconf
     )
 
-    with _install_mock_service_info(mock_asynczeroconf) as svc_info:
-        svc_info.properties[b"id"] = b"AA:AA:aa:aa:AA:AA"
+    svc_info = _get_mock_service_info()
+    svc_info.properties[b"id"] = b"AA:AA:aa:aa:AA:AA"
+    svc_info._set_properties(svc_info.properties)
+
+    with _install_mock_service_info(mock_asynczeroconf, svc_info):
 
         async with controller:
+            await controller._async_update_from_cache(mock_asynczeroconf.zeroconf)
+
             results = [d async for d in controller.async_discover()]
 
     assert results[0].description.id == "aa:aa:aa:aa:aa:aa"