PyPlumIO 0.6.1__py3-none-any.whl → 0.6.2__py3-none-any.whl

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 (48) hide show
  1. pyplumio/__init__.py +3 -1
  2. pyplumio/_version.py +2 -2
  3. pyplumio/const.py +0 -5
  4. pyplumio/data_types.py +2 -2
  5. pyplumio/devices/__init__.py +23 -5
  6. pyplumio/devices/ecomax.py +30 -53
  7. pyplumio/devices/ecoster.py +2 -3
  8. pyplumio/filters.py +199 -136
  9. pyplumio/frames/__init__.py +101 -15
  10. pyplumio/frames/messages.py +8 -65
  11. pyplumio/frames/requests.py +38 -38
  12. pyplumio/frames/responses.py +30 -86
  13. pyplumio/helpers/async_cache.py +13 -8
  14. pyplumio/helpers/event_manager.py +24 -18
  15. pyplumio/helpers/factory.py +0 -3
  16. pyplumio/parameters/__init__.py +38 -35
  17. pyplumio/protocol.py +14 -8
  18. pyplumio/structures/alerts.py +2 -2
  19. pyplumio/structures/ecomax_parameters.py +1 -1
  20. pyplumio/structures/frame_versions.py +3 -2
  21. pyplumio/structures/mixer_parameters.py +5 -3
  22. pyplumio/structures/network_info.py +1 -0
  23. pyplumio/structures/product_info.py +1 -1
  24. pyplumio/structures/program_version.py +2 -2
  25. pyplumio/structures/schedules.py +8 -40
  26. pyplumio/structures/sensor_data.py +498 -0
  27. pyplumio/structures/thermostat_parameters.py +7 -4
  28. pyplumio/utils.py +41 -4
  29. {pyplumio-0.6.1.dist-info → pyplumio-0.6.2.dist-info}/METADATA +4 -4
  30. pyplumio-0.6.2.dist-info/RECORD +50 -0
  31. pyplumio/structures/boiler_load.py +0 -32
  32. pyplumio/structures/boiler_power.py +0 -33
  33. pyplumio/structures/fan_power.py +0 -33
  34. pyplumio/structures/fuel_consumption.py +0 -36
  35. pyplumio/structures/fuel_level.py +0 -39
  36. pyplumio/structures/lambda_sensor.py +0 -57
  37. pyplumio/structures/mixer_sensors.py +0 -80
  38. pyplumio/structures/modules.py +0 -102
  39. pyplumio/structures/output_flags.py +0 -47
  40. pyplumio/structures/outputs.py +0 -88
  41. pyplumio/structures/pending_alerts.py +0 -28
  42. pyplumio/structures/statuses.py +0 -52
  43. pyplumio/structures/temperatures.py +0 -94
  44. pyplumio/structures/thermostat_sensors.py +0 -106
  45. pyplumio-0.6.1.dist-info/RECORD +0 -63
  46. {pyplumio-0.6.1.dist-info → pyplumio-0.6.2.dist-info}/WHEEL +0 -0
  47. {pyplumio-0.6.1.dist-info → pyplumio-0.6.2.dist-info}/licenses/LICENSE +0 -0
  48. {pyplumio-0.6.1.dist-info → pyplumio-0.6.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,498 @@
1
+ """Contains sensor data decoder."""
2
+
3
+ from collections.abc import Generator, MutableMapping
4
+ from contextlib import suppress
5
+ from dataclasses import dataclass
6
+ import math
7
+ import struct
8
+ from typing import Any, Final
9
+
10
+ from typing_extensions import TypeVar
11
+
12
+ from pyplumio.const import ATTR_SCHEDULE, BYTE_UNDEFINED, DeviceState, LambdaState
13
+ from pyplumio.data_types import Float, UnsignedInt, UnsignedShort
14
+ from pyplumio.structures import StructureDecoder
15
+ from pyplumio.utils import ensure_dict
16
+
17
+ ATTR_AIR_IN_TEMP: Final = "air_in_temp"
18
+ ATTR_AIR_OUT_TEMP: Final = "air_out_temp"
19
+ ATTR_ALARM: Final = "alarm"
20
+ ATTR_BLOW_FAN1: Final = "blow_fan1"
21
+ ATTR_BLOW_FAN2: Final = "blow_fan2"
22
+ ATTR_BOILER_LOAD: Final = "boiler_load"
23
+ ATTR_BOILER_POWER: Final = "boiler_power"
24
+ ATTR_CIRCULATION_PUMP_FLAG: Final = "circulation_pump_flag"
25
+ ATTR_CIRCULATION_PUMP: Final = "circulation_pump"
26
+ ATTR_CONTACTS: Final = "contacts"
27
+ ATTR_CURRENT_TEMP: Final = "current_temp"
28
+ ATTR_ECOLAMBDA: Final = "ecolambda"
29
+ ATTR_ECOSTER: Final = "ecoster"
30
+ ATTR_EXCHANGER_TEMP: Final = "exchanger_temp"
31
+ ATTR_EXHAUST_TEMP: Final = "exhaust_temp"
32
+ ATTR_FAN_POWER: Final = "fan_power"
33
+ ATTR_FAN: Final = "fan"
34
+ ATTR_FAN2_EXHAUST: Final = "fan2_exhaust"
35
+ ATTR_FEEDER_TEMP: Final = "feeder_temp"
36
+ ATTR_FEEDER: Final = "feeder"
37
+ ATTR_FEEDER2: Final = "feeder2"
38
+ ATTR_FIREPLACE_PUMP: Final = "fireplace_pump"
39
+ ATTR_FIREPLACE_TEMP: Final = "fireplace_temp"
40
+ ATTR_FUEL_CONSUMPTION: Final = "fuel_consumption"
41
+ ATTR_FUEL_LEVEL: Final = "fuel_level"
42
+ ATTR_GCZ_CONTACT: Final = "gcz_contact"
43
+ ATTR_HEATING_PUMP_FLAG: Final = "heating_pump_flag"
44
+ ATTR_HEATING_PUMP: Final = "heating_pump"
45
+ ATTR_HEATING_STATUS: Final = "heating_status"
46
+ ATTR_HEATING_TARGET: Final = "heating_target"
47
+ ATTR_HEATING_TEMP: Final = "heating_temp"
48
+ ATTR_HYDRAULIC_COUPLER_TEMP: Final = "hydraulic_coupler_temp"
49
+ ATTR_LAMBDA_LEVEL: Final = "lambda_level"
50
+ ATTR_LAMBDA_STATE: Final = "lambda_state"
51
+ ATTR_LAMBDA_TARGET: Final = "lambda_target"
52
+ ATTR_LIGHTER: Final = "lighter"
53
+ ATTR_LOWER_BUFFER_TEMP: Final = "lower_buffer_temp"
54
+ ATTR_LOWER_SOLAR_TEMP: Final = "lower_solar_temp"
55
+ ATTR_MIXER_SENSORS: Final = "mixer_sensors"
56
+ ATTR_MIXERS_AVAILABLE: Final = "mixers_available"
57
+ ATTR_MIXERS_CONNECTED: Final = "mixers_connected"
58
+ ATTR_MODULE_A: Final = "module_a"
59
+ ATTR_MODULE_B: Final = "module_b"
60
+ ATTR_MODULE_C: Final = "module_c"
61
+ ATTR_MODULES: Final = "modules"
62
+ ATTR_OPTICAL_TEMP: Final = "optical_temp"
63
+ ATTR_OUTER_BOILER: Final = "outer_boiler"
64
+ ATTR_OUTER_FEEDER: Final = "outer_feeder"
65
+ ATTR_OUTSIDE_TEMP: Final = "outside_temp"
66
+ ATTR_PANEL: Final = "panel"
67
+ ATTR_PENDING_ALERTS: Final = "pending_alerts"
68
+ ATTR_PUMP: Final = "pump"
69
+ ATTR_RETURN_TEMP: Final = "return_temp"
70
+ ATTR_SOLAR_PUMP_FLAG: Final = "solar_pump_flag"
71
+ ATTR_SOLAR_PUMP: Final = "solar_pump"
72
+ ATTR_STATE: Final = "state"
73
+ ATTR_TARGET_TEMP: Final = "target_temp"
74
+ ATTR_THERMOSTAT_SENSORS: Final = "thermostat_sensors"
75
+ ATTR_THERMOSTAT: Final = "thermostat"
76
+ ATTR_THERMOSTATS_AVAILABLE: Final = "thermostats_available"
77
+ ATTR_THERMOSTATS_CONNECTED: Final = "thermostats_connected"
78
+ ATTR_TOTAL_GAIN: Final = "total_gain"
79
+ ATTR_TRANSMISSION: Final = "transmission"
80
+ ATTR_UPPER_BUFFER_TEMP: Final = "upper_buffer_temp"
81
+ ATTR_UPPER_SOLAR_TEMP: Final = "upper_solar_temp"
82
+ ATTR_WATER_HEATER_PUMP_FLAG: Final = "water_heater_pump_flag"
83
+ ATTR_WATER_HEATER_PUMP: Final = "water_heater_pump"
84
+ ATTR_WATER_HEATER_STATUS: Final = "water_heater_status"
85
+ ATTR_WATER_HEATER_TARGET: Final = "water_heater_target"
86
+ ATTR_WATER_HEATER_TEMP: Final = "water_heater_temp"
87
+
88
+ OUTPUTS: tuple[str, ...] = (
89
+ ATTR_FAN,
90
+ ATTR_FEEDER,
91
+ ATTR_HEATING_PUMP,
92
+ ATTR_WATER_HEATER_PUMP,
93
+ ATTR_CIRCULATION_PUMP,
94
+ ATTR_LIGHTER,
95
+ ATTR_ALARM,
96
+ ATTR_OUTER_BOILER,
97
+ ATTR_FAN2_EXHAUST,
98
+ ATTR_FEEDER2,
99
+ ATTR_OUTER_FEEDER,
100
+ ATTR_SOLAR_PUMP,
101
+ ATTR_FIREPLACE_PUMP,
102
+ ATTR_GCZ_CONTACT,
103
+ ATTR_BLOW_FAN1,
104
+ ATTR_BLOW_FAN2,
105
+ )
106
+
107
+ TEMPERATURES: tuple[str, ...] = (
108
+ ATTR_HEATING_TEMP,
109
+ ATTR_FEEDER_TEMP,
110
+ ATTR_WATER_HEATER_TEMP,
111
+ ATTR_OUTSIDE_TEMP,
112
+ ATTR_RETURN_TEMP,
113
+ ATTR_EXHAUST_TEMP,
114
+ ATTR_OPTICAL_TEMP,
115
+ ATTR_UPPER_BUFFER_TEMP,
116
+ ATTR_LOWER_BUFFER_TEMP,
117
+ ATTR_UPPER_SOLAR_TEMP,
118
+ ATTR_LOWER_SOLAR_TEMP,
119
+ ATTR_FIREPLACE_TEMP,
120
+ ATTR_TOTAL_GAIN,
121
+ ATTR_HYDRAULIC_COUPLER_TEMP,
122
+ ATTR_EXCHANGER_TEMP,
123
+ ATTR_AIR_IN_TEMP,
124
+ ATTR_AIR_OUT_TEMP,
125
+ )
126
+
127
+ STATUSES: tuple[str, ...] = (
128
+ ATTR_HEATING_TARGET,
129
+ ATTR_HEATING_STATUS,
130
+ ATTR_WATER_HEATER_TARGET,
131
+ ATTR_WATER_HEATER_STATUS,
132
+ )
133
+
134
+ MODULES: tuple[str, ...] = (
135
+ ATTR_MODULE_A,
136
+ ATTR_MODULE_B,
137
+ ATTR_MODULE_C,
138
+ ATTR_ECOLAMBDA,
139
+ ATTR_ECOSTER,
140
+ ATTR_PANEL,
141
+ )
142
+
143
+ FUEL_LEVEL_OFFSET: Final = 101
144
+
145
+
146
+ @dataclass(slots=True, kw_only=True)
147
+ class ConnectedModules:
148
+ """Represents a firmware version info for connected module."""
149
+
150
+ module_a: str | None = None
151
+ module_b: str | None = None
152
+ module_c: str | None = None
153
+ ecolambda: str | None = None
154
+ ecoster: str | None = None
155
+ panel: str | None = None
156
+
157
+
158
+ struct_version = struct.Struct("<BBB")
159
+ struct_vendor = struct.Struct("<BB")
160
+
161
+ _DataT = TypeVar("_DataT", bound=MutableMapping)
162
+
163
+
164
+ class SensorDataStructure(StructureDecoder):
165
+ """Represents a sensor data structure."""
166
+
167
+ __slots__ = ("_offset",)
168
+
169
+ _offset: int
170
+
171
+ def _decode_outputs(self, message: bytearray, data: _DataT) -> _DataT:
172
+ """Decode outputs from message."""
173
+ outputs = UnsignedInt.from_bytes(message, self._offset)
174
+ self._offset += outputs.size
175
+ for index, output in enumerate(OUTPUTS):
176
+ data[output] = bool(outputs.value & 2**index)
177
+
178
+ return data
179
+
180
+ def _decode_output_flags(self, message: bytearray, data: _DataT) -> _DataT:
181
+ """Decode output flags from message."""
182
+ output_flags = UnsignedInt.from_bytes(message, self._offset)
183
+ self._offset += output_flags.size
184
+ data[ATTR_HEATING_PUMP_FLAG] = bool(output_flags.value & 0x04)
185
+ data[ATTR_WATER_HEATER_PUMP_FLAG] = bool(output_flags.value & 0x08)
186
+ data[ATTR_CIRCULATION_PUMP_FLAG] = bool(output_flags.value & 0x10)
187
+ data[ATTR_SOLAR_PUMP_FLAG] = bool(output_flags.value & 0x800)
188
+ return data
189
+
190
+ def _decode_temperatures(self, message: bytearray, data: _DataT) -> _DataT:
191
+ """Decode temperatures from message."""
192
+ offset = self._offset
193
+ temperatures = message[offset]
194
+ offset += 1
195
+ for _ in range(temperatures):
196
+ index = message[offset]
197
+ offset += 1
198
+ temp = Float.from_bytes(message, offset)
199
+ offset += temp.size
200
+ if (not math.isnan(temp.value)) and 0 <= index < len(TEMPERATURES):
201
+ # Temperature exists and index is in the correct range.
202
+ data[TEMPERATURES[index]] = temp.value
203
+
204
+ self._offset = offset
205
+ return data
206
+
207
+ def _decode_statuses(self, message: bytearray, data: _DataT) -> _DataT:
208
+ """Decode statuses from message."""
209
+ for index, status in enumerate(STATUSES):
210
+ data[status] = message[self._offset + index]
211
+
212
+ self._offset += len(STATUSES)
213
+ return data
214
+
215
+ def _decode_pending_alerts(self, message: bytearray, data: _DataT) -> _DataT:
216
+ """Decode pending alerts from message."""
217
+ pending_alerts = message[self._offset]
218
+ data[ATTR_PENDING_ALERTS] = pending_alerts
219
+ self._offset += pending_alerts + 1
220
+ return data
221
+
222
+ def _decode_fuel_level(self, message: bytearray, data: _DataT) -> _DataT:
223
+ """Decode fuel level from message."""
224
+ fuel_level = message[self._offset]
225
+ self._offset += 1
226
+ if fuel_level != BYTE_UNDEFINED:
227
+ # Fuel offset requirement on at least ecoMAX 860P6-O.
228
+ # See: https://github.com/denpamusic/PyPlumIO/issues/19
229
+ data[ATTR_FUEL_LEVEL] = (
230
+ fuel_level
231
+ if fuel_level < FUEL_LEVEL_OFFSET
232
+ else fuel_level - FUEL_LEVEL_OFFSET
233
+ )
234
+
235
+ return data
236
+
237
+ def _decode_boiler_load(self, message: bytearray, data: _DataT) -> _DataT:
238
+ """Decode boiler load from message."""
239
+ boiler_load = message[self._offset]
240
+ self._offset += 1
241
+ if boiler_load != BYTE_UNDEFINED:
242
+ data[ATTR_BOILER_LOAD] = boiler_load
243
+
244
+ return data
245
+
246
+ def _decode_float_value(
247
+ self, name: str, message: bytearray, data: _DataT
248
+ ) -> _DataT:
249
+ """Decode float value and increase an offset."""
250
+ float_value = Float.from_bytes(message, self._offset)
251
+ self._offset += float_value.size
252
+ if not math.isnan(float_value.value):
253
+ data[name] = float_value.value
254
+
255
+ return data
256
+
257
+ def _decode_modules(self, message: bytearray, data: _DataT) -> _DataT:
258
+ """Decode modules from message."""
259
+ offset = self._offset
260
+
261
+ def _module_versions() -> Generator[tuple[str, str | None]]:
262
+ """Unpack a module version."""
263
+ nonlocal offset
264
+ for module in MODULES:
265
+ if message[offset] != BYTE_UNDEFINED:
266
+ version_data = struct_version.unpack_from(message, offset)
267
+ version = ".".join(str(i) for i in version_data)
268
+ offset += struct_version.size
269
+ if module == ATTR_MODULE_A:
270
+ vendor_code, vendor_version = struct_vendor.unpack_from(
271
+ message, offset
272
+ )
273
+ version += f".{chr(vendor_code) + str(vendor_version)}"
274
+ offset += struct_vendor.size
275
+ else:
276
+ offset += 1
277
+ version = None
278
+
279
+ yield module, version
280
+
281
+ data[ATTR_MODULES] = ConnectedModules(**dict(_module_versions()))
282
+ self._offset = offset
283
+ return data
284
+
285
+ def _decode_lambda_sensor(self, message: bytearray, data: _DataT) -> _DataT:
286
+ """Decode lambda sensor from message."""
287
+ offset = self._offset
288
+ lambda_state = message[offset]
289
+ offset += 1
290
+ if lambda_state != BYTE_UNDEFINED:
291
+ lambda_target = message[offset]
292
+ offset += 1
293
+ level = UnsignedShort.from_bytes(message, offset)
294
+ offset += level.size
295
+ with suppress(ValueError):
296
+ lambda_state = LambdaState(lambda_state)
297
+
298
+ data[ATTR_LAMBDA_STATE] = lambda_state
299
+ data[ATTR_LAMBDA_TARGET] = lambda_target
300
+ data[ATTR_LAMBDA_LEVEL] = level.value / 10
301
+
302
+ self._offset = offset
303
+ return data
304
+
305
+ def _decode_thermostat_sensors(self, message: bytearray, data: _DataT) -> _DataT:
306
+ """Decode thermostat sensors from message."""
307
+ contact_mask = 1
308
+ schedule_mask = 1 << 3
309
+ offset = self._offset
310
+
311
+ def _unpack_thermostat_sensors(contacts: int) -> dict[str, Any] | None:
312
+ """Unpack sensors for a single thermostat."""
313
+ nonlocal offset, contact_mask, schedule_mask
314
+ state = message[offset]
315
+ offset += 1
316
+ current_temp = Float.from_bytes(message, offset)
317
+ offset += current_temp.size
318
+ target_temp = Float.from_bytes(message, offset)
319
+ offset += target_temp.size
320
+ contacts_state = bool(contacts & contact_mask)
321
+ contact_mask <<= 1
322
+ schedule_state = bool(contacts & schedule_mask)
323
+ schedule_mask <<= 1
324
+
325
+ if math.isnan(current_temp.value) or target_temp.value <= 0:
326
+ return None
327
+
328
+ return {
329
+ ATTR_STATE: state,
330
+ ATTR_CURRENT_TEMP: current_temp.value,
331
+ ATTR_TARGET_TEMP: target_temp.value,
332
+ ATTR_CONTACTS: contacts_state,
333
+ ATTR_SCHEDULE: schedule_state,
334
+ }
335
+
336
+ def _thermostat_sensors(contacts: int) -> Generator[tuple[int, dict[str, Any]]]:
337
+ """Get thermostat sensors."""
338
+ for index in range(thermostats):
339
+ if sensors := _unpack_thermostat_sensors(contacts):
340
+ yield (index, sensors)
341
+
342
+ contacts = message[offset]
343
+ offset += 1
344
+ if contacts != BYTE_UNDEFINED:
345
+ thermostats = message[offset]
346
+ offset += 1
347
+ thermostat_sensors = dict(_thermostat_sensors(contacts))
348
+ data[ATTR_THERMOSTAT_SENSORS] = thermostat_sensors
349
+ data[ATTR_THERMOSTATS_CONNECTED] = len(thermostat_sensors)
350
+ data[ATTR_THERMOSTATS_AVAILABLE] = thermostats
351
+
352
+ self._offset = offset
353
+ return data
354
+
355
+ def _decode_mixer_sensors(self, message: bytearray, data: _DataT) -> _DataT:
356
+ """Decode mixer sensors from message."""
357
+ offset = self._offset
358
+
359
+ def _unpack_mixer_sensors() -> dict[str, Any] | None:
360
+ """Unpack sensors for a single mixer."""
361
+ nonlocal offset
362
+ current_temp = Float.from_bytes(message, offset)
363
+ offset += current_temp.size
364
+ data = None
365
+ if not math.isnan(current_temp.value):
366
+ data = {
367
+ ATTR_CURRENT_TEMP: current_temp.value,
368
+ ATTR_TARGET_TEMP: message[offset],
369
+ ATTR_PUMP: bool(message[offset + 2] & 0x01),
370
+ }
371
+
372
+ offset += 4
373
+ return data
374
+
375
+ def _mixer_sensors(mixers: int) -> Generator[tuple[int, dict[str, Any]]]:
376
+ """Get mixer sensors."""
377
+ for index in range(mixers):
378
+ if sensors := _unpack_mixer_sensors():
379
+ yield (index, sensors)
380
+
381
+ mixers = message[offset]
382
+ offset += 1
383
+ mixer_sensors = dict(_mixer_sensors(mixers))
384
+ data[ATTR_MIXER_SENSORS] = mixer_sensors
385
+ data[ATTR_MIXERS_CONNECTED] = len(mixer_sensors)
386
+ data[ATTR_MIXERS_AVAILABLE] = mixers
387
+ self._offset = offset
388
+ return data
389
+
390
+ def decode(
391
+ self, message: bytearray, offset: int = 0, data: dict[str, Any] | None = None
392
+ ) -> tuple[dict[str, Any], int]:
393
+ """Decode bytes and return message data and offset."""
394
+ data = ensure_dict(data)
395
+ data[ATTR_STATE] = message[offset]
396
+ self._offset = offset + 1
397
+ with suppress(ValueError):
398
+ data[ATTR_STATE] = DeviceState(data[ATTR_STATE])
399
+
400
+ data = self._decode_outputs(message, data)
401
+ data = self._decode_output_flags(message, data)
402
+ data = self._decode_temperatures(message, data)
403
+ data = self._decode_statuses(message, data)
404
+ data = self._decode_pending_alerts(message, data)
405
+ data = self._decode_fuel_level(message, data)
406
+ data[ATTR_TRANSMISSION] = message[self._offset]
407
+ self._offset += 1
408
+ data = self._decode_float_value(ATTR_FAN_POWER, message, data)
409
+ data = self._decode_boiler_load(message, data)
410
+ data = self._decode_float_value(ATTR_BOILER_POWER, message, data)
411
+ data = self._decode_float_value(ATTR_FUEL_CONSUMPTION, message, data)
412
+ data[ATTR_THERMOSTAT] = message[self._offset]
413
+ self._offset += 1
414
+ data = self._decode_modules(message, data)
415
+ data = self._decode_lambda_sensor(message, data)
416
+ data = self._decode_thermostat_sensors(message, data)
417
+ data = self._decode_mixer_sensors(message, data)
418
+ return data, offset
419
+
420
+
421
+ __all__ = [
422
+ "ATTR_AIR_IN_TEMP",
423
+ "ATTR_AIR_OUT_TEMP",
424
+ "ATTR_ALARM",
425
+ "ATTR_BLOW_FAN1",
426
+ "ATTR_BLOW_FAN2",
427
+ "ATTR_BOILER_LOAD",
428
+ "ATTR_BOILER_POWER",
429
+ "ATTR_CIRCULATION_PUMP",
430
+ "ATTR_CIRCULATION_PUMP_FLAG",
431
+ "ATTR_CONTACTS",
432
+ "ATTR_CURRENT_TEMP",
433
+ "ATTR_ECOLAMBDA",
434
+ "ATTR_ECOSTER",
435
+ "ATTR_EXCHANGER_TEMP",
436
+ "ATTR_EXHAUST_TEMP",
437
+ "ATTR_FAN",
438
+ "ATTR_FAN2_EXHAUST",
439
+ "ATTR_FAN_POWER",
440
+ "ATTR_FEEDER",
441
+ "ATTR_FEEDER2",
442
+ "ATTR_FEEDER_TEMP",
443
+ "ATTR_FIREPLACE_PUMP",
444
+ "ATTR_FIREPLACE_TEMP",
445
+ "ATTR_FUEL_CONSUMPTION",
446
+ "ATTR_FUEL_LEVEL",
447
+ "ATTR_GCZ_CONTACT",
448
+ "ATTR_HEATING_PUMP",
449
+ "ATTR_HEATING_PUMP_FLAG",
450
+ "ATTR_HEATING_STATUS",
451
+ "ATTR_HEATING_TARGET",
452
+ "ATTR_HEATING_TEMP",
453
+ "ATTR_HYDRAULIC_COUPLER_TEMP",
454
+ "ATTR_LAMBDA_LEVEL",
455
+ "ATTR_LAMBDA_STATE",
456
+ "ATTR_LAMBDA_TARGET",
457
+ "ATTR_LIGHTER",
458
+ "ATTR_LOWER_BUFFER_TEMP",
459
+ "ATTR_LOWER_SOLAR_TEMP",
460
+ "ATTR_MIXER_SENSORS",
461
+ "ATTR_MIXERS_AVAILABLE",
462
+ "ATTR_MIXERS_CONNECTED",
463
+ "ATTR_MODULE_A",
464
+ "ATTR_MODULE_B",
465
+ "ATTR_MODULE_C",
466
+ "ATTR_MODULES",
467
+ "ATTR_OPTICAL_TEMP",
468
+ "ATTR_OUTER_BOILER",
469
+ "ATTR_OUTER_FEEDER",
470
+ "ATTR_OUTSIDE_TEMP",
471
+ "ATTR_PANEL",
472
+ "ATTR_PENDING_ALERTS",
473
+ "ATTR_PUMP",
474
+ "ATTR_RETURN_TEMP",
475
+ "ATTR_SOLAR_PUMP",
476
+ "ATTR_SOLAR_PUMP_FLAG",
477
+ "ATTR_STATE",
478
+ "ATTR_TARGET_TEMP",
479
+ "ATTR_THERMOSTAT",
480
+ "ATTR_THERMOSTAT_SENSORS",
481
+ "ATTR_THERMOSTATS_AVAILABLE",
482
+ "ATTR_THERMOSTATS_CONNECTED",
483
+ "ATTR_TOTAL_GAIN",
484
+ "ATTR_TRANSMISSION",
485
+ "ATTR_UPPER_BUFFER_TEMP",
486
+ "ATTR_UPPER_SOLAR_TEMP",
487
+ "ATTR_WATER_HEATER_PUMP",
488
+ "ATTR_WATER_HEATER_PUMP_FLAG",
489
+ "ATTR_WATER_HEATER_STATUS",
490
+ "ATTR_WATER_HEATER_TARGET",
491
+ "ATTR_WATER_HEATER_TEMP",
492
+ "ConnectedModules",
493
+ "MODULES",
494
+ "OUTPUTS",
495
+ "SensorDataStructure",
496
+ "STATUSES",
497
+ "TEMPERATURES",
498
+ ]
@@ -3,12 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Generator
6
- from typing import Any, Final
6
+ from typing import Any, Final, TypeAlias
7
7
 
8
8
  from pyplumio.parameters import ParameterValues, unpack_parameter
9
9
  from pyplumio.parameters.thermostat import get_thermostat_parameter_types
10
10
  from pyplumio.structures import StructureDecoder
11
- from pyplumio.structures.thermostat_sensors import ATTR_THERMOSTATS_AVAILABLE
11
+ from pyplumio.structures.sensor_data import ATTR_THERMOSTATS_AVAILABLE
12
12
  from pyplumio.utils import ensure_dict
13
13
 
14
14
  ATTR_THERMOSTAT_PROFILE: Final = "thermostat_profile"
@@ -17,6 +17,9 @@ ATTR_THERMOSTAT_PARAMETERS: Final = "thermostat_parameters"
17
17
  THERMOSTAT_PARAMETER_SIZE: Final = 3
18
18
 
19
19
 
20
+ _ParameterValues: TypeAlias = tuple[int, ParameterValues]
21
+
22
+
20
23
  class ThermostatParametersStructure(StructureDecoder):
21
24
  """Represents a thermostat parameters data structure."""
22
25
 
@@ -26,7 +29,7 @@ class ThermostatParametersStructure(StructureDecoder):
26
29
 
27
30
  def _thermostat_parameter(
28
31
  self, message: bytearray, thermostats: int, start: int, end: int
29
- ) -> Generator[tuple[int, ParameterValues], None, None]:
32
+ ) -> Generator[_ParameterValues]:
30
33
  """Get a single thermostat parameter."""
