PyPlumIO 0.5.29__py3-none-any.whl → 0.5.31__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
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: PyPlumIO
3
- Version: 0.5.29
3
+ Version: 0.5.31
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
@@ -17,32 +17,33 @@ Classifier: Programming Language :: Python :: 3.9
17
17
  Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
20
21
  Classifier: Topic :: Software Development :: Libraries
21
22
  Classifier: Topic :: Home Automation
22
23
  Requires-Python: >=3.9
23
24
  Description-Content-Type: text/markdown
24
25
  License-File: LICENSE
25
- Requires-Dist: dataslots ==1.2.0
26
- Requires-Dist: pyserial-asyncio ==0.6
27
- Requires-Dist: typing-extensions ==4.12.2
28
- Provides-Extra: dev
29
- Requires-Dist: pyplumio[docs,test] ; extra == 'dev'
30
- Requires-Dist: pre-commit ==4.0.1 ; extra == 'dev'
31
- Requires-Dist: tomli ==2.0.2 ; extra == 'dev'
32
- Provides-Extra: docs
33
- Requires-Dist: sphinx ==8.1.3 ; extra == 'docs'
34
- Requires-Dist: sphinx-rtd-theme ==3.0.1 ; extra == 'docs'
35
- Requires-Dist: readthedocs-sphinx-search ==0.3.2 ; extra == 'docs'
26
+ Requires-Dist: dataslots==1.2.0
27
+ Requires-Dist: pyserial-asyncio==0.6
28
+ Requires-Dist: typing-extensions==4.12.2
36
29
  Provides-Extra: test
37
- Requires-Dist: codespell ==2.3.0 ; extra == 'test'
38
- Requires-Dist: coverage ==7.6.4 ; extra == 'test'
39
- Requires-Dist: mypy ==1.13.0 ; extra == 'test'
40
- Requires-Dist: pyserial-asyncio-fast ==0.14 ; extra == 'test'
41
- Requires-Dist: pytest ==8.3.3 ; extra == 'test'
42
- Requires-Dist: pytest-asyncio ==0.24.0 ; extra == 'test'
43
- Requires-Dist: ruff ==0.7.1 ; extra == 'test'
44
- Requires-Dist: tox ==4.23.2 ; extra == 'test'
45
- Requires-Dist: types-pyserial ==3.5.0.20240826 ; extra == 'test'
30
+ Requires-Dist: codespell==2.3.0; extra == "test"
31
+ Requires-Dist: coverage==7.6.10; extra == "test"
32
+ Requires-Dist: mypy==1.14.1; extra == "test"
33
+ Requires-Dist: pyserial-asyncio-fast==0.14; extra == "test"
34
+ Requires-Dist: pytest==8.3.4; extra == "test"
35
+ Requires-Dist: pytest-asyncio==0.25.2; extra == "test"
36
+ Requires-Dist: ruff==0.9.2; extra == "test"
37
+ Requires-Dist: tox==4.23.2; extra == "test"
38
+ Requires-Dist: types-pyserial==3.5.0.20241221; extra == "test"
39
+ Provides-Extra: docs
40
+ Requires-Dist: sphinx==8.1.3; extra == "docs"
41
+ Requires-Dist: sphinx_rtd_theme==3.0.2; extra == "docs"
42
+ Requires-Dist: readthedocs-sphinx-search==0.3.2; extra == "docs"
43
+ Provides-Extra: dev
44
+ Requires-Dist: pyplumio[docs,test]; extra == "dev"
45
+ Requires-Dist: pre-commit==4.0.1; extra == "dev"
46
+ Requires-Dist: tomli==2.2.1; extra == "dev"
46
47
 
47
48
  # PyPlumIO is a native ecoNET library for Plum ecoMAX controllers.
