PyPlumIO 0.5.49__py3-none-any.whl → 0.5.51__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 +2 -7
- pyplumio/devices/__init__.py +12 -7
- pyplumio/devices/ecomax.py +15 -20
- pyplumio/devices/mixer.py +2 -2
- pyplumio/devices/thermostat.py +2 -2
- 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 +10 -13
- 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.49.dist-info → pyplumio-0.5.51.dist-info}/METADATA +3 -3
- {pyplumio-0.5.49.dist-info → pyplumio-0.5.51.dist-info}/RECORD +20 -22
- {pyplumio-0.5.49.dist-info → pyplumio-0.5.51.dist-info}/WHEEL +1 -1
- pyplumio/helpers/schedule.py +0 -180
- pyplumio/helpers/uid.py +0 -44
- {pyplumio-0.5.49.dist-info → pyplumio-0.5.51.dist-info}/licenses/LICENSE +0 -0
- {pyplumio-0.5.49.dist-info → pyplumio-0.5.51.dist-info}/top_level.txt +0 -0
pyplumio/_version.py
CHANGED
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."""
|
@@ -184,6 +177,7 @@ class FrameType(IntEnum):
|
|
184
177
|
REQUEST_SET_MIXER_PARAMETER = 52
|
185
178
|
REQUEST_SCHEDULES = 54
|
186
179
|
REQUEST_SET_SCHEDULE = 55
|
180
|
+
REQUEST_ECOMAX_PARAMETER_CHANGES = 56
|
187
181
|
REQUEST_UID = 57
|
188
182
|
REQUEST_PASSWORD = 58
|
189
183
|
REQUEST_ECOMAX_CONTROL = 59
|
@@ -203,6 +197,7 @@ class FrameType(IntEnum):
|
|
203
197
|
RESPONSE_ECOMAX_CONTROL = 187
|
204
198
|
RESPONSE_ALERTS = 189
|
205
199
|
RESPONSE_PROGRAM_VERSION = 192
|
200
|
+
RESPONSE_ECOMAX_PARAMETER_CHANGES = 212
|
206
201
|
RESPONSE_REGULATOR_DATA_SCHEMA = 213
|
207
202
|
RESPONSE_THERMOSTAT_PARAMETERS = 220
|
208
203
|
RESPONSE_SET_THERMOSTAT_PARAMETER = 221
|
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,
|
@@ -145,21 +145,26 @@ class PhysicalDevice(Device, ABC):
|
|
145
145
|
@event_listener(filter=on_change)
|
146
146
|
async def on_event_frame_versions(self, versions: dict[int, int]) -> None:
|
147
147
|
"""Check frame versions and update outdated frames."""
|
148
|
-
_LOGGER.
|
148
|
+
_LOGGER.debug("Received frame version table")
|
149
149
|
for frame_type, version in versions.items():
|
150
150
|
if (
|
151
151
|
is_known_frame_type(frame_type)
|
152
152
|
and self.supports_frame_type(frame_type)
|
153
153
|
and not self.has_frame_version(frame_type, version)
|
154
154
|
):
|
155
|
-
|
155
|
+
request_frame_type = (
|
156
|
+
FrameType.REQUEST_ECOMAX_PARAMETERS
|
157
|
+
if frame_type == FrameType.REQUEST_ECOMAX_PARAMETER_CHANGES
|
158
|
+
else frame_type
|
159
|
+
)
|
160
|
+
await self._request_frame_version(request_frame_type, version)
|
156
161
|
self._frame_versions[frame_type] = version
|
157
162
|
|
158
163
|
async def _request_frame_version(
|
159
164
|
self, frame_type: FrameType | int, version: int
|
160
165
|
) -> None:
|
161
166
|
"""Request frame version from the device."""
|
162
|
-
_LOGGER.
|
167
|
+
_LOGGER.debug("Updating frame %s to version %i", repr(frame_type), version)
|
163
168
|
request = await Request.create(frame_type, recipient=self.address)
|
164
169
|
self.queue.put_nowait(request)
|
165
170
|
|
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,
|
@@ -234,7 +235,7 @@ class EcoMAX(PhysicalDevice):
|
|
234
235
|
@event_listener
|
235
236
|
async def on_event_setup(self, setup: bool) -> None:
|
236
237
|
"""Request frames required to set up an ecoMAX entry."""
|
237
|
-
_LOGGER.
|
238
|
+
_LOGGER.debug("Setting up device entry")
|
238
239
|
await self.wait_for(ATTR_SENSORS)
|
239
240
|
results = await asyncio.gather(
|
240
241
|
*(
|
@@ -251,14 +252,14 @@ class EcoMAX(PhysicalDevice):
|
|
251
252
|
if errors:
|
252
253
|
self.dispatch_nowait(ATTR_FRAME_ERRORS, errors)
|
253
254
|
|
254
|
-
_LOGGER.
|
255
|
+
_LOGGER.debug("Device entry setup done")
|
255
256
|
|
256
257
|
@event_listener
|
257
258
|
async def on_event_ecomax_parameters(
|
258
259
|
self, parameters: list[tuple[int, ParameterValues]]
|
259
260
|
) -> bool:
|
260
261
|
"""Update ecoMAX parameters and dispatch the events."""
|
261
|
-
_LOGGER.
|
262
|
+
_LOGGER.debug("Received device parameters")
|
262
263
|
product_info: ProductInfo = await self.get(ATTR_PRODUCT)
|
263
264
|
parameter_types = await get_ecomax_parameter_types(product_info)
|
264
265
|
|
@@ -303,11 +304,10 @@ 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
|
-
_LOGGER.
|
310
|
+
_LOGGER.debug("Received mixer parameters")
|
311
311
|
if parameters:
|
312
312
|
await asyncio.gather(
|
313
313
|
*(
|
@@ -320,11 +320,9 @@ 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
|
-
_LOGGER.
|
325
|
+
_LOGGER.debug("Received mixer sensors")
|
328
326
|
if sensors:
|
329
327
|
await asyncio.gather(
|
330
328
|
*(
|
@@ -364,7 +362,7 @@ class EcoMAX(PhysicalDevice):
|
|
364
362
|
@event_listener
|
365
363
|
async def on_event_sensors(self, sensors: dict[str, Any]) -> bool:
|
366
364
|
"""Update ecoMAX sensors and dispatch the events."""
|
367
|
-
_LOGGER.
|
365
|
+
_LOGGER.debug("Received device sensors")
|
368
366
|
await asyncio.gather(
|
369
367
|
*(self.dispatch(name, value) for name, value in sensors.items())
|
370
368
|
)
|
@@ -372,11 +370,10 @@ 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
|
-
_LOGGER.
|
376
|
+
_LOGGER.debug("Received thermostat parameters")
|
380
377
|
if parameters:
|
381
378
|
await asyncio.gather(
|
382
379
|
*(
|
@@ -403,11 +400,9 @@ 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
|
-
_LOGGER.
|
405
|
+
_LOGGER.debug("Received thermostat sensors")
|
411
406
|
if sensors:
|
412
407
|
await asyncio.gather(
|
413
408
|
*(
|
@@ -427,7 +422,7 @@ class EcoMAX(PhysicalDevice):
|
|
427
422
|
self, schedules: list[tuple[int, list[list[bool]]]]
|
428
423
|
) -> dict[str, Schedule]:
|
429
424
|
"""Update schedules."""
|
430
|
-
_LOGGER.
|
425
|
+
_LOGGER.debug("Received device schedules")
|
431
426
|
return {
|
432
427
|
SCHEDULES[index]: Schedule(
|
433
428
|
name=SCHEDULES[index],
|
pyplumio/devices/mixer.py
CHANGED
@@ -29,7 +29,7 @@ class Mixer(VirtualDevice):
|
|
29
29
|
@event_listener
|
30
30
|
async def on_event_mixer_sensors(self, sensors: dict[str, Any]) -> bool:
|
31
31
|
"""Update mixer sensors and dispatch the events."""
|
32
|
-
_LOGGER.
|
32
|
+
_LOGGER.debug("Received mixer %i sensors", self.index)
|
33
33
|
await asyncio.gather(
|
34
34
|
*(self.dispatch(name, value) for name, value in sensors.items())
|
35
35
|
)
|
@@ -40,7 +40,7 @@ class Mixer(VirtualDevice):
|
|
40
40
|
self, parameters: list[tuple[int, ParameterValues]]
|
41
41
|
) -> bool:
|
42
42
|
"""Update mixer parameters and dispatch the events."""
|
43
|
-
_LOGGER.
|
43
|
+
_LOGGER.debug("Received mixer %i parameters", self.index)
|
44
44
|
product_info: ProductInfo = await self.parent.get(ATTR_PRODUCT)
|
45
45
|
parameter_types = get_mixer_parameter_types(product_info)
|
46
46
|
|
pyplumio/devices/thermostat.py
CHANGED
@@ -28,7 +28,7 @@ class Thermostat(VirtualDevice):
|
|
28
28
|
@event_listener
|
29
29
|
async def on_event_thermostat_sensors(self, sensors: dict[str, Any]) -> bool:
|
30
30
|
"""Update thermostat sensors and dispatch the events."""
|
31
|
-
_LOGGER.
|
31
|
+
_LOGGER.debug("Received thermostat %i sensors", self.index)
|
32
32
|
await asyncio.gather(
|
33
33
|
*(self.dispatch(name, value) for name, value in sensors.items())
|
34
34
|
)
|
@@ -39,7 +39,7 @@ class Thermostat(VirtualDevice):
|
|
39
39
|
self, parameters: list[tuple[int, ParameterValues]]
|
40
40
|
) -> bool:
|
41
41
|
"""Update thermostat parameters and dispatch the events."""
|
42
|
-
_LOGGER.
|
42
|
+
_LOGGER.debug("Received thermostat %i parameters", self.index)
|
43
43
|
parameter_types = get_thermostat_parameter_types()
|
44
44
|
|
45
45
|
def _thermostat_parameter_events() -> Generator[Coroutine]:
|
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,20 +230,17 @@ 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
|
-
|
234
|
-
@acache
|
235
|
-
async def _setup_device_entry(device_type: DeviceType) -> PhysicalDevice:
|
236
|
-
"""Set up the device entry."""
|
237
|
-
device = await PhysicalDevice.create(
|
238
|
-
device_type, queue=self._queues.write, network=self._network
|
239
|
-
)
|
240
|
-
device.dispatch_nowait(ATTR_CONNECTED, True)
|
241
|
-
device.dispatch_nowait(ATTR_SETUP, True)
|
242
|
-
self.dispatch_nowait(device_type.name.lower(), device)
|
243
|
-
return device
|
244
|
-
|
233
|
+
name = device_type.name.lower()
|
245
234
|
async with self._entry_lock:
|
246
|
-
|
235
|
+
if name not in self.data:
|
236
|
+
device = await PhysicalDevice.create(
|
237
|
+
device_type, queue=self._queues.write, network=self._network
|
238
|
+
)
|
239
|
+
device.dispatch_nowait(ATTR_CONNECTED, True)
|
240
|
+
device.dispatch_nowait(ATTR_SETUP, True)
|
241
|
+
await self.dispatch(name, device)
|
242
|
+
|
243
|
+
return self.data[name]
|
247
244
|
|
248
245
|
|
249
246
|
__all__ = ["Protocol", "DummyProtocol", "AsyncProtocol"]
|
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
|
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
|
@@ -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,20 +1,20 @@
|
|
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=GxxIjlMXqyiJ35TfmryleP4-ULoCRkGKYVM-gOVMu_0,513
|
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
|
-
pyplumio/devices/mixer.py,sha256=
|
17
|
-
pyplumio/devices/thermostat.py,sha256=
|
16
|
+
pyplumio/devices/mixer.py,sha256=7WdUVgwO4VXmaPNzh3ZWpKr2ooRXWemz2KFHAw35_Rk,2731
|
17
|
+
pyplumio/devices/thermostat.py,sha256=MHMKe45fQ7jKlhBVObJ7McbYQKuF6-LOKSHy-9VNsCU,2253
|
18
18
|
pyplumio/frames/__init__.py,sha256=5lw19oFlN89ZvO8KGwnkwERULQNYiP-hhZKk65LsjYY,7862
|
19
19
|
pyplumio/frames/messages.py,sha256=ImQGWFFTa2eaXfytQmFZKC-IxyPRkxD8qp0bEm16-ws,3628
|
20
20
|
pyplumio/frames/requests.py,sha256=jr-_XSSCCDDTbAmrw95CKyWa5nb7JNeGzZ2jDXIxlAo,7348
|
@@ -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.dist-info/licenses/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
|
61
|
+
pyplumio-0.5.51.dist-info/METADATA,sha256=X9tX4ay9diGk9nUkp-MhZIVc5Ldc6F1FbvSWDNE985k,5612
|
62
|
+
pyplumio-0.5.51.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
|
63
|
+
pyplumio-0.5.51.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
|
64
|
+
pyplumio-0.5.51.dist-info/RECORD,,
|
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
|