PyPlumIO 0.5.19__py3-none-any.whl → 0.5.20__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyPlumIO
3
- Version: 0.5.19
3
+ Version: 0.5.20
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
@@ -38,7 +38,7 @@ Requires-Dist: mypy ==1.10.0 ; extra == 'test'
38
38
  Requires-Dist: pyserial-asyncio-fast ==0.11 ; extra == 'test'
39
39
  Requires-Dist: pytest ==8.2.1 ; extra == 'test'
40
40
  Requires-Dist: pytest-asyncio ==0.23.7 ; extra == 'test'
41
- Requires-Dist: ruff ==0.4.5 ; extra == 'test'
41
+ Requires-Dist: ruff ==0.4.7 ; extra == 'test'
42
42
  Requires-Dist: tox ==4.15.0 ; extra == 'test'
43
43
  Requires-Dist: types-pyserial ==3.5.0.20240527 ; extra == 'test'
44
44
 
@@ -1,15 +1,15 @@
1
1
  pyplumio/__init__.py,sha256=cclyAwy7OsW673iHcwkVrJSNnf32oF51Y_0uEEF5cdI,3293
2
2
  pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
- pyplumio/_version.py,sha256=aaNQTfdqRF8htFsemknyCzmnRLGw2JWETdmpWppxxVQ,413
4
- pyplumio/connection.py,sha256=IFSIUyMj8YkXW7TFP23nNGjs8QoZafgCPYmxaQpvads,6200
3
+ pyplumio/_version.py,sha256=eONbmd59kUJvI6efSg_Cy7fvmhvKc1Uota8ebahOmE0,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=y4JRN5hQVtxMt_y0XwqdSVd_tK_vzPgW8Zje-UunyW0,8184
8
+ pyplumio/protocol.py,sha256=i4C7WYALp6BEHzeMjiebH8GWI2qGIEPk6OlUIx_2UP4,7870
9
9
  pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- pyplumio/stream.py,sha256=lKffNkI__oK3kS3_K0m4nyINm_7-sSv7xbu3xleyhJg,4486
10
+ pyplumio/stream.py,sha256=DqMqdi3HG9hODgfGo4eTKLkfoaSh5RS4kBHNn3ODvVg,4472
11
11
  pyplumio/utils.py,sha256=GV7P1hPLoQsx3uqYviQ15FXJmkmTxwtDibAc-yRarvo,688
12
- pyplumio/devices/__init__.py,sha256=MXNPnIcIykE6zrqh9yaP7vjMf_ysyrHauDerDNkInNQ,6502
12
+ pyplumio/devices/__init__.py,sha256=O5SyEt_x1nJ1JYkG6v3dTZ54tu9sKIdj4l256JhvLHg,6585
13
13
  pyplumio/devices/ecomax.py,sha256=W9YW4nw6v2wdKKqFblUx03_hFJw0dVjnVkDHNL8P2dg,16632
14
14
  pyplumio/devices/ecoster.py,sha256=J4YtPmFmFwaq4LzYf28aMmB97cRAbMsVyUdBLGki42g,313
15
15
  pyplumio/devices/mixer.py,sha256=PGk0lXveN6q5sm0B50YG0yCVYoKi1a9TA3v44bGgi3A,2947
@@ -20,12 +20,12 @@ pyplumio/frames/requests.py,sha256=Ra8xH5oKYhkEUtadN-9ZsJKkt5xZkz5O7edQVsDhNsM,7
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=EJLNykwgTSl_RRE2ulGny3Matr_FSi73rKrdLXIX6ss,6005
23
+ pyplumio/helpers/event_manager.py,sha256=eSaqFGwED_UlugDwGRusfpivolqV6TISs1XnzBH6e60,5863
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
27
  pyplumio/helpers/task_manager.py,sha256=RpdYSguc0cap_Onf9VnL-yCd_KwR2JPD49trZCRKPpI,1090
