PyPlumIO 0.6.0__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 (49) hide show
  1. pyplumio/__init__.py +3 -1
  2. pyplumio/_version.py +2 -2
  3. pyplumio/connection.py +0 -36
  4. pyplumio/const.py +0 -5
  5. pyplumio/data_types.py +2 -2
  6. pyplumio/devices/__init__.py +23 -5
  7. pyplumio/devices/ecomax.py +30 -53
  8. pyplumio/devices/ecoster.py +2 -3
  9. pyplumio/filters.py +199 -136
  10. pyplumio/frames/__init__.py +101 -15
  11. pyplumio/frames/messages.py +8 -65
  12. pyplumio/frames/requests.py +38 -38
  13. pyplumio/frames/responses.py +30 -86
  14. pyplumio/helpers/async_cache.py +13 -8
  15. pyplumio/helpers/event_manager.py +24 -18
  16. pyplumio/helpers/factory.py +0 -3
  17. pyplumio/parameters/__init__.py +38 -35
  18. pyplumio/protocol.py +63 -47
  19. pyplumio/structures/alerts.py +2 -2
  20. pyplumio/structures/ecomax_parameters.py +1 -1
  21. pyplumio/structures/frame_versions.py +3 -2
  22. pyplumio/structures/mixer_parameters.py +5 -3
  23. pyplumio/structures/network_info.py +1 -0
  24. pyplumio/structures/product_info.py +1 -1
  25. pyplumio/structures/program_version.py +2 -2
  26. pyplumio/structures/schedules.py +8 -40
  27. pyplumio/structures/sensor_data.py +498 -0
  28. pyplumio/structures/thermostat_parameters.py +7 -4
  29. pyplumio/utils.py +41 -4
  30. {pyplumio-0.6.0.dist-info → pyplumio-0.6.2.dist-info}/METADATA +7 -8
  31. pyplumio-0.6.2.dist-info/RECORD +50 -0
  32. pyplumio/structures/boiler_load.py +0 -32
  33. pyplumio/structures/boiler_power.py +0 -33
  34. pyplumio/structures/fan_power.py +0 -33
  35. pyplumio/structures/fuel_consumption.py +0 -36
  36. pyplumio/structures/fuel_level.py +0 -39
  37. pyplumio/structures/lambda_sensor.py +0 -57
  38. pyplumio/structures/mixer_sensors.py +0 -80
  39. pyplumio/structures/modules.py +0 -102
  40. pyplumio/structures/output_flags.py +0 -47
  41. pyplumio/structures/outputs.py +0 -88
  42. pyplumio/structures/pending_alerts.py +0 -28
  43. pyplumio/structures/statuses.py +0 -52
  44. pyplumio/structures/temperatures.py +0 -94
  45. pyplumio/structures/thermostat_sensors.py +0 -106
  46. pyplumio-0.6.0.dist-info/RECORD +0 -63
  47. {pyplumio-0.6.0.dist-info → pyplumio-0.6.2.dist-info}/WHEEL +0 -0
  48. {pyplumio-0.6.0.dist-info → pyplumio-0.6.2.dist-info}/licenses/LICENSE +0 -0
  49. {pyplumio-0.6.0.dist-info → pyplumio-0.6.2.dist-info}/top_level.txt +0 -0
pyplumio/protocol.py CHANGED
@@ -27,7 +27,7 @@ from pyplumio.structures.regulator_data import ATTR_REGDATA
27
27
 
28
28
  _LOGGER = logging.getLogger(__name__)
29
29
 
30
- Callback: TypeAlias = Callable[[], Awaitable[None]]
30
+ ConnectionLostCallback: TypeAlias = Callable[[], Awaitable[None]]
31
31
 
32
32
 
33
33
  class Protocol(ABC):
@@ -36,7 +36,7 @@ class Protocol(ABC):
36
36
  connected: asyncio.Event
37
37
  reader: FrameReader | None
38
38
  writer: FrameWriter | None
39
- _on_connection_lost: set[Callback]
39
+ _on_connection_lost: set[ConnectionLostCallback]
40
40
 
41
41
  def __init__(self) -> None:
42
42
  """Initialize a new protocol."""
@@ -53,7 +53,7 @@ class Protocol(ABC):
53
53
  self.writer = None
54
54
 
55
55
  @property
