PyPlumIO 0.5.19__py3-none-any.whl → 0.5.21__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.19.dist-info → PyPlumIO-0.5.21.dist-info}/METADATA +5 -5
- {PyPlumIO-0.5.19.dist-info → PyPlumIO-0.5.21.dist-info}/RECORD +18 -18
- pyplumio/_version.py +2 -2
- pyplumio/connection.py +1 -4
- pyplumio/devices/__init__.py +6 -3
- pyplumio/devices/ecomax.py +33 -23
- pyplumio/devices/mixer.py +3 -2
- pyplumio/devices/thermostat.py +3 -2
- pyplumio/helpers/event_manager.py +9 -14
- pyplumio/helpers/task_manager.py +3 -4
- pyplumio/helpers/timeout.py +6 -20
- pyplumio/protocol.py +35 -48
- pyplumio/stream.py +27 -28
- pyplumio/structures/ecomax_parameters.py +0 -2
- pyplumio/structures/network_info.py +3 -3
- {PyPlumIO-0.5.19.dist-info → PyPlumIO-0.5.21.dist-info}/LICENSE +0 -0
- {PyPlumIO-0.5.19.dist-info → PyPlumIO-0.5.21.dist-info}/WHEEL +0 -0
- {PyPlumIO-0.5.19.dist-info → PyPlumIO-0.5.21.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: PyPlumIO
|
3
|
-
Version: 0.5.
|
3
|
+
Version: 0.5.21
|
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,11 +35,11 @@ Provides-Extra: test
|
|
35
35
|
Requires-Dist: codespell ==2.3.0 ; extra == 'test'
|
36
36
|
Requires-Dist: coverage ==7.5.3 ; extra == 'test'
|
37
37
|
Requires-Dist: mypy ==1.10.0 ; extra == 'test'
|
38
|
-
Requires-Dist: pyserial-asyncio-fast ==0.
|
39
|
-
Requires-Dist: pytest ==8.2.
|
38
|
+
Requires-Dist: pyserial-asyncio-fast ==0.12 ; extra == 'test'
|
39
|
+
Requires-Dist: pytest ==8.2.2 ; extra == 'test'
|
40
40
|
Requires-Dist: pytest-asyncio ==0.23.7 ; extra == 'test'
|
41
|
-
Requires-Dist: ruff ==0.4.
|
42
|
-
Requires-Dist: tox ==4.15.
|
41
|
+
Requires-Dist: ruff ==0.4.9 ; extra == 'test'
|
42
|
+
Requires-Dist: tox ==4.15.1 ; extra == 'test'
|
43
43
|
Requires-Dist: types-pyserial ==3.5.0.20240527 ; extra == 'test'
|
44
44
|
|
45
45
|
# PyPlumIO is a native ecoNET library for Plum ecoMAX controllers.
|
@@ -1,38 +1,38 @@
|
|
1
1
|
pyplumio/__init__.py,sha256=cclyAwy7OsW673iHcwkVrJSNnf32oF51Y_0uEEF5cdI,3293
|
2
2
|
pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
|
3
|
-
pyplumio/_version.py,sha256=
|
4
|
-
pyplumio/connection.py,sha256=
|
3
|
+
pyplumio/_version.py,sha256=qwvYpiZ4pV_W2HOchGsudGTbl7hCvV-xhS0ii39Ac0I,413
|
4
|
+
pyplumio/connection.py,sha256=ZZHXHFpbOBVd9DGZV_H8lpdYtYoc3nP9fRolKATKDnQ,6096
|
5
5
|
pyplumio/const.py,sha256=8rpiVbVb5R_6Rm6J2sgCnaVrkD-2Fzhd1RYMz0MBgwo,3915
|
6
6
|
pyplumio/exceptions.py,sha256=193z3zfnswYhIYPzCIpxCiWat4qI3cV85sqT4YOSo-4,699
|
7
7
|
pyplumio/filters.py,sha256=bIonYc_QbGMsL8aWweSLUmP7gKqDD646zELf_PqqQBg,11161
|
8
|
-
pyplumio/protocol.py,sha256=
|
8
|
+
pyplumio/protocol.py,sha256=Ci4p4bqADfWeGk9fzcGMudRbe-dFFa1UNot9no5Lj3M,7845
|
9
9
|
pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
10
|
-
pyplumio/stream.py,sha256=
|
10
|
+
pyplumio/stream.py,sha256=DqMqdi3HG9hODgfGo4eTKLkfoaSh5RS4kBHNn3ODvVg,4472
|
11
11
|
pyplumio/utils.py,sha256=GV7P1hPLoQsx3uqYviQ15FXJmkmTxwtDibAc-yRarvo,688
|
12
|
-
pyplumio/devices/__init__.py,sha256=
|
13
|
-
pyplumio/devices/ecomax.py,sha256=
|
12
|
+
pyplumio/devices/__init__.py,sha256=O5SyEt_x1nJ1JYkG6v3dTZ54tu9sKIdj4l256JhvLHg,6585
|
13
|
+
pyplumio/devices/ecomax.py,sha256=IPHyC8OjnGaQ_ZztcchgQjEJmNj8LfnP9sTJyslEQ14,16914
|
14
14
|
pyplumio/devices/ecoster.py,sha256=J4YtPmFmFwaq4LzYf28aMmB97cRAbMsVyUdBLGki42g,313
|
15
|
-
pyplumio/devices/mixer.py,sha256=
|
16
|
-
pyplumio/devices/thermostat.py,sha256=
|
15
|
+
pyplumio/devices/mixer.py,sha256=qJAmar7DdsQL1Syg0WOCBVQn3GyBTWEVyr5ZfpGytCk,2975
|
16
|
+
pyplumio/devices/thermostat.py,sha256=HCnLVBX8mn6lmpCgl1DbDoCMI6T97sqmK-36cYcjXVA,2430
|
17
17
|
pyplumio/frames/__init__.py,sha256=BAMbMHbn4F9psrf3sv0eJQA2Jd86qf7LQ5vBQY59gjA,7462
|
18
18
|
pyplumio/frames/messages.py,sha256=QLuvo1wlpDZR1MpOdu7s6fRUX20Dtt6EWFLkAsqyax4,3617
|
19
19
|
pyplumio/frames/requests.py,sha256=Ra8xH5oKYhkEUtadN-9ZsJKkt5xZkz5O7edQVsDhNsM,7221
|
20
20
|
pyplumio/frames/responses.py,sha256=j4awA2-MfsoPdENC4Fvae4_Oa70rDhH19ebmEoAqhh8,6532
|
21
21
|
pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,31
|
22
22
|
pyplumio/helpers/data_types.py,sha256=H_pYkLgIu30lDFU0UUZ1V3vYxa9A_-1nhiJu-HCLuoc,8212
|
23
|
-
pyplumio/helpers/event_manager.py,sha256=
|
23
|
+
pyplumio/helpers/event_manager.py,sha256=dCNLnSRZgewZ9Ppi-JtkxtvOmNd4ZejA7UT4oAT8FWM,5865
|
24
24
|
pyplumio/helpers/factory.py,sha256=eiTkYUCernUn0VNDDdEN4IyjNPrXK8vnJESXyLaqFzE,1017
|
25
25
|
pyplumio/helpers/parameter.py,sha256=gYCA2SLU_lbdtQZq5U64yzpyLoEIa0R1wyJJGmgL63I,8699
|
26
26
|
pyplumio/helpers/schedule.py,sha256=-IZJ-CU4PhFlsE586wTw--ovDrTo2Hs4JneCHhc0e-Y,5013
|
27
|
-
pyplumio/helpers/task_manager.py,sha256=
|
28
|
-
pyplumio/helpers/timeout.py,sha256=
|
27
|
+
pyplumio/helpers/task_manager.py,sha256=y5j7u31V6UE7g2ZhdsYsPykY-Awo73oWsNRUOrLSILg,1075
|
28
|
+
pyplumio/helpers/timeout.py,sha256=k-829fBcHT5IR3isrMSgNbPYK-ubeY1BAwndCDIiX9E,824
|
29
29
|
pyplumio/helpers/typing.py,sha256=y55UdpIpPIRuUBPgfPmZHAwPdIUjQO924-kO7AVXhes,685
|
30
30
|
pyplumio/helpers/uid.py,sha256=yaBjcsFKuhOaznftk33kdIepQHpK-labEQr59QNKhPM,975
|
31
31
|
pyplumio/structures/__init__.py,sha256=EjK-5qJZ0F7lpP2b6epvTMg9cIBl4Kn91nqNkEcLwTc,1299
|
32
32
|
pyplumio/structures/alerts.py,sha256=a1CIf8vSEj5aefdqECIfCY5kV4tQ4kabMkp-_ixeWic,3260
|
33
33
|
pyplumio/structures/boiler_load.py,sha256=p3mOzZUU-g7A2tG_yp8podEqpI81hlsOZmHELyPNRY8,838
|
34
34
|
pyplumio/structures/boiler_power.py,sha256=72qsvccg49FdRdXv2f2K5sGpjT7wAOLFjlIGWpO-DVg,901
|
35
|
-
pyplumio/structures/ecomax_parameters.py,sha256=
|
35
|
+
pyplumio/structures/ecomax_parameters.py,sha256=6HVEh4aNw0CGZD3CVQeYyKXQ0pzueQR_Tpm5fF3_0hA,25815
|
36
36
|
pyplumio/structures/fan_power.py,sha256=Q5fv-7_2NVuLeQPIVIylvgN7M8-a9D8rRUE0QGjyS3w,871
|
37
37
|
pyplumio/structures/frame_versions.py,sha256=x_OSirGYopQYgsRZIM3b1YlKHNIPmCbvAzhzO1wqy5k,1560
|
38
38
|
pyplumio/structures/fuel_consumption.py,sha256=_p2dI4H67Eopn7IF0Gj77A8c_8lNKhhDDAtmugxLd4s,976
|
@@ -41,7 +41,7 @@ pyplumio/structures/lambda_sensor.py,sha256=6iUVyrPe6_QaGPo1lRzOfqorcTIIXRwnq3h8
|
|
41
41
|
pyplumio/structures/mixer_parameters.py,sha256=ny7Ox94IooQd1ua22zGYkXLFaZQWGUYLEIM2_8vXk0U,8249
|
42
42
|
pyplumio/structures/mixer_sensors.py,sha256=O91929Ts1YXFmKdPRc1r_BYDgrqkv5QVtE1nGzLpuAI,2260
|
43
43
|
pyplumio/structures/modules.py,sha256=ukju4TQmRRJfgl94QU4zytZLU5px8nw3sgfSLn9JysU,2520
|
44
|
-
pyplumio/structures/network_info.py,sha256=
|
44
|
+
pyplumio/structures/network_info.py,sha256=ws2UdOhB89oKqmtW1Vsmfj0InRW4Sp6_kL1d4psk-8w,4094
|
45
45
|
pyplumio/structures/output_flags.py,sha256=07N0kxlvR5WZAURuChk_BqSiXR8eaQrtI5qlkgCf4Yc,1345
|
46
46
|
pyplumio/structures/outputs.py,sha256=1xsJPkjN643-aFawqVoupGatUIUJfQG_g252n051Qi0,1916
|
47
47
|
pyplumio/structures/pending_alerts.py,sha256=Uq9WpB4MW9AhDkqmDhk-g0J0h4pVq0Q50z12dYEv6kY,739
|
@@ -54,8 +54,8 @@ pyplumio/structures/statuses.py,sha256=wkoynyMRr1VREwfBC6vU48kPA8ZQ83pcXuciy2xHJ
|
|
54
54
|
pyplumio/structures/temperatures.py,sha256=1CDzehNmbALz1Jyt_9gZNIk52q6Wv-xQXjijVDCVYec,2337
|
55
55
|
pyplumio/structures/thermostat_parameters.py,sha256=pjbWsT6z7mlDiUrC5MWGqMtGP0deeVMYeeTa7yGEwJ8,7706
|
56
56
|
pyplumio/structures/thermostat_sensors.py,sha256=ZmjWgYtTZ5M8Lnz_Q5N4JD8G3MvEmByPFjYsy6XZOmo,3177
|
57
|
-
PyPlumIO-0.5.
|
58
|
-
PyPlumIO-0.5.
|
59
|
-
PyPlumIO-0.5.
|
60
|
-
PyPlumIO-0.5.
|
61
|
-
PyPlumIO-0.5.
|
57
|
+
PyPlumIO-0.5.21.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
|
58
|
+
PyPlumIO-0.5.21.dist-info/METADATA,sha256=mc-yvbFJArE1iYRxb8RCkL2MdoKZjkZKOn8nWgWD8t8,5415
|
59
|
+
PyPlumIO-0.5.21.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
60
|
+
PyPlumIO-0.5.21.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
|
61
|
+
PyPlumIO-0.5.21.dist-info/RECORD,,
|
pyplumio/_version.py
CHANGED
pyplumio/connection.py
CHANGED
@@ -72,10 +72,7 @@ class Connection(ABC, TaskManager):
|
|
72
72
|
async def _connect(self) -> None:
|
73
73
|
"""Establish connection and initialize the protocol object."""
|
74
74
|
try:
|
75
|
-
reader, writer =
|
76
|
-
tuple[asyncio.StreamReader, asyncio.StreamWriter],
|
77
|
-
await self._open_connection(),
|
78
|
-
)
|
75
|
+
reader, writer = await self._open_connection()
|
79
76
|
self.protocol.connection_established(reader, writer)
|
80
77
|
except (OSError, SerialException, asyncio.TimeoutError) as err:
|
81
78
|
raise ConnectionFailedError from err
|
pyplumio/devices/__init__.py
CHANGED
@@ -111,6 +111,11 @@ class Device(ABC, EventManager):
|
|
111
111
|
"""
|
112
112
|
self.create_task(self.set(name, value, timeout, retries))
|
113
113
|
|
114
|
+
async def shutdown(self) -> None:
|
115
|
+
"""Cancel device tasks."""
|
116
|
+
self.cancel_tasks()
|
117
|
+
await self.wait_until_done()
|
118
|
+
|
114
119
|
|
115
120
|
class AddressableDevice(Device, ABC):
|
116
121
|
"""Represents an addressable device."""
|
@@ -139,9 +144,7 @@ class AddressableDevice(Device, ABC):
|
|
139
144
|
"""Set up addressable device."""
|
140
145
|
results = await asyncio.gather(
|
141
146
|
*{
|
142
|
-
self.
|
143
|
-
self.request(description.provides, description.frame_type)
|
144
|
-
)
|
147
|
+
self.request(description.provides, description.frame_type)
|
145
148
|
for description in self._setup_frames
|
146
149
|
},
|
147
150
|
return_exceptions=True,
|
pyplumio/devices/ecomax.py
CHANGED
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|
4
4
|
|
5
5
|
import asyncio
|
6
6
|
from collections.abc import Generator, Iterable, Sequence
|
7
|
-
from contextlib import suppress
|
8
7
|
import logging
|
9
8
|
import time
|
10
9
|
from typing import Any, ClassVar, Final
|
@@ -18,7 +17,7 @@ from pyplumio.const import (
|
|
18
17
|
DeviceType,
|
19
18
|
FrameType,
|
20
19
|
)
|
21
|
-
from pyplumio.devices import AddressableDevice
|
20
|
+
from pyplumio.devices import AddressableDevice, SubDevice
|
22
21
|
from pyplumio.devices.mixer import Mixer
|
23
22
|
from pyplumio.devices.thermostat import Thermostat
|
24
23
|
from pyplumio.filters import on_change
|
@@ -263,8 +262,12 @@ class EcoMAX(AddressableDevice):
|
|
263
262
|
if not parameters:
|
264
263
|
return False
|
265
264
|
|
266
|
-
|
267
|
-
|
265
|
+
await asyncio.gather(
|
266
|
+
*[
|
267
|
+
mixer.dispatch(ATTR_MIXER_PARAMETERS, parameters[mixer.index])
|
268
|
+
for mixer in self._mixers(indexes=parameters.keys())
|
269
|
+
]
|
270
|
+
)
|
268
271
|
|
269
272
|
return True
|
270
273
|
|
@@ -278,14 +281,18 @@ class EcoMAX(AddressableDevice):
|
|
278
281
|
if not sensors:
|
279
282
|
return False
|
280
283
|
|
281
|
-
|
282
|
-
|
284
|
+
await asyncio.gather(
|
285
|
+
*[
|
286
|
+
mixer.dispatch(ATTR_MIXER_SENSORS, sensors[mixer.index])
|
287
|
+
for mixer in self._mixers(indexes=sensors.keys())
|
288
|
+
]
|
289
|
+
)
|
283
290
|
|
284
291
|
return True
|
285
292
|
|
286
293
|
async def _add_schedules(
|
287
294
|
self, schedules: list[tuple[int, list[list[bool]]]]
|
288
|
-
) -> dict[str,
|
295
|
+
) -> dict[str, Schedule]:
|
289
296
|
"""Add schedules to the dataset."""
|
290
297
|
return {
|
291
298
|
SCHEDULES[index]: Schedule(
|
@@ -333,8 +340,9 @@ class EcoMAX(AddressableDevice):
|
|
333
340
|
For each sensor dispatch an event with the sensor's name and
|
334
341
|
value.
|
335
342
|
"""
|
336
|
-
|
337
|
-
|
343
|
+
await asyncio.gather(
|
344
|
+
*[self.dispatch(name, value) for name, value in sensors.items()]
|
345
|
+
)
|
338
346
|
|
339
347
|
return True
|
340
348
|
|
@@ -371,10 +379,14 @@ class EcoMAX(AddressableDevice):
|
|
371
379
|
if not parameters:
|
372
380
|
return False
|
373
381
|
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
382
|
+
await asyncio.gather(
|
383
|
+
*[
|
384
|
+
thermostat.dispatch(
|
385
|
+
ATTR_THERMOSTAT_PARAMETERS, parameters[thermostat.index]
|
386
|
+
)
|
387
|
+
for thermostat in self._thermostats(indexes=parameters.keys())
|
388
|
+
]
|
389
|
+
)
|
378
390
|
|
379
391
|
return True
|
380
392
|
|
@@ -401,10 +413,12 @@ class EcoMAX(AddressableDevice):
|
|
401
413
|
if not sensors:
|
402
414
|
return False
|
403
415
|
|
404
|
-
|
405
|
-
|
406
|
-
ATTR_THERMOSTAT_SENSORS, sensors[thermostat.index]
|
407
|
-
|
416
|
+
await asyncio.gather(
|
417
|
+
*[
|
418
|
+
thermostat.dispatch(ATTR_THERMOSTAT_SENSORS, sensors[thermostat.index])
|
419
|
+
for thermostat in self._thermostats(indexes=sensors.keys())
|
420
|
+
]
|
421
|
+
)
|
408
422
|
|
409
423
|
return True
|
410
424
|
|
@@ -438,10 +452,6 @@ class EcoMAX(AddressableDevice):
|
|
438
452
|
"""Shutdown tasks for the ecoMAX controller and sub-devices."""
|
439
453
|
mixers = self.get_nowait(ATTR_MIXERS, {})
|
440
454
|
thermostats = self.get_nowait(ATTR_THERMOSTATS, {})
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
with suppress(AttributeError):
|
445
|
-
await self.regdata.shutdown()
|
446
|
-
|
455
|
+
devices: Iterable[SubDevice] = (mixers | thermostats).values()
|
456
|
+
await asyncio.gather(*[device.shutdown() for device in devices])
|
447
457
|
await super().shutdown()
|
pyplumio/devices/mixer.py
CHANGED
@@ -37,8 +37,9 @@ class Mixer(SubDevice):
|
|
37
37
|
For each sensor dispatch an event with the
|
38
38
|
sensor's name and value.
|
39
39
|
"""
|
40
|
-
|
41
|
-
|
40
|
+
await asyncio.gather(
|
41
|
+
*[self.dispatch(name, value) for name, value in sensors.items()]
|
42
|
+
)
|
42
43
|
|
43
44
|
return True
|
44
45
|
|
pyplumio/devices/thermostat.py
CHANGED
@@ -33,8 +33,9 @@ class Thermostat(SubDevice):
|
|
33
33
|
For each sensor dispatch an event with the
|
34
34
|
sensor's name and value.
|
35
35
|
"""
|
36
|
-
|
37
|
-
|
36
|
+
await asyncio.gather(
|
37
|
+
*[self.dispatch(name, value) for name, value in sensors.items()]
|
38
|
+
)
|
38
39
|
|
39
40
|
return True
|
40
41
|
|
@@ -138,16 +138,16 @@ class EventManager(TaskManager):
|
|
138
138
|
"""Call a registered callbacks and dispatch the event without waiting."""
|
139
139
|
self.create_task(self.dispatch(name, value))
|
140
140
|
|
141
|
-
def load(self, data: dict[str, Any]) -> None:
|
142
|
-
"""Load
|
143
|
-
|
144
|
-
async def _dispatch_events(data: dict[str, Any]) -> None:
|
145
|
-
"""Dispatch events for a loaded data."""
|
146
|
-
for key, value in data.items():
|
147
|
-
await self.dispatch(key, value)
|
148
|
-
|
141
|
+
async def load(self, data: dict[str, Any]) -> None:
|
142
|
+
"""Load event data."""
|
149
143
|
self.data = data
|
150
|
-
|
144
|
+
await asyncio.gather(
|
145
|
+
*[self.dispatch(name, value) for name, value in data.items()]
|
146
|
+
)
|
147
|
+
|
148
|
+
def load_nowait(self, data: dict[str, Any]) -> None:
|
149
|
+
"""Load event data without waiting."""
|
150
|
+
self.create_task(self.load(data))
|
151
151
|
|
152
152
|
def create_event(self, name: str) -> asyncio.Event:
|
153
153
|
"""Create an event."""
|
@@ -165,11 +165,6 @@ class EventManager(TaskManager):
|
|
165
165
|
if not event.is_set():
|
166
166
|
event.set()
|
167
167
|
|
168
|
-
async def shutdown(self) -> None:
|
169
|
-
"""Cancel scheduled tasks."""
|
170
|
-
self.cancel_tasks()
|
171
|
-
await self.wait_until_done()
|
172
|
-
|
173
168
|
@property
|
174
169
|
def events(self) -> dict[str, asyncio.Event]:
|
175
170
|
"""Return the events."""
|
pyplumio/helpers/task_manager.py
CHANGED
@@ -18,15 +18,14 @@ class TaskManager:
|
|
18
18
|
|
19
19
|
def create_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task:
|
20
20
|
"""Create asyncio task and store a reference for it."""
|
21
|
-
task
|
21
|
+
task = asyncio.create_task(coro)
|
22
22
|
self._tasks.add(task)
|
23
23
|
task.add_done_callback(self._tasks.discard)
|
24
24
|
return task
|
25
25
|
|
26
|
-
def cancel_tasks(self) ->
|
26
|
+
def cancel_tasks(self) -> bool:
|
27
27
|
"""Cancel all tasks."""
|
28
|
-
for task in self._tasks
|
29
|
-
task.cancel()
|
28
|
+
return all(task.cancel() for task in self._tasks)
|
30
29
|
|
31
30
|
async def wait_until_done(self, return_exceptions: bool = True) -> None:
|
32
31
|
"""Wait for all tasks to complete."""
|
pyplumio/helpers/timeout.py
CHANGED
@@ -17,30 +17,16 @@ _LOGGER = logging.getLogger(__name__)
|
|
17
17
|
|
18
18
|
|
19
19
|
def timeout(
|
20
|
-
seconds: int,
|
21
|
-
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T
|
22
|
-
"""Decorate a timeout for the awaitable.
|
23
|
-
|
24
|
-
Return None on exception if raise_exception parameter is set to false.
|
25
|
-
"""
|
20
|
+
seconds: int,
|
21
|
+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T]]]:
|
22
|
+
"""Decorate a timeout for the awaitable."""
|
26
23
|
|
27
24
|
def decorator(
|
28
25
|
func: Callable[P, Awaitable[T]],
|
29
|
-
) -> Callable[P, Coroutine[Any, Any, T
|
26
|
+
) -> Callable[P, Coroutine[Any, Any, T]]:
|
30
27
|
@wraps(func)
|
31
|
-
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T
|
32
|
-
|
33
|
-
return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds)
|
34
|
-
except asyncio.TimeoutError:
|
35
|
-
if raise_exception:
|
36
|
-
raise
|
37
|
-
|
38
|
-
_LOGGER.warning(
|
39
|
-
"Function '%s' timed out after %i seconds",
|
40
|
-
func.__name__,
|
41
|
-
seconds,
|
42
|
-
)
|
43
|
-
return None
|
28
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
29
|
+
return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds)
|
44
30
|
|
45
31
|
return wrapper
|
46
32
|
|
pyplumio/protocol.py
CHANGED
@@ -5,17 +5,12 @@ from __future__ import annotations
|
|
5
5
|
from abc import ABC, abstractmethod
|
6
6
|
import asyncio
|
7
7
|
from collections.abc import Awaitable, Callable
|
8
|
+
from dataclasses import dataclass
|
8
9
|
import logging
|
9
|
-
from typing import NamedTuple, cast
|
10
10
|
|
11
11
|
from pyplumio.const import ATTR_CONNECTED, DeviceType
|
12
12
|
from pyplumio.devices import AddressableDevice
|
13
|
-
from pyplumio.exceptions import
|
14
|
-
FrameDataError,
|
15
|
-
FrameError,
|
16
|
-
ReadError,
|
17
|
-
UnknownDeviceError,
|
18
|
-
)
|
13
|
+
from pyplumio.exceptions import FrameError, ReadError, UnknownDeviceError
|
19
14
|
from pyplumio.frames import Frame
|
20
15
|
from pyplumio.frames.requests import StartMasterRequest
|
21
16
|
from pyplumio.helpers.event_manager import EventManager
|
@@ -101,12 +96,19 @@ class DummyProtocol(Protocol):
|
|
101
96
|
await self.close_writer()
|
102
97
|
|
103
98
|
|
104
|
-
|
99
|
+
@dataclass
|
100
|
+
class Queues:
|
105
101
|
"""Represents asyncio queues."""
|
106
102
|
|
103
|
+
__slots__ = ("read", "write")
|
104
|
+
|
107
105
|
read: asyncio.Queue
|
108
106
|
write: asyncio.Queue
|
109
107
|
|
108
|
+
async def join(self) -> None:
|
109
|
+
"""Wait for queues to finish."""
|
110
|
+
await asyncio.gather(self.read.join(), self.write.join())
|
111
|
+
|
110
112
|
|
111
113
|
class AsyncProtocol(Protocol, EventManager):
|
112
114
|
"""Represents an async protocol.
|
@@ -117,11 +119,11 @@ class AsyncProtocol(Protocol, EventManager):
|
|
117
119
|
The frame producer tries to read frames from the write queue.
|
118
120
|
If any is available, it sends them to the device via frame writer.
|
119
121
|
|
120
|
-
It then reads stream via frame reader
|
121
|
-
|
122
|
+
It then reads stream via frame reader and puts received frame
|
123
|
+
into the read queue.
|
122
124
|
|
123
|
-
Frame consumers read frames from the read queue
|
124
|
-
|
125
|
+
Frame consumers read frames from the read queue, create device
|
126
|
+
entry, if needed, and send frame to the entry for the processing.
|
125
127
|
"""
|
126
128
|
|
127
129
|
consumers_count: int
|
@@ -139,18 +141,10 @@ class AsyncProtocol(Protocol, EventManager):
|
|
139
141
|
super().__init__()
|
140
142
|
self.consumers_count = consumers_count
|
141
143
|
self._network = NetworkInfo(
|
142
|
-
eth=(
|
143
|
-
|
144
|
-
if ethernet_parameters is None
|
145
|
-
else ethernet_parameters
|
146
|
-
),
|
147
|
-
wlan=(
|
148
|
-
WirelessParameters(status=False)
|
149
|
-
if wireless_parameters is None
|
150
|
-
else wireless_parameters
|
151
|
-
),
|
144
|
+
eth=ethernet_parameters or EthernetParameters(status=False),
|
145
|
+
wlan=wireless_parameters or WirelessParameters(status=False),
|
152
146
|
)
|
153
|
-
self._queues = Queues(asyncio.Queue(), asyncio.Queue())
|
147
|
+
self._queues = Queues(read=asyncio.Queue(), write=asyncio.Queue())
|
154
148
|
|
155
149
|
def connection_established(
|
156
150
|
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
@@ -159,7 +153,9 @@ class AsyncProtocol(Protocol, EventManager):
|
|
159
153
|
self.reader = FrameReader(reader)
|
160
154
|
self.writer = FrameWriter(writer)
|
161
155
|
self._queues.write.put_nowait(StartMasterRequest(recipient=DeviceType.ECOMAX))
|
162
|
-
self.create_task(
|
156
|
+
self.create_task(
|
157
|
+
self.frame_producer(self._queues, reader=self.reader, writer=self.writer)
|
158
|
+
)
|
163
159
|
for _ in range(self.consumers_count):
|
164
160
|
self.create_task(self.frame_consumer(self._queues.read))
|
165
161
|
|
@@ -174,52 +170,43 @@ class AsyncProtocol(Protocol, EventManager):
|
|
174
170
|
return
|
175
171
|
|
176
172
|
self.connected.clear()
|
177
|
-
for device in self.data.values():
|
178
|
-
# Notify devices about connection loss.
|
179
|
-
await device.dispatch(ATTR_CONNECTED, False)
|
180
|
-
|
181
173
|
await self.close_writer()
|
182
|
-
|
183
|
-
|
174
|
+
await asyncio.gather(
|
175
|
+
*[device.dispatch(ATTR_CONNECTED, False) for device in self.data.values()]
|
176
|
+
)
|
177
|
+
await asyncio.gather(*[callback() for callback in self.on_connection_lost])
|
184
178
|
|
185
179
|
async def shutdown(self) -> None:
|
186
180
|
"""Shutdown protocol tasks."""
|
187
|
-
await
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
181
|
+
await self._queues.join()
|
182
|
+
self.cancel_tasks()
|
183
|
+
await self.wait_until_done()
|
184
|
+
await asyncio.gather(*[device.shutdown() for device in self.data.values()])
|
192
185
|
if self.connected.is_set():
|
193
186
|
self.connected.clear()
|
194
187
|
await self.close_writer()
|
195
188
|
|
196
|
-
async def frame_producer(
|
189
|
+
async def frame_producer(
|
190
|
+
self, queues: Queues, reader: FrameReader, writer: FrameWriter
|
191
|
+
) -> None:
|
197
192
|
"""Handle frame reads and writes."""
|
198
193
|
await self.connected.wait()
|
199
|
-
reader = cast(FrameReader, self.reader)
|
200
|
-
writer = cast(FrameWriter, self.writer)
|
201
194
|
while self.connected.is_set():
|
202
195
|
try:
|
203
|
-
if queues.write.
|
196
|
+
if not queues.write.empty():
|
204
197
|
await writer.write(await queues.write.get())
|
205
198
|
queues.write.task_done()
|
206
199
|
|
207
200
|
if (response := await reader.read()) is not None:
|
208
201
|
queues.read.put_nowait(response)
|
209
202
|
|
210
|
-
except
|
211
|
-
_LOGGER.warning("Incorrect payload: %s", e)
|
212
|
-
except ReadError as e:
|
213
|
-
_LOGGER.debug("Read error: %s", e)
|
214
|
-
except UnknownDeviceError as e:
|
215
|
-
_LOGGER.debug("Unknown device: %s", e)
|
216
|
-
except FrameError as e:
|
203
|
+
except (ReadError, UnknownDeviceError, FrameError) as e:
|
217
204
|
_LOGGER.debug("Can't process received frame: %s", e)
|
218
205
|
except (OSError, asyncio.TimeoutError):
|
219
206
|
self.create_task(self.connection_lost())
|
220
207
|
break
|
221
|
-
except Exception
|
222
|
-
_LOGGER.exception(
|
208
|
+
except Exception:
|
209
|
+
_LOGGER.exception("Unexpected exception")
|
223
210
|
|
224
211
|
async def frame_consumer(self, queue: asyncio.Queue) -> None:
|
225
212
|
"""Handle frame processing."""
|
pyplumio/stream.py
CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
5
5
|
import asyncio
|
6
6
|
from asyncio import IncompleteReadError, StreamReader, StreamWriter
|
7
7
|
import logging
|
8
|
-
from typing import Final
|
8
|
+
from typing import Final, NamedTuple
|
9
9
|
|
10
10
|
from pyplumio.const import DeviceType
|
11
11
|
from pyplumio.devices import is_known_device_type
|
@@ -54,6 +54,18 @@ class FrameWriter:
|
|
54
54
|
await self._writer.wait_closed()
|
55
55
|
|
56
56
|
|
57
|
+
class Header(NamedTuple):
|
58
|
+
"""Represents a frame header."""
|
59
|
+
|
60
|
+
bytes: bytes
|
61
|
+
frame_start: int
|
62
|
+
frame_length: int
|
63
|
+
recipient: int
|
64
|
+
sender: int
|
65
|
+
econet_type: int
|
66
|
+
econet_version: int
|
67
|
+
|
68
|
+
|
57
69
|
class FrameReader:
|
58
70
|
"""Represents a frame reader."""
|
59
71
|
|
@@ -65,11 +77,11 @@ class FrameReader:
|
|
65
77
|
"""Initialize a new frame reader."""
|
66
78
|
self._reader = reader
|
67
79
|
|
68
|
-
async def _read_header(self) ->
|
80
|
+
async def _read_header(self) -> Header:
|
69
81
|
"""Locate and read a frame header.
|
70
82
|
|
71
83
|
Raise pyplumio.ReadError if header size is too small and
|
72
|
-
OSError
|
84
|
+
OSError if serial connection is broken.
|
73
85
|
"""
|
74
86
|
while buffer := await self._reader.read(1):
|
75
87
|
if FRAME_START not in buffer:
|
@@ -79,23 +91,7 @@ class FrameReader:
|
|
79
91
|
if len(buffer) < struct_header.size:
|
80
92
|
raise ReadError(f"Header can't be less than {struct_header.size} bytes")
|
81
93
|
|
82
|
-
|
83
|
-
_,
|
84
|
-
length,
|
85
|
-
recipient,
|
86
|
-
sender,
|
87
|
-
econet_type,
|
88
|
-
econet_version,
|
89
|
-
] = struct_header.unpack_from(buffer)
|
90
|
-
|
91
|
-
return (
|
92
|
-
buffer,
|
93
|
-
length,
|
94
|
-
recipient,
|
95
|
-
sender,
|
96
|
-
econet_type,
|
97
|
-
econet_version,
|
98
|
-
)
|
94
|
+
return Header(buffer, *struct_header.unpack_from(buffer))
|
99
95
|
|
100
96
|
raise OSError("Serial connection broken")
|
101
97
|
|
@@ -108,8 +104,9 @@ class FrameReader:
|
|
108
104
|
checksum.
|
109
105
|
"""
|
110
106
|
(
|
111
|
-
|
112
|
-
|
107
|
+
header_bytes,
|
108
|
+
_,
|
109
|
+
frame_length,
|
113
110
|
recipient,
|
114
111
|
sender,
|
115
112
|
econet_type,
|
@@ -122,19 +119,21 @@ class FrameReader:
|
|
122
119
|
if not is_known_device_type(sender):
|
123
120
|
raise UnknownDeviceError(f"Unknown sender type ({sender})")
|
124
121
|
|
125
|
-
if
|
126
|
-
raise ReadError(f"Unexpected frame length ({
|
122
|
+
if frame_length > MAX_FRAME_LENGTH or frame_length < MIN_FRAME_LENGTH:
|
123
|
+
raise ReadError(f"Unexpected frame length ({frame_length})")
|
127
124
|
|
128
125
|
try:
|
129
|
-
payload = await self._reader.readexactly(
|
126
|
+
payload = await self._reader.readexactly(frame_length - struct_header.size)
|
130
127
|
except IncompleteReadError as e:
|
131
128
|
raise ReadError(
|
132
129
|
"Got an incomplete frame while trying to read "
|
133
|
-
+ f"'{
|
130
|
+
+ f"'{frame_length - struct_header.size}' bytes"
|
134
131
|
) from e
|
135
132
|
|
136
|
-
if
|
137
|
-
raise ChecksumError(
|
133
|
+
if (checksum := bcc(header_bytes + payload[:-2])) and checksum != payload[-2]:
|
134
|
+
raise ChecksumError(
|
135
|
+
f"Incorrect frame checksum ({checksum} != {payload[-2]})"
|
136
|
+
)
|
138
137
|
|
139
138
|
frame = await Frame.create(
|
140
139
|
frame_type=payload[0],
|
@@ -18,7 +18,7 @@ DEFAULT_NETMASK: Final = "255.255.255.0"
|
|
18
18
|
NETWORK_INFO_SIZE: Final = 25
|
19
19
|
|
20
20
|
|
21
|
-
@dataclass
|
21
|
+
@dataclass(frozen=True)
|
22
22
|
class EthernetParameters:
|
23
23
|
"""Represents an ethernet parameters."""
|
24
24
|
|
@@ -35,7 +35,7 @@ class EthernetParameters:
|
|
35
35
|
status: bool = True
|
36
36
|
|
37
37
|
|
38
|
-
@dataclass
|
38
|
+
@dataclass(frozen=True)
|
39
39
|
class WirelessParameters(EthernetParameters):
|
40
40
|
"""Represents a wireless network parameters."""
|
41
41
|
|
@@ -50,7 +50,7 @@ class WirelessParameters(EthernetParameters):
|
|
50
50
|
signal_quality: int = 100
|
51
51
|
|
52
52
|
|
53
|
-
@dataclass
|
53
|
+
@dataclass(frozen=True)
|
54
54
|
class NetworkInfo:
|
55
55
|
"""Represents a network parameters."""
|
56
56
|
|
File without changes
|
File without changes
|
File without changes
|