PyPlumIO 0.5.50__py3-none-any.whl → 0.5.51.post1__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.51.post1'
21
+ __version_tuple__ = version_tuple = (0, 5, 51, 'post1')
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)
@@ -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
 
@@ -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
@@ -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 time_dt.strftime(TIME_FORMAT)
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.51.post1
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,7 +25,7 @@ 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<5.0,>=4.14.0
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"
@@ -35,8 +35,8 @@ Requires-Dist: numpy<3.0.0,>=2.0.0; extra == "test"
35
35
  Requires-Dist: pyserial-asyncio-fast==0.16; extra == "test"
36
36
  Requires-Dist: pytest==8.3.5; extra == "test"
37
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"
38
+ Requires-Dist: ruff==0.11.10; 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,17 +1,17 @@
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=nacA7xVAM3pSgJxkdGVC41eusaq7HcO6tUp1yOrJlZY,528
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
11
  pyplumio/stream.py,sha256=iCB-XlNXRRE0p7nstb1AkXwVDwcCsgym_ggB_IDiJc8,4926
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
@@ -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=bhfFEICdwdNpIaP584cdDgfDA29s6knXMJAnu3tj_EQ,3688
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=po-LFc3-Na6Rz9786fjBd9Gy3EDC1h6FKEmIM927ky8,12039
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.51.post1.dist-info/licenses/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
61
+ pyplumio-0.5.51.post1.dist-info/METADATA,sha256=dDkVJmjdOWp3XDg_ybZ_zoIYLzYasSXStmvy_IhdP0E,5623
62
+ pyplumio-0.5.51.post1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
63
+ pyplumio-0.5.51.post1.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
64
+ pyplumio-0.5.51.post1.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"]