PyPlumIO 0.5.24__py3-none-any.whl → 0.5.26__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.
Files changed (37) hide show
  1. {PyPlumIO-0.5.24.dist-info → PyPlumIO-0.5.26.dist-info}/METADATA +7 -7
  2. PyPlumIO-0.5.26.dist-info/RECORD +60 -0
  3. {PyPlumIO-0.5.24.dist-info → PyPlumIO-0.5.26.dist-info}/WHEEL +1 -1
  4. pyplumio/_version.py +2 -2
  5. pyplumio/devices/__init__.py +27 -28
  6. pyplumio/devices/ecomax.py +51 -57
  7. pyplumio/devices/ecoster.py +3 -5
  8. pyplumio/devices/mixer.py +5 -8
  9. pyplumio/devices/thermostat.py +3 -3
  10. pyplumio/filters.py +6 -6
  11. pyplumio/frames/__init__.py +6 -6
  12. pyplumio/frames/messages.py +3 -3
  13. pyplumio/frames/requests.py +18 -18
  14. pyplumio/frames/responses.py +15 -15
  15. pyplumio/helpers/data_types.py +105 -69
  16. pyplumio/helpers/event_manager.py +7 -7
  17. pyplumio/helpers/factory.py +5 -6
  18. pyplumio/helpers/parameter.py +24 -15
  19. pyplumio/helpers/schedule.py +50 -46
  20. pyplumio/helpers/timeout.py +1 -1
  21. pyplumio/protocol.py +6 -7
  22. pyplumio/structures/alerts.py +8 -6
  23. pyplumio/structures/ecomax_parameters.py +30 -26
  24. pyplumio/structures/frame_versions.py +2 -3
  25. pyplumio/structures/mixer_parameters.py +9 -6
  26. pyplumio/structures/mixer_sensors.py +10 -8
  27. pyplumio/structures/modules.py +9 -7
  28. pyplumio/structures/network_info.py +16 -16
  29. pyplumio/structures/program_version.py +3 -0
  30. pyplumio/structures/regulator_data.py +2 -4
  31. pyplumio/structures/regulator_data_schema.py +2 -3
  32. pyplumio/structures/schedules.py +33 -35
  33. pyplumio/structures/thermostat_parameters.py +6 -4
  34. pyplumio/structures/thermostat_sensors.py +13 -10
  35. PyPlumIO-0.5.24.dist-info/RECORD +0 -60
  36. {PyPlumIO-0.5.24.dist-info → PyPlumIO-0.5.26.dist-info}/LICENSE +0 -0
  37. {PyPlumIO-0.5.24.dist-info → PyPlumIO-0.5.26.dist-info}/top_level.txt +0 -0
@@ -60,6 +60,7 @@ class EventManager(TaskManager, Generic[T]):
60
60
  become available, defaults to `None`
61
61
  :type timeout: float, optional
62
62
  :return: An event data
63
+ :rtype: T
63
64
  :raise asyncio.TimeoutError: when waiting past specified timeout
64
65
  """
65
66
  await self.wait_for(name, timeout=timeout)
@@ -81,8 +82,9 @@ class EventManager(TaskManager, Generic[T]):
81
82
  :type name: str
82
83
  :param default: default value to return if data is unavailable,
83
84
  defaults to `None`
84
- :type default: Any, optional
85
+ :type default: T, optional
85
86
  :return: An event data
87
+ :rtype: T, optional
86
88
  """
87
89
  try:
88
90
  return self.data[name]
@@ -148,9 +150,9 @@ class EventManager(TaskManager, Generic[T]):
148
150
  async def dispatch(self, name: str, value: T) -> None:
149
151
  """Call registered callbacks and dispatch the event."""
150
152
  if callbacks := self._callbacks.get(name, None):
151
- for callback in list(callbacks):
152
- result = await callback(value)
153
- value = result if result is not None else value
153
+ for callback in callbacks:
154
+ if (result := await callback(value)) is not None:
155
+ value = result
154
156
 