31
34
  parameter_types = get_thermostat_parameter_types()
32
35
  for index in range(start, (start + end) // thermostats):
@@ -40,7 +43,7 @@ class ThermostatParametersStructure(StructureDecoder):
40
43
 
41
44
  def _thermostat_parameters(
42
45
  self, message: bytearray, thermostats: int, start: int, end: int
43
- ) -> Generator[tuple[int, list[tuple[int, ParameterValues]]], None, None]:
46
+ ) -> Generator[tuple[int, list[_ParameterValues]]]:
44
47
  """Get parameters for a thermostat."""
45
48
  for index in range(thermostats):
46
49
  if parameters := list(
pyplumio/utils.py CHANGED
@@ -3,9 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections.abc import Awaitable, Callable, Mapping
7
- from functools import wraps
8
- from typing import ParamSpec, TypeVar
6
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
7
+ from functools import reduce, wraps
8
+ from typing import Annotated, Final, ParamSpec, TypeAlias, TypeVar
9
9
 
10
10
  KT = TypeVar("KT") # Key type.
11
11
  VT = TypeVar("VT") # Value type.
@@ -42,6 +42,36 @@ def is_divisible(a: float, b: float, precision: int = 6) -> bool:
42
42
  return a_scaled % b_scaled == 0
43
43
 
44
44
 
45
+ SingleByte: TypeAlias = Annotated[int, "Single byte integer between 0 and 255"]
46
+
47
+ BITS_PER_BYTE: Final = 8
48
+
49
+
50
+ def join_bits(bits: Sequence[bool]) -> SingleByte:
51
+ """Join eight bits into a single byte."""
52
+ if len(bits) > BITS_PER_BYTE:
53
+ raise ValueError("The number of bits must not exceed 8.")
54
+
55
+ return reduce(lambda byte, bit: (byte << 1) | int(bit), bits, 0)
56
+
57
+
58
+ MAX_BYTE: Final = 255
59
+
60
+
61
+ def split_byte(byte: SingleByte) -> list[bool]:
62
+ """Split single byte into an eight bits."""
63
+ if byte < 0 or byte > MAX_BYTE:
64
+ raise ValueError("Byte value must be between 0 and 255.")
65
+
66
+ if byte == 0:
67
+ return [False, False, False, False, False, False, False, False]
68
+
69
+ if byte == MAX_BYTE:
70
+ return [True, True, True, True, True, True, True, True]
71
+
72
+ return [bool(byte & (1 << bit)) for bit in reversed(range(8))]
73
+
74
+
45
75
  T = TypeVar("T")
46
76
  P = ParamSpec("P")
47
77
 
@@ -62,4 +92,11 @@ def timeout(
62
92
  return decorator
63
93
 
64
94
 
65
- __all__ = ["ensure_dict", "is_divisible", "to_camelcase", "timeout"]
95
+ __all__ = [
96
+ "ensure_dict",
97
+ "is_divisible",
98
+ "to_camelcase",
99
+ "join_bits",
100
+ "split_byte",
101
+ "timeout",
102
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPlumIO
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: PyPlumIO is a native ecoNET library for Plum ecoMAX controllers.
5
5
  Author-email: Denis Paavilainen <denpa@denpa.pro>
6
6
  License: MIT License
@@ -32,9 +32,9 @@ Requires-Dist: numpy<3.0.0,>=2.0.0; extra == "test"
32
32
  Requires-Dist: pyserial-asyncio-fast==0.16; extra == "test"
33
33
  Requires-Dist: pytest==8.4.2; extra == "test"
34
34
  Requires-Dist: pytest-asyncio==1.2.0; extra == "test"
35
- Requires-Dist: ruff==0.13.2; extra == "test"
36
- Requires-Dist: tox==4.30.2; extra == "test"
37
- Requires-Dist: types-pyserial==3.5.0.20250919; extra == "test"
35
+ Requires-Dist: ruff==0.13.3; extra == "test"
36
+ Requires-Dist: tox==4.30.3; extra == "test"
37
+ Requires-Dist: types-pyserial==3.5.0.20251001; extra == "test"
38
38
  Provides-Extra: docs
39
39
  Requires-Dist: sphinx==8.1.3; extra == "docs"
40
40
  Requires-Dist: sphinx_rtd_theme==3.0.2; extra == "docs"
@@ -0,0 +1,50 @@
1
+ pyplumio/__init__.py,sha256=q4TBKjxccWvsms9BHFEnd684rc32r59o4xropBaEM90,3374
2
+ pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
+ pyplumio/_version.py,sha256=RY8LE6fDgFr4EFmdDWXFpTFEh_gnhjqz0rKUDqEfQ3A,704
4
+ pyplumio/connection.py,sha256=4JoutupQSvAO8WXFFuwddpJJODzna5oq-cHJRI4kgZ8,6625
5
+ pyplumio/const.py,sha256=7UMQrjmgqfpInumt67otDsGVfJNW1YRgoW7kDJT-Gy0,5439
6
+ pyplumio/data_types.py,sha256=eVhmXXzEEmze5xMY3SR307FZRDd6V6Vhba1-qRDLVFs,9430
7
+ pyplumio/exceptions.py,sha256=_B_0EgxDxd2XyYv3WpZM733q0cML5m6J-f55QOvYRpI,996
8
+ pyplumio/filters.py,sha256=a4Mfi3f2l0CT_gv95CTc796s1rpydLMMMovJN_Hov_w,17165
9
+ pyplumio/protocol.py,sha256=Z7MZLkBUX_MlONw0UBF_12ggPm3_iZ3AWxn2k7seTEo,12204
10
+ pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ pyplumio/stream.py,sha256=zFMKZ_GxsSGcaBTJigVM1CK3uGjlEJXgcvKqus8MDzk,7740
12
+ pyplumio/utils.py,sha256=PIjIRPaKzxtRroDFbkwAdksL5OHt2qqyFpTDKX6yNSo,2790
13
+ pyplumio/devices/__init__.py,sha256=ItX3JBzUFkSveHtNg1P9Kn7u60yVBo02DOS0D9UPuxc,8818
14
+ pyplumio/devices/ecomax.py,sha256=oJSiG_vzwQJI7NqQr9VIoyWfxwYcvibiDrSyl3LgdMI,15840
15
+ pyplumio/devices/ecoster.py,sha256=bosGErLISuYuEGG6J1TSeEt4NexySJHexYxxSpEddco,325
16
+ pyplumio/devices/mixer.py,sha256=7WdUVgwO4VXmaPNzh3ZWpKr2ooRXWemz2KFHAw35_Rk,2731
17
+ pyplumio/devices/thermostat.py,sha256=MHMKe45fQ7jKlhBVObJ7McbYQKuF6-LOKSHy-9VNsCU,2253
18
+ pyplumio/frames/__init__.py,sha256=6BWAh7wfq2A5ZnLDe2cfGK8K_RKN9uZFl7XSXRXSFvs,10438
19
+ pyplumio/frames/messages.py,sha256=i11MpcIiICBwgJch1yUeDTJ402Z1snh1gZOiSIEaOuA,892
20
+ pyplumio/frames/requests.py,sha256=E2OcKwmQebXazlKpizv0dKX0li9gld2xPzisJAzC8z4,8026
21
+ pyplumio/frames/responses.py,sha256=xLFBEWLDY6yDs_VSlFGfqbtoCzziPNyd6Ss374ByejU,5074
22
+ pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,31
23
+ pyplumio/helpers/async_cache.py,sha256=4GqMx6XhuQ84656ky6zPI8IZZGHYNSeDJextLgKCJM4,1432
24
+ pyplumio/helpers/event_manager.py,sha256=tzV9yWWYLLaF0NRyinZDyYJaumqRM7pIlOOTMpKruZw,8320
25
+ pyplumio/helpers/factory.py,sha256=OfYuWNjBiEdL9X7PqmPVvjTq1BQcu231UmE9E_nahPg,995
26
+ pyplumio/helpers/task_manager.py,sha256=N71F6Ag1HHxdf5zJeCMcEziFdH9lmJKtMPoRGjJ-E40,1209
27
+ pyplumio/parameters/__init__.py,sha256=Ye8nCoUtltpbVHx0yv0DwNHcqeVrqN0CzGyoZdjZvUA,16171
28
+ pyplumio/parameters/ecomax.py,sha256=KjHlkVZK2XYEl4HNSdCRLAnv0KEn7gjnEO_CsKFZwIw,26199
29
+ pyplumio/parameters/mixer.py,sha256=cjwe6AJdboAIEnCeiYNqIRmOVo3dSQqbMTWgiCSx8J8,6606
30
+ pyplumio/parameters/thermostat.py,sha256=sRAndI87jANM8uvdQc1LdkT6_baDxf0AEAFVYRstzNE,5039
31
+ pyplumio/parameters/custom/__init__.py,sha256=EeddoseRsh2Gxche3e3woRBgNszraOnLUs9TciK7dCA,3168
32
+ pyplumio/parameters/custom/ecomax_860d3_hb.py,sha256=IsNgDXmV90QpBilDV4fGSBtIUEQJJbR9rjnfCr3-pHE,2840
33
+ pyplumio/structures/__init__.py,sha256=tb62y-x466WSogdjNpsvqcD3Kiz7xMW604m2-yJH3jc,1329
34
+ pyplumio/structures/alerts.py,sha256=EUA-iB0ZXhaGHHY2lO_d8Hbwech2rJCPGJVjSXTUPW0,3688
35
+ pyplumio/structures/ecomax_parameters.py,sha256=FI53j-UO0qdUnquJvMBZiuCHFBUAIUsMZRGoubnTXyM,1651
36
+ pyplumio/structures/frame_versions.py,sha256=bRBHQGv19ko_9meB3YGA11KiT_UgAi8L9WDgnqMhiVk,1620
37
+ pyplumio/structures/mixer_parameters.py,sha256=qaEMpFLDB0QkMxNq5xc0aG_TkUfIg9WAGM4FbJOuocE,2065
38
+ pyplumio/structures/network_info.py,sha256=ENZbQUQgxdrsq5zrfMT7co4TNap21XJ1LqgTLS3f9Ec,4425
39
+ pyplumio/structures/product_info.py,sha256=LCf4dp-kO-9S3wk7Kz53ek-283Hxr-b0ylSkgjmsRYs,3269
40
+ pyplumio/structures/program_version.py,sha256=o0tjJhi4dqTm5M-9-UpdXFltMZeiPOxYgdkYHyXxX1Y,2572
41
+ pyplumio/structures/regulator_data.py,sha256=SYKI1YPC3mDAth-SpYejttbD0IzBfobjgC-uy_uUKnw,2333
42
+ pyplumio/structures/regulator_data_schema.py,sha256=0SapbZCGzqAHmHC7dwhufszJ9FNo_ZO_XMrFGNiUe-w,1547
43
+ pyplumio/structures/schedules.py,sha256=7VltDMpGrNdylq1iFt0tmj0mxkUYX0vsPx0ADwc5UDU,10714
44
+ pyplumio/structures/sensor_data.py,sha256=tYNqvHi28eVdgESjQ-ZkDU4mLdeZOSxOW9nw2-3FnBU,17174
45
+ pyplumio/structures/thermostat_parameters.py,sha256=XMlnpmnw06Dkqynv1e9qG2IDelfmmTYRT7UJP01RWFo,3025
46
+ pyplumio-0.6.2.dist-info/licenses/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
47
+ pyplumio-0.6.2.dist-info/METADATA,sha256=BOtqquZbHJPAO6klTWNxYAVYbjsmIGHCT07ni6Uwxgk,5520
48
+ pyplumio-0.6.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
49
+ pyplumio-0.6.2.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
50
+ pyplumio-0.6.2.dist-info/RECORD,,