PyPlumIO 0.5.43__py3-none-any.whl → 0.5.44__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 (35) hide show
  1. pyplumio/_version.py +2 -2
  2. pyplumio/const.py +1 -3
  3. pyplumio/devices/__init__.py +16 -28
  4. pyplumio/devices/ecomax.py +103 -59
  5. pyplumio/devices/mixer.py +6 -6
  6. pyplumio/devices/thermostat.py +9 -6
  7. pyplumio/filters.py +44 -22
  8. pyplumio/helpers/async_cache.py +48 -0
  9. pyplumio/helpers/event_manager.py +20 -2
  10. pyplumio/helpers/timeout.py +6 -5
  11. pyplumio/parameters/__init__.py +9 -3
  12. pyplumio/protocol.py +15 -7
  13. pyplumio/structures/alerts.py +1 -1
  14. pyplumio/structures/boiler_power.py +1 -1
  15. pyplumio/structures/fan_power.py +1 -1
  16. pyplumio/structures/frame_versions.py +1 -1
  17. pyplumio/structures/fuel_consumption.py +1 -1
  18. pyplumio/structures/lambda_sensor.py +1 -1
  19. pyplumio/structures/mixer_sensors.py +1 -1
  20. pyplumio/structures/network_info.py +1 -1
  21. pyplumio/structures/output_flags.py +1 -1
  22. pyplumio/structures/outputs.py +1 -1
  23. pyplumio/structures/product_info.py +1 -1
  24. pyplumio/structures/regulator_data.py +1 -1
  25. pyplumio/structures/regulator_data_schema.py +1 -1
  26. pyplumio/structures/temperatures.py +1 -1
  27. pyplumio/structures/thermostat_parameters.py +5 -7
  28. pyplumio/structures/thermostat_sensors.py +1 -1
  29. {pyplumio-0.5.43.dist-info → pyplumio-0.5.44.dist-info}/METADATA +3 -1
  30. pyplumio-0.5.44.dist-info/RECORD +64 -0
  31. {pyplumio-0.5.43.dist-info → pyplumio-0.5.44.dist-info}/WHEEL +1 -1
  32. pyplumio-0.5.43.dist-info/RECORD +0 -63
  33. /pyplumio/{helpers/data_types.py → data_types.py} +0 -0
  34. {pyplumio-0.5.43.dist-info → pyplumio-0.5.44.dist-info}/licenses/LICENSE +0 -0
  35. {pyplumio-0.5.43.dist-info → pyplumio-0.5.44.dist-info}/top_level.txt +0 -0
pyplumio/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.5.43'
21
- __version_tuple__ = version_tuple = (0, 5, 43)
20
+ __version__ = version = '0.5.44'
21
+ __version_tuple__ = version_tuple = (0, 5, 44)
pyplumio/const.py CHANGED
@@ -14,11 +14,11 @@ ATTR_CURRENT_TEMP: Final = "current_temp"
14
14
  ATTR_DEVICE_INDEX: Final = "device_index"
15
15
  ATTR_FRAME_ERRORS: Final = "frame_errors"
16
16
  ATTR_INDEX: Final = "index"
17
- ATTR_LOADED: Final = "loaded"
18
17
  ATTR_OFFSET: Final = "offset"
19
18
  ATTR_PARAMETER: Final = "parameter"
20
19
  ATTR_PASSWORD: Final = "password"
21
20
  ATTR_SCHEDULE: Final = "schedule"
21
+ ATTR_SETUP: Final = "setup"
22
22
  ATTR_SENSORS: Final = "sensors"
23
23
  ATTR_START: Final = "start"
24
24
  ATTR_STATE: Final = "state"
@@ -229,6 +229,4 @@ PERCENTAGE: Final = "%"
229
229
 
230
230
  STATE_ON: Final = "on"
231
231
  STATE_OFF: Final = "off"
232
-
233
-
234
232
  State: TypeAlias = Literal["on", "off"]
@@ -8,14 +8,13 @@ from functools import cache
8
8
  import logging
9
9
  from typing import Any, ClassVar
10
10
 
11
- from pyplumio.const import ATTR_FRAME_ERRORS, ATTR_LOADED, DeviceType, FrameType, State
11
+ from pyplumio.const import ATTR_FRAME_ERRORS, DeviceType, FrameType, State
12
12
  from pyplumio.exceptions import RequestError, UnknownDeviceError
13
13
  from pyplumio.filters import on_change
14
- from pyplumio.frames import DataFrameDescription, Frame, Request, is_known_frame_type
14
+ from pyplumio.frames import Frame, Request, is_known_frame_type
15
15
  from pyplumio.helpers.event_manager import EventManager, event_listener
16
16
  from pyplumio.helpers.factory import create_instance
17
17
  from pyplumio.parameters import NumericType, Parameter
18
- from pyplumio.structures.frame_versions import ATTR_FRAME_VERSIONS
19
18
  from pyplumio.structures.network_info import NetworkInfo
20
19
  from pyplumio.utils import to_camelcase
21
20
 
@@ -126,11 +125,11 @@ class PhysicalDevice(Device, ABC):
126
125
  virtual devices associated with them via parent property.
127
126
  """
128
127
 
129
- __slots__ = ("address", "_network", "_setup_frames", "_frame_versions")
128
+ __slots__ = ("address", "_network", "_frame_versions")
130
129
 
131
130
  address: ClassVar[int]
131
+
132
132
  _network: NetworkInfo
133
- _setup_frames: tuple[DataFrameDescription, ...]
134
133
  _frame_versions: dict[int, int]
135
134
 
136
135
  def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo) -> None:
@@ -139,20 +138,27 @@ class PhysicalDevice(Device, ABC):
139
138
  self._network = network
140
139
  self._frame_versions = {}
141
140
 
142
- @event_listener(ATTR_FRAME_VERSIONS, on_change)
141
+ @event_listener(filter=on_change)
143
142
  async def on_event_frame_versions(self, versions: dict[int, int]) -> None:
144
143
  """Check frame versions and update outdated frames."""
144
+ _LOGGER.info("Received version table")
145
145
  for frame_type, version in versions.items():
146
146
  if (
147
147
  is_known_frame_type(frame_type)
148
148
  and self.supports_frame_type(frame_type)
149
149
  and not self.has_frame_version(frame_type, version)
150
150
  ):
151
- _LOGGER.debug("Updating frame %s to version %i", frame_type, version)
152
- request = await Request.create(frame_type, recipient=self.address)
153
- self.queue.put_nowait(request)
151
+ await self._request_frame_version(frame_type, version)
154
152
  self._frame_versions[frame_type] = version
155
153
 
154
+ async def _request_frame_version(
155
+ self, frame_type: FrameType | int, version: int
156
+ ) -> None:
157
+ """Request frame version from the device."""
158
+ _LOGGER.info("Updating frame %s to version %i", repr(frame_type), version)
159
+ request = await Request.create(frame_type, recipient=self.address)
160
+ self.queue.put_nowait(request)
161
+
156
162
  def has_frame_version(self, frame_type: FrameType | int, version: int) -> bool:
157
163
  """Return True if frame data is up to date, False otherwise."""
158
164
  return (
@@ -171,25 +177,6 @@ class PhysicalDevice(Device, ABC):
171
177
  for name, value in frame.data.items():
172
178
  self.dispatch_nowait(name, value)
173
179
 
174
- async def async_setup(self) -> bool:
175
- """Set up addressable device."""
176
- results = await asyncio.gather(
177
- *(
178
- self.request(description.provides, description.frame_type)
179
- for description in self._setup_frames
180
- ),
181
- return_exceptions=True,
182
- )
183
-
184
- errors = [
185
- result.frame_type for result in results if isinstance(result, RequestError)
186
- ]
187
-
188
- await asyncio.gather(
189
- self.dispatch(ATTR_FRAME_ERRORS, errors), self.dispatch(ATTR_LOADED, True)
190
- )
191
- return True
192
-
193
180
  async def request(
194
181
  self, name: str, frame_type: FrameType, retries: int = 3, timeout: float = 3.0
195
182
  ) -> Any:
@@ -197,6 +184,7 @@ class PhysicalDevice(Device, ABC):
197
184
 
198
185
  If value is not available before timeout, retry request.
199
186
  """
187
+ _LOGGER.info("Requesting '%s' with %s", name, repr(frame_type))
200
188
  request = await Request.create(frame_type, recipient=self.address)
201
189
  while retries > 0:
