python-qube-heatpump 1.11.2__tar.gz → 1.12.0__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.11.2 → python_qube_heatpump-1.12.0}/PKG-INFO +1 -1
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/pyproject.toml +1 -1
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/client.py +167 -67
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/tests/test_client.py +51 -0
- python_qube_heatpump-1.12.0/tests/test_client_batching.py +174 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/.github/workflows/ci.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/.github/workflows/python-publish.yml +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/.gitignore +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/AGENTS.md +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/CLAUDE.md +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/LICENSE +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/README.md +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/pytest.ini +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/__init__.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/const.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/base.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/entities/switches.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/models.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/network.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/py.typed +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/tests/conftest.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/tests/test_const.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/tests/test_entities.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/tests/test_models.py +0 -0
- {python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/tests/test_network.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-qube-heatpump
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.12.0
|
|
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
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/client.py
RENAMED
|
@@ -6,6 +6,7 @@ import logging
|
|
|
6
6
|
import math
|
|
7
7
|
import struct
|
|
8
8
|
import time
|
|
9
|
+
from collections.abc import Iterable
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
from pymodbus.client import AsyncModbusTcpClient
|
|
@@ -21,6 +22,14 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
21
22
|
class QubeClient:
|
|
22
23
|
"""Qube Modbus Client."""
|
|
23
24
|
|
|
25
|
+
# Block-read planning limits. Small address gaps within a block are
|
|
26
|
+
# read along and discarded; a failed block falls back to individual
|
|
27
|
+
# entity reads, so gap registers can never break a value.
|
|
28
|
+
_MAX_BLOCK_REGISTERS = 100 # Modbus allows at most 125 per request
|
|
29
|
+
_MAX_BLOCK_BITS = 256
|
|
30
|
+
_MAX_GAP_REGISTERS = 8
|
|
31
|
+
_MAX_GAP_BITS = 16
|
|
32
|
+
|
|
24
33
|
def __init__(self, host: str, port: int = 502, unit_id: int = 1):
|
|
25
34
|
"""Initialize."""
|
|
26
35
|
self.host = host
|
|
@@ -34,6 +43,21 @@ class QubeClient:
|
|
|
34
43
|
self._next_connect_at: float = 0.0
|
|
35
44
|
# Monotonic clamping for total_increasing counters
|
|
36
45
|
self._monotonic_cache: dict[str, float] = {}
|
|
46
|
+
# Reads that already produced a WARNING (transient failures are
|
|
47
|
+
# logged once per target, then at DEBUG to avoid log spam)
|
|
48
|
+
self._read_failures_warned: set[str] = set()
|
|
49
|
+
|
|
50
|
+
def _log_read_failure(self, target: str, exc: Exception) -> None:
|
|
51
|
+
"""Log a read failure: WARNING on first occurrence per target, DEBUG after.
|
|
52
|
+
|
|
53
|
+
Transient Modbus timeouts recover on the next poll cycle, so
|
|
54
|
+
repeated occurrences should not flood the log at high severity.
|
|
55
|
+
"""
|
|
56
|
+
if target in self._read_failures_warned:
|
|
57
|
+
_LOGGER.debug("Exception reading %s: %s", target, exc)
|
|
58
|
+
else:
|
|
59
|
+
self._read_failures_warned.add(target)
|
|
60
|
+
_LOGGER.warning("Exception reading %s: %s", target, exc)
|
|
37
61
|
|
|
38
62
|
async def connect(self) -> bool:
|
|
39
63
|
"""Connect to the Modbus server."""
|
|
@@ -205,33 +229,12 @@ class QubeClient:
|
|
|
205
229
|
Returns:
|
|
206
230
|
Dictionary mapping entity keys to their values.
|
|
207
231
|
"""
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
except Exception as exc:
|
|
215
|
-
_LOGGER.debug("Error reading sensor %s: %s", key, exc)
|
|
216
|
-
results[key] = None
|
|
217
|
-
|
|
218
|
-
# Read all binary sensors
|
|
219
|
-
for key, entity in BINARY_SENSORS.items():
|
|
220
|
-
try:
|
|
221
|
-
results[key] = await self.read_entity(entity)
|
|
222
|
-
except Exception as exc:
|
|
223
|
-
_LOGGER.debug("Error reading binary sensor %s: %s", key, exc)
|
|
224
|
-
results[key] = None
|
|
225
|
-
|
|
226
|
-
# Read all switches
|
|
227
|
-
for key, entity in SWITCHES.items():
|
|
228
|
-
try:
|
|
229
|
-
results[key] = await self.read_entity(entity)
|
|
230
|
-
except Exception as exc:
|
|
231
|
-
_LOGGER.debug("Error reading switch %s: %s", key, exc)
|
|
232
|
-
results[key] = None
|
|
233
|
-
|
|
234
|
-
return results
|
|
232
|
+
all_entities = [
|
|
233
|
+
*SENSORS.values(),
|
|
234
|
+
*BINARY_SENSORS.values(),
|
|
235
|
+
*SWITCHES.values(),
|
|
236
|
+
]
|
|
237
|
+
return await self.read_entities_batched(all_entities)
|
|
235
238
|
|
|
236
239
|
async def read_value(self, definition: tuple) -> float | None:
|
|
237
240
|
"""Read a single value based on the constant definition."""
|
|
@@ -309,9 +312,141 @@ class QubeClient:
|
|
|
309
312
|
return val
|
|
310
313
|
|
|
311
314
|
except Exception as e:
|
|
312
|
-
|
|
315
|
+
self._log_read_failure(f"address {address}", e)
|
|
313
316
|
return None
|
|
314
317
|
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _register_count(entity: EntityDef) -> int:
|
|
320
|
+
"""Return the number of registers an entity occupies."""
|
|
321
|
+
data_type_str = entity.data_type.value if entity.data_type else None
|
|
322
|
+
return 2 if data_type_str in ("float32", "uint32", "int32") else 1
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _decode_registers(data_type_str: str | None, regs: list[int]) -> float | int:
|
|
326
|
+
"""Decode raw registers based on data type.
|
|
327
|
+
|
|
328
|
+
Qube uses big endian word order (ABCD): regs[0]=MSW, regs[1]=LSW.
|
|
329
|
+
"""
|
|
330
|
+
val: float | int = 0
|
|
331
|
+
if data_type_str == "float32":
|
|
332
|
+
int_val = (regs[0] << 16) | regs[1]
|
|
333
|
+
val = struct.unpack(">f", struct.pack(">I", int_val))[0]
|
|
334
|
+
elif data_type_str == "int16":
|
|
335
|
+
val = regs[0]
|
|
336
|
+
if val > 32767:
|
|
337
|
+
val -= 65536
|
|
338
|
+
elif data_type_str == "uint16":
|
|
339
|
+
val = regs[0]
|
|
340
|
+
elif data_type_str == "uint32":
|
|
341
|
+
int_val = (regs[0] << 16) | regs[1]
|
|
342
|
+
val = int_val
|
|
343
|
+
elif data_type_str == "int32":
|
|
344
|
+
int_val = (regs[0] << 16) | regs[1]
|
|
345
|
+
val = int_val
|
|
346
|
+
if val > 2147483647:
|
|
347
|
+
val -= 4294967296
|
|
348
|
+
return val
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _apply_scaling(entity: EntityDef, val: float | int) -> float | int:
|
|
352
|
+
"""Apply an entity's scale, offset and precision to a raw value."""
|
|
353
|
+
if entity.scale is not None:
|
|
354
|
+
val = val * entity.scale
|
|
355
|
+
if entity.offset is not None:
|
|
356
|
+
val = val + entity.offset
|
|
357
|
+
if entity.precision is not None and isinstance(val, float):
|
|
358
|
+
val = round(val, entity.precision)
|
|
359
|
+
return val
|
|
360
|
+
|
|
361
|
+
def _plan_blocks(
|
|
362
|
+
self, entities: Iterable[EntityDef]
|
|
363
|
+
) -> list[tuple[str, int, int, list[EntityDef]]]:
|
|
364
|
+
"""Group entities into contiguous block reads per input type.
|
|
365
|
+
|
|
366
|
+
Returns a list of (input_type, start_address, count, entities)
|
|
367
|
+
tuples. Entities within _MAX_GAP_* of each other share a block.
|
|
368
|
+
"""
|
|
369
|
+
groups: dict[str, list[EntityDef]] = {}
|
|
370
|
+
for ent in entities:
|
|
371
|
+
input_type_str = ent.input_type.value if ent.input_type else "holding"
|
|
372
|
+
groups.setdefault(input_type_str, []).append(ent)
|
|
373
|
+
|
|
374
|
+
blocks: list[tuple[str, int, int, list[EntityDef]]] = []
|
|
375
|
+
for input_type_str, ents in groups.items():
|
|
376
|
+
is_bits = input_type_str in ("coil", "discrete_input")
|
|
377
|
+
max_gap = self._MAX_GAP_BITS if is_bits else self._MAX_GAP_REGISTERS
|
|
378
|
+
max_block = self._MAX_BLOCK_BITS if is_bits else self._MAX_BLOCK_REGISTERS
|
|
379
|
+
start: int | None = None
|
|
380
|
+
end = 0
|
|
381
|
+
members: list[EntityDef] = []
|
|
382
|
+
for ent in sorted(ents, key=lambda e: e.address):
|
|
383
|
+
count = 1 if is_bits else self._register_count(ent)
|
|
384
|
+
ent_end = ent.address + count
|
|
385
|
+
if start is None:
|
|
386
|
+
start, end, members = ent.address, ent_end, [ent]
|
|
387
|
+
elif ent.address <= end + max_gap and ent_end - start <= max_block:
|
|
388
|
+
end = max(end, ent_end)
|
|
389
|
+
members.append(ent)
|
|
390
|
+
else:
|
|
391
|
+
blocks.append((input_type_str, start, end - start, members))
|
|
392
|
+
start, end, members = ent.address, ent_end, [ent]
|
|
393
|
+
if start is not None:
|
|
394
|
+
blocks.append((input_type_str, start, end - start, members))
|
|
395
|
+
return blocks
|
|
396
|
+
|
|
397
|
+
async def read_entities_batched(
|
|
398
|
+
self, entities: Iterable[EntityDef]
|
|
399
|
+
) -> dict[str, Any]:
|
|
400
|
+
"""Read entities using contiguous block reads.
|
|
401
|
+
|
|
402
|
+
Groups entities into a handful of Modbus block reads instead of
|
|
403
|
+
one transaction per entity. If a block read fails, its entities
|
|
404
|
+
are read individually as a fallback.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Dictionary mapping entity keys to their values (None on error).
|
|
408
|
+
"""
|
|
409
|
+
results: dict[str, Any] = {}
|
|
410
|
+
for input_type_str, start, count, members in self._plan_blocks(entities):
|
|
411
|
+
try:
|
|
412
|
+
if input_type_str == "coil":
|
|
413
|
+
result = await self._client.read_coils(
|
|
414
|
+
start, count=count, device_id=self.unit
|
|
415
|
+
)
|
|
416
|
+
elif input_type_str == "discrete_input":
|
|
417
|
+
result = await self._client.read_discrete_inputs(
|
|
418
|
+
start, count=count, device_id=self.unit
|
|
419
|
+
)
|
|
420
|
+
elif input_type_str == "input":
|
|
421
|
+
result = await self._client.read_input_registers(
|
|
422
|
+
start, count=count, device_id=self.unit
|
|
423
|
+
)
|
|
424
|
+
else: # holding
|
|
425
|
+
result = await self._client.read_holding_registers(
|
|
426
|
+
start, count=count, device_id=self.unit
|
|
427
|
+
)
|
|
428
|
+
if result.isError():
|
|
429
|
+
raise OSError(f"Modbus error response for block @{start}")
|
|
430
|
+
except Exception as exc:
|
|
431
|
+
self._log_read_failure(
|
|
432
|
+
f"block {input_type_str}@{start} (count {count})", exc
|
|
433
|
+
)
|
|
434
|
+
for ent in members:
|
|
435
|
+
results[ent.key] = await self.read_entity(ent)
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
for ent in members:
|
|
439
|
+
offset = ent.address - start
|
|
440
|
+
if input_type_str in ("coil", "discrete_input"):
|
|
441
|
+
results[ent.key] = bool(result.bits[offset])
|
|
442
|
+
else:
|
|
443
|
+
reg_count = self._register_count(ent)
|
|
444
|
+
regs = result.registers[offset : offset + reg_count]
|
|
445
|
+
data_type_str = ent.data_type.value if ent.data_type else None
|
|
446
|
+
val = self._decode_registers(data_type_str, regs)
|
|
447
|
+
results[ent.key] = self._apply_scaling(ent, val)
|
|
448
|
+
return results
|
|
449
|
+
|
|
315
450
|
async def read_entity(self, entity: EntityDef) -> Any:
|
|
316
451
|
"""Read a single entity value based on EntityDef.
|
|
317
452
|
|
|
@@ -324,10 +459,7 @@ class QubeClient:
|
|
|
324
459
|
# Determine register count based on data type
|
|
325
460
|
# Use string comparison to handle potential enum class differences
|
|
326
461
|
data_type_str = entity.data_type.value if entity.data_type else None
|
|
327
|
-
|
|
328
|
-
count = 2
|
|
329
|
-
else:
|
|
330
|
-
count = 1
|
|
462
|
+
count = self._register_count(entity)
|
|
331
463
|
|
|
332
464
|
try:
|
|
333
465
|
# Read based on input type (use string comparison for safety)
|
|
@@ -364,43 +496,11 @@ class QubeClient:
|
|
|
364
496
|
_LOGGER.warning("Error reading address %s", entity.address)
|
|
365
497
|
return None
|
|
366
498
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
# Decode based on data type (use string comparison for safety)
|
|
371
|
-
# Qube uses big endian word order (ABCD): regs[0]=MSW, regs[1]=LSW
|
|
372
|
-
if data_type_str == "float32":
|
|
373
|
-
int_val = (regs[0] << 16) | regs[1]
|
|
374
|
-
val = struct.unpack(">f", struct.pack(">I", int_val))[0]
|
|
375
|
-
elif data_type_str == "int16":
|
|
376
|
-
val = regs[0]
|
|
377
|
-
if val > 32767:
|
|
378
|
-
val -= 65536
|
|
379
|
-
elif data_type_str == "uint16":
|
|
380
|
-
val = regs[0]
|
|
381
|
-
elif data_type_str == "uint32":
|
|
382
|
-
int_val = (regs[0] << 16) | regs[1]
|
|
383
|
-
val = int_val
|
|
384
|
-
elif data_type_str == "int32":
|
|
385
|
-
int_val = (regs[0] << 16) | regs[1]
|
|
386
|
-
val = int_val
|
|
387
|
-
if val > 2147483647:
|
|
388
|
-
val -= 4294967296
|
|
389
|
-
|
|
390
|
-
# Apply scale and offset
|
|
391
|
-
if entity.scale is not None:
|
|
392
|
-
val = val * entity.scale
|
|
393
|
-
if entity.offset is not None:
|
|
394
|
-
val = val + entity.offset
|
|
395
|
-
|
|
396
|
-
# Apply precision rounding if specified
|
|
397
|
-
if entity.precision is not None and isinstance(val, float):
|
|
398
|
-
val = round(val, entity.precision)
|
|
399
|
-
|
|
400
|
-
return val
|
|
499
|
+
val = self._decode_registers(data_type_str, result.registers)
|
|
500
|
+
return self._apply_scaling(entity, val)
|
|
401
501
|
|
|
402
502
|
except Exception as e:
|
|
403
|
-
|
|
503
|
+
self._log_read_failure(f"entity {entity.key}", e)
|
|
404
504
|
return None
|
|
405
505
|
|
|
406
506
|
async def read_sensor(self, key: str) -> float | int | None:
|
|
@@ -529,3 +529,54 @@ async def test_set_sg_ready_mode_unknown(mock_modbus_client):
|
|
|
529
529
|
client = QubeClient("1.2.3.4", 502)
|
|
530
530
|
result = await client.set_sg_ready_mode("turbo")
|
|
531
531
|
assert result is False
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@pytest.mark.asyncio
|
|
535
|
+
async def test_read_entity_failure_logs_warning_once_then_debug(
|
|
536
|
+
mock_modbus_client, caplog
|
|
537
|
+
):
|
|
538
|
+
"""Transient read failures log WARNING on first occurrence, DEBUG after."""
|
|
539
|
+
import logging
|
|
540
|
+
|
|
541
|
+
from python_qube_heatpump.entities import BINARY_SENSORS
|
|
542
|
+
|
|
543
|
+
client = QubeClient("1.2.3.4", 502)
|
|
544
|
+
mock_instance = mock_modbus_client.return_value
|
|
545
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
546
|
+
side_effect=OSError("No response received after 3 retries")
|
|
547
|
+
)
|
|
548
|
+
client._client = mock_instance
|
|
549
|
+
|
|
550
|
+
entity = BINARY_SENSORS["dout_srcpmp_val"]
|
|
551
|
+
|
|
552
|
+
with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
|
|
553
|
+
assert await client.read_entity(entity) is None
|
|
554
|
+
assert await client.read_entity(entity) is None
|
|
555
|
+
|
|
556
|
+
records = [r for r in caplog.records if "dout_srcpmp_val" in r.getMessage()]
|
|
557
|
+
assert len(records) == 2
|
|
558
|
+
assert records[0].levelno == logging.WARNING
|
|
559
|
+
assert records[1].levelno == logging.DEBUG
|
|
560
|
+
assert not any(r.levelno == logging.ERROR for r in records)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@pytest.mark.asyncio
|
|
564
|
+
async def test_read_entity_warns_again_per_entity(mock_modbus_client, caplog):
|
|
565
|
+
"""The warn-once tracking is per entity, not global."""
|
|
566
|
+
import logging
|
|
567
|
+
|
|
568
|
+
from python_qube_heatpump.entities import BINARY_SENSORS
|
|
569
|
+
|
|
570
|
+
client = QubeClient("1.2.3.4", 502)
|
|
571
|
+
mock_instance = mock_modbus_client.return_value
|
|
572
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
573
|
+
side_effect=OSError("No response received after 3 retries")
|
|
574
|
+
)
|
|
575
|
+
client._client = mock_instance
|
|
576
|
+
|
|
577
|
+
with caplog.at_level(logging.DEBUG, logger="python_qube_heatpump.client"):
|
|
578
|
+
await client.read_entity(BINARY_SENSORS["dout_srcpmp_val"])
|
|
579
|
+
await client.read_entity(BINARY_SENSORS["dout_usrpmp_val"])
|
|
580
|
+
|
|
581
|
+
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
582
|
+
assert len(warnings) == 2
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Tests for batched block reads in QubeClient."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from python_qube_heatpump import QubeClient
|
|
8
|
+
from python_qube_heatpump.entities import BINARY_SENSORS, SENSORS, SWITCHES
|
|
9
|
+
from python_qube_heatpump.entities.base import (
|
|
10
|
+
DataType,
|
|
11
|
+
EntityDef,
|
|
12
|
+
InputType,
|
|
13
|
+
Platform,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _bit_response(bits):
|
|
18
|
+
resp = MagicMock()
|
|
19
|
+
resp.isError.return_value = False
|
|
20
|
+
resp.bits = bits
|
|
21
|
+
return resp
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _register_response(registers):
|
|
25
|
+
resp = MagicMock()
|
|
26
|
+
resp.isError.return_value = False
|
|
27
|
+
resp.registers = registers
|
|
28
|
+
return resp
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _float32_regs(value):
|
|
32
|
+
import struct
|
|
33
|
+
|
|
34
|
+
int_val = struct.unpack(">I", struct.pack(">f", value))[0]
|
|
35
|
+
return [(int_val >> 16) & 0xFFFF, int_val & 0xFFFF]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_batched_discrete_inputs_single_transaction(mock_modbus_client):
|
|
40
|
+
"""Adjacent discrete inputs are read in one block transaction."""
|
|
41
|
+
client = QubeClient("1.2.3.4", 502)
|
|
42
|
+
mock_instance = mock_modbus_client.return_value
|
|
43
|
+
mock_instance.read_discrete_inputs = AsyncMock(
|
|
44
|
+
return_value=_bit_response([True, False, True])
|
|
45
|
+
)
|
|
46
|
+
client._client = mock_instance
|
|
47
|
+
|
|
48
|
+
entities = [
|
|
49
|
+
BINARY_SENSORS["dout_srcpmp_val"], # address 0
|
|
50
|
+
BINARY_SENSORS["dout_usrpmp_val"], # address 1
|
|
51
|
+
BINARY_SENSORS["dout_fourwayvlv_val"], # address 2
|
|
52
|
+
]
|
|
53
|
+
results = await client.read_entities_batched(entities)
|
|
54
|
+
|
|
55
|
+
assert mock_instance.read_discrete_inputs.call_count == 1
|
|
56
|
+
call = mock_instance.read_discrete_inputs.call_args
|
|
57
|
+
assert call.args[0] == 0
|
|
58
|
+
assert call.kwargs["count"] >= 3
|
|
59
|
+
assert results == {
|
|
60
|
+
"dout_srcpmp_val": True,
|
|
61
|
+
"dout_usrpmp_val": False,
|
|
62
|
+
"dout_fourwayvlv_val": True,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_batched_input_registers_decode_float32(mock_modbus_client):
|
|
68
|
+
"""Adjacent float32 input registers decode correctly from one block."""
|
|
69
|
+
client = QubeClient("1.2.3.4", 502)
|
|
70
|
+
mock_instance = mock_modbus_client.return_value
|
|
71
|
+
regs = _float32_regs(24.5) + _float32_regs(25.0)
|
|
72
|
+
mock_instance.read_input_registers = AsyncMock(
|
|
73
|
+
return_value=_register_response(regs)
|
|
74
|
+
)
|
|
75
|
+
client._client = mock_instance
|
|
76
|
+
|
|
77
|
+
entities = [
|
|
78
|
+
SENSORS["temp_supply"], # address 20, float32
|
|
79
|
+
SENSORS["temp_return"], # address 22, float32
|
|
80
|
+
]
|
|
81
|
+
results = await client.read_entities_batched(entities)
|
|
82
|
+
|
|
83
|
+
assert mock_instance.read_input_registers.call_count == 1
|
|
84
|
+
call = mock_instance.read_input_registers.call_args
|
|
85
|
+
assert call.args[0] == 20
|
|
86
|
+
assert call.kwargs["count"] == 4
|
|
87
|
+
assert round(results["temp_supply"], 1) == 24.5
|
|
88
|
+
assert round(results["temp_return"], 1) == 25.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def test_batched_far_apart_registers_split_into_blocks(mock_modbus_client):
|
|
93
|
+
"""Registers far apart are read in separate block transactions."""
|
|
94
|
+
client = QubeClient("1.2.3.4", 502)
|
|
95
|
+
mock_instance = mock_modbus_client.return_value
|
|
96
|
+
mock_instance.read_holding_registers = AsyncMock(
|
|
97
|
+
return_value=_register_response(_float32_regs(1.0))
|
|
98
|
+
)
|
|
99
|
+
client._client = mock_instance
|
|
100
|
+
|
|
101
|
+
ent_a = EntityDef(
|
|
102
|
+
key="a",
|
|
103
|
+
name="A",
|
|
104
|
+
address=0,
|
|
105
|
+
input_type=InputType.HOLDING_REGISTER,
|
|
106
|
+
platform=Platform.SENSOR,
|
|
107
|
+
data_type=DataType.FLOAT32,
|
|
108
|
+
)
|
|
109
|
+
ent_b = EntityDef(
|
|
110
|
+
key="b",
|
|
111
|
+
name="B",
|
|
112
|
+
address=500,
|
|
113
|
+
input_type=InputType.HOLDING_REGISTER,
|
|
114
|
+
platform=Platform.SENSOR,
|
|
115
|
+
data_type=DataType.FLOAT32,
|
|
116
|
+
)
|
|
117
|
+
await client.read_entities_batched([ent_a, ent_b])
|
|
118
|
+
|
|
119
|
+
assert mock_instance.read_holding_registers.call_count == 2
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_batched_block_failure_falls_back_to_individual_reads(
|
|
124
|
+
mock_modbus_client,
|
|
125
|
+
):
|
|
126
|
+
"""If a block read fails, entities in it are read individually."""
|
|
127
|
+
client = QubeClient("1.2.3.4", 502)
|
|
128
|
+
mock_instance = mock_modbus_client.return_value
|
|
129
|
+
mock_instance.read_input_registers = AsyncMock(
|
|
130
|
+
side_effect=[
|
|
131
|
+
OSError("No response received after 3 retries"), # block read
|
|
132
|
+
_register_response(_float32_regs(24.5)), # individual read
|
|
133
|
+
_register_response(_float32_regs(25.0)), # individual read
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
client._client = mock_instance
|
|
137
|
+
|
|
138
|
+
entities = [SENSORS["temp_supply"], SENSORS["temp_return"]]
|
|
139
|
+
results = await client.read_entities_batched(entities)
|
|
140
|
+
|
|
141
|
+
assert mock_instance.read_input_registers.call_count == 3
|
|
142
|
+
assert round(results["temp_supply"], 1) == 24.5
|
|
143
|
+
assert round(results["temp_return"], 1) == 25.0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_get_all_entities_uses_batched_reads(mock_modbus_client):
|
|
148
|
+
"""get_all_entities reads all entities in a handful of transactions."""
|
|
149
|
+
client = QubeClient("1.2.3.4", 502)
|
|
150
|
+
mock_instance = mock_modbus_client.return_value
|
|
151
|
+
|
|
152
|
+
def _bits(address, count=1, **kwargs):
|
|
153
|
+
return _bit_response([False] * count)
|
|
154
|
+
|
|
155
|
+
def _regs(address, count=1, **kwargs):
|
|
156
|
+
return _register_response([0] * count)
|
|
157
|
+
|
|
158
|
+
mock_instance.read_coils = AsyncMock(side_effect=_bits)
|
|
159
|
+
mock_instance.read_discrete_inputs = AsyncMock(side_effect=_bits)
|
|
160
|
+
mock_instance.read_input_registers = AsyncMock(side_effect=_regs)
|
|
161
|
+
mock_instance.read_holding_registers = AsyncMock(side_effect=_regs)
|
|
162
|
+
client._client = mock_instance
|
|
163
|
+
|
|
164
|
+
results = await client.get_all_entities()
|
|
165
|
+
|
|
166
|
+
expected_keys = set(SENSORS) | set(BINARY_SENSORS) | set(SWITCHES)
|
|
167
|
+
assert set(results) == expected_keys
|
|
168
|
+
total_calls = (
|
|
169
|
+
mock_instance.read_coils.call_count
|
|
170
|
+
+ mock_instance.read_discrete_inputs.call_count
|
|
171
|
+
+ mock_instance.read_input_registers.call_count
|
|
172
|
+
+ mock_instance.read_holding_registers.call_count
|
|
173
|
+
)
|
|
174
|
+
assert total_calls <= 15
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/.github/ISSUE_TEMPLATE/bug_report.yml
RENAMED
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/.github/ISSUE_TEMPLATE/config.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/.github/workflows/python-publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/docs/modbus-lijst-qube-totaal.pdf
RENAMED
|
File without changes
|
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/__init__.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/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.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/models.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/src/python_qube_heatpump/network.py
RENAMED
|
File without changes
|
{python_qube_heatpump-1.11.2 → python_qube_heatpump-1.12.0}/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
|