155
157
  self.data[name] = value
156
158
  self.set_event(name)
@@ -181,9 +183,7 @@ class EventManager(TaskManager, Generic[T]):
181
183
  def set_event(self, name: str) -> None:
182
184
  """Set an event."""
183
185
  if name in self.events:
184
- event = self.events[name]
185
- if not event.is_set():
186
- event.set()
186
+ self.events[name].set()
187
187
 
188
188
  @property
189
189
  def events(self) -> dict[str, asyncio.Event]:
@@ -13,18 +13,17 @@ _LOGGER = logging.getLogger(__name__)
13
13
  T = TypeVar("T")
14
14
 
15
15
 
16
- async def _load_module(module_name: str) -> ModuleType:
17
- """Load a module by name."""
18
- return await asyncio.get_running_loop().run_in_executor(
19
- None, importlib.import_module, f".{module_name}", "pyplumio"
20
- )
16
+ async def _import_module(name: str) -> ModuleType:
17
+ """Import module by name."""
18
+ loop = asyncio.get_running_loop()
19
+ return await loop.run_in_executor(None, importlib.import_module, f"pyplumio.{name}")
21
20
 
22
21
 
23
22
  async def create_instance(class_path: str, cls: type[T], **kwargs: Any) -> T:
24
23
  """Return a class instance from the class path."""
25
24
  module_name, class_name = class_path.rsplit(".", 1)
26
25
  try:
27
- module = await _load_module(module_name)
26
+ module = await _import_module(module_name)
28
27
  instance = getattr(module, class_name)(**kwargs)
29
28
  if not isinstance(instance, cls):
30
29
  raise TypeError(f"class '{class_name}' should be derived from {cls}")
@@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
6
6
  import asyncio
7
7
  from dataclasses import dataclass
8
8
  import logging
9
- from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, Union
9
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union
10
10
 
11
11
  from dataslots import dataslots
12
12
  from typing_extensions import TypeAlias
@@ -19,10 +19,8 @@ if TYPE_CHECKING:
19
19
 
20
20
  _LOGGER = logging.getLogger(__name__)
21
21
 
22
- SET_TIMEOUT: Final = 5
23
- SET_RETRIES: Final = 5
24
22
 
25
- ParameterValueType: TypeAlias = Union[int, float, bool, Literal["off", "on"]]
23
+ ParameterValue: TypeAlias = Union[int, float, bool, Literal["off", "on"]]
26
24
  ParameterT = TypeVar("ParameterT", bound="Parameter")
27
25
 
28
26
 
@@ -49,7 +47,7 @@ def check_parameter(data: bytearray) -> bool:
49
47
  return any(x for x in data if x != BYTE_UNDEFINED)
50
48
 
51
49
 
52
- def _normalize_parameter_value(value: ParameterValueType) -> int:
50
+ def _normalize_parameter_value(value: ParameterValue) -> int:
53
51
  """Normalize a parameter value."""
54
52
  if value in (STATE_OFF, STATE_ON):
55
53
  return 1 if value == STATE_ON else 0
@@ -170,7 +168,14 @@ class Parameter(ABC):
170
168
  """Compare if parameter value is less that other."""
171
169
  return self._call_relational_method("__lt__", other)
172
170
 
173
- async def set(self, value: Any, retries: int = SET_RETRIES) -> bool:
171
+ def __copy__(self) -> Parameter:
172
+ """Create a copy of parameter."""
173
+ values = type(self.values)(
174
+ self.values.value, self.values.min_value, self.values.max_value
175
+ )
176
+ return type(self)(self.device, self.description, values)
177
+
178
+ async def set(self, value: Any, retries: int = 5, timeout: float = 5.0) -> bool:
174
179
  """Set a parameter value."""
175
180
  if (value := _normalize_parameter_value(value)) == self.values.value:
176
181
  return True
@@ -191,7 +196,7 @@ class Parameter(ABC):
191
196
  return False
192
197
 
193
198
  await self.device.queue.put(await self.create_request())
194
- await asyncio.sleep(SET_TIMEOUT)
199
+ await asyncio.sleep(timeout)
195
200
  retries -= 1
196
201
 
197
202
  return True
@@ -265,13 +270,17 @@ class Number(Parameter):
265
270
 
266
271
  description: NumberDescription
267
272
 
268
- async def set(self, value: int | float, retries: int = SET_RETRIES) -> bool:
273
+ async def set(
274
+ self, value: int | float, retries: int = 5, timeout: float = 5.0
275
+ ) -> bool:
269
276
  """Set a parameter value."""
270
- return await super().set(value, retries)
277
+ return await super().set(value, retries, timeout)
271
278
 
272
- def set_nowait(self, value: int | float, retries: int = SET_RETRIES) -> None:
279
+ def set_nowait(
280
+ self, value: int | float, retries: int = 5, timeout: float = 5.0
281
+ ) -> None:
273
282
  """Set a parameter value without waiting."""
274
- self.device.create_task(self.set(value, retries))
283
+ self.device.create_task(self.set(value, retries, timeout))
275
284
 
276
285
  async def create_request(self) -> Request:
277
286
  """Create a request to change the number."""
@@ -312,16 +321,16 @@ class Switch(Parameter):
312
321
  description: SwitchDescription
313
322
 
314
323
  async def set(
315
- self, value: bool | Literal["off", "on"], retries: int = SET_RETRIES
324
+ self, value: bool | Literal["off", "on"], retries: int = 5, timeout: float = 5.0
316
325
  ) -> bool:
317
326
  """Set a parameter value."""
318
- return await super().set(value, retries)
327
+ return await super().set(value, retries, timeout)
319
328
 
320
329
  def set_nowait(
321
- self, value: bool | Literal["off", "on"], retries: int = SET_RETRIES
330
+ self, value: bool | Literal["off", "on"], retries: int = 5, timeout: float = 5.0
322
331
  ) -> None:
323
332
  """Set a switch value without waiting."""
324
- self.device.create_task(self.set(value, retries))
333
+ self.device.create_task(self.set(value, retries, timeout))
325
334
 
326
335
  async def turn_on(self) -> bool:
327
336
  """Set a switch value to 'on'.
@@ -2,53 +2,65 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Iterable, Iterator, MutableMapping
5
+ from collections.abc import Generator, Iterable, Iterator, MutableMapping
6
6
  from dataclasses import dataclass
7
7
  import datetime as dt
8
8
  from functools import lru_cache
9
9
  import math
10
- from typing import Final, Literal
10
+ from typing import Annotated, Final, Literal, get_args
11
+
12
+ from typing_extensions import TypeAlias
11
13
 
12
14
  from pyplumio.const import STATE_OFF, STATE_ON, FrameType
13
- from pyplumio.devices import AddressableDevice
15
+ from pyplumio.devices import PhysicalDevice
14
16
  from pyplumio.frames import Request
15
17
  from pyplumio.structures.schedules import collect_schedule_data
16
18
 
17
19
  TIME_FORMAT: Final = "%H:%M"
18
- START_OF_DAY: Final = "00:00"
19
- END_OF_DAY: Final = "00:00"
20
20
 
21
21
  STATE_NIGHT: Final = "night"
22
22
  STATE_DAY: Final = "day"
23
23
 
24
24
  ON_STATES: Final = (STATE_ON, STATE_DAY)
25
25
  OFF_STATES: Final = (STATE_OFF, STATE_NIGHT)
26
- ALLOWED_STATES: Final = ON_STATES + OFF_STATES
27
26
 
27
+ ScheduleState: TypeAlias = Literal["on", "off", "day", "night"]
28
+ Time = Annotated[str, "time in HH:MM format"]
29
+
30
+ start_of_day_dt = dt.datetime.strptime("00:00", TIME_FORMAT)
28
31
 
29
- @lru_cache(maxsize=10)
30
- def _parse_interval(start: str, end: str) -> tuple[int, int]:
31
- """Parse an interval string.
32
32
 