56
- def on_connection_lost(self) -> set[Callback]:
56
+ def on_connection_lost(self) -> set[ConnectionLostCallback]:
57
57
  """Return the callbacks that'll be called on connection lost."""
58
58
  return self._on_connection_lost
59
59
 
@@ -105,8 +105,8 @@ class DummyProtocol(Protocol):
105
105
  class Queues:
106
106
  """Represents asyncio queues."""
107
107
 
108
- read: asyncio.Queue[Frame]
109
- write: asyncio.Queue[Frame]
108
+ read: asyncio.Queue[Frame] = field(default_factory=asyncio.Queue)
109
+ write: asyncio.Queue[Frame] = field(default_factory=asyncio.Queue)
110
110
 
111
111
  async def join(self) -> None:
112
112
  """Wait for queues to finish."""
@@ -145,25 +145,29 @@ class Statistics:
145
145
  connection_losses: int = 0
146
146
 
147
147
  #: List of statistics for connected devices
148
- devices: list[DeviceStatistics] = field(default_factory=list)
148
+ devices: set[DeviceStatistics] = field(default_factory=set)
149
149
 
150
- def update_transfer_statistics(
151
- self, sent: Frame | None = None, received: Frame | None = None
152
- ) -> None:
153
- """Update transfer statistics."""
154
- if sent:
155
- self.sent_bytes += sent.length
156
- self.sent_frames += 1
150
+ def update_sent(self, frame: Frame) -> None:
151
+ """Update sent frames statistics."""
152
+ self.sent_bytes += frame.length
153
+ self.sent_frames += 1
157
154
 
158
- if received:
159
- self.received_bytes += received.length
160
- self.received_frames += 1
155
+ def update_received(self, frame: Frame) -> None:
156
+ """Update received frames statistics."""
157
+ self.received_bytes += frame.length
158
+ self.received_frames += 1
161
159
 
162
- def track_connection_loss(self) -> None:
163
- """Increase connection loss counter and store the datetime."""
160
+ def update_connection_lost(self) -> None:
161
+ """Update connection lost counter."""
164
162
  self.connection_losses += 1
165
163
  self.connection_loss_at = datetime.now()
166
164
 
165
+ def update_devices(self, device: PhysicalDevice) -> None:
166
+ """Update connected devices."""
167
+ device_statistics = DeviceStatistics(address=device.address)
168
+ device.subscribe(ATTR_REGDATA, device_statistics.update_last_seen)
169
+ self.devices.add(device_statistics)
170
+
167
171
  def reset_transfer_statistics(self) -> None:
168
172
  """Reset transfer statistics."""
169
173
  self.sent_bytes = 0
@@ -173,18 +177,22 @@ class Statistics:
173
177
  self.failed_frames = 0
174
178
 
175
179
 
176
- @dataclass(slots=True)
180
+ @dataclass(slots=True, kw_only=True)
177
181
  class DeviceStatistics:
178
182
  """Represents a device statistics."""
179
183
 
180
- #: Device name
181
- name: str
184
+ #: Device address
185
+ address: int
182
186
 
183
187
  #: Datetime object representing connection time
184
- connected_since: datetime | Literal["never"] = NEVER
188
+ first_seen: datetime = field(default_factory=datetime.now)
185
189
 
186
190
  #: Datetime object representing time when device was last seen
187
- last_seen: datetime | Literal["never"] = NEVER
191
+ last_seen: datetime = field(default_factory=datetime.now)
192
+
193
+ def __hash__(self) -> int:
194
+ """Return a hash of the statistics based on unique address."""
195
+ return self.address
188
196
 
189
197
  async def update_last_seen(self, _: Any) -> None:
190
198
  """Update last seen property."""
@@ -226,9 +234,9 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
226
234
  eth=ethernet_parameters or EthernetParameters(status=False),
227
235
  wlan=wireless_parameters or WirelessParameters(status=False),
228
236
  )
229
- self._queues = Queues(read=asyncio.Queue(), write=asyncio.Queue())
230
237
  self._entry_lock = asyncio.Lock()
231
238
  self._statistics = Statistics()
239
+ self._queues = Queues()
232
240
 
233
241
  def connection_established(
234
242
  self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
@@ -241,10 +249,10 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
241
249
  self.frame_producer(self._queues, reader=self.reader, writer=self.writer),
242
250
  name="frame_producer_task",
243
251
  )
