PyPlumIO 0.5.51__py3-none-any.whl → 0.5.52__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.
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.51'
21
- __version_tuple__ = version_tuple = (0, 5, 51)
20
+ __version__ = version = '0.5.52'
21
+ __version_tuple__ = version_tuple = (0, 5, 52)
@@ -15,10 +15,14 @@ from pyplumio.utils import ensure_dict, to_camelcase
15
15
 
16
16
  FRAME_START: Final = 0x68
17
17
  FRAME_END: Final = 0x16
18
- HEADER_OFFSET: Final = 0
18
+
19
+
20
+ HEADER_INDEX: Final = 0
19
21
  FRAME_TYPE_SIZE: Final = 1
20
- CRC_SIZE: Final = 1
22
+ BCC_SIZE: Final = 1
23
+ BCC_INDEX: Final = -2
21
24
  DELIMITER_SIZE: Final = 1
25
+
22
26
  ECONET_TYPE: Final = 48
23
27
  ECONET_VERSION: Final = 5
24
28
 
@@ -199,7 +203,7 @@ class Frame(ABC):
199
203
  struct_header.size
200
204
  + FRAME_TYPE_SIZE
201
205
  + len(self.message)
202
- + CRC_SIZE
206
+ + BCC_SIZE
203
207
  + DELIMITER_SIZE
204
208
  )
205
209
 
@@ -209,7 +213,7 @@ class Frame(ABC):
209
213
  buffer = bytearray(struct_header.size)