33
- Intervals should be specified in '%H:%M' format.
33
+ def _get_time_range(
34
+ start: Time, end: Time, step: int = 30
35
+ ) -> Generator[int, None, None]:
36
+ """Get a time range.
37
+
38
+ Start and end times should be specified in HH:MM format, step in
39
+ minutes.
34
40
  """
35
- start_dt = dt.datetime.strptime(start, TIME_FORMAT)
36
- end_dt = dt.datetime.strptime(end, TIME_FORMAT)
37
- start_of_day_dt = dt.datetime.strptime(START_OF_DAY, TIME_FORMAT)
38
- if end_dt == start_of_day_dt:
39
- # Upper bound of interval is midnight.
40
- end_dt += dt.timedelta(hours=23, minutes=30)
41
-
42
- if end_dt <= start_dt:
43
- raise ValueError(
44
- f"Invalid interval ({start}, {end}). "
45
- + "Lower boundary must be less than upper."
46
- )
47
41
 
48
- start_index = math.floor((start_dt - start_of_day_dt).total_seconds() // (60 * 30))
49
- end_index = math.floor((end_dt - start_of_day_dt).total_seconds() // (60 * 30))
42
+ @lru_cache(maxsize=10)
43
+ def _get_time_range_cached(start: Time, end: Time, step: int = 30) -> range:
44
+ """Get a time range and cache it using LRU cache."""
45
+ start_dt = dt.datetime.strptime(start, TIME_FORMAT)
46
+ end_dt = dt.datetime.strptime(end, TIME_FORMAT)
47
+ if end_dt == start_of_day_dt:
48
+ # Upper boundary of the interval is midnight.
49
+ end_dt += dt.timedelta(hours=24) - dt.timedelta(minutes=step)
50
+
51
+ if end_dt <= start_dt:
52
+ raise ValueError(
53
+ f"Invalid interval ({start}, {end}). "
54
+ "Lower boundary must be less than upper."
55
+ )
56
+
57
+ def _dt_to_index(dt: dt.datetime) -> int:
58
+ """Convert datetime to index in schedule list."""
59
+ return math.floor((dt - start_of_day_dt).total_seconds() // (60 * step))
60
+
61
+ return range(_dt_to_index(start_dt), _dt_to_index(end_dt) + 1)
50
62
 
51
- return start_index, end_index
63
+ yield from _get_time_range_cached(start, end, step)
52
64
 
53
65
 
54
66
  class ScheduleDay(MutableMapping):
@@ -91,30 +103,21 @@ class ScheduleDay(MutableMapping):
91
103
  self._intervals.append(item)
92
104
 
93
105
  def set_state(
94
- self,
95
- state: Literal["on", "off", "day", "night"],
96
- start: str = START_OF_DAY,
97
- end: str = END_OF_DAY,
106
+ self, state: ScheduleState, start: Time = "00:00", end: Time = "00:00"
98
107
  ) -> None:
99
- """Set an interval state.
100
-
101
- Can be on of the following:
102
- 'on', 'off', 'day' or 'night'.
103
- """
104
- if state not in ALLOWED_STATES:
108
+ """Set a schedule interval state."""
109
+ if state not in get_args(ScheduleState):
105
110
  raise ValueError(f'state "{state}" is not allowed')
106
111
 
107
- index, end_index = _parse_interval(start, end)
108
- while index <= end_index:
109
- self._intervals[index] = state in ON_STATES
110
- index += 1
112
+ for index in _get_time_range(start, end):
113
+ self._intervals[index] = True if state in ON_STATES else False
111
114
 
112
- def set_on(self, start: str = START_OF_DAY, end: str = END_OF_DAY) -> None:
113
- """Set an interval state to 'on'."""
115
+ def set_on(self, start: Time = "00:00", end: Time = "00:00") -> None:
116
+ """Set a schedule interval state to 'on'."""
114
117
  self.set_state(STATE_ON, start, end)
115
118
 
116
- def set_off(self, start: str = START_OF_DAY, end: str = END_OF_DAY) -> None:
117
- """Set an interval state to 'off'."""
119
+ def set_off(self, start: Time = "00:00", end: Time = "00:00") -> None:
120
+ """Set a schedule interval state to 'off'."""
118
121
  self.set_state(STATE_OFF, start, end)
119
122
 
120
123
  @property
@@ -130,24 +133,25 @@ class Schedule(Iterable):
130
133
  __slots__ = (
131
134
  "name",
132
135
  "device",
136
+ "sunday",
133
137
  "monday",
134
138
  "tuesday",
135
139
  "wednesday",
136
140
  "thursday",
137
141
  "friday",
138
142
  "saturday",
139
- "sunday",
140
143
  )
141
144
 
142
145
  name: str
143
- device: AddressableDevice
146
+ device: PhysicalDevice
147
+
148
+ sunday: ScheduleDay
144
149
  monday: ScheduleDay
145
150
  tuesday: ScheduleDay
146
151
  wednesday: ScheduleDay
147
152
  thursday: ScheduleDay
148
153
  friday: ScheduleDay
149
154
  saturday: ScheduleDay
150
- sunday: ScheduleDay
151
155
 
152
156
  def __iter__(self) -> Iterator[ScheduleDay]:
153
157
  """Return list of days."""
@@ -14,7 +14,7 @@ P = ParamSpec("P")
14
14
 
15
15
 
16
16
  def timeout(
17
- seconds: int,
17
+ seconds: float,
18
18
  ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T]]]:
19
19
  """Decorate a timeout for the awaitable."""
20
20
 
pyplumio/protocol.py CHANGED
@@ -11,7 +11,7 @@ import logging
11
11
  from typing_extensions import TypeAlias
12
12
 
13
13
  from pyplumio.const import ATTR_CONNECTED, DeviceType
14
- from pyplumio.devices import AddressableDevice
14
+ from pyplumio.devices import PhysicalDevice
15
15
  from pyplumio.exceptions import ProtocolError
16
16
  from pyplumio.frames import Frame
17
17
  from pyplumio.frames.requests import StartMasterRequest
@@ -113,7 +113,7 @@ class Queues:
113
113
  await asyncio.gather(self.read.join(), self.write.join())
114
114
 
115
115
 
116
- class AsyncProtocol(Protocol, EventManager[AddressableDevice]):
116
+ class AsyncProtocol(Protocol, EventManager[PhysicalDevice]):
117
117
  """Represents an async protocol.
118
118
 
119
119
  This protocol implements producer-consumers pattern using
@@ -204,7 +204,7 @@ class AsyncProtocol(Protocol, EventManager[AddressableDevice]):
204
204
  await writer.write(await queues.write.get())
205
205
  queues.write.task_done()
206
206
 
207
- if (response := await reader.read()) is not None:
207
+ if response := await reader.read():
208
208
  queues.read.put_nowait(response)
209
209
 
210
210
  except ProtocolError as e:
@@ -224,16 +224,15 @@ class AsyncProtocol(Protocol, EventManager[AddressableDevice]):
224
224
  device.handle_frame(frame)
225
225
  queue.task_done()
226
226
 
227
- async def get_device_entry(self, device_type: DeviceType) -> AddressableDevice:
227
+ async def get_device_entry(self, device_type: DeviceType) -> PhysicalDevice:
228
228
  """Set up or return a device entry."""
229
229
  name = device_type.name.lower()
230
230
  if name not in self.data:
231
- device = await AddressableDevice.create(
231
+ device = await PhysicalDevice.create(
232
232
  device_type, queue=self._queues.write, network=self._network
233
233
  )
234
234
  device.dispatch_nowait(ATTR_CONNECTED, True)
235
235
  self.create_task(device.async_setup(), name=f"device_setup_task ({name})")
236
- self.set_event(name)
237
- self.data[name] = device
236
+ await self.dispatch(name, device)
238
237
 
239
238
  return self.data[name]
@@ -76,12 +76,13 @@ class AlertsStructure(StructureDecoder):
76
76
 
77
77
  def _unpack_alert(self, message: bytearray) -> Alert:
78
78
  """Unpack an alert."""
79
- code = message[self._offset]
80
- self._offset += 1
81
- from_seconds = UnsignedInt.from_bytes(message, self._offset)
82
- self._offset += from_seconds.size
83
- to_seconds = UnsignedInt.from_bytes(message, self._offset)
84
- self._offset += to_seconds.size
79
+ offset = self._offset
80
+ code = message[offset]
81
+ offset += 1
82
+ from_seconds = UnsignedInt.from_bytes(message, offset)
83
+ offset += from_seconds.size
84
+ to_seconds = UnsignedInt.from_bytes(message, offset)
85
+ offset += to_seconds.size
85
86
  from_dt = _seconds_to_datetime(from_seconds.value)
86
87
  to_dt = (
87
88
  None
@@ -91,6 +92,7 @@ class AlertsStructure(StructureDecoder):
91
92
  with suppress(ValueError):
92
93
  code = AlertType(code)
93
94
 
95
+ self._offset = offset
94
96
  return Alert(code, from_dt, to_dt)
95
97
 
96
98
  def decode(
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Generator
6
6
  from dataclasses import dataclass
7
+ from functools import partial
7
8
  from typing import Any, Final
8
9
 
9
10
  from dataslots import dataslots
@@ -18,10 +19,9 @@ from pyplumio.const import (
18
19
  ProductType,
19
20
  UnitOfMeasurement,
20
21
  )
21
- from pyplumio.devices import AddressableDevice
22
+ from pyplumio.devices import PhysicalDevice
22
23
  from pyplumio.frames import Request
23
24
  from pyplumio.helpers.parameter import (
24
- SET_TIMEOUT,
25
25
  Number,
26
26
  NumberDescription,
27
27
  Parameter,
@@ -47,41 +47,40 @@ class EcomaxParameterDescription(ParameterDescription):
47
47
 
48
48
  __slots__ = ()
49
49
 
50
- multiplier: float = 1.0
51
- offset: int = 0
52
-
53
50
 
54
51
  class EcomaxParameter(Parameter):
55
52
  """Represents an ecoMAX parameter."""
56
53
 
57
54
  __slots__ = ()
58
55
 
59
- device: AddressableDevice
56
+ device: PhysicalDevice
60
57
  description: EcomaxParameterDescription
61
58
 
62
59
  async def create_request(self) -> Request:
63
60
  """Create a request to change the parameter."""
61
+ handler = partial(Request.create, recipient=self.device.address)
64
62
  if self.description.name == ATTR_ECOMAX_CONTROL:
65
- frame_type = FrameType.REQUEST_ECOMAX_CONTROL
66
- data = {ATTR_VALUE: self.values.value}
63
+ request = await handler(
64
+ frame_type=FrameType.REQUEST_ECOMAX_CONTROL,
65
+ data={ATTR_VALUE: self.values.value},
66
+ )
67
67
  elif self.description.name == ATTR_THERMOSTAT_PROFILE:
68
- frame_type = FrameType.REQUEST_SET_THERMOSTAT_PARAMETER
69
- data = {
70
- ATTR_INDEX: self._index,
71
- ATTR_VALUE: self.values.value,
72
- ATTR_OFFSET: 0,
73
- ATTR_SIZE: 1,
74
- }
68
+ request = await handler(
69
+ frame_type=FrameType.REQUEST_SET_THERMOSTAT_PARAMETER,
70
+ data={
71
+ ATTR_INDEX: self._index,
72
+ ATTR_VALUE: self.values.value,
73
+ ATTR_OFFSET: 0,
74
+ ATTR_SIZE: 1,
75
+ },
76
+ )
75
77
  else:
76
- frame_type = FrameType.REQUEST_SET_ECOMAX_PARAMETER
77
- data = {
78
- ATTR_INDEX: self._index,
79
- ATTR_VALUE: self.values.value,
80
- }
81
-
82
- return await Request.create(
83
- frame_type, recipient=self.device.address, data=data
84
- )
78
+ request = await handler(
79
+ frame_type=FrameType.REQUEST_SET_ECOMAX_PARAMETER,
80
+ data={ATTR_INDEX: self._index, ATTR_VALUE: self.values.value},
81
+ )
82
+
83
+ return request
85
84
 
86
85
 
87
86
  @dataslots
@@ -89,6 +88,9 @@ class EcomaxParameter(Parameter):
89
88
  class EcomaxNumberDescription(EcomaxParameterDescription, NumberDescription):
90
89
  """Represents an ecoMAX number description."""
91
90
 
91
+ multiplier: float = 1.0
92
+ offset: int = 0
93
+
92
94
 
93
95
  class EcomaxNumber(EcomaxParameter, Number):
94
96
  """Represents a ecoMAX number."""
@@ -97,10 +99,12 @@ class EcomaxNumber(EcomaxParameter, Number):
97
99
 
98
100
  description: EcomaxNumberDescription
99
101
 
100
- async def set(self, value: float | int, retries: int = SET_TIMEOUT) -> bool:
102
+ async def set(
103
+ self, value: float | int, retries: int = 5, timeout: float = 5.0
104
+ ) -> bool:
101
105
  """Set a parameter value."""
102
106
  value = (value + self.description.offset) / self.description.multiplier
103
- return await super().set(value, retries)
107
+ return await super().set(value, retries, timeout)
104
108
 
105
109
  @property
106
110
  def value(self) -> float:
@@ -23,9 +23,8 @@ 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
- self._offset += 1
27
- version = UnsignedShort.from_bytes(message, self._offset)
28
- self._offset += version.size
26
+ version = UnsignedShort.from_bytes(message, self._offset + 1)
27
+ self._offset += version.size + 1
29
28
  with suppress(ValueError):
30
29
  frame_type = FrameType(frame_type)
31
30
 
@@ -18,7 +18,6 @@ from pyplumio.const import (
18
18
  )
19
19
  from pyplumio.frames import Request
20
20
  from pyplumio.helpers.parameter import (
21
- SET_RETRIES,
22
21
  Number,
23
22
  NumberDescription,
24
23
  Parameter,
@@ -45,9 +44,6 @@ class MixerParameterDescription(ParameterDescription):
45
44
 
46
45
  __slots__ = ()
47
46
 
48
- multiplier: float = 1.0
49
- offset: int = 0
50
-
51
47
 
52
48
  class MixerParameter(Parameter):
53
49
  """Represent a mixer parameter."""
@@ -75,6 +71,9 @@ class MixerParameter(Parameter):
75
71
  class MixerNumberDescription(MixerParameterDescription, NumberDescription):
76
72
  """Represent a mixer number description."""
77
73
 
74
+ multiplier: float = 1.0
75
+ offset: int = 0
76
+
78
77
 
79
78
  class MixerNumber(MixerParameter, Number):
80
79
  """Represents a mixer number."""
@@ -83,10 +82,12 @@ class MixerNumber(MixerParameter, Number):
83
82
 
84
83
  description: MixerNumberDescription
85
84
 
86
- async def set(self, value: int | float, retries: int = SET_RETRIES) -> bool:
85
+ async def set(
86
+ self, value: int | float, retries: int = 5, timeout: float = 5.0
87
+ ) -> bool:
87
88
  """Set a parameter value."""
88
89
  value = (value + self.description.offset) / self.description.multiplier
89
- return await super().set(value, retries)
90
+ return await super().set(value, retries, timeout)
90
91
 
91
92
  @property
92
93
  def value(self) -> float:
@@ -264,6 +265,8 @@ MIXER_PARAMETERS: dict[ProductType, tuple[MixerParameterDescription, ...]] = {
264
265
  class MixerParametersStructure(StructureDecoder):
265
266
  """Represents a mixer parameters data structure."""
266
267
 
268
+ __slots__ = ("_offset",)
269
+
267
270
  _offset: int
268
271
 
269
272
  def _mixer_parameter(
@@ -28,18 +28,20 @@ class MixerSensorsStructure(StructureDecoder):
28
28
 
29
29
  def _unpack_mixer_sensors(self, message: bytearray) -> dict[str, Any] | None:
30
30
  """Unpack sensors for a mixer."""
31
- current_temp = Float.from_bytes(message, self._offset)
31
+ offset = self._offset
32
+ current_temp = Float.from_bytes(message, offset)
32
33
  try:
33
- if not math.isnan(current_temp.value):
34
- return {
34
+ return (
35
+ {
35
36
  ATTR_CURRENT_TEMP: current_temp.value,
36
- ATTR_TARGET_TEMP: message[self._offset + 4],
37
- ATTR_PUMP: bool(message[self._offset + 6] & 0x01),
37
+ ATTR_TARGET_TEMP: message[offset + 4],
38
+ ATTR_PUMP: bool(message[offset + 6] & 0x01),
38
39
  }
39
-
40
- return None
40
+ if not math.isnan(current_temp.value)
41
+ else None
42
+ )
41
43
  finally:
42
- self._offset += MIXER_SENSOR_SIZE
44
+ self._offset = offset + MIXER_SENSOR_SIZE
43
45
 
44
46
  def _mixer_sensors(
45
47
  self, message: bytearray, mixers: int
@@ -6,6 +6,8 @@ from dataclasses import dataclass
6
6
  import struct
7
7
  from typing import Any, Final
8
8
 
9
+ from dataslots import dataslots
10
+
9
11
  from pyplumio.const import BYTE_UNDEFINED
10
12
  from pyplumio.structures import StructureDecoder
11
13
  from pyplumio.utils import ensure_dict
@@ -30,6 +32,7 @@ struct_version = struct.Struct("<BBB")
30
32
  struct_vendor = struct.Struct("<BB")
31
33
 
32
34
 
35
+ @dataslots
33
36
  @dataclass
34
37
  class ConnectedModules:
35
38
  """Represents a firmware version info for connected module."""
@@ -55,17 +58,16 @@ class ModulesStructure(StructureDecoder):
55
58
  self._offset += 1
56
59
  return None
57
60
 
58
- version_data = struct_version.unpack_from(message, self._offset)
61
+ offset = self._offset
62
+ version_data = struct_version.unpack_from(message, offset)
59
63
  version = ".".join(str(i) for i in version_data)
60
- self._offset += struct_version.size
61
-
64
+ offset += struct_version.size
62
65
  if module == ATTR_MODULE_A:
63
- vendor_code, vendor_version = struct_vendor.unpack_from(
64
- message, self._offset
65
- )
66
+ vendor_code, vendor_version = struct_vendor.unpack_from(message, offset)
66
67
  version += f".{chr(vendor_code) + str(vendor_version)}"
67
- self._offset += struct_vendor.size
68
+ offset += struct_vendor.size
68
69
 
70
+ self._offset = offset
69
71
  return version
70
72
 
71
73
  def decode(