PyPlumIO 0.5.20__py3-none-any.whl → 0.5.22__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-0.5.20.dist-info → PyPlumIO-0.5.22.dist-info}/METADATA +8 -8
- PyPlumIO-0.5.22.dist-info/RECORD +60 -0
- {PyPlumIO-0.5.20.dist-info → PyPlumIO-0.5.22.dist-info}/WHEEL +1 -1
- pyplumio/__init__.py +2 -2
- pyplumio/_version.py +2 -2
- pyplumio/connection.py +2 -10
- pyplumio/devices/__init__.py +14 -14
- pyplumio/devices/ecomax.py +136 -126
- pyplumio/devices/mixer.py +49 -42
- pyplumio/devices/thermostat.py +35 -33
- pyplumio/exceptions.py +9 -9
- pyplumio/filters.py +56 -37
- pyplumio/frames/__init__.py +6 -6
- pyplumio/frames/messages.py +4 -6
- pyplumio/helpers/data_types.py +8 -7
- pyplumio/helpers/event_manager.py +43 -32
- pyplumio/helpers/parameter.py +43 -16
- pyplumio/helpers/task_manager.py +9 -5
- pyplumio/helpers/timeout.py +0 -3
- pyplumio/helpers/uid.py +2 -2
- pyplumio/protocol.py +34 -33
- pyplumio/stream.py +2 -2
- pyplumio/structures/alerts.py +40 -31
- pyplumio/structures/ecomax_parameters.py +321 -133
- pyplumio/structures/frame_versions.py +5 -6
- pyplumio/structures/lambda_sensor.py +6 -6
- pyplumio/structures/mixer_parameters.py +74 -28
- pyplumio/structures/network_info.py +5 -6
- pyplumio/structures/product_info.py +0 -4
- pyplumio/structures/program_version.py +24 -17
- pyplumio/structures/thermostat_parameters.py +25 -12
- pyplumio/utils.py +12 -7
- PyPlumIO-0.5.20.dist-info/RECORD +0 -61
- pyplumio/helpers/typing.py +0 -29
- {PyPlumIO-0.5.20.dist-info → PyPlumIO-0.5.22.dist-info}/LICENSE +0 -0
- {PyPlumIO-0.5.20.dist-info → PyPlumIO-0.5.22.dist-info}/top_level.txt +0 -0
@@ -3,18 +3,23 @@
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
5
|
import asyncio
|
6
|
-
from collections.abc import
|
7
|
-
from typing import Any
|
6
|
+
from collections.abc import Callable, Coroutine
|
7
|
+
from typing import Any, TypeVar
|
8
8
|
|
9
9
|
from pyplumio.helpers.task_manager import TaskManager
|
10
10
|
|
11
|
+
Callback = Callable[[Any], Coroutine[Any, Any, Any]]
|
12
|
+
CallbackT = TypeVar("CallbackT", bound=Callback)
|
13
|
+
|
11
14
|
|
12
15
|
class EventManager(TaskManager):
|
13
16
|
"""Represents an event manager."""
|
14
17
|
|
18
|
+
__slots__ = ("data", "_events", "_callbacks")
|
19
|
+
|
15
20
|
data: dict[str, Any]
|
16
21
|
_events: dict[str, asyncio.Event]
|
17
|
-
_callbacks: dict[str, list[
|
22
|
+
_callbacks: dict[str, list[Callback]]
|
18
23
|
|
19
24
|
def __init__(self) -> None:
|
20
25
|
"""Initialize a new event manager."""
|
@@ -75,23 +80,23 @@ class EventManager(TaskManager):
|
|
75
80
|
except KeyError:
|
76
81
|
return default
|
77
82
|
|
78
|
-
def subscribe(self, name: str, callback:
|
83
|
+
def subscribe(self, name: str, callback: CallbackT) -> CallbackT:
|
79
84
|
"""Subscribe a callback to the event.
|
80
85
|
|
81
86
|
:param name: Event name or ID
|
82
87
|
:type name: str
|
83
88
|
:param callback: A coroutine callback function, that will be
|
84
89
|
awaited on the with the event data as an argument.
|
85
|
-
:type callback:
|
90
|
+
:type callback: Callback
|
91
|
+
:return: A reference to the callback, that can be used
|
92
|
+
with `EventManager.unsubscribe()`.
|
93
|
+
:rtype: Callback
|
86
94
|
"""
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
self._callbacks[name].append(callback)
|
95
|
+
callbacks = self._callbacks.setdefault(name, [])
|
96
|
+
callbacks.append(callback)
|
97
|
+
return callback
|
91
98
|
|
92
|
-
def subscribe_once(
|
93
|
-
self, name: str, callback: Callable[[Any], Awaitable[Any]]
|
94
|
-
) -> None:
|
99
|
+
def subscribe_once(self, name: str, callback: Callback) -> Callback:
|
95
100
|
"""Subscribe a callback to the event once.
|
96
101
|
|
97
102
|
Callback will be unsubscribed after single event.
|
@@ -100,17 +105,20 @@ class EventManager(TaskManager):
|
|
100
105
|
:type name: str
|
101
106
|
:param callback: A coroutine callback function, that will be
|
102
107
|
awaited on the with the event data as an argument.
|
103
|
-
:type callback:
|
108
|
+
:type callback: Callback
|
109
|
+
:return: A reference to the callback, that can be used
|
110
|
+
with `EventManager.unsubscribe()`.
|
111
|
+
:rtype: Callback
|
104
112
|
"""
|
105
113
|
|
106
|
-
async def
|
114
|
+
async def _call_once(value: Any) -> Any:
|
107
115
|
"""Unsubscribe callback from the event and calls it."""
|
108
|
-
self.unsubscribe(name,
|
116
|
+
self.unsubscribe(name, _call_once)
|
109
117
|
return await callback(value)
|
110
118
|
|
111
|
-
self.subscribe(name,
|
119
|
+
return self.subscribe(name, _call_once)
|
112
120
|
|
113
|
-
def unsubscribe(self, name: str, callback:
|
121
|
+
def unsubscribe(self, name: str, callback: Callback) -> bool:
|
114
122
|
"""Usubscribe a callback from the event.
|
115
123
|
|
116
124
|
:param name: Event name or ID
|
@@ -118,18 +126,22 @@ class EventManager(TaskManager):
|
|
118
126
|
:param callback: A coroutine callback function, previously
|
119
127
|
subscribed to an event using ``subscribe()`` or
|
120
128
|
``subscribe_once()`` methods.
|
121
|
-
:type callback:
|
129
|
+
:type callback: Callback
|
130
|
+
:return: `True` if callback is found, `False` otherwise.
|
131
|
+
:rtype: bool
|
122
132
|
"""
|
123
133
|
if name in self._callbacks and callback in self._callbacks[name]:
|
124
134
|
self._callbacks[name].remove(callback)
|
135
|
+
return True
|
136
|
+
|
137
|
+
return False
|
125
138
|
|
126
139
|
async def dispatch(self, name: str, value: Any) -> None:
|
127
140
|
"""Call registered callbacks and dispatch the event."""
|
128
|
-
if
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
value = return_value if return_value is not None else value
|
141
|
+
if callbacks := self._callbacks.get(name, None):
|
142
|
+
for callback in list(callbacks):
|
143
|
+
result = await callback(value)
|
144
|
+
value = result if result is not None else value
|
133
145
|
|
134
146
|
self.data[name] = value
|
135
147
|
self.set_event(name)
|
@@ -138,16 +150,15 @@ class EventManager(TaskManager):
|
|
138
150
|
"""Call a registered callbacks and dispatch the event without waiting."""
|
139
151
|
self.create_task(self.dispatch(name, value))
|
140
152
|
|
141
|
-
def load(self, data: dict[str, Any]) -> None:
|
142
|
-
"""Load
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
for key, value in data.items():
|
147
|
-
await self.dispatch(key, value)
|
153
|
+
async def load(self, data: dict[str, Any]) -> None:
|
154
|
+
"""Load event data."""
|
155
|
+
await asyncio.gather(
|
156
|
+
*(self.dispatch(name, value) for name, value in data.items())
|
157
|
+
)
|
148
158
|
|
149
|
-
|
150
|
-
|
159
|
+
def load_nowait(self, data: dict[str, Any]) -> None:
|
160
|
+
"""Load event data without waiting."""
|
161
|
+
self.create_task(self.load(data))
|
151
162
|
|
152
163
|
def create_event(self, name: str) -> asyncio.Event:
|
153
164
|
"""Create an event."""
|
pyplumio/helpers/parameter.py
CHANGED
@@ -6,11 +6,10 @@ from abc import ABC
|
|
6
6
|
import asyncio
|
7
7
|
from dataclasses import dataclass
|
8
8
|
import logging
|
9
|
-
from typing import TYPE_CHECKING, Any, Final, Literal
|
9
|
+
from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, Union
|
10
10
|
|
11
11
|
from pyplumio.const import BYTE_UNDEFINED, STATE_OFF, STATE_ON, UnitOfMeasurement
|
12
12
|
from pyplumio.frames import Request
|
13
|
-
from pyplumio.helpers.typing import ParameterValueType
|
14
13
|
|
15
14
|
if TYPE_CHECKING:
|
16
15
|
from pyplumio.devices import Device
|
@@ -20,6 +19,8 @@ _LOGGER = logging.getLogger(__name__)
|
|
20
19
|
SET_TIMEOUT: Final = 5
|
21
20
|
SET_RETRIES: Final = 5
|
22
21
|
|
22
|
+
ParameterValueType = Union[int, float, bool, Literal["off"], Literal["on"]]
|
23
|
+
|
23
24
|
|
24
25
|
def unpack_parameter(
|
25
26
|
data: bytearray, offset: int = 0, size: int = 1
|
@@ -82,42 +83,42 @@ class BinaryParameterDescription(ParameterDescription, ABC):
|
|
82
83
|
class Parameter(ABC):
|
83
84
|
"""Represents a parameter."""
|
84
85
|
|
85
|
-
__slots__ = ("device", "
|
86
|
+
__slots__ = ("device", "description", "_pending_update", "_index", "_values")
|
86
87
|
|
87
88
|
device: Device
|
88
|
-
values: ParameterValues
|
89
89
|
description: ParameterDescription
|
90
90
|
_pending_update: bool
|
91
91
|
_index: int
|
92
|
+
_values: ParameterValues
|
92
93
|
|
93
94
|
def __init__(
|
94
95
|
self,
|
95
96
|
device: Device,
|
96
|
-
values: ParameterValues,
|
97
97
|
description: ParameterDescription,
|
98
|
+
values: ParameterValues | None = None,
|
98
99
|
index: int = 0,
|
99
100
|
):
|
100
101
|
"""Initialize a new parameter."""
|
101
102
|
self.device = device
|
102
|
-
self.values = values
|
103
103
|
self.description = description
|
104
104
|
self._pending_update = False
|
105
105
|
self._index = index
|
106
|
+
self._values = values if values else ParameterValues(0, 0, 0)
|
106
107
|
|
107
108
|
def __repr__(self) -> str:
|
108
109
|
"""Return a serializable string representation."""
|
109
110
|
return (
|
110
111
|
f"{self.__class__.__name__}("
|
111
112
|
f"device={self.device.__class__.__name__}, "
|
112
|
-
f"values={self.values}, "
|
113
113
|
f"description={self.description}, "
|
114
|
+
f"values={self.values}, "
|
114
115
|
f"index={self._index})"
|
115
116
|
)
|
116
117
|
|
117
118
|
def _call_relational_method(self, method_to_call: str, other: Any) -> Any:
|
118
119
|
"""Call a specified relational method."""
|
119
|
-
|
120
|
-
return
|
120
|
+
handler = getattr(self.values.value, method_to_call)
|
121
|
+
return handler(_normalize_parameter_value(other))
|
121
122
|
|
122
123
|
def __int__(self) -> int:
|
123
124
|
"""Return an integer representation of parameter's value."""
|
@@ -163,10 +164,6 @@ class Parameter(ABC):
|
|
163
164
|
"""Compare if parameter value is less that other."""
|
164
165
|
return self._call_relational_method("__lt__", other)
|
165
166
|
|
166
|
-
async def _confirm_update(self, parameter: Parameter) -> None:
|
167
|
-
"""Set parameter as no longer pending update."""
|
168
|
-
self._pending_update = False
|
169
|
-
|
170
167
|
async def create_request(self) -> Request:
|
171
168
|
"""Create a request to change the parameter."""
|
172
169
|
raise NotImplementedError
|
@@ -181,16 +178,14 @@ class Parameter(ABC):
|
|
181
178
|
f"Value must be between '{self.min_value}' and '{self.max_value}'"
|
182
179
|
)
|
183
180
|
|
184
|
-
self.
|
181
|
+
self._values.value = value
|
185
182
|
self._pending_update = True
|
186
|
-
self.device.subscribe_once(self.description.name, self._confirm_update)
|
187
183
|
while self.pending_update:
|
188
184
|
if retries <= 0:
|
189
185
|
_LOGGER.error(
|
190
186
|
"Timed out while trying to set '%s' parameter",
|
191
187
|
self.description.name,
|
192
188
|
)
|
193
|
-
self.device.unsubscribe(self.description.name, self._confirm_update)
|
194
189
|
return False
|
195
190
|
|
196
191
|
await self.device.queue.put(await self.create_request())
|
@@ -203,11 +198,21 @@ class Parameter(ABC):
|
|
203
198
|
"""Set a parameter value without waiting."""
|
204
199
|
self.device.create_task(self.set(value, retries))
|
205
200
|
|
201
|
+
def update(self, values: ParameterValues) -> None:
|
202
|
+
"""Update the parameter values."""
|
203
|
+
self._values = values
|
204
|
+
self._pending_update = False
|
205
|
+
|
206
206
|
@property
|
207
207
|
def pending_update(self) -> bool:
|
208
208
|
"""Check if parameter is pending update on the device."""
|
209
209
|
return self._pending_update
|
210
210
|
|
211
|
+
@property
|
212
|
+
def values(self) -> ParameterValues:
|
213
|
+
"""Return the parameter values."""
|
214
|
+
return self._values
|
215
|
+
|
211
216
|
@property
|
212
217
|
def value(self) -> ParameterValueType:
|
213
218
|
"""Return the parameter value."""
|
@@ -228,6 +233,28 @@ class Parameter(ABC):
|
|
228
233
|
"""Return the unit of measurement."""
|
229
234
|
return self.description.unit_of_measurement
|
230
235
|
|
236
|
+
@classmethod
|
237
|
+
def create_or_update(
|
238
|
+
cls: type[ParameterT],
|
239
|
+
device: Device,
|
240
|
+
description: ParameterDescription,
|
241
|
+
values: ParameterValues,
|
242
|
+
**kwargs: Any,
|
243
|
+
) -> ParameterT:
|
244
|
+
"""Create new parameter or update parameter values."""
|
245
|
+
parameter: ParameterT | None = device.get_nowait(description.name, None)
|
246
|
+
if parameter and isinstance(parameter, cls):
|
247
|
+
parameter.update(values)
|
248
|
+
else:
|
249
|
+
parameter = cls(
|
250
|
+
device=device, description=description, values=values, **kwargs
|
251
|
+
)
|
252
|
+
|
253
|
+
return parameter
|
254
|
+
|
255
|
+
|
256
|
+
ParameterT = TypeVar("ParameterT", bound=Parameter)
|
257
|
+
|
231
258
|
|
232
259
|
class BinaryParameter(Parameter):
|
233
260
|
"""Represents binary device parameter."""
|
pyplumio/helpers/task_manager.py
CHANGED
@@ -10,23 +10,27 @@ from typing import Any
|
|
10
10
|
class TaskManager:
|
11
11
|
"""Represents a task manager."""
|
12
12
|
|
13
|
+
__slots__ = ("_tasks",)
|
14
|
+
|
13
15
|
_tasks: set[asyncio.Task]
|
14
16
|
|
15
17
|
def __init__(self) -> None:
|
16
18
|
"""Initialize a new task manager."""
|
19
|
+
super().__init__()
|
17
20
|
self._tasks = set()
|
18
21
|
|
19
|
-
def create_task(
|
22
|
+
def create_task(
|
23
|
+
self, coro: Coroutine[Any, Any, Any], name: str | None = None
|
24
|
+
) -> asyncio.Task:
|
20
25
|
"""Create asyncio task and store a reference for it."""
|
21
|
-
task
|
26
|
+
task = asyncio.create_task(coro, name=name)
|
22
27
|
self._tasks.add(task)
|
23
28
|
task.add_done_callback(self._tasks.discard)
|
24
29
|
return task
|
25
30
|
|
26
|
-
def cancel_tasks(self) ->
|
31
|
+
def cancel_tasks(self) -> bool:
|
27
32
|
"""Cancel all tasks."""
|
28
|
-
for task in self._tasks
|
29
|
-
task.cancel()
|
33
|
+
return all(task.cancel() for task in self._tasks)
|
30
34
|
|
31
35
|
async def wait_until_done(self, return_exceptions: bool = True) -> None:
|
32
36
|
"""Wait for all tasks to complete."""
|
pyplumio/helpers/timeout.py
CHANGED
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
5
5
|
import asyncio
|
6
6
|
from collections.abc import Awaitable, Callable, Coroutine
|
7
7
|
from functools import wraps
|
8
|
-
import logging
|
9
8
|
from typing import Any, TypeVar
|
10
9
|
|
11
10
|
from typing_extensions import ParamSpec
|
@@ -13,8 +12,6 @@ from typing_extensions import ParamSpec
|
|
13
12
|
T = TypeVar("T")
|
14
13
|
P = ParamSpec("P")
|
15
14
|
|
16
|
-
_LOGGER = logging.getLogger(__name__)
|
17
|
-
|
18
15
|
|
19
16
|
def timeout(
|
20
17
|
seconds: int,
|
pyplumio/helpers/uid.py
CHANGED
@@ -7,6 +7,7 @@ from typing import Final
|
|
7
7
|
|
8
8
|
CRC: Final = 0xA3A3
|
9
9
|
POLYNOMIAL: Final = 0xA001
|
10
|
+
BASE5_KEY: Final = "0123456789ABCDEFGHIJKLMNZPQRSTUV"
|
10
11
|
|
11
12
|
|
12
13
|
def decode_uid(buffer: bytes) -> str:
|
@@ -16,11 +17,10 @@ def decode_uid(buffer: bytes) -> str:
|
|
16
17
|
|
17
18
|
def _base5(buffer: bytes) -> str:
|
18
19
|
"""Encode bytes to a base5 encoded string."""
|
19
|
-
key_string = "0123456789ABCDEFGHIJKLMNZPQRSTUV"
|
20
20
|
number = int.from_bytes(buffer, byteorder="little")
|
21
21
|
output = ""
|
22
22
|
while number:
|
23
|
-
output =
|
23
|
+
output = BASE5_KEY[number & 0b00011111] + output
|
24
24
|
number >>= 5
|
25
25
|
|
26
26
|
return output
|
pyplumio/protocol.py
CHANGED
@@ -10,7 +10,7 @@ import logging
|
|
10
10
|
|
11
11
|
from pyplumio.const import ATTR_CONNECTED, DeviceType
|
12
12
|
from pyplumio.devices import AddressableDevice
|
13
|
-
from pyplumio.exceptions import
|
13
|
+
from pyplumio.exceptions import ProtocolError
|
14
14
|
from pyplumio.frames import Frame
|
15
15
|
from pyplumio.frames.requests import StartMasterRequest
|
16
16
|
from pyplumio.helpers.event_manager import EventManager
|
@@ -23,6 +23,8 @@ from pyplumio.structures.network_info import (
|
|
23
23
|
|
24
24
|
_LOGGER = logging.getLogger(__name__)
|
25
25
|
|
26
|
+
_Callback = Callable[[], Awaitable[None]]
|
27
|
+
|
26
28
|
|
27
29
|
class Protocol(ABC):
|
28
30
|
"""Represents a protocol."""
|
@@ -30,7 +32,7 @@ class Protocol(ABC):
|
|
30
32
|
connected: asyncio.Event
|
31
33
|
reader: FrameReader | None
|
32
34
|
writer: FrameWriter | None
|
33
|
-
_on_connection_lost: set[
|
35
|
+
_on_connection_lost: set[_Callback]
|
34
36
|
|
35
37
|
def __init__(self) -> None:
|
36
38
|
"""Initialize a new protocol."""
|
@@ -47,7 +49,7 @@ class Protocol(ABC):
|
|
47
49
|
self.writer = None
|
48
50
|
|
49
51
|
@property
|
50
|
-
def on_connection_lost(self) -> set[
|
52
|
+
def on_connection_lost(self) -> set[_Callback]:
|
51
53
|
"""Return the callbacks that'll be called on connection lost."""
|
52
54
|
return self._on_connection_lost
|
53
55
|
|
@@ -86,8 +88,7 @@ class DummyProtocol(Protocol):
|
|
86
88
|
if self.connected.is_set():
|
87
89
|
self.connected.clear()
|
88
90
|
await self.close_writer()
|
89
|
-
for callback in self.on_connection_lost
|
90
|
-
await callback()
|
91
|
+
await asyncio.gather(*(callback() for callback in self.on_connection_lost))
|
91
92
|
|
92
93
|
async def shutdown(self) -> None:
|
93
94
|
"""Shutdown the protocol."""
|
@@ -102,13 +103,12 @@ class Queues:
|
|
102
103
|
|
103
104
|
__slots__ = ("read", "write")
|
104
105
|
|
105
|
-
read: asyncio.Queue
|
106
|
-
write: asyncio.Queue
|
106
|
+
read: asyncio.Queue[Frame]
|
107
|
+
write: asyncio.Queue[Frame]
|
107
108
|
|
108
109
|
async def join(self) -> None:
|
109
110
|
"""Wait for queues to finish."""
|
110
|
-
|
111
|
-
await queue.join()
|
111
|
+
await asyncio.gather(self.read.join(), self.write.join())
|
112
112
|
|
113
113
|
|
114
114
|
class AsyncProtocol(Protocol, EventManager):
|
@@ -155,41 +155,42 @@ class AsyncProtocol(Protocol, EventManager):
|
|
155
155
|
self.writer = FrameWriter(writer)
|
156
156
|
self._queues.write.put_nowait(StartMasterRequest(recipient=DeviceType.ECOMAX))
|
157
157
|
self.create_task(
|
158
|
-
self.frame_producer(self._queues, reader=self.reader, writer=self.writer)
|
158
|
+
self.frame_producer(self._queues, reader=self.reader, writer=self.writer),
|
159
|
+
name="frame_producer_task",
|
159
160
|
)
|
160
|
-
for
|
161
|
-
self.create_task(
|
161
|
+
for consumer in range(self.consumers_count):
|
162
|
+
self.create_task(
|
163
|
+
self.frame_consumer(self._queues.read),
|
164
|
+
name=f"frame_consumer_task ({consumer})",
|
165
|
+
)
|
162
166
|
|
163
167
|
for device in self.data.values():
|
164
168
|
device.dispatch_nowait(ATTR_CONNECTED, True)
|
165
169
|
|
166
170
|
self.connected.set()
|
167
171
|
|
168
|
-
async def
|
169
|
-
"""Close the
|
170
|
-
if not self.connected.is_set():
|
171
|
-
return
|
172
|
-
|
172
|
+
async def _connection_close(self) -> None:
|
173
|
+
"""Close the connection if it is established."""
|
173
174
|
self.connected.clear()
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
175
|
+
await asyncio.gather(
|
176
|
+
*(device.dispatch(ATTR_CONNECTED, False) for device in self.data.values())
|
177
|
+
)
|
178
178
|
await self.close_writer()
|
179
|
-
|
180
|
-
|
179
|
+
|
180
|
+
async def connection_lost(self) -> None:
|
181
|
+
"""Close the connection and call connection lost callbacks."""
|
182
|
+
if self.connected.is_set():
|
183
|
+
await self._connection_close()
|
184
|
+
await asyncio.gather(*(callback() for callback in self.on_connection_lost))
|
181
185
|
|
182
186
|
async def shutdown(self) -> None:
|
183
|
-
"""Shutdown protocol
|
187
|
+
"""Shutdown the protocol and close the connection."""
|
184
188
|
await self._queues.join()
|
185
189
|
self.cancel_tasks()
|
186
190
|
await self.wait_until_done()
|
187
|
-
for device in self.data.values():
|
188
|
-
await device.shutdown()
|
189
|
-
|
190
191
|
if self.connected.is_set():
|
191
|
-
self.
|
192
|
-
await self.
|
192
|
+
await self._connection_close()
|
193
|
+
await asyncio.gather(*(device.shutdown() for device in self.data.values()))
|
193
194
|
|
194
195
|
async def frame_producer(
|
195
196
|
self, queues: Queues, reader: FrameReader, writer: FrameWriter
|
@@ -205,7 +206,7 @@ class AsyncProtocol(Protocol, EventManager):
|
|
205
206
|
if (response := await reader.read()) is not None:
|
206
207
|
queues.read.put_nowait(response)
|
207
208
|
|
208
|
-
except
|
209
|
+
except ProtocolError as e:
|
209
210
|
_LOGGER.debug("Can't process received frame: %s", e)
|
210
211
|
except (OSError, asyncio.TimeoutError):
|
211
212
|
self.create_task(self.connection_lost())
|
@@ -213,11 +214,11 @@ class AsyncProtocol(Protocol, EventManager):
|
|
213
214
|
except Exception:
|
214
215
|
_LOGGER.exception("Unexpected exception")
|
215
216
|
|
216
|
-
async def frame_consumer(self, queue: asyncio.Queue) -> None:
|
217
|
+
async def frame_consumer(self, queue: asyncio.Queue[Frame]) -> None:
|
217
218
|
"""Handle frame processing."""
|
218
219
|
await self.connected.wait()
|
219
220
|
while self.connected.is_set():
|
220
|
-
frame
|
221
|
+
frame = await queue.get()
|
221
222
|
device = await self.get_device_entry(frame.sender)
|
222
223
|
device.handle_frame(frame)
|
223
224
|
queue.task_done()
|
@@ -230,7 +231,7 @@ class AsyncProtocol(Protocol, EventManager):
|
|
230
231
|
device_type, queue=self._queues.write, network=self._network
|
231
232
|
)
|
232
233
|
device.dispatch_nowait(ATTR_CONNECTED, True)
|
233
|
-
self.create_task(device.async_setup())
|
234
|
+
self.create_task(device.async_setup(), name=f"device_setup_task ({name})")
|
234
235
|
self.set_event(name)
|
235
236
|
self.data[name] = device
|
236
237
|
|
pyplumio/stream.py
CHANGED
@@ -100,8 +100,8 @@ class FrameReader:
|
|
100
100
|
"""Read the frame and return corresponding handler object.
|
101
101
|
|
102
102
|
Raise pyplumio.ReadError on unexpected frame length or
|
103
|
-
incomplete frame and pyplumio.ChecksumError on incorrect
|
104
|
-
checksum.
|
103
|
+
incomplete frame and pyplumio. Raise ChecksumError on incorrect
|
104
|
+
frame checksum.
|
105
105
|
"""
|
106
106
|
(
|
107
107
|
header_bytes,
|
pyplumio/structures/alerts.py
CHANGED
@@ -3,10 +3,11 @@
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
5
|
from collections.abc import Generator
|
6
|
+
from contextlib import suppress
|
6
7
|
from dataclasses import dataclass
|
7
8
|
from datetime import datetime
|
8
9
|
from functools import lru_cache
|
9
|
-
from typing import Any, Final
|
10
|
+
from typing import Any, Final, Literal, NamedTuple
|
10
11
|
|
11
12
|
from pyplumio.const import AlertType
|
12
13
|
from pyplumio.helpers.data_types import UnsignedInt
|
@@ -19,27 +20,40 @@ ATTR_TOTAL_ALERTS: Final = "total_alerts"
|
|
19
20
|
MAX_UINT32: Final = 4294967295
|
20
21
|
|
21
22
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
("year", 32140800, 2000), # 60sec * 60min * 24h * 31d * 12m
|
30
|
-
("month", 2678400, 1), # 60sec * 60min * 24h * 31d
|
31
|
-
("day", 86400, 1), # 60sec * 60min * 24h
|
32
|
-
("hour", 3600, 0), # 60sec * 60min
|
33
|
-
("minute", 60, 0),
|
34
|
-
("second", 1, 0),
|
35
|
-
)
|
23
|
+
class DateTimeInterval(NamedTuple):
|
24
|
+
"""Represents an alert time interval."""
|
25
|
+
|
26
|
+
name: Literal["year", "month", "day", "hour", "minute", "second"]
|
27
|
+
seconds: int
|
28
|
+
offset: int = 0
|
29
|
+
|
36
30
|
|
37
|
-
|
38
|
-
|
39
|
-
|
31
|
+
DATETIME_INTERVALS: tuple[DateTimeInterval, ...] = (
|
32
|
+
DateTimeInterval("year", seconds=60 * 60 * 24 * 31 * 12, offset=2000),
|
33
|
+
DateTimeInterval("month", seconds=60 * 60 * 24 * 31, offset=1),
|
34
|
+
DateTimeInterval("day", seconds=60 * 60 * 24, offset=1),
|
35
|
+
DateTimeInterval("hour", seconds=60 * 60),
|
36
|
+
DateTimeInterval("minute", seconds=60),
|
37
|
+
DateTimeInterval("second", seconds=1),
|
38
|
+
)
|
39
|
+
|
40
|
+
|
41
|
+
@lru_cache(maxsize=10)
|
42
|
+
def _seconds_to_datetime(timestamp: int) -> datetime:
|
43
|
+
"""Convert timestamp to a datetime object.
|
44
|
+
|
45
|
+
The ecoMAX controller stores alert time as a special timestamp value
|
46
|
+
in seconds counted from Jan 1st, 2000.
|
47
|
+
"""
|
48
|
+
|
49
|
+
def _datetime_kwargs(timestamp: int) -> Generator[Any, None, None]:
|
50
|
+
"""Yield a tuple, that represents a single datetime kwarg."""
|
51
|
+
for name, seconds, offset in DATETIME_INTERVALS:
|
52
|
+
value = timestamp // seconds
|
53
|
+
timestamp -= value * seconds
|
40
54
|
yield name, (value + offset)
|
41
55
|
|
42
|
-
return datetime(**dict(
|
56
|
+
return datetime(**dict(_datetime_kwargs(timestamp)))
|
43
57
|
|
44
58
|
|
45
59
|
@dataclass
|
@@ -62,24 +76,20 @@ class AlertsStructure(StructureDecoder):
|
|
62
76
|
|
63
77
|
def _unpack_alert(self, message: bytearray) -> Alert:
|
64
78
|
"""Unpack an alert."""
|
65
|
-
|
66
|
-
code = message[self._offset]
|
67
|
-
code = AlertType(code)
|
68
|
-
except ValueError:
|
69
|
-
pass
|
70
|
-
|
79
|
+
code = message[self._offset]
|
71
80
|
self._offset += 1
|
72
81
|
from_seconds = UnsignedInt.from_bytes(message, self._offset)
|
73
82
|
self._offset += from_seconds.size
|
74
83
|
to_seconds = UnsignedInt.from_bytes(message, self._offset)
|
75
84
|
self._offset += to_seconds.size
|
76
|
-
|
77
|
-
from_dt = _convert_to_datetime(from_seconds.value)
|
85
|
+
from_dt = _seconds_to_datetime(from_seconds.value)
|
78
86
|
to_dt = (
|
79
87
|
None
|
80
88
|
if to_seconds.value == MAX_UINT32
|
81
|
-
else
|
89
|
+
else _seconds_to_datetime(to_seconds.value)
|
82
90
|
)
|
91
|
+
with suppress(ValueError):
|
92
|
+
code = AlertType(code)
|
83
93
|
|
84
94
|
return Alert(code, from_dt, to_dt)
|
85
95
|
|
@@ -90,12 +100,11 @@ class AlertsStructure(StructureDecoder):
|
|
90
100
|
total_alerts = message[offset + 0]
|
91
101
|
start = message[offset + 1]
|
92
102
|
end = message[offset + 2]
|
93
|
-
|
103
|
+
self._offset = offset + 3
|
94
104
|
if end == 0:
|
95
105
|
# No alerts found.
|
96
|
-
return ensure_dict(data, {ATTR_TOTAL_ALERTS: total_alerts}),
|
106
|
+
return ensure_dict(data, {ATTR_TOTAL_ALERTS: total_alerts}), self._offset
|
97
107
|
|
98
|
-
self._offset = offset + 3
|
99
108
|
return (
|
100
109
|
ensure_dict(
|
101
110
|
data,
|