PyPlumIO 0.6.0__py3-none-any.whl → 0.6.2__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/__init__.py +3 -1
- pyplumio/_version.py +2 -2
- pyplumio/connection.py +0 -36
- pyplumio/const.py +0 -5
- pyplumio/data_types.py +2 -2
- pyplumio/devices/__init__.py +23 -5
- pyplumio/devices/ecomax.py +30 -53
- pyplumio/devices/ecoster.py +2 -3
- pyplumio/filters.py +199 -136
- pyplumio/frames/__init__.py +101 -15
- pyplumio/frames/messages.py +8 -65
- pyplumio/frames/requests.py +38 -38
- pyplumio/frames/responses.py +30 -86
- pyplumio/helpers/async_cache.py +13 -8
- pyplumio/helpers/event_manager.py +24 -18
- pyplumio/helpers/factory.py +0 -3
- pyplumio/parameters/__init__.py +38 -35
- pyplumio/protocol.py +63 -47
- pyplumio/structures/alerts.py +2 -2
- pyplumio/structures/ecomax_parameters.py +1 -1
- pyplumio/structures/frame_versions.py +3 -2
- pyplumio/structures/mixer_parameters.py +5 -3
- pyplumio/structures/network_info.py +1 -0
- pyplumio/structures/product_info.py +1 -1
- pyplumio/structures/program_version.py +2 -2
- pyplumio/structures/schedules.py +8 -40
- pyplumio/structures/sensor_data.py +498 -0
- pyplumio/structures/thermostat_parameters.py +7 -4
- pyplumio/utils.py +41 -4
- {pyplumio-0.6.0.dist-info → pyplumio-0.6.2.dist-info}/METADATA +7 -8
- pyplumio-0.6.2.dist-info/RECORD +50 -0
- pyplumio/structures/boiler_load.py +0 -32
- pyplumio/structures/boiler_power.py +0 -33
- pyplumio/structures/fan_power.py +0 -33
- pyplumio/structures/fuel_consumption.py +0 -36
- pyplumio/structures/fuel_level.py +0 -39
- pyplumio/structures/lambda_sensor.py +0 -57
- pyplumio/structures/mixer_sensors.py +0 -80
- pyplumio/structures/modules.py +0 -102
- pyplumio/structures/output_flags.py +0 -47
- pyplumio/structures/outputs.py +0 -88
- pyplumio/structures/pending_alerts.py +0 -28
- pyplumio/structures/statuses.py +0 -52
- pyplumio/structures/temperatures.py +0 -94
- pyplumio/structures/thermostat_sensors.py +0 -106
- pyplumio-0.6.0.dist-info/RECORD +0 -63
- {pyplumio-0.6.0.dist-info → pyplumio-0.6.2.dist-info}/WHEEL +0 -0
- {pyplumio-0.6.0.dist-info → pyplumio-0.6.2.dist-info}/licenses/LICENSE +0 -0
- {pyplumio-0.6.0.dist-info → pyplumio-0.6.2.dist-info}/top_level.txt +0 -0
pyplumio/protocol.py
CHANGED
@@ -27,7 +27,7 @@ from pyplumio.structures.regulator_data import ATTR_REGDATA
|
|
27
27
|
|
28
28
|
_LOGGER = logging.getLogger(__name__)
|
29
29
|
|
30
|
-
|
30
|
+
ConnectionLostCallback: TypeAlias = Callable[[], Awaitable[None]]
|
31
31
|
|
32
32
|
|
33
33
|
class Protocol(ABC):
|
@@ -36,7 +36,7 @@ class Protocol(ABC):
|
|
36
36
|
connected: asyncio.Event
|
37
37
|
reader: FrameReader | None
|
38
38
|
writer: FrameWriter | None
|
39
|
-
_on_connection_lost: set[
|
39
|
+
_on_connection_lost: set[ConnectionLostCallback]
|
40
40
|
|
41
41
|
def __init__(self) -> None:
|
42
42
|
"""Initialize a new protocol."""
|
@@ -53,7 +53,7 @@ class Protocol(ABC):
|
|
53
53
|
self.writer = None
|
54
54
|
|
55
55
|
@property
|
56
|
-
def on_connection_lost(self) -> set[
|
56
|
+
def on_connection_lost(self) -> set[ConnectionLostCallback]:
|
57
57
|
"""Return the callbacks that'll be called on connection lost."""
|
58
58
|
return self._on_connection_lost
|
59
59
|
|
@@ -105,8 +105,8 @@ class DummyProtocol(Protocol):
|
|
105
105
|
class Queues:
|
106
106
|
"""Represents asyncio queues."""
|
107
107
|
|
108
|
-
read: asyncio.Queue[Frame]
|
109
|
-
write: asyncio.Queue[Frame]
|
108
|
+
read: asyncio.Queue[Frame] = field(default_factory=asyncio.Queue)
|
109
|
+
write: asyncio.Queue[Frame] = field(default_factory=asyncio.Queue)
|
110
110
|
|
111
111
|
async def join(self) -> None:
|
112
112
|
"""Wait for queues to finish."""
|
@@ -145,25 +145,29 @@ class Statistics:
|
|
145
145
|
connection_losses: int = 0
|
146
146
|
|
147
147
|
#: List of statistics for connected devices
|
148
|
-
devices:
|
148
|
+
devices: set[DeviceStatistics] = field(default_factory=set)
|
149
149
|
|
150
|
-
def
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
if sent:
|
155
|
-
self.sent_bytes += sent.length
|
156
|
-
self.sent_frames += 1
|
150
|
+
def update_sent(self, frame: Frame) -> None:
|
151
|
+
"""Update sent frames statistics."""
|
152
|
+
self.sent_bytes += frame.length
|
153
|
+
self.sent_frames += 1
|
157
154
|
|
158
|
-
|
159
|
-
|
160
|
-
|
155
|
+
def update_received(self, frame: Frame) -> None:
|
156
|
+
"""Update received frames statistics."""
|
157
|
+
self.received_bytes += frame.length
|
158
|
+
self.received_frames += 1
|
161
159
|
|
162
|
-
def
|
163
|
-
"""
|
160
|
+
def update_connection_lost(self) -> None:
|
161
|
+
"""Update connection lost counter."""
|
164
162
|
self.connection_losses += 1
|
165
163
|
self.connection_loss_at = datetime.now()
|
166
164
|
|
165
|
+
def update_devices(self, device: PhysicalDevice) -> None:
|
166
|
+
"""Update connected devices."""
|
167
|
+
device_statistics = DeviceStatistics(address=device.address)
|
168
|
+
device.subscribe(ATTR_REGDATA, device_statistics.update_last_seen)
|
169
|
+
self.devices.add(device_statistics)
|
170
|
+
|
167
171
|
def reset_transfer_statistics(self) -> None:
|
168
172
|
"""Reset transfer statistics."""
|
169
173
|
self.sent_bytes = 0
|
@@ -173,18 +177,22 @@ class Statistics:
|
|
173
177
|
self.failed_frames = 0
|
174
178
|
|
175
179
|
|
176
|
-
@dataclass(slots=True)
|
180
|
+
@dataclass(slots=True, kw_only=True)
|
177
181
|
class DeviceStatistics:
|
178
182
|
"""Represents a device statistics."""
|
179
183
|
|
180
|
-
#: Device
|
181
|
-
|
184
|
+
#: Device address
|
185
|
+
address: int
|
182
186
|
|
183
187
|
#: Datetime object representing connection time
|
184
|
-
|
188
|
+
first_seen: datetime = field(default_factory=datetime.now)
|
185
189
|
|
186
190
|
#: Datetime object representing time when device was last seen
|
187
|
-
last_seen: datetime
|
191
|
+
last_seen: datetime = field(default_factory=datetime.now)
|
192
|
+
|
193
|
+
def __hash__(self) -> int:
|
194
|
+
"""Return a hash of the statistics based on unique address."""
|
195
|
+
return self.address
|
188
196
|
|
189
197
|
async def update_last_seen(self, _: Any) -> None:
|
190
198
|
"""Update last seen property."""
|
@@ -226,9 +234,9 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
|
|
226
234
|
eth=ethernet_parameters or EthernetParameters(status=False),
|
227
235
|
wlan=wireless_parameters or WirelessParameters(status=False),
|
228
236
|
)
|
229
|
-
self._queues = Queues(read=asyncio.Queue(), write=asyncio.Queue())
|
230
237
|
self._entry_lock = asyncio.Lock()
|
231
238
|
self._statistics = Statistics()
|
239
|
+
self._queues = Queues()
|
232
240
|
|
233
241
|
def connection_established(
|
234
242
|
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
@@ -241,10 +249,10 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
|
|
241
249
|
self.frame_producer(self._queues, reader=self.reader, writer=self.writer),
|
242
250
|
name="frame_producer_task",
|
243
251
|
)
|
244
|
-
for
|
252
|
+
for consumer_id in range(self.consumers_count):
|
245
253
|
self.create_task(
|
246
254
|
self.frame_consumer(self._queues.read),
|
247
|
-
name=f"frame_consumer_task ({
|
255
|
+
name=f"frame_consumer_task ({consumer_id})",
|
248
256
|
)
|
249
257
|
|
250
258
|
for device in self.data.values():
|
@@ -277,30 +285,39 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
|
|
277
285
|
await self._connection_close()
|
278
286
|
await asyncio.gather(*(device.shutdown() for device in self.data.values()))
|
279
287
|
|
288
|
+
async def _write_from_queue(
|
289
|
+
self, writer: FrameWriter, queue: asyncio.Queue[Frame]
|
290
|
+
) -> None:
|
291
|
+
"""Send frame from the queue to the remote device."""
|
292
|
+
frame = await queue.get()
|
293
|
+
await writer.write(frame)
|
294
|
+
queue.task_done()
|
295
|
+
self.statistics.update_sent(frame)
|
296
|
+
|
297
|
+
async def _read_into_queue(
|
298
|
+
self, reader: FrameReader, queue: asyncio.Queue[Frame]
|
299
|
+
) -> None:
|
300
|
+
"""Read frame from the remote device into the queue."""
|
301
|
+
if frame := await reader.read():
|
302
|
+
queue.put_nowait(frame)
|
303
|
+
self.statistics.update_received(frame)
|
304
|
+
|
280
305
|
async def frame_producer(
|
281
306
|
self, queues: Queues, reader: FrameReader, writer: FrameWriter
|
282
307
|
) -> None:
|
283
308
|
"""Handle frame reads and writes."""
|
284
|
-
statistics = self.statistics
|
285
309
|
await self.connected.wait()
|
286
310
|
while self.connected.is_set():
|
287
311
|
try:
|
288
|
-
request = None
|
289
312
|
if not queues.write.empty():
|
290
|
-
|
291
|
-
await writer.write(request)
|
292
|
-
queues.write.task_done()
|
293
|
-
|
294
|
-
if response := await reader.read():
|
295
|
-
queues.read.put_nowait(response)
|
296
|
-
|
297
|
-
statistics.update_transfer_statistics(request, response)
|
313
|
+
await self._write_from_queue(writer, queues.write)
|
298
314
|
|
315
|
+
await self._read_into_queue(reader, queues.read)
|
299
316
|
except ProtocolError as e:
|
300
|
-
statistics.failed_frames += 1
|
317
|
+
self.statistics.failed_frames += 1
|
301
318
|
_LOGGER.debug("Can't process received frame: %s", e)
|
302
319
|
except (OSError, asyncio.TimeoutError):
|
303
|
-
statistics.
|
320
|
+
self.statistics.update_connection_lost()
|
304
321
|
self.create_task(self.connection_lost())
|
305
322
|
break
|
306
323
|
except Exception:
|
@@ -327,14 +344,7 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
|
|
327
344
|
device.dispatch_nowait(ATTR_CONNECTED, True)
|
328
345
|
device.dispatch_nowait(ATTR_SETUP, True)
|
329
346
|
await self.dispatch(name, device)
|
330
|
-
self.statistics.
|
331
|
-
device_statistics := DeviceStatistics(
|
332
|
-
name=name,
|
333
|
-
connected_since=datetime.now(),
|
334
|
-
last_seen=datetime.now(),
|
335
|
-
)
|
336
|
-
)
|
337
|
-
device.subscribe(ATTR_REGDATA, device_statistics.update_last_seen)
|
347
|
+
self.statistics.update_devices(device)
|
338
348
|
|
339
349
|
return self.data[name]
|
340
350
|
|
@@ -344,4 +354,10 @@ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
|
|
344
354
|
return self._statistics
|
345
355
|
|
346
356
|
|
347
|
-
__all__ = [
|
357
|
+
__all__ = [
|
358
|
+
"Protocol",
|
359
|
+
"DummyProtocol",
|
360
|
+
"AsyncProtocol",
|
361
|
+
"Statistics",
|
362
|
+
"ConnectionLostCallback",
|
363
|
+
]
|
pyplumio/structures/alerts.py
CHANGED
@@ -46,12 +46,12 @@ def seconds_to_datetime(timestamp: int) -> datetime:
|
|
46
46
|
in seconds counted from Jan 1st, 2000.
|
47
47
|
"""
|
48
48
|
|
49
|
-
def datetime_kwargs(timestamp: int) -> Generator[Any,
|
49
|
+
def datetime_kwargs(timestamp: int) -> Generator[tuple[Any, int]]:
|
50
50
|
"""Yield a tuple, that represents a single datetime kwarg."""
|
51
51
|
for name, seconds, offset in DATETIME_INTERVALS:
|
52
52
|
value = timestamp // seconds
|
53
53
|
timestamp -= value * seconds
|
54
|
-
yield name,
|
54
|
+
yield name, value + offset
|
55
55
|
|
56
56
|
return datetime(**dict(datetime_kwargs(timestamp)))
|
57
57
|
|
@@ -24,7 +24,7 @@ class EcomaxParametersStructure(StructureDecoder):
|
|
24
24
|
|
25
25
|
def _ecomax_parameter(
|
26
26
|
self, message: bytearray, start: int, end: int
|
27
|
-
) -> Generator[tuple[int, ParameterValues]
|
27
|
+
) -> Generator[tuple[int, ParameterValues]]:
|
28
28
|
"""Unpack an ecoMAX parameter."""
|
29
29
|
for index in range(start, start + end):
|
30
30
|
if parameter := unpack_parameter(message, self._offset):
|
@@ -23,8 +23,9 @@ class FrameVersionsStructure(StructureDecoder):
|
|
23
23
|
def _unpack_frame_versions(self, message: bytearray) -> tuple[FrameType | int, int]:
|
24
24
|
"""Unpack frame versions."""
|
25
25
|
frame_type = message[self._offset]
|
26
|
-
|
27
|
-
|
26
|
+
self._offset += 1
|
27
|
+
version = UnsignedShort.from_bytes(message, self._offset)
|
28
|
+
self._offset += version.size
|
28
29
|
with suppress(ValueError):
|
29
30
|
frame_type = FrameType(frame_type)
|
30
31
|
|
@@ -3,7 +3,7 @@
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
5
|
from collections.abc import Generator
|
6
|
-
from typing import Any, Final
|
6
|
+
from typing import Any, Final, TypeAlias
|
7
7
|
|
8
8
|
from pyplumio.parameters import ParameterValues, unpack_parameter
|
9
9
|
from pyplumio.structures import StructureDecoder
|
@@ -13,6 +13,8 @@ ATTR_MIXER_PARAMETERS: Final = "mixer_parameters"
|
|
13
13
|
|
14
14
|
MIXER_PARAMETER_SIZE: Final = 3
|
15
15
|
|
16
|
+
_ParameterValues: TypeAlias = tuple[int, ParameterValues]
|
17
|
+
|
16
18
|
|
17
19
|
class MixerParametersStructure(StructureDecoder):
|
18
20
|
"""Represents a mixer parameters data structure."""
|
@@ -23,7 +25,7 @@ class MixerParametersStructure(StructureDecoder):
|
|
23
25
|
|
24
26
|
def _mixer_parameter(
|
25
27
|
self, message: bytearray, start: int, end: int
|
26
|
-
) -> Generator[
|
28
|
+
) -> Generator[_ParameterValues]:
|
27
29
|
"""Get a single mixer parameter."""
|
28
30
|
for index in range(start, start + end):
|
29
31
|
if parameter := unpack_parameter(message, self._offset):
|
@@ -33,7 +35,7 @@ class MixerParametersStructure(StructureDecoder):
|
|
33
35
|
|
34
36
|
def _mixer_parameters(
|
35
37
|
self, message: bytearray, mixers: int, start: int, end: int
|
36
|
-
) -> Generator[tuple[int, list[
|
38
|
+
) -> Generator[tuple[int, list[_ParameterValues]]]:
|
37
39
|
"""Get parameters for a mixer."""
|
38
40
|
for index in range(mixers):
|
39
41
|
if parameters := list(self._mixer_parameter(message, start, end)):
|
@@ -88,6 +88,7 @@ class NetworkInfoStructure(Structure):
|
|
88
88
|
self, message: bytearray, offset: int = 0, data: dict[str, Any] | None = None
|
89
89
|
) -> tuple[dict[str, Any], int]:
|
90
90
|
"""Decode bytes and return message data and offset."""
|
91
|
+
offset += 1
|
91
92
|
return (
|
92
93
|
ensure_dict(
|
93
94
|
data,
|
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|
6
6
|
import struct
|
7
7
|
from typing import Any, Final
|
8
8
|
|
9
|
-
from pyplumio
|
9
|
+
from pyplumio import version_tuple
|
10
10
|
from pyplumio.structures import Structure
|
11
11
|
from pyplumio.utils import ensure_dict
|
12
12
|
|
@@ -14,7 +14,7 @@ ATTR_VERSION: Final = "version"
|
|
14
14
|
|
15
15
|
VERSION_INFO_SIZE: Final = 15
|
16
16
|
|
17
|
-
SOFTWARE_VERSION: Final = ".".join(str(x) for x in
|
17
|
+
SOFTWARE_VERSION: Final = ".".join(str(x) for x in version_tuple[0:3])
|
18
18
|
|
19
19
|
struct_program_version = struct.Struct("<2sB2s3s3HB")
|
20
20
|
|
pyplumio/structures/schedules.py
CHANGED
@@ -2,11 +2,11 @@
|
|
2
2
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
|
-
from collections.abc import Iterable, Iterator, MutableMapping
|
5
|
+
from collections.abc import Iterable, Iterator, MutableMapping
|
6
6
|
from dataclasses import dataclass
|
7
7
|
import datetime as dt
|
8
|
-
from functools import lru_cache
|
9
|
-
from typing import Annotated, Any, Final, get_args
|
8
|
+
from functools import lru_cache
|
9
|
+
from typing import Annotated, Any, Final, TypeAlias, get_args
|
10
10
|
|
11
11
|
from pyplumio.const import (
|
12
12
|
ATTR_PARAMETER,
|
@@ -19,7 +19,6 @@ from pyplumio.const import (
|
|
19
19
|
State,
|
20
20
|
)
|
21
21
|
from pyplumio.devices import Device, PhysicalDevice
|
22
|
-
from pyplumio.exceptions import FrameDataError
|
23
22
|
from pyplumio.frames import Request
|
24
23
|
from pyplumio.parameters import (
|
25
24
|
Number,
|
@@ -31,8 +30,8 @@ from pyplumio.parameters import (
|
|
31
30
|
SwitchDescription,
|
32
31
|
unpack_parameter,
|
33
32
|
)
|
34
|
-
from pyplumio.structures import
|
35
|
-
from pyplumio.utils import ensure_dict
|
33
|
+
from pyplumio.structures import StructureDecoder
|
34
|
+
from pyplumio.utils import ensure_dict, split_byte
|
36
35
|
|
37
36
|
ATTR_SCHEDULES: Final = "schedules"
|
38
37
|
ATTR_SCHEDULE_PARAMETERS: Final = "schedule_parameters"
|
@@ -156,7 +155,7 @@ def collect_schedule_data(name: str, device: Device) -> dict[str, Any]:
|
|
156
155
|
|
157
156
|
TIME_FORMAT: Final = "%H:%M"
|
158
157
|
|
159
|
-
Time = Annotated[str, "Time string in %H:%M format"]
|
158
|
+
Time: TypeAlias = Annotated[str, "Time string in %H:%M format"]
|
160
159
|
|
161
160
|
MIDNIGHT: Final = Time("00:00")
|
162
161
|
MIDNIGHT_DT = dt.datetime.strptime(MIDNIGHT, TIME_FORMAT)
|
@@ -305,51 +304,20 @@ class Schedule(Iterable):
|
|
305
304
|
)
|
306
305
|
|
307
306
|
|
308
|
-
|
309
|
-
"""Split single byte into an eight bits."""
|
310
|
-
return [bool(byte & (1 << bit)) for bit in reversed(range(8))]
|
311
|
-
|
312
|
-
|
313
|
-
def _join_bits(bits: Sequence[int | bool]) -> int:
|
314
|
-
"""Join eight bits into a single byte."""
|
315
|
-
return reduce(lambda bit, byte: (bit << 1) | byte, bits)
|
316
|
-
|
317
|
-
|
318
|
-
class SchedulesStructure(Structure):
|
307
|
+
class SchedulesStructure(StructureDecoder):
|
319
308
|
"""Represents a schedule data structure."""
|
320
309
|
|
321
310
|
__slots__ = ("_offset",)
|
322
311
|
|
323
312
|
_offset: int
|
324
313
|
|
325
|
-
def encode(self, data: dict[str, Any]) -> bytearray:
|
326
|
-
"""Encode data to the bytearray message."""
|
327
|
-
try:
|
328
|
-
header = bytearray(
|
329
|
-
b"\1"
|
330
|
-
+ SCHEDULES.index(data[ATTR_TYPE]).to_bytes(
|
331
|
-
length=1, byteorder="little"
|
332
|
-
)
|
333
|
-
+ int(data[ATTR_SWITCH]).to_bytes(length=1, byteorder="little")
|
334
|
-
+ int(data[ATTR_PARAMETER]).to_bytes(length=1, byteorder="little")
|
335
|
-
)
|
336
|
-
schedule = data[ATTR_SCHEDULE]
|
337
|
-
except (KeyError, ValueError) as e:
|
338
|
-
raise FrameDataError from e
|
339
|
-
|
340
|
-
return header + bytearray(
|
341
|
-
_join_bits(day[i : i + 8])
|
342
|
-
for day in schedule
|
343
|
-
for i in range(0, len(day), 8)
|
344
|
-
)
|
345
|
-
|
346
314
|
def _unpack_schedule(self, message: bytearray) -> list[list[bool]]:
|
347
315
|
"""Unpack a schedule."""
|
348
316
|
offset = self._offset
|
349
317
|
schedule = [
|
350
318
|
bit
|
351
319
|
for i in range(offset, offset + SCHEDULE_SIZE)
|
352
|
-
for bit in
|
320
|
+
for bit in split_byte(message[i])
|
353
321
|
]
|
354
322
|
self._offset = offset + SCHEDULE_SIZE
|
355
323
|
# Split the schedule. Each day consists of 48 half-hour intervals.
|