48
49
  [![PyPI version](https://badge.fury.io/py/PyPlumIO.svg)](https://badge.fury.io/py/PyPlumIO)
@@ -1,16 +1,16 @@
1
1
  pyplumio/__init__.py,sha256=ditJTIOFGJDg60atHzOpiggdUrZHpSynno7MtpZUGVk,3299
2
2
  pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
- pyplumio/_version.py,sha256=YLYDNYQKnQz5-FefYBZUB86Rwzn5G_lMcWd9gf_aubQ,413
3
+ pyplumio/_version.py,sha256=Kr7Tq9KjyxEp8tm444HGzXvFWTTLR8xyIJ5ABXwwKGA,413
4
4
  pyplumio/connection.py,sha256=6mUbcjGxxEhMVIbzZgCqH-Ez-fcYoRj7ZbVSzpikpNA,5949
5
5
  pyplumio/const.py,sha256=LyXa5aVy2KxnZq7H7F8s5SYsAgEC2UzZYMMRauliB2E,5502
6
6
  pyplumio/exceptions.py,sha256=Wn-y5AJ5xfaBlHhTUVKB27_0Us8_OVHqh-sicnr9sYA,700
7
- pyplumio/filters.py,sha256=KK_AV_EHy5gj9s9BNZbn9i0RnT3uZsdEg6gdve1WYrY,11152
7
+ pyplumio/filters.py,sha256=AMW1zHQ1YjJfHX7e87Dhv7AGixJ3y9Vn-_JAQn7vIsg,12526
8
8
  pyplumio/protocol.py,sha256=VRxrj8vZ1FMawqblKkyxg_V61TBSvVynd9u0JXYnMUU,8090
9
9
  pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  pyplumio/stream.py,sha256=mtMpnUR3TfEmL5JUGXr6GnpPGBwzCokqIKDWp4vYiVg,4654
11
11
  pyplumio/utils.py,sha256=TnBzRopinyp92wruguijxcIYmaeyNVTFX0dygI5FCMU,823
12
- pyplumio/devices/__init__.py,sha256=0tDa30WPvI53uSLUu2PStIgvbst-cUvYLe3SSCRHDZc,6551
13
- pyplumio/devices/ecomax.py,sha256=CEdU7nMyJGGVNMANEwN4fgAzf6O8ufuIN0_CQcGqO3k,16886
12
+ pyplumio/devices/__init__.py,sha256=BEUpL2XxEk4YDfp4w0VX_LI4JQwK27eGWoJ6al_e1nE,8038
13
+ pyplumio/devices/ecomax.py,sha256=ybFLJN7O3unBcyzuVmYTssBv86bPiiTGvFpFJezwUE4,15478
14
14
  pyplumio/devices/ecoster.py,sha256=jNWli7ye9T6yfkcFJZhhUHH7KOv-L6AgYFp_dKyv3OM,263
15
15
  pyplumio/devices/mixer.py,sha256=CnHWrJELtFgs2YTHGpQwKr2UTRdetX76OvLBA2PH-fs,3207
16
16
  pyplumio/devices/thermostat.py,sha256=-CZNRyywoDU6csFu85KSmQ5woVXY0x6peXkeOsi_fqg,2617
@@ -22,7 +22,7 @@ pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,
22
22
  pyplumio/helpers/data_types.py,sha256=nB3afOLmppgSCWkZoX1-1yWPNMMNSem77x7XQ1Mi8H8,9103
23
23
  pyplumio/helpers/event_manager.py,sha256=xQOfiP_nP1Pz5zhB6HU5gXyyJXjhisYshL8_HRxDgt8,6412
24
24
  pyplumio/helpers/factory.py,sha256=6ArzJDq3MiiMaRpMEP0kC6wJWsoqOqe32V1RCxg1478,1005
25
- pyplumio/helpers/parameter.py,sha256=iCtKkYXJI0Zj4ifU3HCwoq_qqSE03giblSP1rqsWzcY,11463
25
+ pyplumio/helpers/parameter.py,sha256=hOGYkhd7-MG1euNdbHYN_2vXFlWPi-XLy_yAotYSyck,12311
26
26
  pyplumio/helpers/schedule.py,sha256=PnVEkgthg6tHpHvZK9fXJz9VKNDyQ_7BFT4TTVEwNhI,5310
27
27
  pyplumio/helpers/task_manager.py,sha256=HAd69yGTRL0zQsu-ywnbLu1UXiJzgHWuhYWA--vs4lQ,1181
28
28
  pyplumio/helpers/timeout.py,sha256=JAhWNtIpcXyVILIwHWVy5mYofqbbRDGKLdTUKkQuajs,772
@@ -31,7 +31,7 @@ pyplumio/structures/__init__.py,sha256=EjK-5qJZ0F7lpP2b6epvTMg9cIBl4Kn91nqNkEcLw
31
31
  pyplumio/structures/alerts.py,sha256=8ievMl5_tUBlnTLCiZoIloucIngCcoAYy6uI9sSXrt0,3664
32
32
  pyplumio/structures/boiler_load.py,sha256=p3mOzZUU-g7A2tG_yp8podEqpI81hlsOZmHELyPNRY8,838
33
33
  pyplumio/structures/boiler_power.py,sha256=72qsvccg49FdRdXv2f2K5sGpjT7wAOLFjlIGWpO-DVg,901
34
- pyplumio/structures/ecomax_parameters.py,sha256=OYQZ0XJF-lqV_GdMaLTek4Gd6etIwhEJIyZyBS189O4,27959
34
+ pyplumio/structures/ecomax_parameters.py,sha256=wLwhTdKee-UtCU5NuRfOHRPvnjDOtDNUDeUgeO0VH8w,27974
35
35
  pyplumio/structures/fan_power.py,sha256=Q5fv-7_2NVuLeQPIVIylvgN7M8-a9D8rRUE0QGjyS3w,871
36
36
  pyplumio/structures/frame_versions.py,sha256=hbcVuhuPNy5qd39Vk7w4WdPCW-TNx1cAYWzA2mXocyk,1548
37
37
  pyplumio/structures/fuel_consumption.py,sha256=_p2dI4H67Eopn7IF0Gj77A8c_8lNKhhDDAtmugxLd4s,976
@@ -48,13 +48,13 @@ pyplumio/structures/product_info.py,sha256=uiEN6DFQlzmBvQByTirFzXQShoex0YGdFS9WI
48
48
  pyplumio/structures/program_version.py,sha256=R-medELYHDlk_ALsw5HOVbZRb7JD3yBUsGwqwVCjrkU,2550
49
49
  pyplumio/structures/regulator_data.py,sha256=z2mSE-cxImn8YRr_yZCcDlIbXnKdETkN7GigV5vEJqA,2265
50
50
  pyplumio/structures/regulator_data_schema.py,sha256=XM6M9ep3NyogbLPqp88mMTg8Sa9e5SFzV5I5pSYw5GY,1487
51
- pyplumio/structures/schedules.py,sha256=YzlfgprZq4pDfl-NBHl-EblhxatmDYr0UOkkHBW0Jok,6707
51
+ pyplumio/structures/schedules.py,sha256=_D8HmxMVvAAPb0cc_xSxXFRNwR9u-RWuyTy0Z5KscUk,6717
52
52
  pyplumio/structures/statuses.py,sha256=wkoynyMRr1VREwfBC6vU48kPA8ZQ83pcXuciy2xHJrk,1166
53
53
  pyplumio/structures/temperatures.py,sha256=1CDzehNmbALz1Jyt_9gZNIk52q6Wv-xQXjijVDCVYec,2337
54
- pyplumio/structures/thermostat_parameters.py,sha256=SewPHuw7f4PTvzKXcOQA8SNjQyuyBWNIk6jsUJX82BI,8321
54
+ pyplumio/structures/thermostat_parameters.py,sha256=QA-ZyulBG3P10sqgdI7rmpQYlKm9SJIXxBxAXs8Bwow,8295
55
55
  pyplumio/structures/thermostat_sensors.py,sha256=8e1TxYIJTQKT0kIGO9gG4hGdLOBUpIhiPToQyOMyeNE,3237
56
- PyPlumIO-0.5.29.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
- PyPlumIO-0.5.29.dist-info/METADATA,sha256=lRGUv-VdjhGInA3PozigXBjMGH8mJg_TquWmaUSvdFA,5490
58
- PyPlumIO-0.5.29.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
59
- PyPlumIO-0.5.29.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
- PyPlumIO-0.5.29.dist-info/RECORD,,
56
+ PyPlumIO-0.5.31.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
+ PyPlumIO-0.5.31.dist-info/METADATA,sha256=thPWL1GWRLLFkIdMOBz4CUN4LZmGxEA_BvvWYKjR-xA,5510
58
+ PyPlumIO-0.5.31.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
59
+ PyPlumIO-0.5.31.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
+ PyPlumIO-0.5.31.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.3.0)
2
+ Generator: setuptools (75.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
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.29'
16
- __version_tuple__ = version_tuple = (0, 5, 29)
15
+ __version__ = version = '0.5.31'
16
+ __version_tuple__ = version_tuple = (0, 5, 31)
@@ -5,17 +5,21 @@ from __future__ import annotations
5
5
  from abc import ABC
6
6
  import asyncio
7
7
  from functools import cache
8
+ import logging
8
9
  from typing import Any, ClassVar
9
10
 
10
11
  from pyplumio.const import ATTR_FRAME_ERRORS, ATTR_LOADED, DeviceType, FrameType
11
12
  from pyplumio.exceptions import UnknownDeviceError
12
- from pyplumio.frames import DataFrameDescription, Frame, Request
13
+ from pyplumio.frames import DataFrameDescription, Frame, Request, is_known_frame_type
13
14
  from pyplumio.helpers.event_manager import EventManager
14
15
  from pyplumio.helpers.factory import create_instance
15
16
  from pyplumio.helpers.parameter import Parameter, ParameterValue
17
+ from pyplumio.structures.frame_versions import ATTR_FRAME_VERSIONS
16
18
  from pyplumio.structures.network_info import NetworkInfo
17
19
  from pyplumio.utils import to_camelcase
18
20
 
21
+ _LOGGER = logging.getLogger(__name__)
22
+
19
23
 
20
24
  @cache
21
25
  def is_known_device_type(device_type: int) -> bool:
@@ -103,9 +107,6 @@ class Device(ABC, EventManager):
103
107
  this value is used to determine failure when
104
108
  retrying and doesn't block, defaults to `None`
105
109
  :type timeout: float, optional
106
- :return: `True` if parameter was successfully set, `False`
107
- otherwise.
108
- :rtype: bool
109
110
  """
110
111
  self.create_task(self.set(name, value, retries, timeout))
111
112
 
@@ -125,11 +126,44 @@ class PhysicalDevice(Device, ABC):
125
126
  address: ClassVar[int]
126
127
  _network: NetworkInfo
127
128
  _setup_frames: tuple[DataFrameDescription, ...]
129
+ _frame_versions: dict[int, int]
128
130
 
129
131
  def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo) -> None:
130
132
  """Initialize a new physical device."""
131
133
  super().__init__(queue)
132
134
  self._network = network
135
+ self._frame_versions = {}
136
+
137
+ async def update_frame_versions(versions: dict[int, int]) -> None:
138
+ """Check frame versions and update outdated frames."""
139
+ for frame_type, version in versions.items():
140
+ if (
141
+ is_known_frame_type(frame_type)
142
+ and self.supports_frame_type(frame_type)
143
+ and not self.has_frame_version(frame_type, version)
144
+ ):
145
+ _LOGGER.debug(
146
+ "Updating frame %s to version %i", repr(frame_type), version
147
+ )
148
+ request = await Request.create(frame_type, recipient=self.address)
149
+ self.queue.put_nowait(request)
150
+ self._frame_versions[frame_type] = version
151
+
152
+ self.subscribe(ATTR_FRAME_VERSIONS, update_frame_versions)
153
+
154
+ def has_frame_version(self, frame_type: int, version: int | None = None) -> bool:
155
+ """Return True if frame data is up to date, False otherwise."""
156
+ if frame_type not in self._frame_versions:
157
+ return False
158
+
159
+ if version is None or self._frame_versions[frame_type] == version:
160
+ return True
161
+
162
+ return False
163
+
164
+ def supports_frame_type(self, frame_type: int) -> bool:
165
+ """Check if frame type is supported by the device."""
166
+ return frame_type not in self.data.get(ATTR_FRAME_ERRORS, [])
133
167
 
134
168
  def handle_frame(self, frame: Frame) -> None:
135
169
  """Handle frame received from the device."""
@@ -9,7 +9,6 @@ import time
9
9
  from typing import Any, Final
10
10
 
11
11
  from pyplumio.const import (
12
- ATTR_FRAME_ERRORS,
13
12
  ATTR_PASSWORD,
14
13
  ATTR_SENSORS,
15
14
  ATTR_STATE,
@@ -21,7 +20,7 @@ from pyplumio.devices import PhysicalDevice
21
20
  from pyplumio.devices.mixer import Mixer
22
21
  from pyplumio.devices.thermostat import Thermostat
23
22
  from pyplumio.filters import on_change
24
- from pyplumio.frames import DataFrameDescription, Frame, Request, is_known_frame_type
23
+ from pyplumio.frames import DataFrameDescription, Frame, Request
25
24
  from pyplumio.helpers.parameter import ParameterValues
26
25
  from pyplumio.helpers.schedule import Schedule, ScheduleDay
27
26
  from pyplumio.structures.alerts import ATTR_TOTAL_ALERTS
@@ -35,7 +34,6 @@ from pyplumio.structures.ecomax_parameters import (
35
34
  EcomaxSwitch,
36
35
  EcomaxSwitchDescription,
37
36
  )
38
- from pyplumio.structures.frame_versions import ATTR_FRAME_VERSIONS
39
37
  from pyplumio.structures.fuel_consumption import ATTR_FUEL_CONSUMPTION
40
38
  from pyplumio.structures.mixer_parameters import ATTR_MIXER_PARAMETERS
41
39
  from pyplumio.structures.mixer_sensors import ATTR_MIXER_SENSORS
@@ -106,17 +104,14 @@ class EcoMAX(PhysicalDevice):
106
104
 
107
105
  address = DeviceType.ECOMAX
108
106
 
109
- _frame_versions: dict[int, int]
110
107
  _fuel_burned_timestamp_ns: int
111
108
  _setup_frames = SETUP_FRAME_TYPES
112
109
 
113
110
  def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo) -> None:
114
111
  """Initialize a new ecoMAX controller."""
115
112
  super().__init__(queue, network)
116
- self._frame_versions = {}
117
113
  self._fuel_burned_timestamp_ns = time.perf_counter_ns()
118
114
  self.subscribe(ATTR_ECOMAX_PARAMETERS, self._handle_ecomax_parameters)
119
- self.subscribe(ATTR_FRAME_VERSIONS, self._update_frame_versions)
120
115
  self.subscribe(ATTR_FUEL_CONSUMPTION, self._add_burned_fuel_counter)
121
116
  self.subscribe(ATTR_MIXER_PARAMETERS, self._handle_mixer_parameters)
122
117
  self.subscribe(ATTR_MIXER_SENSORS, self._handle_mixer_sensors)
@@ -142,17 +137,6 @@ class EcoMAX(PhysicalDevice):
142
137
 
143
138
  super().handle_frame(frame)
144
139
 
145
- def _has_frame_version(self, frame_type: FrameType | int, version: int) -> bool:
146
- """Check if ecoMAX controller has this version of the frame."""
147
- return (
148
- frame_type in self._frame_versions
149
- and self._frame_versions[frame_type] == version
150
- )
151
-
152
- def _frame_is_supported(self, frame_type: FrameType | int) -> bool:
153
- """Check if frame is supported by the device."""
154
- return frame_type not in self.data.get(ATTR_FRAME_ERRORS, [])
155
-
156
140
  def _mixers(self, indexes: Iterable[int]) -> Generator[Mixer, None, None]:
157
141
  """Iterate through the mixer indexes.
158
142
 
@@ -224,19 +208,6 @@ class EcoMAX(PhysicalDevice):
224
208
  await asyncio.gather(*_ecomax_parameter_events())
225
209
  return True
226
210
 
227
- async def _update_frame_versions(self, versions: dict[int, int]) -> None:
228
- """Check frame versions and update outdated frames."""
229
- for frame_type, version in versions.items():
230
- if (
231
- is_known_frame_type(frame_type)
232
- and self._frame_is_supported(frame_type)
233
- and not self._has_frame_version(frame_type, version)
234
- ):
235
- # We don't have this frame or it's version has changed.
236
- request = await Request.create(frame_type, recipient=self.address)
237
- self.queue.put_nowait(request)
238
- self._frame_versions[frame_type] = version
239
-
240
211
  async def _add_burned_fuel_counter(self, fuel_consumption: float) -> None:
241
212
  """Calculate fuel burned since last sensor's data message."""
242
213
  current_timestamp_ns = time.perf_counter_ns()
pyplumio/filters.py CHANGED
@@ -125,6 +125,52 @@ class Filter(ABC):
125
125
  """Set a new value for the callback."""
126
126
 
127
127
 
128
+ class _Clamp(Filter):
129
+ """Represents a clamp filter.
130
+
131
+ Calls callback with a value clamped between specified boundaries.
132
+ """
133
+
134
+ __slots__ = ("_min_value", "_max_value")
135
+
136
+ _min_value: float
137
+ _max_value: float
138
+
139
+ def __init__(self, callback: Callback, min_value: float, max_value: float) -> None:
140
+ """Initialize a new clamp filter."""
141
+ super().__init__(callback)
142
+ self._min_value = min_value
143
+ self._max_value = max_value
144
+
145
+ async def __call__(self, new_value: Any) -> Any:
146
+ """Set a new value for the callback."""
147
+ if new_value < self._min_value:
148
+ return await self._callback(self._min_value)
149
+
150
+ if new_value > self._max_value:
151
+ return await self._callback(self._max_value)
152
+
153
+ return await self._callback(new_value)
154
+
155
+
156
+ def clamp(callback: Callback, min_value: float, max_value: float) -> _Clamp:
157
+ """Return a clamp filter.
158
+
159
+ A callback function will be called with value clamped between
160
+ specified boundaries.
161
+
162
+ :param callback: A callback function to be awaited on new value
163
+ :type callback: Callback
164
+ :param min_value: A lower boundary
165
+ :type min_value: float
166
+ :param max_value: An upper boundary
167
+ :type max_value: float
168
+ :return: An instance of callable filter
169
+ :rtype: _Clamp
170
+ """
171
+ return _Clamp(callback, min_value, max_value)
172
+
173
+
128
174
  class _OnChange(Filter):
129
175
  """Represents a value changed filter.
130
176
 
@@ -151,7 +197,7 @@ def on_change(callback: Callback) -> _OnChange:
151
197
 
152
198
  :param callback: A callback function to be awaited on value change
153
199
  :type callback: Callback
154
- :return: A instance of callable filter
200
+ :return: An instance of callable filter
155
201
  :rtype: _OnChange
156
202
  """
157
203
  return _OnChange(callback)
@@ -201,7 +247,7 @@ def debounce(callback: Callback, min_calls: int) -> _Debounce:
201
247
  :param min_calls: Value shouldn't change for this amount of
202
248
  filter calls
203
249
  :type min_calls: int
204
- :return: A instance of callable filter
250
+ :return: An instance of callable filter
205
251
  :rtype: _Debounce
206
252
  """
207
253
  return _Debounce(callback, min_calls)
@@ -248,7 +294,7 @@ def throttle(callback: Callback, seconds: float) -> _Throttle:
248
294
  :param seconds: A callback will be awaited at most once per
249
295
  this amount of seconds
250
296
  :type seconds: float
251
- :return: A instance of callable filter
297
+ :return: An instance of callable filter
252
298
  :rtype: _Throttle
253
299
  """
254
300
  return _Throttle(callback, seconds)
@@ -285,7 +331,7 @@ def delta(callback: Callback) -> _Delta:
285
331
  :param callback: A callback function that will be awaited with
286
332
  difference between values in two subsequent calls
287
333
  :type callback: Callback
288
- :return: A instance of callable filter
334
+ :return: An instance of callable filter
289
335
  :rtype: _Delta
290
336
  """
291
337
  return _Delta(callback)
@@ -340,7 +386,7 @@ def aggregate(callback: Callback, seconds: float) -> _Aggregate:
340
386
  :param seconds: A callback will be awaited with a sum of values
341
387
  aggregated over this amount of seconds.
342
388
  :type seconds: float
343
- :return: A instance of callable filter
389
+ :return: An instance of callable filter
344
390
  :rtype: _Aggregate
345
391
  """
346
392
  return _Aggregate(callback, seconds)
@@ -382,7 +428,7 @@ def custom(callback: Callback, filter_fn: Callable[[Any], bool]) -> _Custom:
382
428
  :param filter_fn: Filter function, that will be called with a
383
429
  value and should return `True` to await filter's callback
384
430
  :type filter_fn: Callable[[Any], bool]
385
- :return: A instance of callable filter
431
+ :return: An instance of callable filter
386
432
  :rtype: _Custom
387
433
  """
388
434
  return _Custom(callback, filter_fn)
@@ -77,11 +77,19 @@ class ParameterDescription:
77
77
  class Parameter(ABC):
78
78
  """Represents a base parameter."""
79
79
 
80
- __slots__ = ("device", "description", "_pending_update", "_index", "_values")
80
+ __slots__ = (
81
+ "device",
82
+ "description",
83
+ "_pending_update",
84
+ "_previous_value",
85
+ "_index",
86
+ "_values",
87
+ )
81
88
 
82
89
  device: Device
83
90
  description: ParameterDescription
84
91
  _pending_update: bool
92
+ _previous_value: int
85
93
  _index: int
86
94
  _values: ParameterValues
87
95
 
@@ -96,6 +104,7 @@ class Parameter(ABC):
96
104
  self.device = device
97
105
  self.description = description
98
106
  self._pending_update = False
107
+ self._previous_value = 0
99
108
  self._index = index
100
109
  self._values = values if values else ParameterValues(0, 0, 0)
101
110
 
@@ -175,16 +184,32 @@ class Parameter(ABC):
175
184
  )
176
185
  return type(self)(self.device, self.description, values)
177
186
 
178
- async def set(self, value: Any, retries: int = 5, timeout: float = 5.0) -> bool:
179
- """Set a parameter value."""
180
- if (value := _normalize_parameter_value(value)) == self.values.value:
181
- return True
187
+ def validate(self, value: ParameterValue) -> int:
188
+ """Validate a parameter value."""
189
+ value = _normalize_parameter_value(value)
190
+ if value == self.values.value:
191
+ raise ValueError("Parameter value is unchanged.")
182
192
 
183
193
  if value < self.values.min_value or value > self.values.max_value:
184
194
  raise ValueError(
185
195
  f"Value must be between '{self.min_value}' and '{self.max_value}'"
186
196
  )
187
197
 
198
+ return value
199
+
200
+ async def set(self, value: Any, retries: int = 5, timeout: float = 5.0) -> bool:
201
+ """Set a parameter value."""
202
+ return await self._try_set(self.validate(value), retries, timeout)
203
+
204
+ def set_nowait(self, value: Any, retries: int = 5, timeout: float = 5.0) -> None:
205
+ """Set a parameter value without waiting."""
206
+ self.device.create_task(self._try_set(self.validate(value), retries, timeout))
207
+
208
+ async def _try_set(
209
+ self, value: Any, retries: int = 5, timeout: float = 5.0
210
+ ) -> bool:
211
+ """Try to set a parameter value."""
212
+ self._previous_value = self._values.value
188
213
  self._values.value = value
189
214
  self._pending_update = True
190
215
  while self.pending_update:
@@ -203,8 +228,10 @@ class Parameter(ABC):
203
228
 
204
229
  def update(self, values: ParameterValues) -> None:
205
230
  """Update the parameter values."""
231
+ if self.pending_update and self._previous_value != values.value:
232
+ self._pending_update = False
233
+
206
234
  self._values = values
207
- self._pending_update = False
208
235
 
209
236
  @property
210
237
  def pending_update(self) -> bool:
@@ -280,7 +307,7 @@ class Number(Parameter):
280
307
  self, value: int | float, retries: int = 5, timeout: float = 5.0
281
308
  ) -> None:
282
309
  """Set a parameter value without waiting."""
283
- self.device.create_task(self.set(value, retries, timeout))
310
+ super().set_nowait(value, retries, timeout)
284
311
 
285
312
  async def create_request(self) -> Request:
286
313
  """Create a request to change the number."""
@@ -330,7 +357,7 @@ class Switch(Parameter):
330
357
  self, value: bool | Literal["off", "on"], retries: int = 5, timeout: float = 5.0
331
358
  ) -> None:
332
359
  """Set a switch value without waiting."""
333
- self.device.create_task(self.set(value, retries, timeout))
360
+ super().set_nowait(value, retries, timeout)
334
361
 
335
362
  async def turn_on(self) -> bool:
336
363
  """Set a switch value to 'on'.
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from collections.abc import Generator
6
6
  from dataclasses import dataclass
7
7
  from functools import partial
8
- from typing import Any, Final
8
+ from typing import TYPE_CHECKING, Any, Final
9
9
 
10
10
  from dataslots import dataslots
11
11
 
@@ -19,7 +19,6 @@ from pyplumio.const import (
19
19
  ProductType,
20
20
  UnitOfMeasurement,
21
21
  )
22
- from pyplumio.devices import PhysicalDevice
23
22
  from pyplumio.frames import Request
24
23
  from pyplumio.helpers.parameter import (
25
24
  Number,
@@ -35,6 +34,9 @@ from pyplumio.structures import StructureDecoder
35
34
  from pyplumio.structures.thermostat_parameters import ATTR_THERMOSTAT_PROFILE
36
35
  from pyplumio.utils import ensure_dict
37
36
 
37
+ if TYPE_CHECKING:
38
+ from pyplumio.devices.ecomax import EcoMAX
39
+
38
40
  ATTR_ECOMAX_CONTROL: Final = "ecomax_control"
39
41
  ATTR_ECOMAX_PARAMETERS: Final = "ecomax_parameters"
40
42
 
@@ -53,7 +55,7 @@ class EcomaxParameter(Parameter):
53
55
 
54
56
  __slots__ = ()
55
57
 
56
- device: PhysicalDevice
58
+ device: EcoMAX
57
59
  description: EcomaxParameterDescription
58
60
 
59
61
  async def create_request(self) -> Request:
@@ -466,8 +468,8 @@ ECOMAX_PARAMETERS: dict[ProductType, tuple[EcomaxParameterDescription, ...]] = {
466
468
  ),
467
469
  EcomaxNumberDescription(
468
470
  name="max_fuel_flow",
469
- multiplier=0.1,
470
- unit_of_measurement=UnitOfMeasurement.KILOGRAMS_PER_HOUR,
471
+ multiplier=20,
472
+ unit_of_measurement=UnitOfMeasurement.GRAMS,
471
473
  ),
472
474
  EcomaxNumberDescription(
473
475
  name="feeder_calibration",
@@ -100,7 +100,7 @@ class ScheduleParameter(Parameter):
100
100
 
101
101
  async def create_request(self) -> Request:
102
102
  """Create a request to change the parameter."""
103
- schedule_name, _ = self.description.name.split("_schedule_", 1)
103
+ schedule_name = self.description.name.split("_schedule_", 1)[0]
104
104
  return await Request.create(
105
105
  FrameType.REQUEST_SET_SCHEDULE,
106
106
  recipient=self.device.address,
@@ -163,7 +163,7 @@ def _split_byte(byte: int) -> list[bool]:
163
163
 
164
164
  def _join_bits(bits: Sequence[int | bool]) -> int:
165
165
  """Join eight bits into a single byte."""
166
- return reduce(lambda x, y: (x << 1) | y, bits)
166
+ return reduce(lambda bit, byte: (bit << 1) | byte, bits)
167
167
 
168
168
 
169
169
  class SchedulesStructure(Structure):
@@ -113,25 +113,20 @@ class ThermostatNumber(ThermostatParameter, Number):
113
113
  @property
114
114
  def value(self) -> float:
115
115
  """Return the value."""
116
- return round(
117
- self.values.value * self.description.multiplier, self.description.precision
118
- )
116
+ value = self.values.value * self.description.multiplier
117
+ return round(value, self.description.precision)
119
118
 
120
119
  @property
121
120
  def min_value(self) -> float:
122
121
  """Return the minimum allowed value."""
123
- return round(
124
- self.values.min_value * self.description.multiplier,
125
- self.description.precision,
126
- )
122
+ value = self.values.min_value * self.description.multiplier
123
+ return round(value, self.description.precision)
127
124
 
128
125
  @property
129
126
  def max_value(self) -> float:
130
127
  """Return the maximum allowed value."""
131
- return round(
132
- self.values.max_value * self.description.multiplier,
133
- self.description.precision,
134
- )
128
+ value = self.values.max_value * self.description.multiplier
129
+ return round(value, self.description.precision)
135
130
 
136
131
 
137
132
  @dataslots