244
- for consumer in range(self.consumers_count):
252
+ for consumer_id in range(self.consumers_count):
245
253
  self.create_task(
246
254
  self.frame_consumer(self._queues.read),
247
- name=f"frame_consumer_task ({consumer})",
255
+ name=f"frame_consumer_task ({consumer_id})",
248
256
  )
249
257
 
250
258
  for device in self.data.values():
@@ -277,30 +285,39 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
277
285
  await self._connection_close()
278
286
  await asyncio.gather(*(device.shutdown() for device in self.data.values()))
279
287
 
288
+ async def _write_from_queue(
289
+ self, writer: FrameWriter, queue: asyncio.Queue[Frame]
290
+ ) -> None:
291
+ """Send frame from the queue to the remote device."""
292
+ frame = await queue.get()
293
+ await writer.write(frame)
294
+ queue.task_done()
295
+ self.statistics.update_sent(frame)
296
+
297
+ async def _read_into_queue(
298
+ self, reader: FrameReader, queue: asyncio.Queue[Frame]
299
+ ) -> None:
300
+ """Read frame from the remote device into the queue."""
301
+ if frame := await reader.read():
302
+ queue.put_nowait(frame)
303
+ self.statistics.update_received(frame)
304
+
280
305
  async def frame_producer(
281
306
  self, queues: Queues, reader: FrameReader, writer: FrameWriter
282
307
  ) -> None:
283
308
  """Handle frame reads and writes."""
284
- statistics = self.statistics
285
309
  await self.connected.wait()
286
310
  while self.connected.is_set():
287
311
  try:
288
- request = None
289
312
  if not queues.write.empty():
290
- request = await queues.write.get()
291
- await writer.write(request)
292
- queues.write.task_done()
293
-
294
- if response := await reader.read():
295
- queues.read.put_nowait(response)
296
-
297
- statistics.update_transfer_statistics(request, response)
313
+ await self._write_from_queue(writer, queues.write)
298
314
 
315
+ await self._read_into_queue(reader, queues.read)
299
316
  except ProtocolError as e:
300
- statistics.failed_frames += 1
317
+ self.statistics.failed_frames += 1
301
318
  _LOGGER.debug("Can't process received frame: %s", e)
302
319
  except (OSError, asyncio.TimeoutError):
303
- statistics.track_connection_loss()
320
+ self.statistics.update_connection_lost()
304
321
  self.create_task(self.connection_lost())
305
322
  break
306
323
  except Exception:
@@ -327,14 +344,7 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
327
344
  device.dispatch_nowait(ATTR_CONNECTED, True)
328
345
  device.dispatch_nowait(ATTR_SETUP, True)
329
346
  await self.dispatch(name, device)
330
- self.statistics.devices.append(
331
- device_statistics := DeviceStatistics(
332
- name=name,
333
- connected_since=datetime.now(),
334
- last_seen=datetime.now(),
335
- )
336
- )
337
- device.subscribe(ATTR_REGDATA, device_statistics.update_last_seen)
347
+ self.statistics.update_devices(device)
338
348
 
339
349
  return self.data[name]
340
350
 
@@ -344,4 +354,10 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
344
354
  return self._statistics
345
355
 
346
356
 
347
- __all__ = ["Protocol", "DummyProtocol", "AsyncProtocol", "Statistics"]
357
+ __all__ = [
358
+ "Protocol",
359
+ "DummyProtocol",
360
+ "AsyncProtocol",
361
+ "Statistics",
362
+ "ConnectionLostCallback",
363
+ ]
@@ -46,12 +46,12 @@ def seconds_to_datetime(timestamp: int) -> datetime:
46
46
  in seconds counted from Jan 1st, 2000.
