PyPlumIO 0.5.50__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.50'
21
- __version_tuple__ = version_tuple = (0, 5, 50)
20
+ __version__ = version = '0.5.52'
21
+ __version_tuple__ = version_tuple = (0, 5, 52)
pyplumio/const.py CHANGED
@@ -91,13 +91,6 @@ class ProductType(IntEnum):
91
91
  ECOMAX_I = 1
92
92
 
93
93
 
94
- @unique
95
- class ProductModel(Enum):
96
- """Contains device models."""
97
-
98
- ECOMAX_860D3_HB = "ecoMAX 860D3-HB"
99
-
100
-
101
94
  @unique
102
95
  class AlertType(IntEnum):
103
96
  """Contains alert types."""
@@ -64,7 +64,7 @@ class Device(ABC, EventManager):
64
64
  self,
65
65
  name: str,
66
66
  value: NumericType | State | bool,
67
- retries: int = 5,
67
+ retries: int = 0,
68
68
  timeout: float | None = None,
69
69
  ) -> bool:
70
70
  """Set a parameter value.
@@ -74,7 +74,7 @@ class Device(ABC, EventManager):
74
74
  :param value: New value for the parameter
75
75
  :type value: int | float | bool | Literal["on", "off"]
76
76
  :param retries: Try setting parameter for this amount of
77
- times, defaults to 5
77
+ times, defaults to 0 (disabled)
78
78
  :type retries: int, optional
79
79
  :param timeout: Wait this amount of seconds for confirmation,
80
80
  defaults to `None`
@@ -96,7 +96,7 @@ class Device(ABC, EventManager):
96
96
  self,
97
97
  name: str,
98
98
  value: NumericType | State | bool,
99
- retries: int = 5,
99
+ retries: int = 0,
100
100
  timeout: float | None = None,
101
101
  ) -> None:
102
102
  """Set a parameter value without waiting for the result.
@@ -106,7 +106,7 @@ class Device(ABC, EventManager):
106
106
  :param value: New value for the parameter
107
107
  :type value: int | float | bool | Literal["on", "off"]
108
108
  :param retries: Try setting parameter for this amount of
109
- times, defaults to 5
109
+ times, defaults to 0 (disabled)
110
110
  :type retries: int, optional
111
111
  :param timeout: Wait this amount of seconds for confirmation.
112
112
  As this method operates in the background without waiting,
@@ -27,7 +27,6 @@ from pyplumio.exceptions import RequestError
27
27
  from pyplumio.filters import on_change
28
28
  from pyplumio.frames import DataFrameDescription, Frame, Request
29
29
  from pyplumio.helpers.event_manager import event_listener
30
- from pyplumio.helpers.schedule import Schedule, ScheduleDay
31
30
  from pyplumio.parameters import ParameterValues
32
31
  from pyplumio.parameters.ecomax import (
33
32
  ECOMAX_CONTROL_PARAMETER,
@@ -51,6 +50,8 @@ from pyplumio.structures.schedules import (
51
50
  ATTR_SCHEDULES,
52
51
  SCHEDULE_PARAMETERS,
53
52
  SCHEDULES,
53
+ Schedule,
54
+ ScheduleDay,
54
55
  ScheduleNumber,
55
56
  ScheduleSwitch,
56
57
  ScheduleSwitchDescription,
@@ -303,8 +304,7 @@ class EcoMAX(PhysicalDevice):
303
304
 
304
305
  @event_listener
305
306
  async def on_event_mixer_parameters(
306
- self,
307
- parameters: dict[int, list[tuple[int, ParameterValues]]] | None,
307
+ self, parameters: dict[int, Any] | None
308
308
  ) -> bool:
309
309
  """Handle mixer parameters and dispatch the events."""
310
310
  _LOGGER.debug("Received mixer parameters")
@@ -320,9 +320,7 @@ class EcoMAX(PhysicalDevice):
320
320
  return False
321
321
 
322
322
  @event_listener
323
- async def on_event_mixer_sensors(
324
- self, sensors: dict[int, dict[str, Any]] | None
325
- ) -> bool:
323
+ async def on_event_mixer_sensors(self, sensors: dict[int, Any] | None) -> bool:
326
324
  """Update mixer sensors and dispatch the events."""
327
325
  _LOGGER.debug("Received mixer sensors")
328
326
  if sensors:
@@ -372,8 +370,7 @@ class EcoMAX(PhysicalDevice):
372
370
 
373
371
  @event_listener
374
372
  async def on_event_thermostat_parameters(
375
- self,
376
- parameters: dict[int, list[tuple[int, ParameterValues]]] | None,
373
+ self, parameters: dict[int, Any] | None
377
374
  ) -> bool:
378
375
  """Handle thermostat parameters and dispatch the events."""
379
376
  _LOGGER.debug("Received thermostat parameters")
@@ -403,9 +400,7 @@ class EcoMAX(PhysicalDevice):
403
400
  return None
404
401
 
405
402
  @event_listener
406
- async def on_event_thermostat_sensors(
407
- self, sensors: dict[int, dict[str, Any]] | None
408
- ) -> bool:
403
+ async def on_event_thermostat_sensors(self, sensors: dict[int, Any] | None) -> bool:
409
404
  """Update thermostat sensors and dispatch the events."""
410
405
  _LOGGER.debug("Received thermostat sensors")
411
406
  if sensors:
pyplumio/filters.py CHANGED
@@ -86,7 +86,7 @@ def is_close(
86
86
  ) -> bool:
87
87
  """Check if value is significantly changed."""
88
88
  if isinstance(old, Parameter) and isinstance(new, Parameter):
89
- return new.pending_update or old.values.__ne__(new.values)
89
+ return new.update_pending.is_set() or old.values.__ne__(new.values)
90
90
 
91
91
  if tolerance and isinstance(old, SupportsFloat) and isinstance(new, SupportsFloat):
92
92
  return not math.isclose(old, new, abs_tol=tolerance)
@@ -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),
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from abc import ABC, abstractmethod
6
6
  import asyncio
7
+ from contextlib import suppress
7
8
  from dataclasses import dataclass
8
9
  import logging
9
10
  from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, get_args
@@ -72,11 +73,19 @@ NumericType: TypeAlias = Union[int, float]
72
73
  class Parameter(ABC):
73
74
  """Represents a base parameter."""
74
75
 
75
- __slots__ = ("device", "description", "_pending_update", "_index", "_values")
76
+ __slots__ = (
77
+ "device",
78
+ "description",
79
+ "_update_done",
80
+ "_update_pending",
81
+ "_index",
82
+ "_values",
83
+ )
76
84
 
77
85
  device: Device
78
86
  description: ParameterDescription
79
- _pending_update: bool
87
+ _update_done: asyncio.Event
88
+ _update_pending: asyncio.Event
80
89
  _index: int
81
90
  _values: ParameterValues
82
91
 
@@ -91,8 +100,9 @@ class Parameter(ABC):
91
100
  self.device = device
92
101
  self.description = description
93
102
  self._index = index
94
- self._pending_update = False
95
103
  self._index = index
104
+ self._update_done = asyncio.Event()
105
+ self._update_pending = asyncio.Event()
96
106
  self._values = values if values else ParameterValues(0, 0, 0)
97
107
 
98
108
  def __repr__(self) -> str:
@@ -171,21 +181,19 @@ class Parameter(ABC):
171
181
  )