202
190
  try:
@@ -3,15 +3,15 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections.abc import Coroutine, Generator, Iterable, Sequence
6
+ from collections.abc import Coroutine, Generator, Iterable
7
7
  import logging
8
8
  import time
9
9
  from typing import Any, Final
10
10
 
11
11
  from pyplumio.const import (
12
+ ATTR_FRAME_ERRORS,
12
13
  ATTR_PASSWORD,
13
14
  ATTR_SENSORS,
14
- ATTR_STATE,
15
15
  STATE_OFF,
16
16
  STATE_ON,
17
17
  DeviceState,
@@ -22,6 +22,7 @@ from pyplumio.const import (
22
22
  from pyplumio.devices import PhysicalDevice
23
23
  from pyplumio.devices.mixer import Mixer
24
24
  from pyplumio.devices.thermostat import Thermostat
25
+ from pyplumio.exceptions import RequestError
25
26
  from pyplumio.filters import on_change
26
27
  from pyplumio.frames import DataFrameDescription, Frame, Request
27
28
  from pyplumio.helpers.event_manager import event_listener
@@ -40,14 +41,12 @@ from pyplumio.structures.ecomax_parameters import (
40
41
  ATTR_ECOMAX_CONTROL,
41
42
  ATTR_ECOMAX_PARAMETERS,
42
43
  )
43
- from pyplumio.structures.fuel_consumption import ATTR_FUEL_CONSUMPTION
44
44
  from pyplumio.structures.mixer_parameters import ATTR_MIXER_PARAMETERS
45
45
  from pyplumio.structures.mixer_sensors import ATTR_MIXER_SENSORS
46
46
  from pyplumio.structures.network_info import ATTR_NETWORK, NetworkInfo
47
47
  from pyplumio.structures.product_info import ATTR_PRODUCT, ProductInfo
48
48
  from pyplumio.structures.regulator_data_schema import ATTR_REGDATA_SCHEMA
49
49
  from pyplumio.structures.schedules import (
50
- ATTR_SCHEDULE_PARAMETERS,
51
50
  ATTR_SCHEDULES,
52
51
  SCHEDULE_PARAMETERS,
53
52
  SCHEDULES,
@@ -55,20 +54,53 @@ from pyplumio.structures.schedules import (
55
54
  ScheduleSwitch,
56
55
  ScheduleSwitchDescription,
57
56
  )
58
- from pyplumio.structures.thermostat_parameters import (
59
- ATTR_THERMOSTAT_PARAMETERS,
60
- ATTR_THERMOSTAT_PROFILE,
61
- )
57
+ from pyplumio.structures.thermostat_parameters import ATTR_THERMOSTAT_PARAMETERS
62
58
  from pyplumio.structures.thermostat_sensors import ATTR_THERMOSTAT_SENSORS
63
59
 
60
+ _LOGGER = logging.getLogger(__name__)
61
+
62
+
64
63
  ATTR_MIXERS: Final = "mixers"
65
64
  ATTR_THERMOSTATS: Final = "thermostats"
66
65
  ATTR_FUEL_BURNED: Final = "fuel_burned"
67
66
 
68
- NANOSECONDS_IN_SECOND: Final = 1_000_000_000
69
- MAX_TIME_SINCE_LAST_FUEL_UPDATE_NS: Final = 300 * NANOSECONDS_IN_SECOND
67
+ MAX_TIME_SINCE_LAST_FUEL_UPDATE: Final = 5 * 60
68
+
69
+
70
+ class FuelMeter:
71
+ """Represents a fuel meter.
72
+
73
+ Calculates the fuel burned based on the time
74
+ elapsed since the last sensor message, which contains fuel
75
+ consumption data. If the elapsed time is within the acceptable
76
+ range, it returns the fuel burned data. Otherwise, it logs a
77
+ warning and returns None.
78
+ """
79
+
80
+ __slots__ = ("_last_update_time",)
81
+
82
+ _last_update_time: float
70
83
 
71
- SETUP_FRAME_TYPES: tuple[DataFrameDescription, ...] = (
84
+ def __init__(self) -> None:
85
+ """Initialize a new fuel meter."""
86
+ self._last_update_time = time.monotonic()
87
+
88
+ def calculate(self, fuel_consumption: float) -> float | None:
89
+ """Calculate the amount of burned fuel since last update."""
90
+ current_time = time.monotonic()
91
+ time_since_update = current_time - self._last_update_time
92
+ self._last_update_time = current_time
93
+ if time_since_update < MAX_TIME_SINCE_LAST_FUEL_UPDATE:
94
+ return fuel_consumption * (time_since_update / 3600)
95
+
96
+ _LOGGER.warning(
97
+ "Skipping outdated fuel consumption data (was %f seconds old)",
98
+ time_since_update,
99
+ )
100
+ return None
101
+
102
+
103
+ REQUIRED: tuple[DataFrameDescription, ...] = (
72
104
  DataFrameDescription(
73
105
  frame_type=FrameType.REQUEST_UID,
74
106
  provides=ATTR_PRODUCT,
@@ -103,28 +135,22 @@ SETUP_FRAME_TYPES: tuple[DataFrameDescription, ...] = (
103
135
  ),
104
136
  )
105
137
 
106
- _LOGGER = logging.getLogger(__name__)
138
+ REQUIRED_TYPES = [description.frame_type for description in REQUIRED]
107
139
 
108
140
 
109
141
  class EcoMAX(PhysicalDevice):
110
142
  """Represents an ecoMAX controller."""
111
143
 
112
- __slots__ = ("_fuel_burned_time_ns",)
144
+ __slots__ = ("_fuel_meter",)
113
145
 
114
- address = DeviceType.ECOMAX
115
- _setup_frames = SETUP_FRAME_TYPES
146
+ _fuel_meter: FuelMeter
116
147
 
117
- _fuel_burned_time_ns: int
148
+ address = DeviceType.ECOMAX
118
149
 
119
150
  def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo) -> None:
120
151
  """Initialize a new ecoMAX controller."""
121
152
  super().__init__(queue, network)
122
- self._fuel_burned_time_ns = time.perf_counter_ns()
123
-
124
- async def async_setup(self) -> bool:
125
- """Set up an ecoMAX controller."""
126
- await self.wait_for(ATTR_SENSORS)
127
- return await super().async_setup()
153
+ self._fuel_meter = FuelMeter()
128
154
 
129
155
  def handle_frame(self, frame: Frame) -> None:
130
156
  """Handle frame received from the ecoMAX device."""
@@ -162,6 +188,13 @@ class EcoMAX(PhysicalDevice):
162
188
 
163
189
  return self.dispatch_nowait(ATTR_THERMOSTATS, thermostats)
164
190
 
191
+ async def _request_frame_version(
192
+ self, frame_type: FrameType | int, version: int
193
+ ) -> None:
194
+ """Request frame version from the device."""
195
+ if frame_type not in REQUIRED_TYPES:
196
+ await super()._request_frame_version(frame_type, version)
197
+
165
198
  async def _set_ecomax_state(self, state: State) -> bool:
166
199
  """Try to set the ecoMAX control state."""
167
200
  try:
@@ -196,11 +229,34 @@ class EcoMAX(PhysicalDevice):
196
229
  await asyncio.gather(*(device.shutdown() for device in devices))
197
230
  await super().shutdown()
198
231
 
199
- @event_listener(ATTR_ECOMAX_PARAMETERS)
232
+ @event_listener
233
+ async def on_event_setup(self, setup: bool) -> None:
234
+ """Request frames required to set up an ecoMAX entry."""
235
+ _LOGGER.info("Setting up device entry")
236
+ await self.wait_for(ATTR_SENSORS)
237
+ results = await asyncio.gather(
238
+ *(
239
+ self.request(description.provides, description.frame_type)
240
+ for description in REQUIRED
241
+ ),
242
+ return_exceptions=True,
243
+ )
244
+
245
+ errors = [
246
+ result.frame_type for result in results if isinstance(result, RequestError)
247
+ ]
248
+
249
+ if errors:
250
+ self.dispatch_nowait(ATTR_FRAME_ERRORS, errors)
251
+
252
+ _LOGGER.info("Device entry setup done")
253
+
254
+ @event_listener
200
255
  async def on_event_ecomax_parameters(
201
- self, parameters: Sequence[tuple[int, ParameterValues]]
256
+ self, parameters: list[tuple[int, ParameterValues]]
202
257
  ) -> bool:
203
258
  """Update ecoMAX parameters and dispatch the events."""
259
+ _LOGGER.info("Received device parameters")
204
260
  product_info: ProductInfo = await self.get(ATTR_PRODUCT)
205
261
 
206
262
  def _ecomax_parameter_events() -> Generator[Coroutine, Any, None]:
@@ -236,37 +292,20 @@ class EcoMAX(PhysicalDevice):
236
292
  await asyncio.gather(*_ecomax_parameter_events())
237
293
  return True
238
294
 
239
- @event_listener(ATTR_FUEL_CONSUMPTION)
295
+ @event_listener
240
296
  async def on_event_fuel_consumption(self, fuel_consumption: float) -> None:
241
- """Update the amount of burned fuel.
242
-
243
- This method calculates the fuel burned based on the time
244
- elapsed since the last sensor message, which contains fuel
245
- consumption data. If the elapsed time is within the acceptable
246
- range, it dispatches the fuel burned data. Otherwise, it logs a
247
- warning and skips the outdated data.
248
- """
249
- time_ns = time.perf_counter_ns()
250
- nanoseconds_passed = time_ns - self._fuel_burned_time_ns
251
- self._fuel_burned_time_ns = time_ns
252
- if nanoseconds_passed < MAX_TIME_SINCE_LAST_FUEL_UPDATE_NS:
253
- return self.dispatch_nowait(
254
- ATTR_FUEL_BURNED,
255
- fuel_consumption * nanoseconds_passed / (3600 * NANOSECONDS_IN_SECOND),
256
- )
257
-
258
- _LOGGER.warning(
259
- "Skipping outdated fuel consumption data: %f (was %i seconds old)",
260
- fuel_consumption,
261
- nanoseconds_passed / NANOSECONDS_IN_SECOND,
262
- )
297
+ """Update the amount of burned fuel."""
298
+ fuel_burned = self._fuel_meter.calculate(fuel_consumption)
299
+ if fuel_burned is not None:
300
+ self.dispatch_nowait(ATTR_FUEL_BURNED, fuel_burned)
263
301
 
264
- @event_listener(ATTR_MIXER_PARAMETERS)
302
+ @event_listener
265
303
  async def on_event_mixer_parameters(
266
304
  self,
267
- parameters: dict[int, Sequence[tuple[int, ParameterValues]]] | None,
305
+ parameters: dict[int, list[tuple[int, ParameterValues]]] | None,
268
306
  ) -> bool:
269
307
  """Handle mixer parameters and dispatch the events."""
308
+ _LOGGER.info("Received mixer parameters")
270
309
  if parameters:
271
310
  await asyncio.gather(
272
311
  *(
@@ -278,11 +317,12 @@ class EcoMAX(PhysicalDevice):
278
317
 
279
318
  return False
280
319
 
281
- @event_listener(ATTR_MIXER_SENSORS)
320
+ @event_listener
282
321
  async def on_event_mixer_sensors(
283
322
  self, sensors: dict[int, dict[str, Any]] | None
284
323
  ) -> bool:
285
324
  """Update mixer sensors and dispatch the events."""
325
+ _LOGGER.info("Received mixer sensors")
286
326
  if sensors:
287
327
  await asyncio.gather(
288
328
  *(
@@ -294,9 +334,9 @@ class EcoMAX(PhysicalDevice):
294
334
 
295
335
  return False
296
336
 
297
- @event_listener(ATTR_SCHEDULE_PARAMETERS)
337
+ @event_listener
298
338
  async def on_event_schedule_parameters(
299
- self, parameters: Sequence[tuple[int, ParameterValues]]
339
+ self, parameters: list[tuple[int, ParameterValues]]
300
340
  ) -> bool:
301
341
  """Update schedule parameters and dispatch the events."""
302
342
 
@@ -319,20 +359,22 @@ class EcoMAX(PhysicalDevice):
319
359
  await asyncio.gather(*_schedule_parameter_events())
320
360
  return True
321
361
 
322
- @event_listener(ATTR_SENSORS)
362
+ @event_listener
323
363
  async def on_event_sensors(self, sensors: dict[str, Any]) -> bool:
324
364
  """Update ecoMAX sensors and dispatch the events."""
365
+ _LOGGER.info("Received device sensors")
325
366
  await asyncio.gather(
326
367
  *(self.dispatch(name, value) for name, value in sensors.items())
327
368
  )
328
369
  return True
329
370
 
330
- @event_listener(ATTR_THERMOSTAT_PARAMETERS)
371
+ @event_listener
331
372
  async def on_event_thermostat_parameters(
332
373
  self,
333
- parameters: dict[int, Sequence[tuple[int, ParameterValues]]] | None,
374
+ parameters: dict[int, list[tuple[int, ParameterValues]]] | None,
334
375
  ) -> bool:
335
376
  """Handle thermostat parameters and dispatch the events."""
377
+ _LOGGER.info("Received thermostat parameters")
336
378
  if parameters:
337
379
  await asyncio.gather(
338
380
  *(
@@ -346,7 +388,7 @@ class EcoMAX(PhysicalDevice):
346
388
 
347
389
  return False
348
390
 
349
- @event_listener(ATTR_THERMOSTAT_PROFILE)
391
+ @event_listener
350
392
  async def on_event_thermostat_profile(
351
393
  self, values: ParameterValues | None
352
394
  ) -> EcomaxNumber | None:
@@ -358,11 +400,12 @@ class EcoMAX(PhysicalDevice):
358
400
 
359
401
  return None
360
402
 
361
- @event_listener(ATTR_THERMOSTAT_SENSORS)
403
+ @event_listener
362
404
  async def on_event_thermostat_sensors(
363
405
  self, sensors: dict[int, dict[str, Any]] | None
364
406
  ) -> bool:
365
407
  """Update thermostat sensors and dispatch the events."""
408
+ _LOGGER.info("Received thermostat sensors")
366
409
  if sensors:
367
410
  await asyncio.gather(
368
411
  *(
@@ -377,11 +420,12 @@ class EcoMAX(PhysicalDevice):
377
420
 
378
421
  return False
379
422
 
380
- @event_listener(ATTR_SCHEDULES)
423
+ @event_listener
381
424
  async def on_event_schedules(
382
425
  self, schedules: list[tuple[int, list[list[bool]]]]
383
426
  ) -> dict[str, Schedule]:
384
427
  """Update schedules."""
428
+ _LOGGER.info("Received device schedules")
385
429
  return {
386
430
  SCHEDULES[index]: Schedule(
387
431
  name=SCHEDULES[index],
@@ -397,7 +441,7 @@ class EcoMAX(PhysicalDevice):
397
441
  for index, schedule in schedules
398
442
  }
399
443
 
400
- @event_listener(ATTR_STATE, on_change)
444
+ @event_listener(filter=on_change)
401
445
  async def on_event_state(self, state: DeviceState) -> None:
402
446
  """Update the ecoMAX control parameter."""
403
447
  await self.dispatch(
pyplumio/devices/mixer.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections.abc import Coroutine, Generator, Sequence
6
+ from collections.abc import Coroutine, Generator
7
7
  import logging
8
8
  from typing import Any
9
9
 
@@ -16,8 +16,6 @@ from pyplumio.parameters.mixer import (
16
16
  MixerSwitchDescription,
17
17
  get_mixer_parameter_types,
18
18
  )
19
- from pyplumio.structures.mixer_parameters import ATTR_MIXER_PARAMETERS
20
- from pyplumio.structures.mixer_sensors import ATTR_MIXER_SENSORS
21
19
  from pyplumio.structures.product_info import ATTR_PRODUCT, ProductInfo
22
20
 
23
21
  _LOGGER = logging.getLogger(__name__)
@@ -28,19 +26,21 @@ class Mixer(VirtualDevice):
28
26
 
29
27
  __slots__ = ()
30
28
 
31
- @event_listener(ATTR_MIXER_SENSORS)
29
+ @event_listener
32
30
  async def on_event_mixer_sensors(self, sensors: dict[str, Any]) -> bool:
33
31
  """Update mixer sensors and dispatch the events."""
32
+ _LOGGER.info("Received mixer %i sensors", self.index)
34
33
  await asyncio.gather(
35
34
  *(self.dispatch(name, value) for name, value in sensors.items())
36
35
  )
37
36
  return True
38
37
 
39
- @event_listener(ATTR_MIXER_PARAMETERS)
38
+ @event_listener
40
39
  async def on_event_mixer_parameters(
41
- self, parameters: Sequence[tuple[int, ParameterValues]]
40
+ self, parameters: list[tuple[int, ParameterValues]]
42
41
  ) -> bool:
43
42
  """Update mixer parameters and dispatch the events."""
43
+ _LOGGER.info("Received mixer %i parameters", self.index)
44
44
  product_info: ProductInfo = await self.parent.get(ATTR_PRODUCT)
45
45
 
46
46
  def _mixer_parameter_events() -> Generator[Coroutine, Any, None]:
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections.abc import Coroutine, Generator, Sequence
6
+ from collections.abc import Coroutine, Generator
7
+ import logging
7
8
  from typing import Any
8
9
 
9
10
  from pyplumio.devices import VirtualDevice
@@ -15,8 +16,8 @@ from pyplumio.parameters.thermostat import (
15
16
  ThermostatSwitchDescription,
16
17
  get_thermostat_parameter_types,
17
18
  )
18
- from pyplumio.structures.thermostat_parameters import ATTR_THERMOSTAT_PARAMETERS
19
- from pyplumio.structures.thermostat_sensors import ATTR_THERMOSTAT_SENSORS
19
+
20
+ _LOGGER = logging.getLogger()
20
21
 
21
22
 
22
23
  class Thermostat(VirtualDevice):
@@ -24,19 +25,21 @@ class Thermostat(VirtualDevice):
24
25
 
25
26
  __slots__ = ()
26
27
 
27
- @event_listener(ATTR_THERMOSTAT_SENSORS)
28
+ @event_listener
28
29
  async def on_event_thermostat_sensors(self, sensors: dict[str, Any]) -> bool:
29
30
  """Update thermostat sensors and dispatch the events."""
31
+ _LOGGER.info("Received thermostat %i sensors", self.index)
30
32
  await asyncio.gather(
31
33
  *(self.dispatch(name, value) for name, value in sensors.items())
32
34
  )
33
35
  return True
34
36
 
35
- @event_listener(ATTR_THERMOSTAT_PARAMETERS)
37
+ @event_listener
36
38
  async def on_event_thermostat_parameters(
37
- self, parameters: Sequence[tuple[int, ParameterValues]]
39
+ self, parameters: list[tuple[int, ParameterValues]]
38
40
  ) -> bool:
39
41
  """Update thermostat parameters and dispatch the events."""
42
+ _LOGGER.info("Received thermostat %i parameters", self.index)
40
43
 
41
44
  def _thermostat_parameter_events() -> Generator[Coroutine, Any, None]:
42
45
  """Get dispatch calls for thermostat parameter events."""
pyplumio/filters.py CHANGED
@@ -4,7 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  from abc import ABC, abstractmethod
6
6
  from collections.abc import Callable
7
+ from contextlib import suppress
7
8
  from copy import copy
9
+ from decimal import Decimal
10
+ import logging
8
11
  import math
9
12
  import time
10
13
  from typing import (
@@ -22,6 +25,16 @@ from typing_extensions import TypeAlias
22
25
  from pyplumio.helpers.event_manager import Callback
23
26
  from pyplumio.parameters import Parameter
24
27
 
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+ numpy_installed = False
31
+ with suppress(ImportError):
32
+ import numpy as np
33
+
34
+ _LOGGER.info("Using numpy for improved float precision")
35
+ numpy_installed = True
36
+
37
+
25
38
  UNDEFINED: Final = "undefined"
26
39
  TOLERANCE: Final = 0.1
27
40
 
@@ -129,44 +142,50 @@ class _Aggregate(Filter):
129
142
  """Represents an aggregate filter.
130
143
 
131
144
  Calls a callback with a sum of values collected over a specified
132
- time period.
145
+ time period or when sample size limit reached.
133
146
  """
134
147
 
135
- __slots__ = ("_sum", "_last_update", "_timeout")
148
+ __slots__ = ("_values", "_sample_size", "_timeout", "_last_call_time")
136
149
 
137
- _sum: complex
138
- _last_update: float
150
+ _values: list[float | int | Decimal]
151
+ _sample_size: int
139
152
  _timeout: float
153
+ _last_call_time: float
140
154
 
141
- def __init__(self, callback: Callback, seconds: float) -> None:
155
+ def __init__(self, callback: Callback, seconds: float, sample_size: int) -> None:
142
156
  """Initialize a new aggregate filter."""
143
157
  super().__init__(callback)
144
- self._last_update = time.monotonic()
158
+ self._last_call_time = time.monotonic()
145
159
  self._timeout = seconds
146
- self._sum = 0.0
160
+ self._sample_size = sample_size
161
+ self._values = []
147
162
 
148
163
  async def __call__(self, new_value: Any) -> Any:
149
164
  """Set a new value for the callback."""
150
- current_timestamp = time.monotonic()
151
- try:
152
- self._sum += new_value
153
- except TypeError as e:
154
- raise ValueError(
155
- "Aggregate filter can only be used with numeric values"
156
- ) from e
157
-
158
- if current_timestamp - self._last_update >= self._timeout:
159
- result = await self._callback(self._sum)
160
- self._last_update = current_timestamp
161
- self._sum = 0.0
165
+ if not isinstance(new_value, (float, int, Decimal)):
166
+ raise TypeError(
167
+ "Aggregate filter can only be used with numeric values, got "
168
+ f"{type(new_value).__name__}: {new_value}"
169
+ )
170
+
171
+ current_time = time.monotonic()
172
+ self._values.append(new_value)
173
+ time_since_call = current_time - self._last_call_time
174
+ if time_since_call >= self._timeout or len(self._values) >= self._sample_size:
175
+ result = await self._callback(
176
+ np.sum(self._values) if numpy_installed else sum(self._values)
177
+ )
178
+ self._last_call_time = current_time
179
+ self._values = []
162
180
  return result
163
181
 
164
182
 
165
- def aggregate(callback: Callback, seconds: float) -> _Aggregate:
183
+ def aggregate(callback: Callback, seconds: float, sample_size: int) -> _Aggregate:
166
184
  """Create a new aggregate filter.
167
185
 
168
186
  A callback function will be called with a sum of values collected
169
- over a specified time period. Can only be used with numeric values.
187
+ over a specified time period or when sample size limit reached.
188
+ Can only be used with numeric values.
170
189
 
171
190
  :param callback: A callback function to be awaited once filter
172
191
  conditions are fulfilled
@@ -174,10 +193,13 @@ def aggregate(callback: Callback, seconds: float) -> _Aggregate:
174
193
  :param seconds: A callback will be awaited with a sum of values
175
194
  aggregated over this amount of seconds.
176
195
  :type seconds: float
196
+ :param sample_size: The maximum number of values to aggregate
197
+ before calling the callback
198
+ :type sample_size: int
177
199
  :return: An instance of callable filter
178
200
  :rtype: _Aggregate
179
201
  """
180
- return _Aggregate(callback, seconds)
202
+ return _Aggregate(callback, seconds, sample_size)
181
203
 
182
204
 
183
205
  class _Clamp(Filter):
@@ -0,0 +1,48 @@
1
+ """Contains a simple async cache for caching results of async functions."""
2
+
3
+ from collections.abc import Awaitable
4
+ from functools import wraps
5
+ from typing import Any, Callable, TypeVar, cast
6
+
7
+ from typing_extensions import ParamSpec
8
+
9
+ T = TypeVar("T")
10
+ P = ParamSpec("P")
11
+
12
+
13
+ class AsyncCache:
14
+ """A simple cache for asynchronous functions."""
15
+
16
+ __slots__ = ("cache",)
17
+
18
+ cache: dict[str, Any]
19
+
20
+ def __init__(self) -> None:
21
+ """Initialize the cache."""
22
+ self.cache = {}
23
+
24
+ async def get(self, key: str, coro: Callable[..., Awaitable[Any]]) -> Any:
25
+ """Get a value from the cache or compute and store it."""
26
+ if key not in self.cache:
27
+ self.cache[key] = await coro()
28
+
29
+ return self.cache[key]
30
+
31
+
32
+ # Create a global cache instance
33
+ async_cache = AsyncCache()
34
+
35
+
36
+ def acache(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
37
+ """Cache the result of an async function."""
38
+
39
+ @wraps(func)
40
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
41
+ func_name = f"{func.__module__}.{func.__qualname__}"
42
+ key = f"{func_name}:{args}:{kwargs}"
43
+ return cast(T, await async_cache.get(key, lambda: func(*args, **kwargs)))
44
+
45
+ return wrapper
46
+
47
+
48
+ __all__ = ["acache", "async_cache"]
@@ -17,7 +17,17 @@ _CallableT: TypeAlias = Callable[..., Any]
17
17
  _CallbackT = TypeVar("_CallbackT", bound=Callback)
18
18
 
19
19
 
20
- def event_listener(event: str, filter: _CallableT | None = None) -> _CallableT:
20
+ @overload
21
+ def event_listener(name: _CallableT, filter: None = None) -> Callback: ...
22
+
23
+
24
+ @overload
25
+ def event_listener(
26
+ name: str | None = None, filter: _CallableT | None = None
27
+ ) -> _CallableT: ...
28
+
29
+
30
+ def event_listener(name: Any = None, filter: _CallableT | None = None) -> Any:
21
31
  """Mark a function as an event listener.
22
32
 
23
33
  This decorator attaches metadata to the function, identifying it
@@ -26,11 +36,19 @@ def event_listener(event: str, filter: _CallableT | None = None) -> _CallableT:
26
36
 
27
37
  def decorator(func: _CallbackT) -> _CallbackT:
28
38
  # Attach metadata to the function to mark it as a listener.
39
+ event = (
40
+ name
41
+ if isinstance(name, str)
42
+ else func.__qualname__.split("on_event_", 1)[1]
43
+ )
29
44
  setattr(func, "_on_event", event)
30
45
  setattr(func, "_on_event_filter", filter)
31
46
  return func
32
47
 
33
- return decorator
48
+ if callable(name):
49
+ return decorator(name)
50
+ else:
51
+ return decorator
34
52
 
35
53
 
36
54
  T = TypeVar("T")
@@ -5,19 +5,20 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  from collections.abc import Awaitable, Callable
7
7
  from functools import wraps
8
- from typing import Any, TypeVar
8
+ from typing import TypeVar
9
9
 
10
- from typing_extensions import ParamSpec, TypeAlias
10
+ from typing_extensions import ParamSpec
11
11
 
12
12
  T = TypeVar("T")
13
13
  P = ParamSpec("P")
14
- _CallableT: TypeAlias = Callable[..., Any]
15
14
 
16
15
 
17
- def timeout(seconds: float) -> _CallableT:
16
+ def timeout(
17
+ seconds: float,
18
+ ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
18
19
  """Decorate a timeout for the awaitable."""
19
20
 
20
- def decorator(func: Callable[P, Awaitable[T]]) -> _CallableT:
21
+ def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
21
22
  @wraps(func)
22
23
  async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
23
24
  return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds)
@@ -196,7 +196,7 @@ class Parameter(ABC):
196
196
  self, value: int, retries: int = 5, timeout: float = 5.0
197
197
  ) -> bool:
198
198
  """Attempt to update a parameter value on the remote device."""
199
- _LOGGER.debug(
199
+ _LOGGER.info(
200
200
  "Attempting to update '%s' parameter to %d", self.description.name, value
201
201
  )
202
202
  if value == self.values.value:
@@ -509,8 +509,8 @@ def patch_parameter_types(
509
509
  """Patch the parameter types based on the provided overrides.
510
510
 
511
511
  Note:
512
- The `# type: ignore[assignment]` comment is used to suppress a type-checking
513
- error caused by mypy bug. For more details, see:
512
+ The `# type: ignore[assignment]` comment is used to suppress a
513
+ type-checking error caused by mypy bug. For more details, see:
514
514
  https://github.com/python/mypy/issues/13596
515
515
 
516
516
  """
@@ -522,6 +522,12 @@ def patch_parameter_types(
522
522
  }
523
523
  for index, description in enumerate(parameter_types):
524
524
  if description.name in replacements:
525
+ _LOGGER.info(
526
+ "Replacing parameter description for '%s' with '%s' (%s)",
527
+ description.name,
528
+ replacements[description.name],
529
+ product_info.model,
530
+ )
525
531
  parameter_types[index] = replacements[description.name] # type: ignore[assignment]
526
532
 
527
533
  return parameter_types
pyplumio/protocol.py CHANGED
@@ -10,11 +10,12 @@ import logging
10
10
 
11
11
  from typing_extensions import TypeAlias
12
12
 
13
- from pyplumio.const import ATTR_CONNECTED, DeviceType
13
+ from pyplumio.const import ATTR_CONNECTED, ATTR_SETUP, DeviceType
14
14
  from pyplumio.devices import PhysicalDevice
15
15
  from pyplumio.exceptions import ProtocolError
16
16
  from pyplumio.frames import Frame
17
17
  from pyplumio.frames.requests import StartMasterRequest
18
+ from pyplumio.helpers.async_cache import acache
18
19
  from pyplumio.helpers.event_manager import EventManager
19
20
  from pyplumio.stream import FrameReader, FrameWriter
20
21
  from pyplumio.structures.network_info import (
@@ -132,6 +133,7 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
132
133
  consumers_count: int
133
134
  _network: NetworkInfo
134
135
  _queues: Queues
136
+ _entry_lock: asyncio.Lock
135
137
 
136
138
  def __init__(
137
139
  self,
@@ -147,6 +149,7 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
147
149
  wlan=wireless_parameters or WirelessParameters(status=False),
148
150
  )
149
151
  self._queues = Queues(read=asyncio.Queue(), write=asyncio.Queue())
152
+ self._entry_lock = asyncio.Lock()
150
153
 
151
154
  def connection_established(
152
155
  self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
@@ -224,18 +227,23 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
224
227
  device.handle_frame(frame)
225
228
  queue.task_done()
226
229
 
230
+ @acache
227
231
  async def get_device_entry(self, device_type: DeviceType) -> PhysicalDevice:
228
- """Set up or return a device entry."""
229
- name = device_type.name.lower()
230
- if name not in self.data:
232
+ """Return the device entry."""
233
+
234
+ @acache
235
+ async def _setup_device_entry(device_type: DeviceType) -> PhysicalDevice:
236
+ """Set up the device entry."""
231
237
  device = await PhysicalDevice.create(
232
238
  device_type, queue=self._queues.write, network=self._network
233
239
  )
234
240
  device.dispatch_nowait(ATTR_CONNECTED, True)
235
- self.create_task(device.async_setup(), name=f"device_setup_task ({name})")
236
- await self.dispatch(name, device)
241
+ device.dispatch_nowait(ATTR_SETUP, True)
242
+ self.dispatch_nowait(device_type.name.lower(), device)
243
+ return device
237
244
 
238
- return self.data[name]
245
+ async with self._entry_lock:
246
+ return await _setup_device_entry(device_type)
239
247
 
240
248
 
241
249
  __all__ = ["Protocol", "DummyProtocol", "AsyncProtocol"]
@@ -10,7 +10,7 @@ from functools import lru_cache
10
10
  from typing import Any, Final, Literal, NamedTuple
11
11
 
12
12
  from pyplumio.const import AlertType
13
- from pyplumio.helpers.data_types import UnsignedInt
13
+ from pyplumio.data_types import UnsignedInt
14
14
  from pyplumio.structures import StructureDecoder
15
15
  from pyplumio.utils import ensure_dict
16
16
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import math
6
6
  from typing import Any, Final
7
7
 
8
- from pyplumio.helpers.data_types import Float
8
+ from pyplumio.data_types import Float
9
9
  from pyplumio.structures import StructureDecoder
10
10
  from pyplumio.utils import ensure_dict
11
11
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import math
6
6
  from typing import Any, Final
7
7
 
8
- from pyplumio.helpers.data_types import Float
8
+ from pyplumio.data_types import Float
9
9
  from pyplumio.structures import StructureDecoder
10
10
  from pyplumio.utils import ensure_dict
11
11
 
@@ -6,7 +6,7 @@ from contextlib import suppress
6
6
  from typing import Any, Final
7
7
 
8
8
  from pyplumio.const import FrameType
9
- from pyplumio.helpers.data_types import UnsignedShort
9
+ from pyplumio.data_types import UnsignedShort
10
10
  from pyplumio.structures import StructureDecoder
11
11
  from pyplumio.utils import ensure_dict
12
12
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import math
6
6
  from typing import Any, Final
7
7
 
8
- from pyplumio.helpers.data_types import Float
8
+ from pyplumio.data_types import Float
9
9
  from pyplumio.structures import StructureDecoder
10
10
  from pyplumio.utils import ensure_dict
11
11
 
@@ -7,7 +7,7 @@ import math
7
7
  from typing import Any, Final
8
8
 
9
9
  from pyplumio.const import BYTE_UNDEFINED, LambdaState
10
- from pyplumio.helpers.data_types import UnsignedShort
10
+ from pyplumio.data_types import UnsignedShort
11
11
  from pyplumio.structures import StructureDecoder
12
12
  from pyplumio.utils import ensure_dict
13
13
 
@@ -7,7 +7,7 @@ import math
7
7
  from typing import Any, Final
8
8
 
9
9
  from pyplumio.const import ATTR_CURRENT_TEMP, ATTR_TARGET_TEMP
10
- from pyplumio.helpers.data_types import Float
10
+ from pyplumio.data_types import Float
11
11
  from pyplumio.structures import StructureDecoder
12
12
  from pyplumio.utils import ensure_dict
13
13
 
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
6
6
  from typing import Any, Final
7
7
 
8
8
  from pyplumio.const import EncryptionType
9
- from pyplumio.helpers.data_types import IPv4, VarString
9
+ from pyplumio.data_types import IPv4, VarString
10
10
  from pyplumio.structures import Structure
11
11
  from pyplumio.utils import ensure_dict
12
12
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any, Final
6
6
 
7
- from pyplumio.helpers.data_types import UnsignedInt
7
+ from pyplumio.data_types import UnsignedInt
8
8
  from pyplumio.structures import StructureDecoder
9
9
  from pyplumio.utils import ensure_dict
10
10
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any, Final
6
6
 
7
- from pyplumio.helpers.data_types import UnsignedInt
7
+ from pyplumio.data_types import UnsignedInt
8
8
  from pyplumio.structures import StructureDecoder
9
9
  from pyplumio.utils import ensure_dict
10
10
 
@@ -9,7 +9,7 @@ import struct
9
9
  from typing import Any, Final
10
10
 
11
11
  from pyplumio.const import ProductType
12
- from pyplumio.helpers.data_types import UnsignedShort, VarBytes, VarString
12
+ from pyplumio.data_types import UnsignedShort, VarBytes, VarString
13
13
  from pyplumio.helpers.uid import unpack_uid
14
14
  from pyplumio.structures import StructureDecoder
15
15
  from pyplumio.utils import ensure_dict
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any, Final
6
6
 
7
- from pyplumio.helpers.data_types import BitArray, DataType
7
+ from pyplumio.data_types import BitArray, DataType
8
8
  from pyplumio.structures import StructureDecoder
9
9
  from pyplumio.structures.frame_versions import FrameVersionsStructure
10
10
  from pyplumio.structures.regulator_data_schema import ATTR_REGDATA_SCHEMA
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any, Final
6
6
 
7
- from pyplumio.helpers.data_types import DATA_TYPES, DataType, UnsignedShort
7
+ from pyplumio.data_types import DATA_TYPES, DataType, UnsignedShort
8
8
  from pyplumio.structures import StructureDecoder
9
9
  from pyplumio.utils import ensure_dict
10
10
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import math
6
6
  from typing import Any, Final
7
7
 
8
- from pyplumio.helpers.data_types import Float
8
+ from pyplumio.data_types import Float
9
9
  from pyplumio.structures import StructureDecoder
10
10
  from pyplumio.utils import ensure_dict
11
11
 
@@ -52,13 +52,11 @@ class ThermostatParametersStructure(StructureDecoder):
52
52
  self, message: bytearray, offset: int = 0, data: dict[str, Any] | None = None
53
53
  ) -> tuple[dict[str, Any], int]:
54
54
  """Decode bytes and return message data and offset."""
55
- if (device := self.frame.handler) is not None and (
56
- thermostats := device.get_nowait(ATTR_THERMOSTATS_AVAILABLE, 0)
57
- ) == 0:
58
- return (
59
- ensure_dict(data, {ATTR_THERMOSTAT_PARAMETERS: None}),
60
- offset,
61
- )
55
+ device = self.frame.handler
56
+ if not device or not (
57
+ thermostats := device.get_nowait(ATTR_THERMOSTATS_AVAILABLE, None)
58
+ ):
59
+ return ensure_dict(data, {ATTR_THERMOSTAT_PARAMETERS: None}), offset
62
60
 
63
61
  start = message[offset + 1]
64
62
  end = message[offset + 2]
@@ -13,7 +13,7 @@ from pyplumio.const import (
13
13
  ATTR_TARGET_TEMP,
14
14
  BYTE_UNDEFINED,
15
15
  )
16
- from pyplumio.helpers.data_types import Float
16
+ from pyplumio.data_types import Float
17
17
  from pyplumio.structures import StructureDecoder
18
18
  from pyplumio.utils import ensure_dict
19
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPlumIO
3
- Version: 0.5.43
3
+ Version: 0.5.44
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
@@ -29,7 +29,9 @@ Requires-Dist: typing-extensions==4.13.2
29
29
  Provides-Extra: test
30
30
  Requires-Dist: codespell==2.4.1; extra == "test"
31
31
  Requires-Dist: coverage==7.8.0; extra == "test"
32
+ Requires-Dist: freezegun==1.5.1; extra == "test"
32
33
  Requires-Dist: mypy==1.15.0; extra == "test"
34
+ Requires-Dist: numpy<3.0.0,>=2.0.0; extra == "test"
33
35
  Requires-Dist: pyserial-asyncio-fast==0.16; extra == "test"
34
36
  Requires-Dist: pytest==8.3.5; extra == "test"
35
37
  Requires-Dist: pytest-asyncio==0.26.0; extra == "test"
@@ -0,0 +1,64 @@
1
+ pyplumio/__init__.py,sha256=3H5SO4WFw5mBTFeEyD4w0H8-MsNo93NyOH3RyMN7IS0,3337
2
+ pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
+ pyplumio/_version.py,sha256=jmLwpXAvOwShA-gbD-jNugBDxB5dkqo8l6U4A1WR2nw,513
4
+ pyplumio/connection.py,sha256=9MCPb8W62uqCrzd1YCROcn9cCjRY8E65934FnJDF5Js,5902
5
+ pyplumio/const.py,sha256=FxF97bl_GunYuB8Wo72zCzHtznRCM64ygC2qfaR3UyA,5684
6
+ pyplumio/data_types.py,sha256=r-QOIZiIpBFo4kRongyu8n0BHTaEU6wWMTmNkWBNjq8,9223
7
+ pyplumio/exceptions.py,sha256=_B_0EgxDxd2XyYv3WpZM733q0cML5m6J-f55QOvYRpI,996
8
+ pyplumio/filters.py,sha256=1AIEesbIBOceLdz--7SwDb8IPn8aLNk4gd1F6EJCZ-s,13667
9
+ pyplumio/protocol.py,sha256=UnrXDouo4dDi7hqwJYHoEAvCoYJs3PgP1DFBBwRqBrw,8427
10
+ pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ pyplumio/stream.py,sha256=iCB-XlNXRRE0p7nstb1AkXwVDwcCsgym_ggB_IDiJc8,4926
12
+ pyplumio/utils.py,sha256=D6_SJzYkFjXoUrlNPt_mIQAP8hjMU05RsTqlAFphj3Y,1205
13
+ pyplumio/devices/__init__.py,sha256=dqEB8swvH5ZnY7KKyTJs9f3GR37I_0PrsY9Uet-663w,7926
14
+ pyplumio/devices/ecomax.py,sha256=GgugaaaVHHoCrl9zGH2B5WBlMJF_CVTkRwMIe_xJuYI,16114
15
+ pyplumio/devices/ecoster.py,sha256=X46ky5XT8jHMFq9sBW0ve8ZI_tjItQDMt4moXsW-ogY,307
16
+ pyplumio/devices/mixer.py,sha256=cuqhWcIeGgHNIVqmRm-NyvaRb-ltGY5YZXrQN81xKP0,2744
17
+ pyplumio/devices/thermostat.py,sha256=Wylm0xgheS6E_y-w0E8JEKax6Gckfw7qZu9fgv8iSMc,2266
18
+ pyplumio/frames/__init__.py,sha256=5lw19oFlN89ZvO8KGwnkwERULQNYiP-hhZKk65LsjYY,7862
19
+ pyplumio/frames/messages.py,sha256=ImQGWFFTa2eaXfytQmFZKC-IxyPRkxD8qp0bEm16-ws,3628
20
+ pyplumio/frames/requests.py,sha256=jr-_XSSCCDDTbAmrw95CKyWa5nb7JNeGzZ2jDXIxlAo,7348
21
+ pyplumio/frames/responses.py,sha256=M6Ky4gg2AoShmRXX0x6nftajxrvmQLKPVRWbwyhvI0E,6663
22
+ pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,31
23
+ pyplumio/helpers/async_cache.py,sha256=TlxpL4P3IZEobS4KyP86cEtqZghKtPWmVj57n5FpvTQ,1259
24
+ pyplumio/helpers/event_manager.py,sha256=aKNlhsPNTy3eOSfWVb9TJxtIsN9GAQv9XxhOi_BOhlM,8097
25
+ pyplumio/helpers/factory.py,sha256=AUPnTLDAPL8uq-fxn7ndMs_mOrocEo6keQjDk4Ig1ko,1156
26
+ pyplumio/helpers/schedule.py,sha256=4XdijJBnhsfsj3l2hiKsrLA_a6oxTzg67ZnjG2IJlB0,5301
27
+ pyplumio/helpers/task_manager.py,sha256=N71F6Ag1HHxdf5zJeCMcEziFdH9lmJKtMPoRGjJ-E40,1209
28
+ pyplumio/helpers/timeout.py,sha256=ZTWMhgk5lOUNsR-qqNxUOif5rDHH1eOMAvRTngb1QH4,745
29
+ pyplumio/helpers/uid.py,sha256=HeM5Zmd0qfNmVya6RKE-bBYzdxG-pAiViOZRHqw33VU,1011
30
+ pyplumio/parameters/__init__.py,sha256=kEDaPqJLRemc6puIGThmjfAbdt86oZnXIUE34scXG5Q,16603
31
+ pyplumio/parameters/ecomax.py,sha256=MfjiXYW_MoEPMPdmeeZi2Wb8tNhZKTHXYxSBCQNYf9g,27606
32
+ pyplumio/parameters/mixer.py,sha256=RjhfUU_62STyNV0Ud9A4G4FEvVwo02qGVl8h1QvqQXI,6646
33
+ pyplumio/parameters/thermostat.py,sha256=-DK2Mb78CGrKmdhwAD0M3GiGJatczPnl1e2gVeT19tI,5070
34
+ pyplumio/structures/__init__.py,sha256=emZVH5OFgdTUPbEJoznMKitmK0nlPm0I4SmF86It1Do,1345
35
+ pyplumio/structures/alerts.py,sha256=fglFcxHoZ3hZJscPHmbJJqTr2mH8YXRW0T18L4Dirds,3692
36
+ pyplumio/structures/boiler_load.py,sha256=e-6itp9L6iJeeOyhSTiOclHLuYmqG7KkcepsHwJSQSI,894
37
+ pyplumio/structures/boiler_power.py,sha256=7CdOk-pYLEpy06oRBAeichvq8o-a2RcesB0tzo9ccBs,951
38
+ pyplumio/structures/ecomax_parameters.py,sha256=E_s5bO0RqX8p1rM5DtYAsEXcHqS8P6Tg4AGm21cxsnM,1663
39
+ pyplumio/structures/fan_power.py,sha256=l9mDB_Ugnn1gKJFh9fppwzoi0i1_3eBvHAD6uPAdiDI,915
40
+ pyplumio/structures/frame_versions.py,sha256=n-L93poxY3i_l3oMWg0PzRXGrkY9cgv-Eyuf9XObaR4,1602
41
+ pyplumio/structures/fuel_consumption.py,sha256=Cf3Z14gEZnkVEt-OAXNka3-T8fKIMHQaVSeQdQYXnPg,1034
42
+ pyplumio/structures/fuel_level.py,sha256=-zUKApVJaZZzc1q52vqO3K2Mya43c6vRgw45d2xgy5Q,1123
43
+ pyplumio/structures/lambda_sensor.py,sha256=0ZNEhzvNVtZt9CY0ZnZ7-N3fdWnuuexcrkztKq-lBSw,1708
44
+ pyplumio/structures/mixer_parameters.py,sha256=JMSySqI7TUGKdFtDp1P5DJm5EAbijMSz-orRrAe1KlQ,2041
45
+ pyplumio/structures/mixer_sensors.py,sha256=ChgLhC3p4fyoPy1EKe0BQTvXOPZEISbcK2HyamrNaN8,2450
46
+ pyplumio/structures/modules.py,sha256=LviFz3_pPvSQ5i_Mr2S9o5miVad8O4qn48CR_z7yG24,2791
47
+ pyplumio/structures/network_info.py,sha256=HYhROSMbVxqYxsOa7aF3xetQXEs0xGvhzH-4OHETZVQ,4327
48
+ pyplumio/structures/output_flags.py,sha256=upVIgAH2JNncHFXvjE-t6oTFF-JNwwZbyGjfrcKWtz0,1508
49
+ pyplumio/structures/outputs.py,sha256=3NP5lArzQiihRC4QzBuWAHL9hhjvGxNkKmeoYZnDD-0,2291
50
+ pyplumio/structures/pending_alerts.py,sha256=b1uMmDHTGv8eE0h1vGBrKsPxlwBmUad7HgChnDDLK_g,801
51
+ pyplumio/structures/product_info.py,sha256=yXHFQv6LcA7kj8ErAY-fK4z33EIgDnvVDNe3Dccg_Bo,2472
52
+ pyplumio/structures/program_version.py,sha256=qHmmPComCOa-dgq7cFAucEGuRS-jWYwWi40VCiPS7cc,2621
53
+ pyplumio/structures/regulator_data.py,sha256=SYKI1YPC3mDAth-SpYejttbD0IzBfobjgC-uy_uUKnw,2333
54
+ pyplumio/structures/regulator_data_schema.py,sha256=0SapbZCGzqAHmHC7dwhufszJ9FNo_ZO_XMrFGNiUe-w,1547
55
+ pyplumio/structures/schedules.py,sha256=JWMyi-D_a2M8k17Zis7-o9L3Zn-Lvzdh1ewXDQeoaYo,7092
56
+ pyplumio/structures/statuses.py,sha256=1h-EUw1UtuS44E19cNOSavUgZeAxsLgX3iS0eVC8pLI,1325
57
+ pyplumio/structures/temperatures.py,sha256=2VD3P_vwp9PEBkOn2-WhifOR8w-UYNq35aAxle0z2Vg,2831
58
+ pyplumio/structures/thermostat_parameters.py,sha256=st3x3HkjQm3hqBrn_fpvPDQu8fuc-Sx33ONB19ViQak,3007
59
+ pyplumio/structures/thermostat_sensors.py,sha256=rO9jTZWGQpThtJqVdbbv8sYMYHxJi4MfwZQza69L2zw,3399
60
+ pyplumio-0.5.44.dist-info/licenses/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
61
+ pyplumio-0.5.44.dist-info/METADATA,sha256=ShYqD3uUOfz0g4IhgAT1fA15CjI9RtXViXYY238Nsuo,5611
62
+ pyplumio-0.5.44.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
63
+ pyplumio-0.5.44.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
64
+ pyplumio-0.5.44.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (79.0.1)
2
+ Generator: setuptools (80.3.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,63 +0,0 @@
1
- pyplumio/__init__.py,sha256=3H5SO4WFw5mBTFeEyD4w0H8-MsNo93NyOH3RyMN7IS0,3337
2
- pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
- pyplumio/_version.py,sha256=AtaNtvbmZhmRRCLmwQp9AiFD_qy1xi0FmP9tZWoRRsg,513
4
- pyplumio/connection.py,sha256=9MCPb8W62uqCrzd1YCROcn9cCjRY8E65934FnJDF5Js,5902
5
- pyplumio/const.py,sha256=efsoFXbja8oFhn0fiATnHsNKhV26z0XdWHn84MHc1pE,5688
6
- pyplumio/exceptions.py,sha256=_B_0EgxDxd2XyYv3WpZM733q0cML5m6J-f55QOvYRpI,996
7
- pyplumio/filters.py,sha256=Z_U3V7VEBkCwXWcAy9zmauoZtQj7BSCEODzQhT9K2tc,12784
8
- pyplumio/protocol.py,sha256=oyZZKVazLTlS1sMKQA4zThp39-HqIGXW7BUGctHznN4,8146
9
- pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- pyplumio/stream.py,sha256=iCB-XlNXRRE0p7nstb1AkXwVDwcCsgym_ggB_IDiJc8,4926
11
- pyplumio/utils.py,sha256=D6_SJzYkFjXoUrlNPt_mIQAP8hjMU05RsTqlAFphj3Y,1205
12
- pyplumio/devices/__init__.py,sha256=70MJSd43ipd6V0E7az0Ba_5JKTX3ICE3Ct-GDX8GTzU,8370
13
- pyplumio/devices/ecomax.py,sha256=_lAMMZw9hW1KEKXREgaOZTIsvBPss_fDGjB44GZNXUM,15005
14
- pyplumio/devices/ecoster.py,sha256=X46ky5XT8jHMFq9sBW0ve8ZI_tjItQDMt4moXsW-ogY,307
15
- pyplumio/devices/mixer.py,sha256=YLe_3XCjjX0PHrICPT-dR7uelTTFofHlqC8twtg8Wyg,2810
16
- pyplumio/devices/thermostat.py,sha256=OYWYBl-8R7ooTgAQSHkhwHH74Rjjh8IJJZNyi93omgg,2306
17
- pyplumio/frames/__init__.py,sha256=5lw19oFlN89ZvO8KGwnkwERULQNYiP-hhZKk65LsjYY,7862
18
- pyplumio/frames/messages.py,sha256=ImQGWFFTa2eaXfytQmFZKC-IxyPRkxD8qp0bEm16-ws,3628
19
- pyplumio/frames/requests.py,sha256=jr-_XSSCCDDTbAmrw95CKyWa5nb7JNeGzZ2jDXIxlAo,7348
20
- pyplumio/frames/responses.py,sha256=M6Ky4gg2AoShmRXX0x6nftajxrvmQLKPVRWbwyhvI0E,6663
21
- pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,31
22
- pyplumio/helpers/data_types.py,sha256=r-QOIZiIpBFo4kRongyu8n0BHTaEU6wWMTmNkWBNjq8,9223
23
- pyplumio/helpers/event_manager.py,sha256=J-y5ErPvR2ek3rhYrASElwgtCaEM6wuELJQqH74VIWY,7686
24
- pyplumio/helpers/factory.py,sha256=AUPnTLDAPL8uq-fxn7ndMs_mOrocEo6keQjDk4Ig1ko,1156
25
- pyplumio/helpers/schedule.py,sha256=4XdijJBnhsfsj3l2hiKsrLA_a6oxTzg67ZnjG2IJlB0,5301
26
- pyplumio/helpers/task_manager.py,sha256=N71F6Ag1HHxdf5zJeCMcEziFdH9lmJKtMPoRGjJ-E40,1209
27
- pyplumio/helpers/timeout.py,sha256=nbro-58ncTCI___kMtE221_UVwNXtqt-oY4Dc5sx_Dg,728
28
- pyplumio/helpers/uid.py,sha256=HeM5Zmd0qfNmVya6RKE-bBYzdxG-pAiViOZRHqw33VU,1011
29
- pyplumio/parameters/__init__.py,sha256=1K4TzQ3qhTX7Slixap_PW4N38d1whgXm13mNwjRuw-s,16371
30
- pyplumio/parameters/ecomax.py,sha256=MfjiXYW_MoEPMPdmeeZi2Wb8tNhZKTHXYxSBCQNYf9g,27606
31
- pyplumio/parameters/mixer.py,sha256=RjhfUU_62STyNV0Ud9A4G4FEvVwo02qGVl8h1QvqQXI,6646
32
- pyplumio/parameters/thermostat.py,sha256=-DK2Mb78CGrKmdhwAD0M3GiGJatczPnl1e2gVeT19tI,5070
33
- pyplumio/structures/__init__.py,sha256=emZVH5OFgdTUPbEJoznMKitmK0nlPm0I4SmF86It1Do,1345
34
- pyplumio/structures/alerts.py,sha256=teoUlbhvpnPLntPXCRmNO7YZa0RxvPBD724mqxkQD28,3700
35
- pyplumio/structures/boiler_load.py,sha256=e-6itp9L6iJeeOyhSTiOclHLuYmqG7KkcepsHwJSQSI,894
36
- pyplumio/structures/boiler_power.py,sha256=Nxc8DiOQRD6dr3fo-xxiu171t9ablYJIRL8RQe0SEzo,959
37
- pyplumio/structures/ecomax_parameters.py,sha256=E_s5bO0RqX8p1rM5DtYAsEXcHqS8P6Tg4AGm21cxsnM,1663
38
- pyplumio/structures/fan_power.py,sha256=MwiaU8ht8BLSaGLw5dQvVleDcHCtyrvO1DT9iX0oxlg,923
39
- pyplumio/structures/frame_versions.py,sha256=MS5bLW1y3SRq6Swjd1mkzy9xJkBXYQq313XAY1RnMjc,1610
40
- pyplumio/structures/fuel_consumption.py,sha256=Ki_i_T4fq--6F8d4spXuY13JEYZllGwcU5U589mwuKo,1042
41
- pyplumio/structures/fuel_level.py,sha256=-zUKApVJaZZzc1q52vqO3K2Mya43c6vRgw45d2xgy5Q,1123
42
- pyplumio/structures/lambda_sensor.py,sha256=ySSSsN5BHvvvZgJX2rh7K_cdfW0QI_uGH-l2XqWJmmQ,1716
43
- pyplumio/structures/mixer_parameters.py,sha256=JMSySqI7TUGKdFtDp1P5DJm5EAbijMSz-orRrAe1KlQ,2041
44
- pyplumio/structures/mixer_sensors.py,sha256=B7jBL4KPE3lCh9TX_mLcsOCdabKbgudu4FJSWqud1QE,2458
45
- pyplumio/structures/modules.py,sha256=LviFz3_pPvSQ5i_Mr2S9o5miVad8O4qn48CR_z7yG24,2791
46
- pyplumio/structures/network_info.py,sha256=EpqCaHY7V3gVzZ04STy08vv7RUdtI9tUAhWWlqBnMMQ,4335
47
- pyplumio/structures/output_flags.py,sha256=sqZm-jJC9MuJM1qezMh59wan7MUpqEXgahdHc0nzmYY,1516
48
- pyplumio/structures/outputs.py,sha256=9S7Cvc6Jjy1kLgV4d5SNY2suWrUo4gwbp7PDoAdGhkA,2299
49
- pyplumio/structures/pending_alerts.py,sha256=b1uMmDHTGv8eE0h1vGBrKsPxlwBmUad7HgChnDDLK_g,801
50
- pyplumio/structures/product_info.py,sha256=IwEfXQ6LGyhoh3ox_i8dRurT0gKJfQdhYln76Q268TI,2480
51
- pyplumio/structures/program_version.py,sha256=qHmmPComCOa-dgq7cFAucEGuRS-jWYwWi40VCiPS7cc,2621
52
- pyplumio/structures/regulator_data.py,sha256=H5vg70EV_OkJ6Np3qkQ-ekXYdzHH5S2YGv9SGfpXRhY,2341
53
- pyplumio/structures/regulator_data_schema.py,sha256=dY3YeaTfwbhz3jyvYtSLJBxyASDqPZsEeGIHN_Rbew0,1555
54
- pyplumio/structures/schedules.py,sha256=JWMyi-D_a2M8k17Zis7-o9L3Zn-Lvzdh1ewXDQeoaYo,7092
55
- pyplumio/structures/statuses.py,sha256=1h-EUw1UtuS44E19cNOSavUgZeAxsLgX3iS0eVC8pLI,1325
56
- pyplumio/structures/temperatures.py,sha256=xm_UETl0tqhoIeIjEESQQkAvE3oU5zjJLxgek2SqEag,2839
57
- pyplumio/structures/thermostat_parameters.py,sha256=5xlsDkb0Bh5pAodZDiLp3ojM__mWAz4XQ3fmwfs-o0c,3051
58
- pyplumio/structures/thermostat_sensors.py,sha256=oUcyTPMuq8GpPc2HnVh2wpNC5QNNMj82WF6bsU58MFA,3407
59
- pyplumio-0.5.43.dist-info/licenses/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
60
- pyplumio-0.5.43.dist-info/METADATA,sha256=ZnTjSCqiyRG_d_siYFQn5XRvGLRUT28gsg7Rswo0hU4,5510
61
- pyplumio-0.5.43.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
62
- pyplumio-0.5.43.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
63
- pyplumio-0.5.43.dist-info/RECORD,,
File without changes