210
214
  struct_header.pack_into(
211
215
  buffer,
212
- HEADER_OFFSET,
216
+ HEADER_INDEX,
213
217
  FRAME_START,
214
218
  self.length,
215
219
  int(self.recipient),
pyplumio/stream.py CHANGED
@@ -5,14 +5,16 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  from asyncio import IncompleteReadError, StreamReader, StreamWriter
7
7
  import logging
8
- from typing import Final, NamedTuple
8
+ from typing import Final, NamedTuple, SupportsIndex
9
9
 
10
10
  from pyplumio.const import DeviceType
11
11
  from pyplumio.devices import is_known_device_type
12
12
  from pyplumio.exceptions import ChecksumError, ReadError, UnknownDeviceError
13
13
  from pyplumio.frames import (
14
+ BCC_INDEX,
14
15
  DELIMITER_SIZE,
15
16
  FRAME_START,
17
+ FRAME_TYPE_SIZE,
16
18
  HEADER_SIZE,
17
19
  Frame,
18
20
  bcc,
@@ -26,6 +28,8 @@ WRITER_TIMEOUT: Final = 10
26
28
  MIN_FRAME_LENGTH: Final = 10
27
29
  MAX_FRAME_LENGTH: Final = 1000
28
30
 
31
+ DEFAULT_BUFFER_SIZE: Final = 5000
32
+
29
33
  _LOGGER = logging.getLogger(__name__)
30
34
 
31
35
 
@@ -63,6 +67,103 @@ class FrameWriter:
63
67
  await self._writer.wait_closed()
64
68
 
65
69
 
70
+ class BufferManager:
71
+ """Represents a buffered reader for reading frames."""
72
+
73
+ __slots__ = ("_buffer", "_reader")
74
+
75
+ _buffer: bytearray
76
+ _reader: StreamReader
77
+
78
+ def __init__(self, reader: StreamReader) -> None:
79
+ """Initialize a new buffered reader."""
80
+ self._buffer = bytearray()
81
+ self._reader = reader
82
+
83
+ async def ensure_buffer(self, size: int) -> None:
84
+ """Ensure the internal buffer size."""
85
+ bytes_to_read = size - len(self._buffer)
86
+ if bytes_to_read <= 0:
87
+ return None
88
+
89
+ try:
90
+ data = await self._reader.readexactly(bytes_to_read)
91
+ self._buffer.extend(data)
92
+ self.trim_to(size)
93
+ except IncompleteReadError as e:
94
+ raise ReadError(
95
+ f"Incomplete read. Tried to read {bytes_to_read} additional bytes "
96
+ f"to reach a total of {size}, but only {len(e.partial)} bytes were "
97
+ "available from stream."
98
+ ) from e
99
+ except asyncio.CancelledError:
100
+ _LOGGER.debug("Read operation cancelled while ensuring buffer")
101
+ raise
102
+ except Exception as e:
103
+ raise OSError(
104
+ f"Serial connection broken while trying to ensure {size} bytes: {e}"
105
+ ) from e
106
+
107
+ async def consume(self, size: int) -> None:
108
+ """Consume the specified number of bytes from the buffer."""
109
+ await self.ensure_buffer(size)
110
+ self._buffer = self._buffer[size:]
111
+
112
+ async def peek(self, size: int) -> bytearray:
113
+ """Read the specified number of bytes without consuming them."""
114
+ await self.ensure_buffer(size)
115
+ return self._buffer[:size]
116
+
117
+ async def read(self, size: int) -> bytearray:
118
+ """Read the bytes from buffer or stream and consume them."""
119
+ try:
120
+ return await self.peek(size)
121
+ finally:
122
+ await self.consume(size)
123
+
124
+ def seek_to(self, delimiter: SupportsIndex) -> bool:
125
+ """Trim the buffer to the first occurrence of the delimiter.
126
+
127
+ Returns True if the delimiter was found and trimmed, False otherwise.
128
+ """
129
+ if not self._buffer or (index := self._buffer.find(delimiter)) == -1:
130
+ return False
131
+
132
+ self._buffer = self._buffer[index:]
133
+ return True
134
+
135
+ def trim_to(self, size: int) -> None:
136
+ """Trim buffer to size."""
137
+ if len(self._buffer) > size:
138
+ self._buffer = self._buffer[-size:]
139
+
140
+ async def fill(self) -> None:
141
+ """Fill the buffer with data from the stream."""
142
+ try:
143
+ chunk = await self._reader.read(MAX_FRAME_LENGTH)
144
+ except asyncio.CancelledError:
145
+ _LOGGER.debug("Read operation cancelled while filling read buffer.")
146
+ raise
147
+ except Exception as e:
148
+ raise OSError(
149
+ f"Serial connection broken while filling read buffer: {e}"
150
+ ) from e
151
+
152
+ if not chunk:
153
+ _LOGGER.debug("Stream ended while filling read buffer.")
154
+ raise OSError(
155
+ "Serial connection broken: stream ended while filling read buffer"
156
+ )
157
+
158
+ self._buffer.extend(chunk)
159
+ self.trim_to(DEFAULT_BUFFER_SIZE)
160
+
161
+ @property
162
+ def buffer(self) -> bytearray:
163
+ """Return the internal buffer."""
164
+ return self._buffer
165
+
166
+
66
167
  class Header(NamedTuple):
67
168
  """Represents a frame header."""
68
169
 
@@ -76,81 +177,65 @@ class Header(NamedTuple):
76
177
  class FrameReader:
77
178
  """Represents a frame reader."""
78
179
 
79
- __slots__ = ("_reader",)
180
+ __slots__ = ("_buffer",)
80
181
 
81
- _reader: StreamReader
182
+ _buffer: BufferManager
82
183
 
83
184
  def __init__(self, reader: StreamReader) -> None:
84
185
  """Initialize a new frame reader."""
85
- self._reader = reader
86
-
87
- async def _read_header(self) -> tuple[Header, bytes]:
88
- """Locate and read a frame header.
89
-
90
- Raise pyplumio.ReadError if header size is too small and
91
- OSError if serial connection is broken.
92
- """
93
- while buffer := await self._reader.read(DELIMITER_SIZE):
94
- if FRAME_START not in buffer:
95
- continue
96
-
97
- try:
98
- buffer += await self._reader.readexactly(HEADER_SIZE - DELIMITER_SIZE)
99
- except IncompleteReadError as e:
100
- raise ReadError(
101
- f"Incomplete header, expected {e.expected} bytes"
102
- ) from e
186
+ self._buffer = BufferManager(reader)
103
187
 
104
- return Header(*struct_header.unpack_from(buffer)[DELIMITER_SIZE:]), buffer
188
+ async def _read_header(self) -> Header:
189
+ """Locate and read a frame header."""
190
+ while True:
191
+ if self._buffer.seek_to(FRAME_START):
192
+ header_bytes = await self._buffer.peek(HEADER_SIZE)
193
+ return Header(*struct_header.unpack_from(header_bytes)[DELIMITER_SIZE:])
105
194
 
106
- raise OSError("Serial connection broken")
195
+ await self._buffer.fill()
107
196
 
108
197
  @timeout(READER_TIMEOUT)
109
198
  async def read(self) -> Frame | None:
110
- """Read the frame and return corresponding handler object.
111
-
112
- Raise pyplumio.UnknownDeviceError when sender device has an
113
- unknown address, raise pyplumio.ReadError on unexpected frame
114
- length or incomplete frame, raise pyplumio.ChecksumError on
115
- incorrect frame checksum.
116
- """
117
- header, buffer = await self._read_header()
199
+ """Read the frame and return corresponding handler object."""
200
+ header = await self._read_header()
118
201
  frame_length, recipient, sender, econet_type, econet_version = header
119
202
 
120
- if recipient not in (DeviceType.ECONET, DeviceType.ALL):
121
- # Not an intended recipient, ignore the frame.
122
- return None
123
-
124
- if not is_known_device_type(sender):
125
- raise UnknownDeviceError(f"Unknown sender type ({sender})")
126
-
127
203
  if frame_length > MAX_FRAME_LENGTH or frame_length < MIN_FRAME_LENGTH:
204
+ await self._buffer.consume(HEADER_SIZE)
128
205
  raise ReadError(
129
206
  f"Unexpected frame length ({frame_length}), expected between "
130
207
  f"{MIN_FRAME_LENGTH} and {MAX_FRAME_LENGTH}"
131
208
  )
132
209
 
133
- try:
134
- buffer += await self._reader.readexactly(frame_length - HEADER_SIZE)
135
- except IncompleteReadError as e:
136
- raise ReadError(f"Incomplete frame, expected {e.expected} bytes") from e
137
-
138
- if (checksum := bcc(buffer[:-2])) and checksum != buffer[-2]:
210
+ frame_bytes = await self._buffer.peek(frame_length)
211
+ checksum = bcc(frame_bytes[:BCC_INDEX])
212
+ if checksum != frame_bytes[BCC_INDEX]:
213
+ await self._buffer.consume(HEADER_SIZE)
139
214
  raise ChecksumError(
140
215
  f"Incorrect frame checksum: calculated {checksum}, "
141
- f"expected {buffer[-2]}. "
142
- f"Frame data: {buffer.hex()}"
216
+ f"expected {frame_bytes[BCC_INDEX]}. Frame data: {frame_bytes.hex()}"
217
+ )
218
+
219
+ await self._buffer.consume(frame_length)
220
+ if recipient not in (DeviceType.ECONET, DeviceType.ALL):
221
+ _LOGGER.debug(
222
+ "Skipping frame intended for different recipient (%s)", recipient
143
223
  )
224
+ return None
225
+
226
+ if not is_known_device_type(sender):
227
+ raise UnknownDeviceError(f"Unknown sender type ({sender})")
144
228
 
229
+ payload_bytes = frame_bytes[HEADER_SIZE:BCC_INDEX]
145
230
  frame = await Frame.create(
146
- frame_type=buffer[HEADER_SIZE],
231
+ frame_type=payload_bytes[0],
147
232
  recipient=DeviceType(recipient),
148
233
  sender=DeviceType(sender),
149
234
  econet_type=econet_type,
150
235
  econet_version=econet_version,
151
- message=buffer[HEADER_SIZE + 1 : -2],
236
+ message=payload_bytes[FRAME_TYPE_SIZE:],
152
237
  )
153
- _LOGGER.debug("Received frame: %s, bytes: %s", frame, buffer.hex())
238
+ _LOGGER.debug("Received frame: %s, bytes: %s", frame, frame_bytes.hex())
154
239
 
155
240
  return frame
156
241
 
@@ -121,4 +121,4 @@ class AlertsStructure(StructureDecoder):
121
121
  )
122
122
 
123
123
 
124
- __all__ = ["AlertsStructure", "Alert"]
124
+ __all__ = ["AlertsStructure", "Alert", "ATTR_ALERTS", "ATTR_TOTAL_ALERTS"]
@@ -175,7 +175,7 @@ def get_time(
175
175
  ) -> Time:
176
176
  """Return time for a specific index."""
177
177
  time_dt = start + (step * index)
178
- return time_dt.strftime(TIME_FORMAT)
178
+ return f"{time_dt.hour:02d}:{time_dt.minute:02d}"
179
179
 
180
180
 
181
181
  @lru_cache(maxsize=10)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPlumIO
3
- Version: 0.5.51
3
+ Version: 0.5.52
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
@@ -25,17 +25,17 @@ Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
26
  Requires-Dist: dataslots==1.2.0
27
27
  Requires-Dist: pyserial-asyncio==0.6
28
- Requires-Dist: typing-extensions==4.13.2
28
+ Requires-Dist: typing-extensions==4.14.0
29
29
  Provides-Extra: test
30
30
  Requires-Dist: codespell==2.4.1; extra == "test"
31
- Requires-Dist: coverage==7.8.0; extra == "test"
32
- Requires-Dist: freezegun==1.5.1; extra == "test"
33
- Requires-Dist: mypy==1.15.0; extra == "test"
31
+ Requires-Dist: coverage==7.9.1; extra == "test"
32
+ Requires-Dist: freezegun==1.5.2; extra == "test"
33
+ Requires-Dist: mypy==1.16.1; extra == "test"
34
34
  Requires-Dist: numpy<3.0.0,>=2.0.0; extra == "test"
35
35
  Requires-Dist: pyserial-asyncio-fast==0.16; extra == "test"
36
- Requires-Dist: pytest==8.3.5; extra == "test"
37
- Requires-Dist: pytest-asyncio==0.26.0; extra == "test"
38
- Requires-Dist: ruff==0.11.10; extra == "test"
36
+ Requires-Dist: pytest==8.4.1; extra == "test"
37
+ Requires-Dist: pytest-asyncio==1.0.0; extra == "test"
38
+ Requires-Dist: ruff==0.11.13; extra == "test"
39
39
  Requires-Dist: tox==4.26.0; extra == "test"
40
40
  Requires-Dist: types-pyserial==3.5.0.20250326; extra == "test"
41
41
  Provides-Extra: docs
@@ -1,6 +1,6 @@
1
1
  pyplumio/__init__.py,sha256=3H5SO4WFw5mBTFeEyD4w0H8-MsNo93NyOH3RyMN7IS0,3337
2
2
  pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
- pyplumio/_version.py,sha256=GxxIjlMXqyiJ35TfmryleP4-ULoCRkGKYVM-gOVMu_0,513
3
+ pyplumio/_version.py,sha256=yGNU5eJ0S_ns8vdsPIX0Yq-JAj22V0bb0R9aJSWHpgE,513
4
4
  pyplumio/connection.py,sha256=9MCPb8W62uqCrzd1YCROcn9cCjRY8E65934FnJDF5Js,5902
5
5
  pyplumio/const.py,sha256=eoq-WNJ8TO3YlP7dC7KkVQRKGjt9FbRZ6M__s29vb1U,5659
6
6
  pyplumio/data_types.py,sha256=r-QOIZiIpBFo4kRongyu8n0BHTaEU6wWMTmNkWBNjq8,9223
@@ -8,14 +8,14 @@ pyplumio/exceptions.py,sha256=_B_0EgxDxd2XyYv3WpZM733q0cML5m6J-f55QOvYRpI,996
8
8
  pyplumio/filters.py,sha256=8IPDa8GQLKf4OdoLwlTxFyffvZXt-VrE6nKpttMVTLg,15400
9
9
  pyplumio/protocol.py,sha256=DWM-yJnm2EQPLvGzXNlkQ0IpKQn44e-WkNB_DqZAag8,8313
10
10
  pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- pyplumio/stream.py,sha256=iCB-XlNXRRE0p7nstb1AkXwVDwcCsgym_ggB_IDiJc8,4926
11
+ pyplumio/stream.py,sha256=XzDZ1mkwUwy8wHtVII2W9t_8NpCQEwFmnlMeHwh8cd0,7832
12
12
  pyplumio/utils.py,sha256=D6_SJzYkFjXoUrlNPt_mIQAP8hjMU05RsTqlAFphj3Y,1205
13
13
  pyplumio/devices/__init__.py,sha256=OLPY_Kk5E2SiJ4FLN2g6zmKQdQfutePV5jRH9kRHAMA,8260
14
14
  pyplumio/devices/ecomax.py,sha256=3_Hk6RaQ2e9WqIJ2NdPhofgVFjLbWIyR3TsRmMG35WY,16043
15
15
  pyplumio/devices/ecoster.py,sha256=X46ky5XT8jHMFq9sBW0ve8ZI_tjItQDMt4moXsW-ogY,307
16
16
  pyplumio/devices/mixer.py,sha256=7WdUVgwO4VXmaPNzh3ZWpKr2ooRXWemz2KFHAw35_Rk,2731
17
17
  pyplumio/devices/thermostat.py,sha256=MHMKe45fQ7jKlhBVObJ7McbYQKuF6-LOKSHy-9VNsCU,2253
18
- pyplumio/frames/__init__.py,sha256=5lw19oFlN89ZvO8KGwnkwERULQNYiP-hhZKk65LsjYY,7862
18
+ pyplumio/frames/__init__.py,sha256=iHydFDClh3EDjElBis6nicNmF0QnahguBEtY_HOHsck,7885
19
19
  pyplumio/frames/messages.py,sha256=ImQGWFFTa2eaXfytQmFZKC-IxyPRkxD8qp0bEm16-ws,3628
20
20
  pyplumio/frames/requests.py,sha256=jr-_XSSCCDDTbAmrw95CKyWa5nb7JNeGzZ2jDXIxlAo,7348
21
21
  pyplumio/frames/responses.py,sha256=M6Ky4gg2AoShmRXX0x6nftajxrvmQLKPVRWbwyhvI0E,6663
@@ -32,7 +32,7 @@ pyplumio/parameters/thermostat.py,sha256=-DK2Mb78CGrKmdhwAD0M3GiGJatczPnl1e2gVeT
32
32
  pyplumio/parameters/custom/__init__.py,sha256=o1khThLf4FMrjErFIcikAc6jI9gn5IyZlo7LNKKqJG4,3194
33
33
  pyplumio/parameters/custom/ecomax_860d3_hb.py,sha256=IsNgDXmV90QpBilDV4fGSBtIUEQJJbR9rjnfCr3-pHE,2840
34
34
  pyplumio/structures/__init__.py,sha256=emZVH5OFgdTUPbEJoznMKitmK0nlPm0I4SmF86It1Do,1345
35
- pyplumio/structures/alerts.py,sha256=bhfFEICdwdNpIaP584cdDgfDA29s6knXMJAnu3tj_EQ,3688
35
+ pyplumio/structures/alerts.py,sha256=Whl_WyHV9sXr321SuJAYBc1wUawNzi7xMZc41M8qToY,3724
36
36
  pyplumio/structures/boiler_load.py,sha256=e-6itp9L6iJeeOyhSTiOclHLuYmqG7KkcepsHwJSQSI,894
37
37
  pyplumio/structures/boiler_power.py,sha256=7CdOk-pYLEpy06oRBAeichvq8o-a2RcesB0tzo9ccBs,951
38
38
  pyplumio/structures/ecomax_parameters.py,sha256=E_s5bO0RqX8p1rM5DtYAsEXcHqS8P6Tg4AGm21cxsnM,1663
@@ -52,13 +52,13 @@ pyplumio/structures/product_info.py,sha256=Y5Q5UzKcxrixkB3Fd_BZaj1DdUNvUw1XASqR1
52
52
  pyplumio/structures/program_version.py,sha256=qHmmPComCOa-dgq7cFAucEGuRS-jWYwWi40VCiPS7cc,2621
53
53
  pyplumio/structures/regulator_data.py,sha256=SYKI1YPC3mDAth-SpYejttbD0IzBfobjgC-uy_uUKnw,2333
54
54
  pyplumio/structures/regulator_data_schema.py,sha256=0SapbZCGzqAHmHC7dwhufszJ9FNo_ZO_XMrFGNiUe-w,1547
55
- pyplumio/structures/schedules.py,sha256=po-LFc3-Na6Rz9786fjBd9Gy3EDC1h6FKEmIM927ky8,12039
55
+ pyplumio/structures/schedules.py,sha256=SGD9p12G_BVU2PSR1k5AS1cgx_bujFw8rqKSFohtEbc,12052
56
56
  pyplumio/structures/statuses.py,sha256=1h-EUw1UtuS44E19cNOSavUgZeAxsLgX3iS0eVC8pLI,1325
57
57
  pyplumio/structures/temperatures.py,sha256=2VD3P_vwp9PEBkOn2-WhifOR8w-UYNq35aAxle0z2Vg,2831
58
58
  pyplumio/structures/thermostat_parameters.py,sha256=st3x3HkjQm3hqBrn_fpvPDQu8fuc-Sx33ONB19ViQak,3007
59
59
  pyplumio/structures/thermostat_sensors.py,sha256=rO9jTZWGQpThtJqVdbbv8sYMYHxJi4MfwZQza69L2zw,3399
60
- pyplumio-0.5.51.dist-info/licenses/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
61
- pyplumio-0.5.51.dist-info/METADATA,sha256=X9tX4ay9diGk9nUkp-MhZIVc5Ldc6F1FbvSWDNE985k,5612
62
- pyplumio-0.5.51.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
63
- pyplumio-0.5.51.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
64
- pyplumio-0.5.51.dist-info/RECORD,,
60
+ pyplumio-0.5.52.dist-info/licenses/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
61
+ pyplumio-0.5.52.dist-info/METADATA,sha256=FMrf9h1q6n2yj7eafOP4MxGkRwTPEw3a0rMDi_xvyh4,5611
62
+ pyplumio-0.5.52.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
63
+ pyplumio-0.5.52.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
64
+ pyplumio-0.5.52.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.8.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5