172
182
  return type(self)(self.device, self.description, values)
173
183
 
174
- async def set(self, value: Any, retries: int = 5, timeout: float = 5.0) -> bool:
184
+ async def set(self, value: Any, retries: int = 0, timeout: float = 5.0) -> bool:
175
185
  """Set a parameter value."""
176
186
  self.validate(value)
177
187
  return await self._attempt_update(self._pack_value(value), retries, timeout)
178
188
 
179
- def set_nowait(self, value: Any, retries: int = 5, timeout: float = 5.0) -> None:
189
+ def set_nowait(self, value: Any, retries: int = 0, timeout: float = 5.0) -> None:
180
190
  """Set a parameter value without waiting."""
181
191
  self.validate(value)
182
192
  self.device.create_task(
183
193
  self._attempt_update(self._pack_value(value), retries, timeout)
184
194
  )
185
195
 
186
- async def _attempt_update(
187
- self, value: int, retries: int = 5, timeout: float = 5.0
188
- ) -> bool:
196
+ async def _attempt_update(self, value: int, retries: int, timeout: float) -> bool:
189
197
  """Attempt to update a parameter value on the remote device."""
190
198
  _LOGGER.info(
191
199
  "Attempting to update '%s' parameter to %d", self.description.name, value
@@ -196,36 +204,58 @@ class Parameter(ABC):
196
204
 
197
205
  self._values.value = value
198
206
  request = await self.create_request()
199
- if self.description.optimistic or not (initial_retries := retries):
200
- # No retries
207
+ if self.description.optimistic:
201
208
  await self.device.queue.put(request)
202
209
  return True
203
210
 
204
- self._pending_update = True
205
- while self.pending_update:
206
- if retries <= 0:
207
- _LOGGER.warning(
208
- "Unable to confirm update of '%s' parameter after %d retries",
209
- self.description.name,
210
- initial_retries,
211
- )
212
- return False
211
+ self.update_done.clear()
212
+ self.update_pending.set()
213
+ if retries > 0:
214
+ return await self._attempt_update_with_retries(
215
+ request, retries=retries, timeout=timeout
216
+ )
213
217
 
214
- await self.device.queue.put(request)
215
- await asyncio.sleep(timeout)
216
- retries -= 1
218
+ return await self._send_update_request(request, timeout=timeout)
217
219
 
218
- return True
220
+ async def _attempt_update_with_retries(
221
+ self, request: Request, retries: int, timeout: float
222
+ ) -> bool:
223
+ """Send update request and retry until success."""
224
+ for _ in range(retries):
225
+ if await self._send_update_request(request, timeout=timeout):
226
+ return True
227
+
228
+ _LOGGER.warning(
229
+ "Unable to confirm update of '%s' parameter after %d retries",
230
+ self.description.name,
231
+ retries,
232
+ )
233
+ return False
234
+
235
+ async def _send_update_request(self, request: Request, timeout: float) -> bool:
236
+ """Send update request to the remote and confirm the result."""
237
+ await self.device.queue.put(request)
238
+ with suppress(asyncio.TimeoutError):
239
+ # Wait for the update to be done
240
+ await asyncio.wait_for(self.update_done.wait(), timeout=timeout)
241
+
242
+ return self.update_done.is_set()
219
243
 
220
244
  def update(self, values: ParameterValues) -> None:
221
245
  """Update the parameter values."""
222
- self._pending_update = False
246
+ self.update_done.set()
247
+ self.update_pending.clear()
223
248
  self._values = values
224
249
 
225
250
  @property
226
- def pending_update(self) -> bool:
227
- """Check if parameter is pending update on the device."""
228
- return self._pending_update
251
+ def update_done(self) -> asyncio.Event:
252
+ """Check if parameter is updated on the device."""
253
+ return self._update_done
254
+
255
+ @property
256
+ def update_pending(self) -> asyncio.Event:
257
+ """Check if parameter is updated on the device."""
258
+ return self._update_pending
229
259
 
230
260
  @property
231
261
  def values(self) -> ParameterValues:
@@ -325,16 +355,16 @@ class Number(Parameter):
325
355
  return True
326
356
 
327
357
  async def set(
328
- self, value: NumericType, retries: int = 5, timeout: float = 5.0
358
+ self, value: NumericType, retries: int = 0, timeout: float = 5.0
329
359
  ) -> bool:
330
360
  """Set a parameter value."""
331
- return await super().set(value, retries, timeout)
361
+ return await super().set(value, retries=retries, timeout=timeout)
332
362
 
333
363
  def set_nowait(
334
- self, value: NumericType, retries: int = 5, timeout: float = 5.0
364
+ self, value: NumericType, retries: int = 0, timeout: float = 5.0
335
365
  ) -> None:
336
366
  """Set a parameter value without waiting."""
337
- super().set_nowait(value, retries, timeout)
367
+ super().set_nowait(value, retries=retries, timeout=timeout)
338
368
 
339
369
  async def create_request(self) -> Request:
340
370
  """Create a request to change the number."""
@@ -420,16 +450,16 @@ class Switch(Parameter):
420
450
  return True
421
451
 
422
452
  async def set(
423
- self, value: State | bool, retries: int = 5, timeout: float = 5.0
453
+ self, value: State | bool, retries: int = 0, timeout: float = 5.0
424
454
  ) -> bool:
425
455
  """Set a parameter value."""
426
- return await super().set(value, retries, timeout)
456
+ return await super().set(value, retries=retries, timeout=timeout)
427
457
 
428
458
  def set_nowait(
429
- self, value: State | bool, retries: int = 5, timeout: float = 5.0
459
+ self, value: State | bool, retries: int = 0, timeout: float = 5.0
430
460
  ) -> None:
431
461
  """Set a switch value without waiting."""
432
- super().set_nowait(value, retries, timeout)
462
+ super().set_nowait(value, retries=retries, timeout=timeout)
433
463
 
434
464
  async def turn_on(self) -> bool:
435
465
  """Set a switch value to 'on'.
@@ -29,7 +29,7 @@ class Signature:
29
29
  class CustomParameter:
30
30
  """Represents a custom parameter."""
31
31
 
32
- __slot__ = ("original", "replacement")
32
+ __slots__ = ("original", "replacement")
33
33
 
34
34
  original: str
35
35
  replacement: ParameterDescription
@@ -38,8 +38,6 @@ class CustomParameter:
38
38
  class CustomParameters:
39
39
  """Represents a custom parameters."""
40
40
 
41
- __slots__ = ("signature", "replacements")
42
-
43
41
  signature: ClassVar[Signature]
44
42
  replacements: ClassVar[Sequence[CustomParameter]]
45
43
 
@@ -8,8 +8,6 @@ from pyplumio.parameters.ecomax import EcomaxNumberDescription, EcomaxSwitchDesc
8
8
  class EcoMAX860D3HB(CustomParameters):
9
9
  """Replacements for ecoMAX 860D3-HB."""
10
10
 
11
- __slots__ = ()
12
-
13
11
  signature = Signature(model="ecoMAX 860D3-HB", id=48)
14
12
 
15
13
  replacements = (
pyplumio/protocol.py CHANGED
@@ -230,15 +230,15 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
230
230
  @acache
231
231
  async def get_device_entry(self, device_type: DeviceType) -> PhysicalDevice:
232
232
  """Return the device entry."""
233
+ name = device_type.name.lower()
233
234
  async with self._entry_lock:
234
- name = device_type.name.lower()
235
235
  if name not in self.data:
236
236
  device = await PhysicalDevice.create(
237
237
  device_type, queue=self._queues.write, network=self._network
238
238
  )
239
239
  device.dispatch_nowait(ATTR_CONNECTED, True)
240
240
  device.dispatch_nowait(ATTR_SETUP, True)
241
- await self.dispatch(device_type.name.lower(), device)
241
+ await self.dispatch(name, device)
242
242
 
243
243
  return self.data[name]
244
244
 
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
 
@@ -99,7 +99,7 @@ class AlertsStructure(StructureDecoder):
99
99
  self, message: bytearray, offset: int = 0, data: dict[str, Any] | None = None
100
100
  ) -> tuple[dict[str, Any], int]:
101
101
  """Decode bytes and return message data and offset."""
102
- total_alerts = message[offset + 0]
102
+ total_alerts = message[offset]
103
103
  start = message[offset + 1]
104
104
  end = message[offset + 2]
105
105
  self._offset = offset + 3
@@ -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"]
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from contextlib import suppress
6
- import math
7
6
  from typing import Any, Final
8
7
 
9
8
  from pyplumio.const import BYTE_UNDEFINED, LambdaState
@@ -43,9 +42,7 @@ class LambdaSensorStructure(StructureDecoder):
43
42
  {
44
43
  ATTR_LAMBDA_STATE: lambda_state,
45
44
  ATTR_LAMBDA_TARGET: lambda_target,
46
- ATTR_LAMBDA_LEVEL: (
47
- None if math.isnan(level.value) else (level.value / 10)
48
- ),
45
+ ATTR_LAMBDA_LEVEL: level.value / 10,
49
46
  },
50
47
  ),
51
48
  offset,
@@ -3,20 +3,55 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
- from functools import cache
6
+ from functools import cache, reduce
7
7
  import re
8
8
  import struct
9
9
  from typing import Any, Final
10
10
 
11
11
  from pyplumio.const import ProductType
12
12
  from pyplumio.data_types import UnsignedShort, VarBytes, VarString
13
- from pyplumio.helpers.uid import unpack_uid
14
13
  from pyplumio.structures import StructureDecoder
15
14
  from pyplumio.utils import ensure_dict
16
15
 
17
16
  ATTR_PRODUCT: Final = "product"
18
17
 
19
18
 
19
+ CRC: Final = 0xA3A3
20
+ POLYNOMIAL: Final = 0xA001
21
+ BASE5_KEY: Final = "0123456789ABCDEFGHIJKLMNZPQRSTUV"
22
+
23
+
24
+ def _base5(buffer: bytes) -> str:
25
+ """Encode bytes to a base5 encoded string."""
26
+ number = int.from_bytes(buffer, byteorder="little")
27
+ output = []
28
+ while number:
29
+ output.append(BASE5_KEY[number & 0b00011111])
30
+ number >>= 5
31
+
32
+ return "".join(reversed(output))
33
+
34
+
35
+ def _crc16(buffer: bytes) -> bytes:
36
+ """Return a CRC 16."""
37
+ crc16 = reduce(_crc16_byte, buffer, CRC)
38
+ return crc16.to_bytes(length=2, byteorder="little")
39
+
40
+
41
+ def _crc16_byte(crc: int, byte: int) -> int:
42
+ """Add a byte to the CRC."""
43
+ crc ^= byte
44
+ for _ in range(8):
45
+ crc = (crc >> 1) ^ POLYNOMIAL if crc & 1 else crc >> 1
46
+
47
+ return crc
48
+
49
+
50
+ def unpack_uid(buffer: bytes) -> str:
51
+ """Unpack UID from bytes."""
52
+ return _base5(buffer + _crc16(buffer))
53
+
54
+
20
55
  @cache
21
56
  def format_model_name(model_name: str) -> str:
22
57
  """Format a device model name."""
@@ -2,10 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Sequence
5
+ from collections.abc import Iterable, Iterator, MutableMapping, Sequence
6
6
  from dataclasses import dataclass
7
- from functools import reduce
8
- from typing import Any, Final
7
+ import datetime as dt
8
+ from functools import lru_cache, reduce
9
+ from typing import Annotated, Any, Final, get_args
9
10
 
10
11
  from dataslots import dataslots
11
12
 
@@ -14,7 +15,10 @@ from pyplumio.const import (
14
15
  ATTR_SCHEDULE,
15
16
  ATTR_SWITCH,
16
17
  ATTR_TYPE,
18
+ STATE_OFF,
19
+ STATE_ON,
17
20
  FrameType,
21
+ State,
18
22
  )
19
23
  from pyplumio.devices import Device, PhysicalDevice
20
24
  from pyplumio.exceptions import FrameDataError
@@ -156,6 +160,169 @@ def collect_schedule_data(name: str, device: Device) -> dict[str, Any]:
156
160
  }
157
161
 
158
162
 
163
+ TIME_FORMAT: Final = "%H:%M"
164
+
165
+ Time = Annotated[str, "Time string in %H:%M format"]
166
+
167
+ MIDNIGHT: Final = Time("00:00")
168
+ MIDNIGHT_DT = dt.datetime.strptime(MIDNIGHT, TIME_FORMAT)
169
+
170
+ STEP = dt.timedelta(minutes=30)
171
+
172
+
173
+ def get_time(
174
+ index: int, start: dt.datetime = MIDNIGHT_DT, step: dt.timedelta = STEP
175
+ ) -> Time:
176
+ """Return time for a specific index."""
177
+ time_dt = start + (step * index)
178
+ return f"{time_dt.hour:02d}:{time_dt.minute:02d}"
179
+
180
+
181
+ @lru_cache(maxsize=10)
182
+ def get_time_range(start: Time, end: Time, step: dt.timedelta = STEP) -> list[Time]:
183
+ """Get a time range.
184
+
185
+ Start and end boundaries should be specified in %H:%M format.
186
+ Both are inclusive.
187
+ """
188
+ start_dt = dt.datetime.strptime(start, TIME_FORMAT)
189
+ end_dt = dt.datetime.strptime(end, TIME_FORMAT)
190
+
191
+ if end_dt == MIDNIGHT_DT:
192
+ # Upper boundary of the interval is midnight.
193
+ end_dt += dt.timedelta(hours=24) - step
194
+
195
+ if end_dt <= start_dt:
196
+ raise ValueError(
197
+ f"Invalid time range: start time ({start}) must be earlier "
198
+ f"than end time ({end})."
199
+ )
200
+
201
+ seconds = (end_dt - start_dt).total_seconds()
202
+ steps = seconds // step.total_seconds() + 1
203
+
204
+ return [get_time(index, start=start_dt, step=step) for index in range(int(steps))]
205
+
206
+
207
+ class ScheduleDay(MutableMapping):
208
+ """Represents a single day of schedule."""
209
+
210
+ __slots__ = ("_schedule",)
211
+
212
+ _schedule: dict[Time, bool]
213
+
214
+ def __init__(self, schedule: dict[Time, bool]) -> None:
215
+ """Initialize a new schedule day."""
216
+ self._schedule = schedule
217
+
218
+ def __repr__(self) -> str:
219
+ """Return serializable representation of the class."""
220
+ return f"ScheduleDay({self._schedule})"
221
+
222
+ def __len__(self) -> int:
223
+ """Return a schedule length."""
224
+ return self._schedule.__len__()
225
+
226
+ def __iter__(self) -> Iterator[Time]:
227
+ """Return an iterator."""
228
+ return self._schedule.__iter__()
229
+
230
+ def __getitem__(self, time: Time) -> State:
231
+ """Return a schedule item."""
232
+ state = self._schedule.__getitem__(time)
233
+ return STATE_ON if state else STATE_OFF
234
+
235
+ def __delitem__(self, time: Time) -> None:
236
+ """Delete a schedule item."""
237
+ self._schedule.__delitem__(time)
238
+
239
+ def __setitem__(self, time: Time, state: State | bool) -> None:
240
+ """Set a schedule item."""
241
+ if state in get_args(State):
242
+ state = True if state == STATE_ON else False
243
+ if isinstance(state, bool):
244
+ self._schedule.__setitem__(time, state)
245
+ else:
246
+ raise TypeError(
247
+ f"Expected boolean value or one of: {', '.join(get_args(State))}."
248
+ )
249
+
250
+ def set_state(
251
+ self, state: State | bool, start: Time = MIDNIGHT, end: Time = MIDNIGHT
252
+ ) -> None:
253
+ """Set a schedule interval state."""
254
+ for time in get_time_range(start, end):
255
+ self.__setitem__(time, state)
256
+
257
+ def set_on(self, start: Time = MIDNIGHT, end: Time = MIDNIGHT) -> None:
258
+ """Set a schedule interval state to 'on'."""
259
+ self.set_state(STATE_ON, start, end)
260
+
261
+ def set_off(self, start: Time = MIDNIGHT, end: Time = MIDNIGHT) -> None:
262
+ """Set a schedule interval state to 'off'."""
263
+ self.set_state(STATE_OFF, start, end)
264
+
265
+ @property
266
+ def schedule(self) -> dict[Time, bool]:
267
+ """Return the schedule."""
268
+ return self._schedule
269
+
270
+ @classmethod
271
+ def from_iterable(cls: type[ScheduleDay], intervals: Iterable[bool]) -> ScheduleDay:
272
+ """Make schedule day from iterable."""
273
+ return cls({get_time(index): state for index, state in enumerate(intervals)})
274
+
275
+
276
+ @dataclass
277
+ class Schedule(Iterable):
278
+ """Represents a weekly schedule."""
279
+
280
+ __slots__ = (
281
+ "name",
282
+ "device",
283
+ "sunday",
284
+ "monday",
285
+ "tuesday",
286
+ "wednesday",
287
+ "thursday",
288
+ "friday",
289
+ "saturday",
290
+ )
291
+
292
+ name: str
293
+ device: PhysicalDevice
294
+
295
+ sunday: ScheduleDay
296
+ monday: ScheduleDay
297
+ tuesday: ScheduleDay
298
+ wednesday: ScheduleDay
299
+ thursday: ScheduleDay
300
+ friday: ScheduleDay
301
+ saturday: ScheduleDay
302
+
303
+ def __iter__(self) -> Iterator[ScheduleDay]:
304
+ """Return list of days."""
305
+ return (
306
+ self.sunday,
307
+ self.monday,
308
+ self.tuesday,
309
+ self.wednesday,
310
+ self.thursday,
311
+ self.friday,
312
+ self.saturday,
313
+ ).__iter__()
314
+
315
+ async def commit(self) -> None:
316
+ """Commit a weekly schedule to the device."""
317
+ await self.device.queue.put(
318
+ await Request.create(
319
+ FrameType.REQUEST_SET_SCHEDULE,
320
+ recipient=self.device.address,
321
+ data=collect_schedule_data(self.name, self.device),
322
+ )
323
+ )
324
+
325
+
159
326
  def _split_byte(byte: int) -> list[bool]:
160
327
  """Split single byte into an eight bits."""
161
328
  return [bool(byte & (1 << bit)) for bit in reversed(range(8))]
@@ -245,6 +412,8 @@ __all__ = [
245
412
  "ATTR_SCHEDULE_PARAMETERS",
246
413
  "ATTR_SCHEDULE_SWITCH",
247
414
  "ATTR_SCHEDULE_PARAMETER",
415
+ "Schedule",
416
+ "ScheduleDay",
248
417
  "ScheduleParameterDescription",
249
418
  "ScheduleParameter",
250
419
  "ScheduleNumberDescription",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPlumIO
3
- Version: 0.5.50
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,18 +25,18 @@ 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.9; extra == "test"
39
- Requires-Dist: tox==4.25.0; 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
+ 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
42
42
  Requires-Dist: sphinx==8.1.3; extra == "docs"
@@ -1,21 +1,21 @@
1
1
  pyplumio/__init__.py,sha256=3H5SO4WFw5mBTFeEyD4w0H8-MsNo93NyOH3RyMN7IS0,3337
2
2
  pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
- pyplumio/_version.py,sha256=W2qdBOtSMf_VJh64HmusP_jxYyzDL0VrJZ_ZkeEZpKE,513
3
+ pyplumio/_version.py,sha256=yGNU5eJ0S_ns8vdsPIX0Yq-JAj22V0bb0R9aJSWHpgE,513
4
4
  pyplumio/connection.py,sha256=9MCPb8W62uqCrzd1YCROcn9cCjRY8E65934FnJDF5Js,5902
5
- pyplumio/const.py,sha256=QIM5ueC8dx40Ccg1QHLI_mZ5wpBTptOofJWPdOOR7hk,5770
5
+ pyplumio/const.py,sha256=eoq-WNJ8TO3YlP7dC7KkVQRKGjt9FbRZ6M__s29vb1U,5659
6
6
  pyplumio/data_types.py,sha256=r-QOIZiIpBFo4kRongyu8n0BHTaEU6wWMTmNkWBNjq8,9223
7
7
  pyplumio/exceptions.py,sha256=_B_0EgxDxd2XyYv3WpZM733q0cML5m6J-f55QOvYRpI,996
8
- pyplumio/filters.py,sha256=s35hmm8HxqktMOtNZMdXbqMeywBNKoIsAc5hWd0AEpk,15391
9
- pyplumio/protocol.py,sha256=9F_nkzsEuv6YWjuDyaoFH29dLaPBxeBxndogbWUG2jw,8337
8
+ pyplumio/filters.py,sha256=8IPDa8GQLKf4OdoLwlTxFyffvZXt-VrE6nKpttMVTLg,15400
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
- pyplumio/devices/__init__.py,sha256=n0h2RwLxjm9YPjRoxURpMzVjfk2SP-GZQKJTcoMkxcc,8238
14
- pyplumio/devices/ecomax.py,sha256=5jc1dgWG8LCSeC4ZrQ9-hEYRsZB_N1i0V-L-aj0r0Is,16200
13
+ pyplumio/devices/__init__.py,sha256=OLPY_Kk5E2SiJ4FLN2g6zmKQdQfutePV5jRH9kRHAMA,8260
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
@@ -23,18 +23,16 @@ pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,
23
23
  pyplumio/helpers/async_cache.py,sha256=EGQcU8LWJpVx3Hk6iaI-3mqAhnR5ACfBOGb9tWw-VSY,1305
24
24
  pyplumio/helpers/event_manager.py,sha256=aKNlhsPNTy3eOSfWVb9TJxtIsN9GAQv9XxhOi_BOhlM,8097
25
25
  pyplumio/helpers/factory.py,sha256=c3sitnkUjJWz7fPpTE9uRIpa8h46Qim3xsAblMw3eDo,1049
26
- pyplumio/helpers/schedule.py,sha256=ODNfMuqRZuFnnFxzFFvbE0sSQ6sxp4EUyxPMDBym-L0,5308
27
26
  pyplumio/helpers/task_manager.py,sha256=N71F6Ag1HHxdf5zJeCMcEziFdH9lmJKtMPoRGjJ-E40,1209
28
27
  pyplumio/helpers/timeout.py,sha256=2wAcOug-2TgdCchKbfv0VhAfNzP-MPM0TEFtRNFJ_m8,803
29
- pyplumio/helpers/uid.py,sha256=HeM5Zmd0qfNmVya6RKE-bBYzdxG-pAiViOZRHqw33VU,1011
30
- pyplumio/parameters/__init__.py,sha256=g9AWuCgEByGpLaiXRqE8f9xZ208ut2DNhEiDUJ0SJhs,14963
28
+ pyplumio/parameters/__init__.py,sha256=2YTJJF-ehMLbPJwp02I1fycyLsatIXPSlTxXLwtkHtk,16054
31
29
  pyplumio/parameters/ecomax.py,sha256=4UqI7cokCt7qS9Du4-7JgLhM7mhHCHt8SWPl_qncmXQ,26239
32
30
  pyplumio/parameters/mixer.py,sha256=RjhfUU_62STyNV0Ud9A4G4FEvVwo02qGVl8h1QvqQXI,6646
33
31
  pyplumio/parameters/thermostat.py,sha256=-DK2Mb78CGrKmdhwAD0M3GiGJatczPnl1e2gVeT19tI,5070
34
- pyplumio/parameters/custom/__init__.py,sha256=tI_MXv0ExI8do7nPjA3oWiu0m0alGLlctggNNrgq09c,3240
35
- pyplumio/parameters/custom/ecomax_860d3_hb.py,sha256=DgPm-o2iOxveLR-2KTrf5xC9KRVJxBHK_3cQV4UNsEs,2860
32
+ pyplumio/parameters/custom/__init__.py,sha256=o1khThLf4FMrjErFIcikAc6jI9gn5IyZlo7LNKKqJG4,3194
33
+ pyplumio/parameters/custom/ecomax_860d3_hb.py,sha256=IsNgDXmV90QpBilDV4fGSBtIUEQJJbR9rjnfCr3-pHE,2840
36
34
  pyplumio/structures/__init__.py,sha256=emZVH5OFgdTUPbEJoznMKitmK0nlPm0I4SmF86It1Do,1345
37
- pyplumio/structures/alerts.py,sha256=fglFcxHoZ3hZJscPHmbJJqTr2mH8YXRW0T18L4Dirds,3692
35
+ pyplumio/structures/alerts.py,sha256=Whl_WyHV9sXr321SuJAYBc1wUawNzi7xMZc41M8qToY,3724
38
36
  pyplumio/structures/boiler_load.py,sha256=e-6itp9L6iJeeOyhSTiOclHLuYmqG7KkcepsHwJSQSI,894
39
37
  pyplumio/structures/boiler_power.py,sha256=7CdOk-pYLEpy06oRBAeichvq8o-a2RcesB0tzo9ccBs,951
40
38
  pyplumio/structures/ecomax_parameters.py,sha256=E_s5bO0RqX8p1rM5DtYAsEXcHqS8P6Tg4AGm21cxsnM,1663
@@ -42,7 +40,7 @@ pyplumio/structures/fan_power.py,sha256=l9mDB_Ugnn1gKJFh9fppwzoi0i1_3eBvHAD6uPAd
42
40
  pyplumio/structures/frame_versions.py,sha256=n-L93poxY3i_l3oMWg0PzRXGrkY9cgv-Eyuf9XObaR4,1602
43
41
  pyplumio/structures/fuel_consumption.py,sha256=Cf3Z14gEZnkVEt-OAXNka3-T8fKIMHQaVSeQdQYXnPg,1034
44
42
  pyplumio/structures/fuel_level.py,sha256=-zUKApVJaZZzc1q52vqO3K2Mya43c6vRgw45d2xgy5Q,1123
45
- pyplumio/structures/lambda_sensor.py,sha256=0ZNEhzvNVtZt9CY0ZnZ7-N3fdWnuuexcrkztKq-lBSw,1708
43
+ pyplumio/structures/lambda_sensor.py,sha256=09nM4Hwn1X275LzFpDihtpzkazwgJXAbx4NFqUkhbNM,1609
46
44
  pyplumio/structures/mixer_parameters.py,sha256=JMSySqI7TUGKdFtDp1P5DJm5EAbijMSz-orRrAe1KlQ,2041
47
45
  pyplumio/structures/mixer_sensors.py,sha256=ChgLhC3p4fyoPy1EKe0BQTvXOPZEISbcK2HyamrNaN8,2450
48
46
  pyplumio/structures/modules.py,sha256=LviFz3_pPvSQ5i_Mr2S9o5miVad8O4qn48CR_z7yG24,2791
@@ -50,17 +48,17 @@ pyplumio/structures/network_info.py,sha256=HYhROSMbVxqYxsOa7aF3xetQXEs0xGvhzH-4O
50
48
  pyplumio/structures/output_flags.py,sha256=upVIgAH2JNncHFXvjE-t6oTFF-JNwwZbyGjfrcKWtz0,1508
51
49
  pyplumio/structures/outputs.py,sha256=3NP5lArzQiihRC4QzBuWAHL9hhjvGxNkKmeoYZnDD-0,2291
52
50
  pyplumio/structures/pending_alerts.py,sha256=b1uMmDHTGv8eE0h1vGBrKsPxlwBmUad7HgChnDDLK_g,801
53
- pyplumio/structures/product_info.py,sha256=yXHFQv6LcA7kj8ErAY-fK4z33EIgDnvVDNe3Dccg_Bo,2472
51
+ pyplumio/structures/product_info.py,sha256=Y5Q5UzKcxrixkB3Fd_BZaj1DdUNvUw1XASqR1oKMqn0,3308
54
52
  pyplumio/structures/program_version.py,sha256=qHmmPComCOa-dgq7cFAucEGuRS-jWYwWi40VCiPS7cc,2621
55
53
  pyplumio/structures/regulator_data.py,sha256=SYKI1YPC3mDAth-SpYejttbD0IzBfobjgC-uy_uUKnw,2333
56
54
  pyplumio/structures/regulator_data_schema.py,sha256=0SapbZCGzqAHmHC7dwhufszJ9FNo_ZO_XMrFGNiUe-w,1547
57
- pyplumio/structures/schedules.py,sha256=JWMyi-D_a2M8k17Zis7-o9L3Zn-Lvzdh1ewXDQeoaYo,7092
55
+ pyplumio/structures/schedules.py,sha256=SGD9p12G_BVU2PSR1k5AS1cgx_bujFw8rqKSFohtEbc,12052
58
56
  pyplumio/structures/statuses.py,sha256=1h-EUw1UtuS44E19cNOSavUgZeAxsLgX3iS0eVC8pLI,1325
59
57
  pyplumio/structures/temperatures.py,sha256=2VD3P_vwp9PEBkOn2-WhifOR8w-UYNq35aAxle0z2Vg,2831
60
58
  pyplumio/structures/thermostat_parameters.py,sha256=st3x3HkjQm3hqBrn_fpvPDQu8fuc-Sx33ONB19ViQak,3007
61
59
  pyplumio/structures/thermostat_sensors.py,sha256=rO9jTZWGQpThtJqVdbbv8sYMYHxJi4MfwZQza69L2zw,3399
62
- pyplumio-0.5.50.dist-info/licenses/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
63
- pyplumio-0.5.50.dist-info/METADATA,sha256=3cCn3n--0U3EccfpB_R7fKOwj2oX2w7bc4fyii4OzTg,5611
64
- pyplumio-0.5.50.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
65
- pyplumio-0.5.50.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
66
- pyplumio-0.5.50.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.7.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,180 +0,0 @@
1
- """Contains a schedule helper classes."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Iterable, Iterator, MutableMapping
6
- from dataclasses import dataclass
7
- import datetime as dt
8
- from functools import lru_cache
9
- from typing import Annotated, Final, get_args
10
-
11
- from pyplumio.const import STATE_OFF, STATE_ON, FrameType, State
12
- from pyplumio.devices import PhysicalDevice
13
- from pyplumio.frames import Request
14
- from pyplumio.structures.schedules import collect_schedule_data
15
-
16
- TIME_FORMAT: Final = "%H:%M"
17
-
18
-
19
- Time = Annotated[str, "Time string in %H:%M format"]
20
-
21
- MIDNIGHT: Final = Time("00:00")
22
- MIDNIGHT_DT = dt.datetime.strptime(MIDNIGHT, TIME_FORMAT)
23
-
24
- STEP = dt.timedelta(minutes=30)
25
-
26
-
27
- def get_time(
28
- index: int, start: dt.datetime = MIDNIGHT_DT, step: dt.timedelta = STEP
29
- ) -> Time:
30
- """Return time for a specific index."""
31
- time_dt = start + (step * index)
32
- return time_dt.strftime(TIME_FORMAT)
33
-
34
-
35
- @lru_cache(maxsize=10)
36
- def get_time_range(start: Time, end: Time, step: dt.timedelta = STEP) -> list[Time]:
37
- """Get a time range.
38
-
39
- Start and end boundaries should be specified in %H:%M format.
40
- Both are inclusive.
41
- """
42
- start_dt = dt.datetime.strptime(start, TIME_FORMAT)
43
- end_dt = dt.datetime.strptime(end, TIME_FORMAT)
44
-
45
- if end_dt == MIDNIGHT_DT:
46
- # Upper boundary of the interval is midnight.
47
- end_dt += dt.timedelta(hours=24) - step
48
-
49
- if end_dt <= start_dt:
50
- raise ValueError(
51
- f"Invalid time range: start time ({start}) must be earlier "
52
- f"than end time ({end})."
53
- )
54
-
55
- seconds = (end_dt - start_dt).total_seconds()
56
- steps = seconds // step.total_seconds() + 1
57
-
58
- return [get_time(index, start=start_dt, step=step) for index in range(int(steps))]
59
-
60
-
61
- class ScheduleDay(MutableMapping):
62
- """Represents a single day of schedule."""
63
-
64
- __slots__ = ("_schedule",)
65
-
66
- _schedule: dict[Time, bool]
67
-
68
- def __init__(self, schedule: dict[Time, bool]) -> None:
69
- """Initialize a new schedule day."""
70
- self._schedule = schedule
71
-
72
- def __repr__(self) -> str:
73
- """Return serializable representation of the class."""
74
- return f"ScheduleDay({self._schedule})"
75
-
76
- def __len__(self) -> int:
77
- """Return a schedule length."""
78
- return self._schedule.__len__()
79
-
80
- def __iter__(self) -> Iterator[Time]:
81
- """Return an iterator."""
82
- return self._schedule.__iter__()
83
-
84
- def __getitem__(self, time: Time) -> State:
85
- """Return a schedule item."""
86
- state = self._schedule.__getitem__(time)
87
- return STATE_ON if state else STATE_OFF
88
-
89
- def __delitem__(self, time: Time) -> None:
90
- """Delete a schedule item."""
91
- self._schedule.__delitem__(time)
92
-
93
- def __setitem__(self, time: Time, state: State | bool) -> None:
94
- """Set a schedule item."""
95
- if state in get_args(State):
96
- state = True if state == STATE_ON else False
97
- if isinstance(state, bool):
98
- self._schedule.__setitem__(time, state)
99
- else:
100
- raise TypeError(
101
- f"Expected boolean value or one of: {', '.join(get_args(State))}."
102
- )
103
-
104
- def set_state(
105
- self, state: State | bool, start: Time = MIDNIGHT, end: Time = MIDNIGHT
106
- ) -> None:
107
- """Set a schedule interval state."""
108
- for time in get_time_range(start, end):
109
- self.__setitem__(time, state)
110
-
111
- def set_on(self, start: Time = MIDNIGHT, end: Time = MIDNIGHT) -> None:
112
- """Set a schedule interval state to 'on'."""
113
- self.set_state(STATE_ON, start, end)
114
-
115
- def set_off(self, start: Time = MIDNIGHT, end: Time = MIDNIGHT) -> None:
116
- """Set a schedule interval state to 'off'."""
117
- self.set_state(STATE_OFF, start, end)
118
-
119
- @property
120
- def schedule(self) -> dict[Time, bool]:
121
- """Return the schedule."""
122
- return self._schedule
123
-
124
- @classmethod
125
- def from_iterable(cls: type[ScheduleDay], intervals: Iterable[bool]) -> ScheduleDay:
126
- """Make schedule day from iterable."""
127
- return cls({get_time(index): state for index, state in enumerate(intervals)})
128
-
129
-
130
- @dataclass
131
- class Schedule(Iterable):
132
- """Represents a weekly schedule."""
133
-
134
- __slots__ = (
135
- "name",
136
- "device",
137
- "sunday",
138
- "monday",
139
- "tuesday",
140
- "wednesday",
141
- "thursday",
142
- "friday",
143
- "saturday",
144
- )
145
-
146
- name: str
147
- device: PhysicalDevice
148
-
149
- sunday: ScheduleDay
150
- monday: ScheduleDay
151
- tuesday: ScheduleDay
152
- wednesday: ScheduleDay
153
- thursday: ScheduleDay
154
- friday: ScheduleDay
155
- saturday: ScheduleDay
156
-
157
- def __iter__(self) -> Iterator[ScheduleDay]:
158
- """Return list of days."""
159
- return (
160
- self.sunday,
161
- self.monday,
162
- self.tuesday,
163
- self.wednesday,
164
- self.thursday,
165
- self.friday,
166
- self.saturday,
167
- ).__iter__()
168
-
169
- async def commit(self) -> None:
170
- """Commit a weekly schedule to the device."""
171
- await self.device.queue.put(
172
- await Request.create(
173
- FrameType.REQUEST_SET_SCHEDULE,
174
- recipient=self.device.address,
175
- data=collect_schedule_data(self.name, self.device),
176
- )
177
- )
178
-
179
-
180
- __all__ = ["Schedule", "ScheduleDay"]
pyplumio/helpers/uid.py DELETED
@@ -1,44 +0,0 @@
1
- """Contains UID helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- from functools import reduce
6
- from typing import Final
7
-
8
- CRC: Final = 0xA3A3
9
- POLYNOMIAL: Final = 0xA001
10
- BASE5_KEY: Final = "0123456789ABCDEFGHIJKLMNZPQRSTUV"
11
-
12
-
13
- def unpack_uid(buffer: bytes) -> str:
14
- """Unpack UID from bytes."""
15
- return base5(buffer + crc16(buffer))
16
-
17
-
18
- def base5(buffer: bytes) -> str:
19
- """Encode bytes to a base5 encoded string."""
20
- number = int.from_bytes(buffer, byteorder="little")
21
- output = []
22
- while number:
23
- output.append(BASE5_KEY[number & 0b00011111])
24
- number >>= 5
25
-
26
- return "".join(reversed(output))
27
-
28
-
29
- def crc16(buffer: bytes) -> bytes:
30
- """Return a CRC 16."""
31
- crc16 = reduce(crc16_byte, buffer, CRC)
32
- return crc16.to_bytes(length=2, byteorder="little")
33
-
34
-
35
- def crc16_byte(crc: int, byte: int) -> int:
36
- """Add a byte to the CRC."""
37
- crc ^= byte
38
- for _ in range(8):
39
- crc = (crc >> 1) ^ POLYNOMIAL if crc & 1 else crc >> 1
40
-
41
- return crc
42
-
43
-
44
- __all__ = ["unpack_uid"]