28
- pyplumio/helpers/timeout.py,sha256=ou_pgExP5HqhrOdqEiqadVnRnelPd-N69VIIrDVq-aA,1287
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
@@ -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.19.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
58
- PyPlumIO-0.5.19.dist-info/METADATA,sha256=7Vbk_OIL0hieiiT0U5LFjYi7dAJkzwAXrxeVnZmG_-A,5415
59
- PyPlumIO-0.5.19.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
60
- PyPlumIO-0.5.19.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
61
- PyPlumIO-0.5.19.dist-info/RECORD,,
57
+ PyPlumIO-0.5.20.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
58
+ PyPlumIO-0.5.20.dist-info/METADATA,sha256=nTyxUOv5kPz-K5xNnm389H-eFlLN48mXYMmc3E4zHQ0,5415
59
+ PyPlumIO-0.5.20.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
60
+ PyPlumIO-0.5.20.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
61
+ PyPlumIO-0.5.20.dist-info/RECORD,,
pyplumio/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.5.19'
16
- __version_tuple__ = version_tuple = (0, 5, 19)
15
+ __version__ = version = '0.5.20'
16
+ __version_tuple__ = version_tuple = (0, 5, 20)
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 = cast(
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
@@ -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.create_task(
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,
@@ -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."""
@@ -17,30 +17,16 @@ _LOGGER = logging.getLogger(__name__)
17
17
 
18
18
 
19
19
  def timeout(
20
- seconds: int, raise_exception: bool = True
21
- ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T | None]]]:
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 | None]]:
26
+ ) -> Callable[P, Coroutine[Any, Any, T]]:
30
27
  @wraps(func)
31
- async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | None:
32
- try:
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,20 @@ class DummyProtocol(Protocol):
101
96
  await self.close_writer()
102
97
 
103
98
 
104
- class Queues(NamedTuple):
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
+ for queue in (self.read, self.write):
111
+ await queue.join()
112
+
110
113
 
111
114
  class AsyncProtocol(Protocol, EventManager):
112
115
  """Represents an async protocol.
@@ -117,11 +120,11 @@ class AsyncProtocol(Protocol, EventManager):
117
120
  The frame producer tries to read frames from the write queue.
118
121
  If any is available, it sends them to the device via frame writer.
119
122
 
120
- It then reads stream via frame reader, creates device entry and puts
121
- received frame into the read queue.
123
+ It then reads stream via frame reader and puts received frame
124
+ into the read queue.
122
125
 
123
- Frame consumers read frames from the read queue and send frame to
124
- their respective device class for further processing.
126
+ Frame consumers read frames from the read queue, create device
127
+ entry, if needed, and send frame to the entry for the processing.
125
128
  """
126
129
 
127
130
  consumers_count: int
@@ -139,18 +142,10 @@ class AsyncProtocol(Protocol, EventManager):
139
142
  super().__init__()
140
143
  self.consumers_count = consumers_count
141
144
  self._network = NetworkInfo(
142
- eth=(
143
- EthernetParameters(status=False)
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
- ),
145
+ eth=ethernet_parameters or EthernetParameters(status=False),
146
+ wlan=wireless_parameters or WirelessParameters(status=False),
152
147
  )
153
- self._queues = Queues(asyncio.Queue(), asyncio.Queue())
148
+ self._queues = Queues(read=asyncio.Queue(), write=asyncio.Queue())
154
149
 
155
150
  def connection_established(
156
151
  self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
@@ -159,7 +154,9 @@ class AsyncProtocol(Protocol, EventManager):
159
154
  self.reader = FrameReader(reader)
160
155
  self.writer = FrameWriter(writer)
161
156
  self._queues.write.put_nowait(StartMasterRequest(recipient=DeviceType.ECOMAX))
162
- self.create_task(self.frame_producer(self._queues))
157
+ self.create_task(
158
+ self.frame_producer(self._queues, reader=self.reader, writer=self.writer)
159
+ )
163
160
  for _ in range(self.consumers_count):
164
161
  self.create_task(self.frame_consumer(self._queues.read))
165
162
 
@@ -184,8 +181,9 @@ class AsyncProtocol(Protocol, EventManager):
184
181
 
185
182
  async def shutdown(self) -> None:
186
183
  """Shutdown protocol tasks."""
187
- await asyncio.gather(*[queue.join() for queue in self._queues])
188
- await super(Protocol, self).shutdown()
184
+ await self._queues.join()
185
+ self.cancel_tasks()
186
+ await self.wait_until_done()
189
187
  for device in self.data.values():
190
188
  await device.shutdown()
191
189
 
@@ -193,33 +191,27 @@ class AsyncProtocol(Protocol, EventManager):
193
191
  self.connected.clear()
194
192
  await self.close_writer()
195
193
 
196
- async def frame_producer(self, queues: Queues) -> None:
194
+ async def frame_producer(
195
+ self, queues: Queues, reader: FrameReader, writer: FrameWriter
196
+ ) -> None:
197
197
  """Handle frame reads and writes."""
198
198
  await self.connected.wait()
199
- reader = cast(FrameReader, self.reader)
200
- writer = cast(FrameWriter, self.writer)
201
199
  while self.connected.is_set():
202
200
  try:
203
- if queues.write.qsize() > 0:
201
+ if not queues.write.empty():
204
202
  await writer.write(await queues.write.get())
205
203
  queues.write.task_done()
206
204
 
207
205
  if (response := await reader.read()) is not None:
208
206
  queues.read.put_nowait(response)
209
207
 
210
- except FrameDataError as e:
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:
208
+ except (ReadError, UnknownDeviceError, FrameError) as e:
217
209
  _LOGGER.debug("Can't process received frame: %s", e)
218
210
  except (OSError, asyncio.TimeoutError):
219
211
  self.create_task(self.connection_lost())
220
212
  break
221
- except Exception as e: # pylint: disable=broad-except
222
- _LOGGER.exception(e)
213
+ except Exception:
214
+ _LOGGER.exception("Unexpected exception")
223
215
 
224
216
  async def frame_consumer(self, queue: asyncio.Queue) -> None:
225
217
  """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) -> tuple[bytes, int, int, int, int, int]:
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 on broken connection.
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
- header,
112
- length,
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 length > MAX_FRAME_LENGTH or length < MIN_FRAME_LENGTH:
126
- raise ReadError(f"Unexpected frame length ({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(length - struct_header.size)
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"'{length - struct_header.size}' bytes"
130
+ + f"'{frame_length - struct_header.size}' bytes"
134
131
  ) from e
135
132
 
136
- if payload[-2] != bcc(header + payload[:-2]):
137
- raise ChecksumError(f"Incorrect frame checksum ({payload[-2]})")
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],