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.
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/PKG-INFO +19 -1
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/README.md +17 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/pyproject.toml +2 -1
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/__init__.py +5 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/client.py +4 -2
- python_qube_heatpump-1.16.1/src/python_qube_heatpump/mdns.py +141 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client.py +17 -0
- python_qube_heatpump-1.16.1/tests/test_mdns.py +183 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/workflows/ci.yml +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/workflows/python-publish.yml +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.gitignore +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/AGENTS.md +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/CLAUDE.md +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/LICENSE +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/docs/modbus-lijst-qube-totaal.pdf +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/pytest.ini +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/const.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/__init__.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/base.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/sensors.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/entities/switches.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/models.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/network.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/py.typed +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/conftest.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client_batching.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client_read_all.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client_resilience.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_client_writes.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_const.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_entities.py +0 -0
- {python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/tests/test_models.py +0 -0
- {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.
|
|
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.
|
|
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]
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/__init__.py
RENAMED
|
@@ -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
|
]
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/client.py
RENAMED
|
@@ -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
|
|
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
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/ISSUE_TEMPLATE/bug_report.yml
RENAMED
|
File without changes
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/ISSUE_TEMPLATE/config.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/.github/workflows/python-publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/docs/modbus-lijst-qube-totaal.pdf
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/const.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/models.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/network.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.15.0 → python_qube_heatpump-1.16.1}/src/python_qube_heatpump/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|