47
47
  """
48
48
 
49
- def datetime_kwargs(timestamp: int) -> Generator[Any, None, None]:
49
+ def datetime_kwargs(timestamp: int) -> Generator[tuple[Any, int]]:
50
50
  """Yield a tuple, that represents a single datetime kwarg."""
51
51
  for name, seconds, offset in DATETIME_INTERVALS:
52
52
  value = timestamp // seconds
53
53
  timestamp -= value * seconds
54
- yield name, (value + offset)
54
+ yield name, value + offset
55
55
 
56
56
  return datetime(**dict(datetime_kwargs(timestamp)))
57
57
 
@@ -24,7 +24,7 @@ class EcomaxParametersStructure(StructureDecoder):
24
24
 
25
25
  def _ecomax_parameter(
26
26
  self, message: bytearray, start: int, end: int
27
- ) -> Generator[tuple[int, ParameterValues], None, None]:
27
+ ) -> Generator[tuple[int, ParameterValues]]:
28
28
  """Unpack an ecoMAX parameter."""
29
29
  for index in range(start, start + end):
30
30
  if parameter := unpack_parameter(message, self._offset):
@@ -23,8 +23,9 @@ class FrameVersionsStructure(StructureDecoder):
23
23
  def _unpack_frame_versions(self, message: bytearray) -> tuple[FrameType | int, int]:
24
24
  """Unpack frame versions."""
25
25
  frame_type = message[self._offset]
26
- version = UnsignedShort.from_bytes(message, self._offset + 1)
27
- self._offset += version.size + 1
26
+ self._offset += 1
27
+ version = UnsignedShort.from_bytes(message, self._offset)
28
+ self._offset += version.size
28
29
  with suppress(ValueError):
29
30
  frame_type = FrameType(frame_type)
30
31
 
@@ -3,7 +3,7 @@
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.structures import StructureDecoder
@@ -13,6 +13,8 @@ ATTR_MIXER_PARAMETERS: Final = "mixer_parameters"
13
13
 
14
14
  MIXER_PARAMETER_SIZE: Final = 3
15
15
 
16
+ _ParameterValues: TypeAlias = tuple[int, ParameterValues]
17
+
16
18
 
17
19
  class MixerParametersStructure(StructureDecoder):
18
20
  """Represents a mixer parameters data structure."""
@@ -23,7 +25,7 @@ class MixerParametersStructure(StructureDecoder):
23
25
 
24
26
  def _mixer_parameter(
25
27
  self, message: bytearray, start: int, end: int
26
- ) -> Generator[tuple[int, ParameterValues], None, None]:
28
+ ) -> Generator[_ParameterValues]:
27
29
  """Get a single mixer parameter."""
28
30
  for index in range(start, start + end):
29
31
  if parameter := unpack_parameter(message, self._offset):
@@ -33,7 +35,7 @@ class MixerParametersStructure(StructureDecoder):
33
35
 
34
36
  def _mixer_parameters(
35
37
  self, message: bytearray, mixers: int, start: int, end: int
36
- ) -> Generator[tuple[int, list[tuple[int, ParameterValues]]], None, None]:
38
+ ) -> Generator[tuple[int, list[_ParameterValues]]]:
37
39
  """Get parameters for a mixer."""
38
40
  for index in range(mixers):
39
41
  if parameters := list(self._mixer_parameter(message, start, end)):
@@ -88,6 +88,7 @@ class NetworkInfoStructure(Structure):
88
88
  self, message: bytearray, offset: int = 0, data: dict[str, Any] | None = None
89
89
  ) -> tuple[dict[str, Any], int]:
90
90
  """Decode bytes and return message data and offset."""
91
+ offset += 1
91
92
  return (
92
93
  ensure_dict(
93
94
  data,
@@ -63,7 +63,7 @@ def format_model_name(model_name: str) -> str:
63
63
  return model_name
64
64
 
65
65
 
66
- @dataclass(frozen=True, slots=True)
66
+ @dataclass(frozen=True, slots=True, kw_only=True)
67
67
  class ProductInfo:
68
68
  """Represents a product info provided by an UID response."""
69
69
 
@@ -6,7 +6,7 @@ from dataclasses import dataclass
6
6
  import struct
7
7
  from typing import Any, Final
8
8
 
9
- from pyplumio._version import __version_tuple__
9
+ from pyplumio import version_tuple
10
10
  from pyplumio.structures import Structure
11
11
  from pyplumio.utils import ensure_dict
12
12
 
@@ -14,7 +14,7 @@ ATTR_VERSION: Final = "version"
14
14
 
15
15
  VERSION_INFO_SIZE: Final = 15
16
16
 
17
- SOFTWARE_VERSION: Final = ".".join(str(x) for x in __version_tuple__[0:3])
17
+ SOFTWARE_VERSION: Final = ".".join(str(x) for x in version_tuple[0:3])
18
18
 
19
19
  struct_program_version = struct.Struct("<2sB2s3s3HB")
20
20
 
@@ -2,11 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Iterable, Iterator, MutableMapping, Sequence
5
+ from collections.abc import Iterable, Iterator, MutableMapping
6
6
  from dataclasses import dataclass
7
7
  import datetime as dt
8
- from functools import lru_cache, reduce
9
- from typing import Annotated, Any, Final, get_args
8
+ from functools import lru_cache
9
+ from typing import Annotated, Any, Final, TypeAlias, get_args
10
10
 
11
11
  from pyplumio.const import (
12
12
  ATTR_PARAMETER,
@@ -19,7 +19,6 @@ from pyplumio.const import (
19
19
  State,
20
20
  )
21
21
  from pyplumio.devices import Device, PhysicalDevice
22
- from pyplumio.exceptions import FrameDataError
23
22
  from pyplumio.frames import Request
24
23
  from pyplumio.parameters import (
25
24
  Number,
@@ -31,8 +30,8 @@ from pyplumio.parameters import (
31
30
  SwitchDescription,
32
31
  unpack_parameter,
33
32
  )
34
- from pyplumio.structures import Structure
35
- from pyplumio.utils import ensure_dict
33
+ from pyplumio.structures import StructureDecoder
34
+ from pyplumio.utils import ensure_dict, split_byte
36
35
 
37
36
  ATTR_SCHEDULES: Final = "schedules"
38
37
  ATTR_SCHEDULE_PARAMETERS: Final = "schedule_parameters"
@@ -156,7 +155,7 @@ def collect_schedule_data(name: str, device: Device) -> dict[str, Any]:
156
155
 
157
156
  TIME_FORMAT: Final = "%H:%M"
158
157
 
159
- Time = Annotated[str, "Time string in %H:%M format"]
158
+ Time: TypeAlias = Annotated[str, "Time string in %H:%M format"]
160
159
 
161
160
  MIDNIGHT: Final = Time("00:00")
162
161
  MIDNIGHT_DT = dt.datetime.strptime(MIDNIGHT, TIME_FORMAT)
@@ -305,51 +304,20 @@ class Schedule(Iterable):
305
304
  )
306
305
 
307
306
 
308
- def _split_byte(byte: int) -> list[bool]:
309
- """Split single byte into an eight bits."""
310
- return [bool(byte & (1 << bit)) for bit in reversed(range(8))]
311
-
312
-
313
- def _join_bits(bits: Sequence[int | bool]) -> int:
314
- """Join eight bits into a single byte."""
315
- return reduce(lambda bit, byte: (bit << 1) | byte, bits)
316
-
317
-
318
- class SchedulesStructure(Structure):
307
+ class SchedulesStructure(StructureDecoder):
319
308
  """Represents a schedule data structure."""
320
309
 
321
310
  __slots__ = ("_offset",)
322
311
 
323
312
  _offset: int
324
313
 
325
- def encode(self, data: dict[str, Any]) -> bytearray:
326
- """Encode data to the bytearray message."""
327
- try:
328
- header = bytearray(
329
- b"\1"
330
- + SCHEDULES.index(data[ATTR_TYPE]).to_bytes(
331
- length=1, byteorder="little"
332
- )
333
- + int(data[ATTR_SWITCH]).to_bytes(length=1, byteorder="little")
334
- + int(data[ATTR_PARAMETER]).to_bytes(length=1, byteorder="little")
335
- )
336
- schedule = data[ATTR_SCHEDULE]
337
- except (KeyError, ValueError) as e:
338
- raise FrameDataError from e
339
-
340
- return header + bytearray(
341
- _join_bits(day[i : i + 8])
342
- for day in schedule
343
- for i in range(0, len(day), 8)
344
- )
345
-
346
314
  def _unpack_schedule(self, message: bytearray) -> list[list[bool]]:
347
315
  """Unpack a schedule."""
348
316
  offset = self._offset
349
317
  schedule = [
350
318
  bit
351
319
  for i in range(offset, offset + SCHEDULE_SIZE)
352
- for bit in _split_byte(message[i])
320
+ for bit in split_byte(message[i])
353
321
  ]
354
322
  self._offset = offset + SCHEDULE_SIZE
355
323
  # Split the schedule. Each day consists of 48 half-hour intervals.