python-qube-heatpump 1.12.0__tar.gz → 1.13.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.
Files changed (32) hide show
  1. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/PKG-INFO +1 -1
  2. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/pyproject.toml +1 -1
  3. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/client.py +131 -40
  4. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/tests/test_client.py +22 -11
  5. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/tests/test_client_batching.py +75 -0
  6. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  7. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  8. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  9. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/.github/workflows/ci.yml +0 -0
  10. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/.github/workflows/python-publish.yml +0 -0
  11. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/.gitignore +0 -0
  12. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/AGENTS.md +0 -0
  13. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/CLAUDE.md +0 -0
  14. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/LICENSE +0 -0
  15. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/README.md +0 -0
  16. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/docs/modbus-lijst-qube-totaal.pdf +0 -0
  17. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/pytest.ini +0 -0
  18. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/__init__.py +0 -0
  19. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/const.py +0 -0
  20. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/__init__.py +0 -0
  21. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/base.py +0 -0
  22. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/binary_sensors.py +0 -0
  23. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/sensors.py +0 -0
  24. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/entities/switches.py +0 -0
  25. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/models.py +0 -0
  26. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/network.py +0 -0
  27. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/src/python_qube_heatpump/py.typed +0 -0
  28. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/tests/conftest.py +0 -0
  29. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/tests/test_const.py +0 -0
  30. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/tests/test_entities.py +0 -0
  31. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.0}/tests/test_models.py +0 -0
  32. {python_qube_heatpump-1.12.0 → python_qube_heatpump-1.13.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.12.0
3
+ Version: 1.13.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-qube-heatpump"
7
- version = "1.12.0"
7
+ version = "1.13.0"
8
8
  authors = [
9
9
  { name="MattieGit", email="6250046+MattieGit@users.noreply.github.com" },
10
10
  ]
@@ -18,6 +18,112 @@ from .models import QubeState
18
18
 
19
19
  _LOGGER = logging.getLogger(__name__)
20
20
 
21
+ # Entities read by get_all_data() (used by the official HA core integration).
22
+ # These mirror the register definitions in `const` exactly (address, scale,
23
+ # offset) so batching cannot change any value get_all_data() returns.
24
+ # Note: some of these intentionally differ from the corresponding entries in
25
+ # entities/sensors.py (e.g. compressor_speed has a x60 RPM scale here but not
26
+ # there; cop_calc is unrounded here but rounded to 1 decimal there) — reusing
27
+ # SENSORS would silently change get_all_data()'s output, so a dedicated table
28
+ # is kept instead.
29
+ # Format: (key, address, input_type, data_type, scale, offset)
30
+ _CORE_STATE_REGISTERS: tuple[
31
+ tuple[str, int, InputType, DataType, float | None, float | None], ...
32
+ ] = (
33
+ ("temp_supply", 20, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
34
+ ("temp_return", 22, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
35
+ ("temp_source_in", 24, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
36
+ ("temp_source_out", 26, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
37
+ ("temp_room", 28, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
38
+ ("temp_dhw", 30, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
39
+ ("temp_outside", 32, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
40
+ ("power_thermic", 36, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
41
+ ("power_electric", 61, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
42
+ (
43
+ "energy_total_electric",
44
+ 69,
45
+ InputType.INPUT_REGISTER,
46
+ DataType.FLOAT32,
47
+ None,
48
+ None,
49
+ ),
50
+ (
51
+ "energy_total_thermic",
52
+ 71,
53
+ InputType.INPUT_REGISTER,
54
+ DataType.FLOAT32,
55
+ None,
56
+ None,
57
+ ),
58
+ ("cop_calc", 34, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
59
+ ("status_code", 38, InputType.INPUT_REGISTER, DataType.UINT16, None, None),
60
+ ("compressor_speed", 45, InputType.INPUT_REGISTER, DataType.FLOAT32, 60, None),
61
+ ("flow_rate", 18, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
62
+ (
63
+ "setpoint_room_heat_day",
64
+ 27,
65
+ InputType.HOLDING_REGISTER,
66
+ DataType.FLOAT32,
67
+ None,
68
+ None,
69
+ ),
70
+ (
71
+ "setpoint_room_heat_night",
72
+ 29,
73
+ InputType.HOLDING_REGISTER,
74
+ DataType.FLOAT32,
75
+ None,
76
+ None,
77
+ ),
78
+ (
79
+ "setpoint_room_cool_day",
80
+ 31,
81
+ InputType.HOLDING_REGISTER,
82
+ DataType.FLOAT32,
83
+ None,
84
+ None,
85
+ ),
86
+ (
87
+ "setpoint_room_cool_night",
88
+ 33,
89
+ InputType.HOLDING_REGISTER,
90
+ DataType.FLOAT32,
91
+ None,
92
+ None,
93
+ ),
94
+ ("setpoint_dhw", 173, InputType.HOLDING_REGISTER, DataType.FLOAT32, None, None),
95
+ (
96
+ "usr_pid_heatsetp",
97
+ 101,
98
+ InputType.HOLDING_REGISTER,
99
+ DataType.FLOAT32,
100
+ None,
101
+ None,
102
+ ),
103
+ (
104
+ "usr_pid_coolsetp",
105
+ 103,
106
+ InputType.HOLDING_REGISTER,
107
+ DataType.FLOAT32,
108
+ None,
109
+ None,
110
+ ),
111
+ ("modbus_roomtemp", 75, InputType.INPUT_REGISTER, DataType.FLOAT32, None, None),
112
+ )
113
+
114
+ _CORE_STATE_ENTITIES: tuple[EntityDef, ...] = tuple(
115
+ EntityDef(
116
+ key=key,
117
+ name=key,
118
+ address=address,
119
+ input_type=input_type,
120
+ data_type=data_type,
121
+ scale=scale,
122
+ offset=offset,
123
+ )
124
+ for key, address, input_type, data_type, scale, offset in _CORE_STATE_REGISTERS
125
+ )
126
+
21
127
 
22
128
  class QubeClient:
23
129
  """Qube Modbus Client."""
@@ -109,53 +215,27 @@ class QubeClient:
109
215
 
110
216
  state = QubeState()
111
217
 
112
- # Helper to read and assign
113
- async def _read(const_def):
114
- return await self.read_value(const_def)
115
-
116
- # Fetch temperature sensors
117
- state.temp_supply = await _read(const.TEMP_SUPPLY)
118
- state.temp_return = await _read(const.TEMP_RETURN)
119
- state.temp_source_in = await _read(const.TEMP_SOURCE_IN)
120
- state.temp_source_out = await _read(const.TEMP_SOURCE_OUT)
121
- state.temp_room = await _read(const.TEMP_ROOM)
122
- state.temp_dhw = await _read(const.TEMP_DHW)
123
- state.temp_outside = await _read(const.TEMP_OUTSIDE)
124
-
125
- # Fetch power and energy sensors
126
- state.power_thermic = await _read(const.POWER_THERMIC)
127
- state.power_electric = await _read(const.POWER_ELECTRIC_CALC)
128
- state.energy_total_electric = await _read(const.ENERGY_ELECTRIC_TOTAL)
129
- state.energy_total_thermic = await _read(const.ENERGY_THERMIC_TOTAL)
130
- state.cop_calc = await _read(const.COP_CALC)
131
-
132
- # Fetch operation sensors
133
- state.status_code = await _read(const.STATUS_CODE)
134
- state.compressor_speed = await _read(const.COMPRESSOR_SPEED)
135
- flow_rate = await _read(const.FLOW_RATE)
218
+ # Batch-read the core sensor fields plus all binary sensors together.
219
+ # _plan_blocks groups by input type (input/holding/discrete_input/coil)
220
+ # regardless of which list an entity came from, so combining both
221
+ # tables into a single read_entities_batched() call still yields one
222
+ # handful of block transactions instead of ~59 per-field reads.
223
+ entities = [*_CORE_STATE_ENTITIES, *BINARY_SENSORS.values()]
224
+ results = await self.read_entities_batched(entities)
225
+
226
+ for ent in _CORE_STATE_ENTITIES:
227
+ setattr(state, ent.key, results.get(ent.key))
228
+
229
+ flow_rate = state.flow_rate
136
230
  if flow_rate is not None and flow_rate < 0:
137
231
  flow_rate = 0.0
138
232
  state.flow_rate = flow_rate
139
233
 
140
- # Fetch setpoints (holding registers)
141
- state.setpoint_room_heat_day = await _read(const.SETPOINT_HEAT_DAY)
142
- state.setpoint_room_heat_night = await _read(const.SETPOINT_HEAT_NIGHT)
143
- state.setpoint_room_cool_day = await _read(const.SETPOINT_COOL_DAY)
144
- state.setpoint_room_cool_night = await _read(const.SETPOINT_COOL_NIGHT)
145
- state.setpoint_dhw = await _read(const.USER_DHW_SETPOINT)
146
- state.usr_pid_heatsetp = await _read(const.USER_HEAT_SETPOINT)
147
- state.usr_pid_coolsetp = await _read(const.USER_COOL_SETPOINT)
148
-
149
- # LinQ thermostat room temperature (optional)
150
- state.modbus_roomtemp = await _read(const.TEMP_ROOM_MODBUS)
151
-
152
234
  self._apply_monotonic_clamping(state)
153
235
 
154
- # Fetch binary sensors
155
- binary_data = await self.read_all_binary_sensors()
156
- for key, value in binary_data.items():
236
+ for key in BINARY_SENSORS:
157
237
  if hasattr(state, key):
158
- setattr(state, key, value)
238
+ setattr(state, key, results.get(key))
159
239
 
160
240
  # Compute unified status (status_code + anti-legionella override)
161
241
  state.status = const.resolve_status(state.status_code, state.req_antileg_1)
@@ -427,6 +507,17 @@ class QubeClient:
427
507
  )
428
508
  if result.isError():
429
509
  raise OSError(f"Modbus error response for block @{start}")
510
+ if input_type_str in ("coil", "discrete_input"):
511
+ if len(result.bits) < count:
512
+ raise OSError(
513
+ f"Short bit response for block {input_type_str}@{start} "
514
+ f"(got {len(result.bits)}, expected {count})"
515
+ )
516
+ elif len(result.registers) < count:
517
+ raise OSError(
518
+ f"Short register response for block {input_type_str}@{start} "
519
+ f"(got {len(result.registers)}, expected {count})"
520
+ )
430
521
  except Exception as exc:
431
522
  self._log_read_failure(
432
523
  f"block {input_type_str}@{start} (count {count})", exc
@@ -331,12 +331,18 @@ async def test_get_all_data_auto_reconnects(mock_modbus_client):
331
331
  client._client = mock_instance
332
332
  client._connected = False
333
333
 
334
- # Mock successful register reads
335
- mock_resp = MagicMock()
336
- mock_resp.isError.return_value = False
337
- mock_resp.registers = [0, 0]
338
- mock_instance.read_input_registers = AsyncMock(return_value=mock_resp)
339
- mock_instance.read_holding_registers = AsyncMock(return_value=mock_resp)
334
+ # Mock successful register reads. Batched block reads request a
335
+ # variable number of registers per block, so the mock must honor the
336
+ # requested `count` (a real Modbus server would too), unlike a fixed
337
+ # 2-register response.
338
+ def _resp(address, count=1, **kwargs):
339
+ resp = MagicMock()
340
+ resp.isError.return_value = False
341
+ resp.registers = [0] * count
342
+ return resp
343
+
344
+ mock_instance.read_input_registers = AsyncMock(side_effect=_resp)
345
+ mock_instance.read_holding_registers = AsyncMock(side_effect=_resp)
340
346
 
341
347
  state = await client.get_all_data()
342
348
  assert state is not None
@@ -424,11 +430,16 @@ async def test_get_all_data_applies_clamping(mock_modbus_client):
424
430
  client._client = mock_instance
425
431
  client._connected = True
426
432
 
427
- mock_resp = MagicMock()
428
- mock_resp.isError.return_value = False
429
- mock_resp.registers = [0, 0]
430
- mock_instance.read_input_registers = AsyncMock(return_value=mock_resp)
431
- mock_instance.read_holding_registers = AsyncMock(return_value=mock_resp)
433
+ # Batched block reads request a variable `count` of registers per
434
+ # block, so the mock must honor it (a real Modbus server would too).
435
+ def _resp(address, count=1, **kwargs):
436
+ resp = MagicMock()
437
+ resp.isError.return_value = False
438
+ resp.registers = [0] * count
439
+ return resp
440
+
441
+ mock_instance.read_input_registers = AsyncMock(side_effect=_resp)
442
+ mock_instance.read_holding_registers = AsyncMock(side_effect=_resp)
432
443
 
433
444
  state = await client.get_all_data()
434
445
  assert state is not None
@@ -143,6 +143,41 @@ async def test_batched_block_failure_falls_back_to_individual_reads(
143
143
  assert round(results["temp_return"], 1) == 25.0
144
144
 
145
145
 
146
+ @pytest.mark.asyncio
147
+ async def test_batched_short_successful_response_falls_back_to_individual_reads(
148
+ mock_modbus_client,
149
+ ):
150
+ """A well-formed but too-short block response degrades gracefully.
151
+
152
+ A device can return isError()==False with fewer registers/bits than the
153
+ requested count (a malformed-but-"successful" response). Previously this
154
+ slipped past the isError() check and crashed with an uncaught IndexError
155
+ while decoding, instead of falling back to individual reads like a failed
156
+ block does. Since get_all_data() is now polled by HA core every 15s via
157
+ this same batching path, such a response must degrade the same way a
158
+ failed block does, not raise.
159
+ """
160
+ client = QubeClient("1.2.3.4", 502)
161
+ mock_instance = mock_modbus_client.return_value
162
+ mock_instance.read_input_registers = AsyncMock(
163
+ side_effect=[
164
+ # Block read: isError() is False, but only 2 registers came back
165
+ # for a block that needs 4 (two float32 entities).
166
+ _register_response(_float32_regs(24.5)),
167
+ _register_response(_float32_regs(24.5)), # individual read
168
+ _register_response(_float32_regs(25.0)), # individual read
169
+ ]
170
+ )
171
+ client._client = mock_instance
172
+
173
+ entities = [SENSORS["temp_supply"], SENSORS["temp_return"]]
174
+ results = await client.read_entities_batched(entities)
175
+
176
+ assert mock_instance.read_input_registers.call_count == 3
177
+ assert round(results["temp_supply"], 1) == 24.5
178
+ assert round(results["temp_return"], 1) == 25.0
179
+
180
+
146
181
  @pytest.mark.asyncio
147
182
  async def test_get_all_entities_uses_batched_reads(mock_modbus_client):
148
183
  """get_all_entities reads all entities in a handful of transactions."""
@@ -172,3 +207,43 @@ async def test_get_all_entities_uses_batched_reads(mock_modbus_client):
172
207
  + mock_instance.read_holding_registers.call_count
173
208
  )
174
209
  assert total_calls <= 15
210
+
211
+
212
+ @pytest.mark.asyncio
213
+ async def test_get_all_data_uses_batched_reads(mock_modbus_client):
214
+ """get_all_data (used by HA core, polled every 15s) reads via block reads.
215
+
216
+ Previously get_all_data() performed one Modbus transaction per field
217
+ (~59 transactions: 23 core sensors + 37 binary sensors). It should now
218
+ use the same batched block-read infrastructure as get_all_entities(),
219
+ landing in a handful of transactions instead.
220
+ """
221
+ client = QubeClient("1.2.3.4", 502)
222
+ mock_instance = mock_modbus_client.return_value
223
+
224
+ def _bits(address, count=1, **kwargs):
225
+ return _bit_response([False] * count)
226
+
227
+ def _regs(address, count=1, **kwargs):
228
+ return _register_response([0] * count)
229
+
230
+ mock_instance.read_coils = AsyncMock(side_effect=_bits)
231
+ mock_instance.read_discrete_inputs = AsyncMock(side_effect=_bits)
232
+ mock_instance.read_input_registers = AsyncMock(side_effect=_regs)
233
+ mock_instance.read_holding_registers = AsyncMock(side_effect=_regs)
234
+ client._client = mock_instance
235
+ client._connected = True
236
+
237
+ state = await client.get_all_data()
238
+
239
+ assert state is not None
240
+ total_calls = (
241
+ mock_instance.read_coils.call_count
242
+ + mock_instance.read_discrete_inputs.call_count
243
+ + mock_instance.read_input_registers.call_count
244
+ + mock_instance.read_holding_registers.call_count
245
+ )
246
+ # Old per-register/per-entity path made ~59 transactions (23 core
247
+ # fields + 37 binary sensors). Batched block reads should collapse
248
+ # this into a handful of transactions.
249
+ assert total_calls <= 15