python-qube-heatpump 1.15.0__tar.gz → 1.16.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/PKG-INFO +19 -1
  2. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/README.md +17 -0
  3. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/pyproject.toml +2 -1
  4. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/__init__.py +5 -0
  5. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/client.py +4 -2
  6. python_qube_heatpump-1.16.1/src/python_qube_heatpump/mdns.py +141 -0
  7. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client.py +17 -0
  8. python_qube_heatpump-1.16.1/tests/test_mdns.py +183 -0
  9. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  10. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  11. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  12. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/workflows/ci.yml +0 -0
  13. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/workflows/python-publish.yml +0 -0
  14. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.gitignore +0 -0
  15. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/AGENTS.md +0 -0
  16. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/CLAUDE.md +0 -0
  17. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/LICENSE +0 -0
  18. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  19. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/pytest.ini +0 -0
  20. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/const.py +0 -0
  21. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/__init__.py +0 -0
  22. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/base.py +0 -0
  23. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  24. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/sensors.py +0 -0
  25. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/switches.py +0 -0
  26. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/models.py +0 -0
  27. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/network.py +0 -0
  28. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/py.typed +0 -0
  29. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/conftest.py +0 -0
  30. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client_batching.py +0 -0
  31. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client_read_all.py +0 -0
  32. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client_resilience.py +0 -0
  33. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client_writes.py +0 -0
  34. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_const.py +0 -0
  35. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_entities.py +0 -0
  36. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_models.py +0 -0
  37. {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_network.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-qube-heatpump
3
- Version: 1.15.0
3
+ Version: 1.16.1
4
4
  Summary: Async Modbus client for Qube Heat Pumps
5
5
  Project-URL: Homepage, https://github.com/MattieGit/python-qube-heatpump
6
6
  Project-URL: Bug Tracker, https://github.com/MattieGit/python-qube-heatpump/issues
@@ -11,6 +11,7 @@ Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.12
13
13
  Requires-Dist: pymodbus>=3.11.0
14
+ Requires-Dist: zeroconf>=0.132.0
14
15
  Provides-Extra: test
15
16
  Requires-Dist: pytest; extra == 'test'
16
17
  Requires-Dist: pytest-asyncio; extra == 'test'
@@ -53,8 +54,25 @@ asyncio.run(main())
53
54
  - **Entity definitions** for sensors, binary sensors, and switches
54
55
  - **FLOAT32 decoding** with big endian (ABCD) byte order
55
56
  - **Type-safe dataclasses** for entity definitions
57
+ - **mDNS device information**: panel software version, controller firmware and UUID from the controller's advertisement
56
58
  - **Monotonic clamping with reset detection** for the energy totals: sub-kWh jitter is clamped, while a drop of more than 1 kWh that persists for 3 consecutive reads is accepted as a counter reset (logged as a warning); `clear_monotonic_cache()` forgets all baselines
57
59
 
60
+ ## Device information over mDNS
61
+
62
+ The controller advertises a `_workstation._tcp` mDNS service whose TXT record holds the software version shown on the panel, the controller firmware and a stable UUID. Register 77 (`async_get_software_version()`) reads 0.0 on recent firmware, so use this for the version instead:
63
+
64
+ ```python
65
+ from zeroconf.asyncio import AsyncZeroconf
66
+ from python_qube_heatpump import async_get_device_info
67
+
68
+ aiozc = AsyncZeroconf() # in Home Assistant: zeroconf.async_get_async_instance(hass)
69
+ info = await async_get_device_info("192.168.1.100", aiozc)
70
+ if info:
71
+ print(info.software_version, info.controller_firmware, info.uuid)
72
+ ```
73
+
74
+ The lookup returns `None` when no record is found within the timeout (default 5 s), for example when mDNS is not forwarded between VLANs. `parse_device_info(properties)` parses TXT properties you already have, such as those from a zeroconf discovery.
75
+
58
76
  ## Entity Definitions
59
77
 
60
78
  The library provides pre-defined entity definitions for all Qube Modbus registers:
@@ -34,8 +34,25 @@ asyncio.run(main())
34
34
  - **Entity definitions** for sensors, binary sensors, and switches
35
35
  - **FLOAT32 decoding** with big endian (ABCD) byte order
36
36
  - **Type-safe dataclasses** for entity definitions
37
+ - **mDNS device information**: panel software version, controller firmware and UUID from the controller's advertisement
37
38
  - **Monotonic clamping with reset detection** for the energy totals: sub-kWh jitter is clamped, while a drop of more than 1 kWh that persists for 3 consecutive reads is accepted as a counter reset (logged as a warning); `clear_monotonic_cache()` forgets all baselines
38
39
 
40
+ ## Device information over mDNS
41
+
42
+ The controller advertises a `_workstation._tcp` mDNS service whose TXT record holds the software version shown on the panel, the controller firmware and a stable UUID. Register 77 (`async_get_software_version()`) reads 0.0 on recent firmware, so use this for the version instead:
43
+
44
+ ```python
45
+ from zeroconf.asyncio import AsyncZeroconf
46
+ from python_qube_heatpump import async_get_device_info
47
+
48
+ aiozc = AsyncZeroconf() # in Home Assistant: zeroconf.async_get_async_instance(hass)
49
+ info = await async_get_device_info("192.168.1.100", aiozc)
50
+ if info:
51
+ print(info.software_version, info.controller_firmware, info.uuid)
52
+ ```
53
+
54
+ The lookup returns `None` when no record is found within the timeout (default 5 s), for example when mDNS is not forwarded between VLANs. `parse_device_info(properties)` parses TXT properties you already have, such as those from a zeroconf discovery.
55
+
39
56
  ## Entity Definitions
40
57
 
41
58
  The library provides pre-defined entity definitions for all Qube Modbus registers:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-qube-heatpump"
7
- version = "1.15.0"
7
+ version = "1.16.1"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -18,6 +18,7 @@ classifiers = [
18
18
  ]
19
19
  dependencies = [
20
20
  "pymodbus>=3.11.0",
21
+ "zeroconf>=0.132.0",
21
22
  ]
22
23
 
23
24
  [project.optional-dependencies]
@@ -17,6 +17,7 @@ from .entities import (
17
17
  SENSORS,
18
18
  SWITCHES,
19
19
  )
20
+ from .mdns import QubeDeviceInfo, async_get_device_info, parse_device_info
20
21
  from .models import QubeState
21
22
  from .network import async_get_mac_address
22
23
 
@@ -41,4 +42,8 @@ __all__ = [
41
42
  "resolve_status",
42
43
  # Network
43
44
  "async_get_mac_address",
45
+ # mDNS device information
46
+ "QubeDeviceInfo",
47
+ "async_get_device_info",
48
+ "parse_device_info",
44
49
  ]
@@ -394,10 +394,12 @@ class QubeClient:
394
394
  Reads InputRegister 77 (GeneralMng.Softversion).
395
395
 
396
396
  Returns:
397
- Version as string (e.g., "2.15"), or None on error.
397
+ Version as string (e.g., "2.15"), or None on error or when the
398
+ register reads 0 (it is not populated on some firmware, e.g.
399
+ 4.1.00; use ``async_get_device_info`` for the panel version).
398
400
  """
399
401
  value = await self.read_value(const.SOFTWARE_VERSION)
400
- if value is None:
402
+ if not value:
401
403
  return None
402
404
  return f"{value:.2f}"
403
405
 
@@ -0,0 +1,141 @@
1
+ """Device information from the Qube's mDNS advertisement.
2
+
3
+ The Qube's Carel controller advertises a ``_workstation._tcp`` service whose
4
+ TXT record carries the panel software version (``ProjectRelease``), the
5
+ controller firmware (``FWRelease``) and a stable ``Uuid``. The software
6
+ version register (77) returns 0.0 on recent firmware, so this is the only
7
+ reliable source of the version shown on the panel.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import contextlib
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass
16
+ import logging
17
+ import socket
18
+
19
+ from zeroconf import ServiceStateChange, Zeroconf
20
+ from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
21
+
22
+ _LOGGER = logging.getLogger(__name__)
23
+
24
+ SERVICE_TYPE = "_workstation._tcp.local."
25
+ CAREL_VENDOR_ID = "000A5C"
26
+
27
+ # Per-service resolve timeout (ms) and overall lookup timeout (s)
28
+ _RESOLVE_TIMEOUT_MS = 1500
29
+ DEFAULT_LOOKUP_TIMEOUT = 5.0
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class QubeDeviceInfo:
34
+ """Device information advertised by the Qube over mDNS."""
35
+
36
+ uuid: str
37
+ software_version: str | None
38
+ controller_firmware: str | None
39
+ project_name: str | None
40
+
41
+
42
+ def _decode(value: str | bytes | None) -> str | None:
43
+ """Decode a TXT value; empty values become None."""
44
+ if isinstance(value, bytes):
45
+ value = value.decode("utf-8", errors="replace")
46
+ return value or None
47
+
48
+
49
+ def parse_device_info(
50
+ properties: Mapping[str, str | None] | Mapping[bytes, bytes | None],
51
+ ) -> QubeDeviceInfo | None:
52
+ """Parse the TXT properties of a Qube ``_workstation._tcp`` service.
53
+
54
+ Accepts both raw zeroconf properties (bytes) and decoded properties
55
+ (str, as Home Assistant passes them). Keys are matched case-insensitively.
56
+
57
+ Returns None when the record does not come from a Carel controller or
58
+ has no Uuid.
59
+ """
60
+ txt: dict[str, str | None] = {}
61
+ for key, value in properties.items():
62
+ name = _decode(key)
63
+ if name is not None:
64
+ txt[name.lower()] = _decode(value)
65
+
66
+ vendor = txt.get("vendor")
67
+ uuid = txt.get("uuid")
68
+ if vendor is None or vendor.upper() != CAREL_VENDOR_ID or uuid is None:
69
+ return None
70
+
71
+ return QubeDeviceInfo(
72
+ uuid=uuid,
73
+ software_version=txt.get("projectrelease"),
74
+ controller_firmware=txt.get("fwrelease"),
75
+ project_name=txt.get("projectname"),
76
+ )
77
+
78
+
79
+ def _resolve_ip(host: str) -> str | None:
80
+ """Resolve a hostname to an IPv4 address."""
81
+ try:
82
+ return socket.gethostbyname(host)
83
+ except OSError:
84
+ return None
85
+
86
+
87
+ def _matches_host(info: AsyncServiceInfo, host: str, ip: str | None) -> bool:
88
+ """Return True if the resolved service belongs to the given host."""
89
+ if ip is not None and ip in info.parsed_addresses():
90
+ return True
91
+ server = (info.server or "").lower().rstrip(".")
92
+ return server == host.lower().rstrip(".")
93
+
94
+
95
+ async def async_get_device_info(
96
+ host: str,
97
+ aiozc: AsyncZeroconf,
98
+ timeout: float = DEFAULT_LOOKUP_TIMEOUT,
99
+ ) -> QubeDeviceInfo | None:
100
+ """Look up the mDNS device information of the Qube at ``host``.
101
+
102
+ Browses ``_workstation._tcp`` services and returns the first Carel
103
+ record whose address (or mDNS hostname) matches ``host``. The caller
104
+ owns ``aiozc``; Home Assistant integrations must pass the shared
105
+ instance from ``zeroconf.async_get_async_instance``.
106
+
107
+ Returns None if no matching record is found within ``timeout`` seconds,
108
+ for example when mDNS is not forwarded between VLANs.
109
+ """
110
+ ip = await asyncio.to_thread(_resolve_ip, host)
111
+ names: asyncio.Queue[str] = asyncio.Queue()
112
+ seen: set[str] = set()
113
+
114
+ def _on_change(
115
+ zeroconf: Zeroconf,
116
+ service_type: str,
117
+ name: str,
118
+ state_change: ServiceStateChange,
119
+ ) -> None:
120
+ if state_change is not ServiceStateChange.Removed and name not in seen:
121
+ seen.add(name)
122
+ names.put_nowait(name)
123
+
124
+ browser = AsyncServiceBrowser(aiozc.zeroconf, [SERVICE_TYPE], handlers=[_on_change])
125
+ try:
126
+ async with asyncio.timeout(timeout):
127
+ while True:
128
+ name = await names.get()
129
+ info = AsyncServiceInfo(SERVICE_TYPE, name)
130
+ if not await info.async_request(aiozc.zeroconf, _RESOLVE_TIMEOUT_MS):
131
+ continue
132
+ if not _matches_host(info, host, ip):
133
+ continue
134
+ if (device := parse_device_info(info.properties)) is not None:
135
+ return device
136
+ except TimeoutError:
137
+ _LOGGER.debug("No mDNS device information found for %s", host)
138
+ return None
139
+ finally:
140
+ with contextlib.suppress(Exception):
141
+ await browser.async_cancel()
@@ -246,6 +246,23 @@ async def test_get_software_version(mock_modbus_client):
246
246
  assert isinstance(result, str)
247
247
 
248
248
 
249
+ @pytest.mark.asyncio
250
+ async def test_get_software_version_zero(mock_modbus_client):
251
+ """Test an unpopulated register (reads 0) returns None, not "0.00"."""
252
+ client = QubeClient("1.2.3.4", 502)
253
+ mock_instance = mock_modbus_client.return_value
254
+
255
+ mock_resp = MagicMock()
256
+ mock_resp.isError.return_value = False
257
+ mock_resp.registers = [0, 0]
258
+
259
+ mock_instance.read_input_registers = AsyncMock(return_value=mock_resp)
260
+ client._client = mock_instance
261
+
262
+ result = await client.async_get_software_version()
263
+ assert result is None
264
+
265
+
249
266
  @pytest.mark.asyncio
250
267
  async def test_get_software_version_error(mock_modbus_client):
251
268
  """Test software version returns None on error."""
@@ -0,0 +1,183 @@
1
+ """Test mDNS device information lookup."""
2
+
3
+ from unittest.mock import AsyncMock, MagicMock, patch
4
+
5
+ import pytest
6
+ from zeroconf import ServiceStateChange
7
+
8
+ from python_qube_heatpump.mdns import (
9
+ SERVICE_TYPE,
10
+ QubeDeviceInfo,
11
+ async_get_device_info,
12
+ parse_device_info,
13
+ )
14
+
15
+ # TXT record captured from a Qube (fw 4.1.00)
16
+ QUBE_TXT = {
17
+ b"Vendor": b"000A5C",
18
+ b"MachineType": b"312",
19
+ b"HWCode": b"344",
20
+ b"InterfaceType": b"11",
21
+ b"Uuid": b"000100000007B5EA",
22
+ b"FWRelease": b"v5.1.007",
23
+ b"ToolRelease": b"5.1.9",
24
+ b"ProjectRelease": b"4.1.00",
25
+ b"ProjectName": b"DEQSIHPB000CR",
26
+ b"OemId": b"",
27
+ b"MachineCode": None,
28
+ }
29
+
30
+ QUBE_INFO = QubeDeviceInfo(
31
+ uuid="000100000007B5EA",
32
+ software_version="4.1.00",
33
+ controller_firmware="v5.1.007",
34
+ project_name="DEQSIHPB000CR",
35
+ )
36
+
37
+
38
+ def test_parse_bytes_properties():
39
+ """Raw zeroconf properties are parsed."""
40
+ assert parse_device_info(QUBE_TXT) == QUBE_INFO
41
+
42
+
43
+ def test_parse_str_properties_case_insensitive():
44
+ """Decoded properties with different key case are parsed."""
45
+ props = {
46
+ "vendor": "000a5c",
47
+ "UUID": "000100000007B5EA",
48
+ "projectrelease": "4.1.00",
49
+ }
50
+ assert parse_device_info(props) == QubeDeviceInfo(
51
+ uuid="000100000007B5EA",
52
+ software_version="4.1.00",
53
+ controller_firmware=None,
54
+ project_name=None,
55
+ )
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ "props",
60
+ [
61
+ {b"Vendor": b"001122", b"Uuid": b"X"},
62
+ {b"Vendor": b"000A5C"},
63
+ {b"Vendor": b"000A5C", b"Uuid": b""},
64
+ {b"Uuid": b"X"},
65
+ {},
66
+ ],
67
+ )
68
+ def test_parse_rejects_non_qube(props):
69
+ """Records from other vendors or without a Uuid are ignored."""
70
+ assert parse_device_info(props) is None
71
+
72
+
73
+ def _service(name: str, addresses: list[str], server: str, props: dict):
74
+ """Build a fake resolved AsyncServiceInfo."""
75
+ info = MagicMock()
76
+ info.name = name
77
+ info.server = server
78
+ info.properties = props
79
+ info.parsed_addresses.return_value = addresses
80
+ info.async_request = AsyncMock(return_value=True)
81
+ return info
82
+
83
+
84
+ def _patch_zeroconf(services: dict):
85
+ """Patch the browser to announce ``services`` and the resolver to return them."""
86
+
87
+ def browser(zc, types, handlers):
88
+ assert types == [SERVICE_TYPE]
89
+ for name in services:
90
+ handlers[0](
91
+ zeroconf=zc,
92
+ service_type=SERVICE_TYPE,
93
+ name=name,
94
+ state_change=ServiceStateChange.Added,
95
+ )
96
+ mock = MagicMock()
97
+ mock.async_cancel = AsyncMock()
98
+ return mock
99
+
100
+ return (
101
+ patch("python_qube_heatpump.mdns.AsyncServiceBrowser", side_effect=browser),
102
+ patch(
103
+ "python_qube_heatpump.mdns.AsyncServiceInfo",
104
+ side_effect=lambda type_, name: services[name],
105
+ ),
106
+ )
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_lookup_matches_ip():
111
+ """The Carel record whose address matches the host is returned."""
112
+ services = {
113
+ "PC._workstation._tcp.local.": _service(
114
+ "PC", ["192.168.1.10"], "pc.local.", {b"Vendor": b"000A5C", b"Uuid": b"Y"}
115
+ ),
116
+ "Qube._workstation._tcp.local.": _service(
117
+ "Qube", ["192.168.5.208"], "Qube.local.", QUBE_TXT
118
+ ),
119
+ }
120
+ browser, info = _patch_zeroconf(services)
121
+ with (
122
+ browser,
123
+ info,
124
+ patch("python_qube_heatpump.mdns._resolve_ip", return_value="192.168.5.208"),
125
+ ):
126
+ result = await async_get_device_info("192.168.5.208", MagicMock())
127
+
128
+ assert result == QUBE_INFO
129
+
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_lookup_matches_mdns_hostname():
133
+ """A .local host that does not resolve via DNS matches on the SRV server."""
134
+ services = {
135
+ "Qube._workstation._tcp.local.": _service(
136
+ "Qube", ["192.168.5.208"], "Qube.local.", QUBE_TXT
137
+ ),
138
+ }
139
+ browser, info = _patch_zeroconf(services)
140
+ with (
141
+ browser,
142
+ info,
143
+ patch("python_qube_heatpump.mdns._resolve_ip", return_value=None),
144
+ ):
145
+ result = await async_get_device_info("qube.local", MagicMock())
146
+
147
+ assert result == QUBE_INFO
148
+
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_lookup_skips_unresolved_and_non_qube():
152
+ """Services that fail to resolve or are not Carel are skipped until timeout."""
153
+ unresolved = _service("A", ["192.168.5.208"], "a.local.", QUBE_TXT)
154
+ unresolved.async_request = AsyncMock(return_value=False)
155
+ services = {
156
+ "A._workstation._tcp.local.": unresolved,
157
+ "B._workstation._tcp.local.": _service(
158
+ "B", ["192.168.5.208"], "b.local.", {b"Vendor": b"001122"}
159
+ ),
160
+ }
161
+ browser, info = _patch_zeroconf(services)
162
+ with (
163
+ browser,
164
+ info,
165
+ patch("python_qube_heatpump.mdns._resolve_ip", return_value="192.168.5.208"),
166
+ ):
167
+ result = await async_get_device_info("192.168.5.208", MagicMock(), timeout=0.05)
168
+
169
+ assert result is None
170
+
171
+
172
+ @pytest.mark.asyncio
173
+ async def test_lookup_times_out_without_services():
174
+ """No advertisement (e.g. mDNS blocked) returns None after the timeout."""
175
+ browser, info = _patch_zeroconf({})
176
+ with (
177
+ browser,
178
+ info,
179
+ patch("python_qube_heatpump.mdns._resolve_ip", return_value="10.0.0.5"),
180
+ ):
181
+ result = await async_get_device_info("10.0.0.5", MagicMock(), timeout=0.05)
182
+
183
+ assert result is None