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 +2 -2
- pyplumio/const.py +0 -7
- pyplumio/devices/__init__.py +4 -4
- pyplumio/devices/ecomax.py +6 -11
- pyplumio/filters.py +1 -1
- pyplumio/parameters/__init__.py +65 -35
- pyplumio/parameters/custom/__init__.py +1 -3
- pyplumio/parameters/custom/ecomax_860d3_hb.py +0 -2
- pyplumio/protocol.py +2 -2
- pyplumio/structures/alerts.py +1 -1
- pyplumio/structures/lambda_sensor.py +1 -4
- pyplumio/structures/product_info.py +37 -2
- pyplumio/structures/schedules.py +172 -3
- {pyplumio-0.5.50.dist-info → pyplumio-0.5.51.post1.dist-info}/METADATA +4 -4
- {pyplumio-0.5.50.dist-info → pyplumio-0.5.51.post1.dist-info}/RECORD +18 -20
- {pyplumio-0.5.50.dist-info → pyplumio-0.5.51.post1.dist-info}/WHEEL +1 -1
- pyplumio/helpers/schedule.py +0 -180
- pyplumio/helpers/uid.py +0 -44
- {pyplumio-0.5.50.dist-info → pyplumio-0.5.51.post1.dist-info}/licenses/LICENSE +0 -0
- {pyplumio-0.5.50.dist-info → pyplumio-0.5.51.post1.dist-info}/top_level.txt +0 -0
pyplumio/_version.py
CHANGED
@@ -17,5 +17,5 @@ __version__: str
|
|
17
17
|
__version_tuple__: VERSION_TUPLE
|
18
18
|
version_tuple: VERSION_TUPLE
|
19
19
|
|
20
|
-
__version__ = version = '0.5.
|
21
|
-
__version_tuple__ = version_tuple = (0, 5,
|
20
|
+
__version__ = version = '0.5.51.post1'
|
21
|
+
__version_tuple__ = version_tuple = (0, 5, 51, 'post1')
|
pyplumio/const.py
CHANGED
pyplumio/devices/__init__.py
CHANGED
@@ -64,7 +64,7 @@ class Device(ABC, EventManager):
|
|
64
64
|
self,
|
65
65
|
name: str,
|
66
66
|
value: NumericType | State | bool,
|
67
|
-
retries: int =
|
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
|
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 =
|
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
|
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,
|
pyplumio/devices/ecomax.py
CHANGED
@@ -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.
|
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)
|
pyplumio/parameters/__init__.py
CHANGED
@@ -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__ = (
|
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
|
-
|
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 =
|
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 =
|
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
|
200
|
-
# No retries
|
207
|
+
if self.description.optimistic:
|
201
208
|
await self.device.queue.put(request)
|
202
209
|
return True
|
203
210
|
|
204
|
-
self.
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
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
|
-
|
215
|
-
await asyncio.sleep(timeout)
|
216
|
-
retries -= 1
|
218
|
+
return await self._send_update_request(request, timeout=timeout)
|
217
219
|
|
218
|
-
|
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.
|
246
|
+
self.update_done.set()
|
247
|
+
self.update_pending.clear()
|
223
248
|
self._values = values
|
224
249
|
|
225
250
|
@property
|
226
|
-
def
|
227
|
-
"""Check if parameter is
|
228
|
-
return self.
|
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 =
|
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 =
|
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 =
|
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 =
|
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
|
-
|
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(
|
241
|
+
await self.dispatch(name, device)
|
242
242
|
|
243
243
|
return self.data[name]
|
244
244
|
|
pyplumio/structures/alerts.py
CHANGED
@@ -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
|
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."""
|
pyplumio/structures/schedules.py
CHANGED
@@ -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
|
-
|
8
|
-
from
|
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.
|
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
|
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.
|
39
|
-
Requires-Dist: tox==4.
|
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=
|
3
|
+
pyplumio/_version.py,sha256=nacA7xVAM3pSgJxkdGVC41eusaq7HcO6tUp1yOrJlZY,528
|
4
4
|
pyplumio/connection.py,sha256=9MCPb8W62uqCrzd1YCROcn9cCjRY8E65934FnJDF5Js,5902
|
5
|
-
pyplumio/const.py,sha256=
|
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=
|
9
|
-
pyplumio/protocol.py,sha256=
|
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=
|
14
|
-
pyplumio/devices/ecomax.py,sha256=
|
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/
|
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=
|
35
|
-
pyplumio/parameters/custom/ecomax_860d3_hb.py,sha256=
|
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=
|
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=
|
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=
|
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=
|
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.
|
63
|
-
pyplumio-0.5.
|
64
|
-
pyplumio-0.5.
|
65
|
-
pyplumio-0.5.
|
66
|
-
pyplumio-0.5.
|
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,,
|
pyplumio/helpers/schedule.py
DELETED
@@ -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"]
|
File without changes
|
File without changes
|