PyPlumIO 0.5.28__py3-none-any.whl → 0.5.30__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.28
3
+ Version: 0.5.30
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
@@ -22,27 +22,27 @@ Classifier: Topic :: Home Automation
22
22
  Requires-Python: >=3.9
23
23
  Description-Content-Type: text/markdown
24
24
  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 ==3.8.0 ; extra == 'dev'
31
- Requires-Dist: tomli ==2.0.1 ; extra == 'dev'
32
- Provides-Extra: docs
33
- Requires-Dist: sphinx ==7.4.7 ; extra == 'docs'
34
- Requires-Dist: sphinx-rtd-theme ==2.0.0 ; extra == 'docs'
35
- Requires-Dist: readthedocs-sphinx-search ==0.3.2 ; extra == 'docs'
25
+ Requires-Dist: dataslots==1.2.0
26
+ Requires-Dist: pyserial-asyncio==0.6
27
+ Requires-Dist: typing-extensions==4.12.2
36
28
  Provides-Extra: test
37
- Requires-Dist: codespell ==2.3.0 ; extra == 'test'
38
- Requires-Dist: coverage ==7.6.1 ; extra == 'test'
39
- Requires-Dist: mypy ==1.11.2 ; extra == 'test'
40
- Requires-Dist: pyserial-asyncio-fast ==0.14 ; extra == 'test'
41
- Requires-Dist: pytest ==8.3.2 ; extra == 'test'
42
- Requires-Dist: pytest-asyncio ==0.24.0 ; extra == 'test'
43
- Requires-Dist: ruff ==0.6.3 ; extra == 'test'
44
- Requires-Dist: tox ==4.18.0 ; extra == 'test'
45
- Requires-Dist: types-pyserial ==3.5.0.20240826 ; extra == 'test'
29
+ Requires-Dist: codespell==2.3.0; extra == "test"
30
+ Requires-Dist: coverage==7.6.8; extra == "test"
31
+ Requires-Dist: mypy==1.13.0; extra == "test"
32
+ Requires-Dist: pyserial-asyncio-fast==0.14; extra == "test"
33
+ Requires-Dist: pytest==8.3.4; extra == "test"
34
+ Requires-Dist: pytest-asyncio==0.24.0; extra == "test"
35
+ Requires-Dist: ruff==0.8.1; extra == "test"
36
+ Requires-Dist: tox==4.23.2; extra == "test"
37
+ Requires-Dist: types-pyserial==3.5.0.20240826; extra == "test"
38
+ Provides-Extra: docs
39
+ Requires-Dist: sphinx==8.1.3; extra == "docs"
40
+ Requires-Dist: sphinx_rtd_theme==3.0.2; extra == "docs"
41
+ Requires-Dist: readthedocs-sphinx-search==0.3.2; extra == "docs"
42
+ Provides-Extra: dev
43
+ Requires-Dist: pyplumio[docs,test]; extra == "dev"
44
+ Requires-Dist: pre-commit==4.0.1; extra == "dev"
45
+ Requires-Dist: tomli==2.2.1; extra == "dev"
46
46
 
47
47
  # PyPlumIO is a native ecoNET library for Plum ecoMAX controllers.
48
48
  [![PyPI version](https://badge.fury.io/py/PyPlumIO.svg)](https://badge.fury.io/py/PyPlumIO)
@@ -1,29 +1,29 @@
1
1
  pyplumio/__init__.py,sha256=ditJTIOFGJDg60atHzOpiggdUrZHpSynno7MtpZUGVk,3299
2
2
  pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
- pyplumio/_version.py,sha256=CKxZe-gNrWXIa-w5PqPynJOQ9nZVzuvxxoFx7QtLKfw,413
3
+ pyplumio/_version.py,sha256=PRcySwknPDLJzIxasFyVZNYI8LFj_28fjnJMHK2q0Ko,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=r2DZHwHG0cPWzTcsIX1pjAH19BR3iCOQsaqJ3T106t4,11188
7
+ pyplumio/filters.py,sha256=KK_AV_EHy5gj9s9BNZbn9i0RnT3uZsdEg6gdve1WYrY,11152
8
8
  pyplumio/protocol.py,sha256=VRxrj8vZ1FMawqblKkyxg_V61TBSvVynd9u0JXYnMUU,8090
9
9
  pyplumio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- pyplumio/stream.py,sha256=IVCQFKBtRafRgUkr93p_wN5mXZAD3Jw1d091dfEIK20,4479
10
+ pyplumio/stream.py,sha256=mtMpnUR3TfEmL5JUGXr6GnpPGBwzCokqIKDWp4vYiVg,4654
11
11
  pyplumio/utils.py,sha256=TnBzRopinyp92wruguijxcIYmaeyNVTFX0dygI5FCMU,823
12
- pyplumio/devices/__init__.py,sha256=uKziGOX_pcd-MEt7eqdwyZidLk7-9Uu07_U49Hlel78,6532
13
- pyplumio/devices/ecomax.py,sha256=oLl6aYbgC6MtsxCiW0eLSevpXVjVOYar--fn0Qzaz9w,16878
12
+ pyplumio/devices/__init__.py,sha256=YN09bGsyJ5WVmF8_-veUckqUl57eBwmfNr8M5qhyqy0,8149
13
+ pyplumio/devices/ecomax.py,sha256=ybFLJN7O3unBcyzuVmYTssBv86bPiiTGvFpFJezwUE4,15478
14
14
  pyplumio/devices/ecoster.py,sha256=jNWli7ye9T6yfkcFJZhhUHH7KOv-L6AgYFp_dKyv3OM,263
15
- pyplumio/devices/mixer.py,sha256=VE9Kjpq-sTLGgR8F-qnQjOuN8BrD4edrvu1L2X6m4uM,3199
16
- pyplumio/devices/thermostat.py,sha256=1vOUWppTzY7iN6zDhNUFlb5rrqOCs4_cegn25h_bags,2609
17
- pyplumio/frames/__init__.py,sha256=QqghzVt0r1STmeDlYs_hriHfzZj96-hzRQsxlFdv6Ls,7497
15
+ pyplumio/devices/mixer.py,sha256=CnHWrJELtFgs2YTHGpQwKr2UTRdetX76OvLBA2PH-fs,3207
16
+ pyplumio/devices/thermostat.py,sha256=-CZNRyywoDU6csFu85KSmQ5woVXY0x6peXkeOsi_fqg,2617
17
+ pyplumio/frames/__init__.py,sha256=30ECFT_5IneUrpOJGxjHyeuX-i4S1ikX8Pg1HO8Yxkg,7686
18
18
  pyplumio/frames/messages.py,sha256=iDwZOPdVOZaIcEHYnkwtCazH_N6BjyEDtiJBjTRaePY,3570
19
19
  pyplumio/frames/requests.py,sha256=nbSuOLue2rI4WgtXslqTGfFnWBlwzLE6I9wraKC1uqg,6854
20
20
  pyplumio/frames/responses.py,sha256=Ch1AVBmD6Ek7BazoEMDDEa6ad_fUdUXf4bNssQOu0sI,6228
21
21
  pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,31
22
- pyplumio/helpers/data_types.py,sha256=fNQmZ-fifqNl01jDXoF0BjmBJoDKVr4spZNHBuiUVEY,9059
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
26
- pyplumio/helpers/schedule.py,sha256=l-dQwy8TUjlPqiMFcQaMCTxzgtAvQpyuutRIdfI06zo,5302
25
+ pyplumio/helpers/parameter.py,sha256=yO3KSX8FtU-dMe5FzNW-RXs5yW-lYu2F5Po-4ghcSnM,12533
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
29
29
  pyplumio/helpers/uid.py,sha256=J7gN8i8LE0g6tfL66BJbwsQQqzBBxWx7giyvqaJh4BM,976
@@ -31,13 +31,13 @@ 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=tV97N6uQ1VQmY88Rdtm_BhX2kR7tDGDOfGYFCpynX0A,27880
34
+ pyplumio/structures/ecomax_parameters.py,sha256=ki9YSzVRhCD_rYHFogJoyk0NwyNlWdZrHcp-rrCuKCU,28440
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
38
38
  pyplumio/structures/fuel_level.py,sha256=mJpp1dnRD1wXi_6EyNX7TNXosjcr905rSHOnuZ5VD74,1069
39
39
  pyplumio/structures/lambda_sensor.py,sha256=JNSCiBJoM8Uk3OGbmFIigaLOntQST5U_UrmCpaQBlM0,1595
40
- pyplumio/structures/mixer_parameters.py,sha256=4PR_BgNVpeBzR3Q29GtpF9uJnHxPZ4jgyfhyysPmAUA,8919
40
+ pyplumio/structures/mixer_parameters.py,sha256=S_YOigzM9TsdEjk5EluME5fJ8owO84-xOFT9tTdBgEo,9553
41
41
  pyplumio/structures/mixer_sensors.py,sha256=-cN7U-Fr2fmAQ5McQL7bZUC8CFlb1y8TN0f_dqy3UK0,2312
42
42
  pyplumio/structures/modules.py,sha256=oXUIqrOAV1dZzBV5zUH3HDUSFvNOjpUSx0TF9nZVnbs,2569
43
43
  pyplumio/structures/network_info.py,sha256=kPxmIaDGm5SyLRKVFzcrODlUtB0u5JjiZqekoKSyDpA,4159
@@ -46,15 +46,15 @@ pyplumio/structures/outputs.py,sha256=1xsJPkjN643-aFawqVoupGatUIUJfQG_g252n051Qi
46
46
  pyplumio/structures/pending_alerts.py,sha256=Uq9WpB4MW9AhDkqmDhk-g0J0h4pVq0Q50z12dYEv6kY,739
47
47
  pyplumio/structures/product_info.py,sha256=uiEN6DFQlzmBvQByTirFzXQShoex0YGdFS9WI-MAxPc,2405
48
48
  pyplumio/structures/program_version.py,sha256=R-medELYHDlk_ALsw5HOVbZRb7JD3yBUsGwqwVCjrkU,2550
49
- pyplumio/structures/regulator_data.py,sha256=wqtRWPiwC4H_98nu9g1Po5wCqqoqUMuPBE0w93vIcuI,2271
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=rKPWC5qcKAh6xxs91oY23NqQcWby8e3gXrD6qJAVDGo,7153
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=EzJVZZhZ19beshRG7X6lw9VWDdq4mQoMFSOhWuW7_0U,8064
54
+ pyplumio/structures/thermostat_parameters.py,sha256=6r8_EU9T1IJ0vzZMduiLzsAaaWr6cKW_zHHAGsfGZI4,8804
55
55
  pyplumio/structures/thermostat_sensors.py,sha256=8e1TxYIJTQKT0kIGO9gG4hGdLOBUpIhiPToQyOMyeNE,3237
56
- PyPlumIO-0.5.28.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
- PyPlumIO-0.5.28.dist-info/METADATA,sha256=eFyYRpMwxZF6tmMLwiG2_5HZeacgq7ndFuzBSK8luAE,5490
58
- PyPlumIO-0.5.28.dist-info/WHEEL,sha256=cVxcB9AmuTcXqmwrtPhNK88dr7IR_b6qagTj0UvIEbY,91
59
- PyPlumIO-0.5.28.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
- PyPlumIO-0.5.28.dist-info/RECORD,,
56
+ PyPlumIO-0.5.30.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
+ PyPlumIO-0.5.30.dist-info/METADATA,sha256=KMMtx2B4gTlnFBoLVzVHGs75Od9qf6BN1tH3pqeYgqM,5458
58
+ PyPlumIO-0.5.30.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
59
+ PyPlumIO-0.5.30.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
+ PyPlumIO-0.5.30.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (74.1.2)
2
+ Generator: setuptools (75.6.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.28'
16
- __version_tuple__ = version_tuple = (0, 5, 28)
15
+ __version__ = version = '0.5.30'
16
+ __version_tuple__ = version_tuple = (0, 5, 30)
@@ -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:
@@ -45,7 +49,7 @@ class Device(ABC, EventManager):
45
49
 
46
50
  queue: asyncio.Queue[Frame]
47
51
 
48
- def __init__(self, queue: asyncio.Queue[Frame]):
52
+ def __init__(self, queue: asyncio.Queue[Frame]) -> None:
49
53
  """Initialize a new device."""
50
54
  super().__init__()
51
55
  self.queue = queue
@@ -125,15 +129,48 @@ class PhysicalDevice(Device, ABC):
125
129
  address: ClassVar[int]
126
130
  _network: NetworkInfo
127
131
  _setup_frames: tuple[DataFrameDescription, ...]
132
+ _frame_versions: dict[int, int]
128
133
 
129
- def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo):
134
+ def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo) -> None:
130
135
  """Initialize a new physical device."""
131
136
  super().__init__(queue)
132
137
  self._network = network
138
+ self._frame_versions = {}
139
+
140
+ async def update_frame_versions(versions: dict[int, int]) -> None:
141
+ """Check frame versions and update outdated frames."""
142
+ for frame_type, version in versions.items():
143
+ if (
144
+ is_known_frame_type(frame_type)
145
+ and self.supports_frame_type(frame_type)
146
+ and not self.has_frame_version(frame_type, version)
147
+ ):
148
+ _LOGGER.debug(
149
+ "Updating frame %s to version %i", repr(frame_type), version
150
+ )
151
+ request = await Request.create(frame_type, recipient=self.address)
152
+ self.queue.put_nowait(request)
153
+ self._frame_versions[frame_type] = version
154
+
155
+ self.subscribe(ATTR_FRAME_VERSIONS, update_frame_versions)
156
+
157
+ def has_frame_version(self, frame_type: int, version: int | None = None) -> bool:
158
+ """Return True if frame data is up to date, False otherwise."""
159
+ if frame_type not in self._frame_versions:
160
+ return False
161
+
162
+ if version is None or self._frame_versions[frame_type] == version:
163
+ return True
164
+
165
+ return False
166
+
167
+ def supports_frame_type(self, frame_type: int) -> bool:
168
+ """Check if frame type is supported by the device."""
169
+ return frame_type not in self.data.get(ATTR_FRAME_ERRORS, [])
133
170
 
134
171
  def handle_frame(self, frame: Frame) -> None:
135
172
  """Handle frame received from the device."""
136
- frame.sender_device = self
173
+ frame.assign_to(self)
137
174
  if frame.data is not None:
138
175
  for name, value in frame.data.items():
139
176
  self.dispatch_nowait(name, value)
@@ -188,7 +225,7 @@ class VirtualDevice(Device, ABC):
188
225
 
189
226
  def __init__(
190
227
  self, queue: asyncio.Queue[Frame], parent: PhysicalDevice, index: int = 0
191
- ):
228
+ ) -> None:
192
229
  """Initialize a new sub-device."""
193
230
  super().__init__(queue)
194
231
  self.parent = parent
@@ -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
- def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo):
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/devices/mixer.py CHANGED
@@ -30,7 +30,7 @@ class Mixer(VirtualDevice):
30
30
 
31
31
  def __init__(
32
32
  self, queue: asyncio.Queue[Frame], parent: PhysicalDevice, index: int = 0
33
- ):
33
+ ) -> None:
34
34
  """Initialize a new mixer."""
35
35
  super().__init__(queue, parent, index)
36
36
  self.subscribe(ATTR_MIXER_SENSORS, self._handle_mixer_sensors)
@@ -26,7 +26,7 @@ class Thermostat(VirtualDevice):
26
26
 
27
27
  def __init__(
28
28
  self, queue: asyncio.Queue[Frame], parent: PhysicalDevice, index: int = 0
29
- ):
29
+ ) -> None:
30
30
  """Initialize a new thermostat."""
31
31
  super().__init__(queue, parent, index)
32
32
  self.subscribe(ATTR_THERMOSTAT_SENSORS, self._handle_thermostat_sensors)
pyplumio/filters.py CHANGED
@@ -66,13 +66,12 @@ def _significantly_changed(
66
66
  def _significantly_changed(old: Comparable, new: Comparable) -> bool:
67
67
  """Check if value is significantly changed."""
68
68
  if isinstance(old, Parameter) and isinstance(new, Parameter):
69
- result = new.pending_update or old.values != new.values
70
- elif isinstance(old, SupportsFloat) and isinstance(new, SupportsFloat):
71
- result = not math.isclose(old, new, abs_tol=TOLERANCE)
72
- else:
73
- result = old != new
69
+ return new.pending_update or old.values.__ne__(new.values)
74
70
 
75
- return result
71
+ if isinstance(old, SupportsFloat) and isinstance(new, SupportsFloat):
72
+ return not math.isclose(old, new, abs_tol=TOLERANCE)
73
+
74
+ return old.__ne__(new)
76
75
 
77
76
 
78
77
  @overload
@@ -91,10 +90,11 @@ def _diffence_between(
91
90
  """Return a difference between values."""
92
91
  if isinstance(old, list) and isinstance(new, list):
93
92
  return [x for x in new if x not in old]
94
- elif isinstance(old, SupportsSubtraction) and isinstance(new, SupportsSubtraction):
95
- return new - old
96
- else:
97
- return None
93
+
94
+ if isinstance(old, SupportsSubtraction) and isinstance(new, SupportsSubtraction):
95
+ return new.__sub__(old)
96
+
97
+ return None
98
98
 
99
99
 
100
100
  class Filter(ABC):
@@ -24,6 +24,7 @@ ECONET_VERSION: Final = 5
24
24
 
25
25
  # Frame header structure.
26
26
  struct_header = struct.Struct("<BH4B")
27
+ HEADER_SIZE = struct_header.size
27
28
 
28
29
  if TYPE_CHECKING:
29
30
  from pyplumio.devices import PhysicalDevice
@@ -73,22 +74,20 @@ class Frame(ABC):
73
74
 
74
75
  __slots__ = (
75
76
  "recipient",
76
- "recipient_device",
77
77
  "sender",
78
- "sender_device",
79
78
  "econet_type",
80
79
  "econet_version",
80
+ "_handler",
81
81
  "_message",
82
82
  "_data",
83
83
  )
84
84
 
85
85
  recipient: DeviceType
86
- recipient_device: PhysicalDevice | None
87
86
  sender: DeviceType
88
- sender_device: PhysicalDevice | None
89
87
  econet_type: int
90
88
  econet_version: int
91
89
  frame_type: ClassVar[FrameType]
90
+ _handler: PhysicalDevice | None
92
91
  _message: bytearray | None
93
92
  _data: dict[str, Any] | None
94
93
 
@@ -104,11 +103,10 @@ class Frame(ABC):
104
103
  ) -> None:
105
104
  """Process a frame data and message."""
106
105
  self.recipient = recipient
107
- self.recipient_device = None
108
106
  self.sender = sender
109
- self.sender_device = None
110
107
  self.econet_type = econet_type
111
108
  self.econet_version = econet_version
109
+ self._handler = None
112
110
  self._data = data if not kwargs else ensure_dict(data, kwargs)
113
111
  self._message = message
114
112
 
@@ -153,6 +151,15 @@ class Frame(ABC):
153
151
  """Return a frame message represented as hex string."""
154
152
  return self.bytes.hex(*args, **kwargs)
155
153
 
154
+ def assign_to(self, device: PhysicalDevice) -> None:
155
+ """Assign device to the frame."""
156
+ self._handler = device
157
+
158
+ @property
159
+ def handler(self) -> PhysicalDevice | None:
160
+ """Return the device associated to the frame."""
161
+ return self._handler
162
+
156
163
  @property
157
164
  def data(self) -> dict[str, Any]:
158
165
  """Return the frame data."""
@@ -19,7 +19,7 @@ class DataType(ABC, Generic[T]):
19
19
  _value: T
20
20
  _size: int
21
21
 
22
- def __init__(self, value: T | None = None):
22
+ def __init__(self, value: T | None = None) -> None:
23
23
  """Initialize a new data type."""
24
24
  if value is not None:
25
25
  self._value = value
@@ -112,7 +112,7 @@ class BitArray(DataType[int]):
112
112
 
113
113
  _index: int
114
114
 
115
- def __init__(self, value: bool | None = None, index: int = 0):
115
+ def __init__(self, value: bool | None = None, index: int = 0) -> None:
116
116
  """Initialize a new bit array."""
117
117
  super().__init__(value)
118
118
  self._index = index
@@ -199,7 +199,7 @@ class String(DataType[str]):
199
199
 
200
200
  __slots__ = ()
201
201
 
202
- def __init__(self, value: str = ""):
202
+ def __init__(self, value: str = "") -> None:
203
203
  """Initialize a new null-terminated string data type."""
204
204
  super().__init__(value)
205
205
  self._size = len(self.value) + 1
@@ -219,7 +219,7 @@ class VarBytes(DataType[bytes]):
219
219
 
220
220
  __slots__ = ()
221
221
 
222
- def __init__(self, value: bytes = b""):
222
+ def __init__(self, value: bytes = b"") -> None:
223
223
  """Initialize a new variable-length bytes data type."""
224
224
  super().__init__(value)
225
225
  self._size = len(value) + 1
@@ -239,7 +239,7 @@ class VarString(DataType[str]):
239
239
 
240
240
  __slots__ = ()
241
241
 
242
- def __init__(self, value: str = ""):
242
+ def __init__(self, value: str = "") -> None:
243
243
  """Initialize a new variable length bytes data type."""
244
244
  super().__init__(value)
245
245
  self._size = len(value) + 1
@@ -326,7 +326,7 @@ class UnsignedInt(BuiltInDataType[int]):
326
326
  _struct = struct.Struct("<I")
327
327
 
328
328
 
329
- class Float(BuiltInDataType[int]):
329
+ class Float(BuiltInDataType[float]):
330
330
  """Represents a float."""
331
331
 
332
332
  __slots__ = ()
@@ -334,7 +334,7 @@ class Float(BuiltInDataType[int]):
334
334
  _struct = struct.Struct("<f")
335
335
 
336
336
 
337
- class Double(BuiltInDataType[int]):
337
+ class Double(BuiltInDataType[float]):
338
338
  """Represents a double."""
339
339
 
340
340
  __slots__ = ()
@@ -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
 
@@ -185,6 +194,7 @@ class Parameter(ABC):
185
194
  f"Value must be between '{self.min_value}' and '{self.max_value}'"
186
195
  )
187
196
 
197
+ self._previous_value = self._values.value
188
198
  self._values.value = value
189
199
  self._pending_update = True
190
200
  while self.pending_update:
@@ -196,6 +206,9 @@ class Parameter(ABC):
196
206
  return False
197
207
 
198
208
  await self.device.queue.put(await self.create_request())
209
+ if not self.is_tracking_changes:
210
+ await self.force_refresh()
211
+
199
212
  await asyncio.sleep(timeout)
200
213
  retries -= 1
201
214
 
@@ -203,8 +216,19 @@ class Parameter(ABC):
203
216
 
204
217
  def update(self, values: ParameterValues) -> None:
205
218
  """Update the parameter values."""
219
+ if self.pending_update and self._previous_value != values.value:
220
+ self._pending_update = False
221
+
206
222
  self._values = values
207
- self._pending_update = False
223
+
224
+ async def force_refresh(self) -> None:
225
+ """Refresh the parameter from remote."""
226
+ await self.device.queue.put(await self.create_refresh_request())
227
+
228
+ @property
229
+ def is_tracking_changes(self) -> bool:
230
+ """Return True if remote's tracking changes, False otherwise."""
231
+ return False
208
232
 
209
233
  @property
210
234
  def pending_update(self) -> bool:
@@ -254,6 +278,10 @@ class Parameter(ABC):
254
278
  async def create_request(self) -> Request:
255
279
  """Create a request to change the parameter."""
256
280
 
281
+ @abstractmethod
282
+ async def create_refresh_request(self) -> Request:
283
+ """Create a request to refresh the parameter."""
284
+
257
285
 
258
286
  @dataslots
259
287
  @dataclass
@@ -286,6 +314,10 @@ class Number(Parameter):
286
314
  """Create a request to change the number."""
287
315
  return Request()
288
316
 
317
+ async def create_refresh_request(self) -> Request:
318
+ """Create a request to refresh the number."""
319
+ return Request()
320
+
289
321
  @property
290
322
  def value(self) -> int | float:
291
323
  """Return the value."""
@@ -362,6 +394,10 @@ class Switch(Parameter):
362
394
  """Create a request to change the switch."""
363
395
  return Request()
364
396
 
397
+ async def create_refresh_request(self) -> Request:
398
+ """Create a request to refresh the switch."""
399
+ return Request()
400
+
365
401
  @property
366
402
  def value(self) -> Literal["off", "on"]:
367
403
  """Return the value."""
@@ -70,7 +70,7 @@ class ScheduleDay(MutableMapping):
70
70
 
71
71
  _intervals: list[bool]
72
72
 
73
- def __init__(self, intervals: list[bool]):
73
+ def __init__(self, intervals: list[bool]) -> None:
74
74
  """Initialize a new schedule day."""
75
75
  self._intervals = intervals
76
76
 
pyplumio/stream.py CHANGED
@@ -10,7 +10,14 @@ from typing import Final, NamedTuple
10
10
  from pyplumio.const import DeviceType
11
11
  from pyplumio.devices import is_known_device_type
12
12
  from pyplumio.exceptions import ChecksumError, ReadError, UnknownDeviceError
13
- from pyplumio.frames import FRAME_START, Frame, bcc, struct_header
13
+ from pyplumio.frames import (
14
+ DELIMITER_SIZE,
15
+ FRAME_START,
16
+ HEADER_SIZE,
17
+ Frame,
18
+ bcc,
19
+ struct_header,
20
+ )
14
21
  from pyplumio.helpers.timeout import timeout
15
22
 
16
23
  READER_TIMEOUT: Final = 10
@@ -29,7 +36,7 @@ class FrameWriter:
29
36
 
30
37
  _writer: StreamWriter
31
38
 
32
- def __init__(self, writer: StreamWriter):
39
+ def __init__(self, writer: StreamWriter) -> None:
33
40
  """Initialize a new frame writer."""
34
41
  self._writer = writer
35
42
 
@@ -46,7 +53,7 @@ class FrameWriter:
46
53
  self._writer.close()
47
54
  await self.wait_closed()
48
55
  except (OSError, asyncio.TimeoutError):
49
- _LOGGER.exception("Unexpected error while closing the writer")
56
+ _LOGGER.exception("Unexpected error, while closing the writer")
50
57
 
51
58
  @timeout(WRITER_TIMEOUT)
52
59
  async def wait_closed(self) -> None:
@@ -57,8 +64,6 @@ class FrameWriter:
57
64
  class Header(NamedTuple):
58
65
  """Represents a frame header."""
59
66
 
60
- bytes: bytes
61
- frame_start: int
62
67
  frame_length: int
63
68
  recipient: int
64
69
  sender: int
@@ -73,25 +78,28 @@ class FrameReader:
73
78
 
74
79
  _reader: StreamReader
75
80
 
76
- def __init__(self, reader: StreamReader):
81
+ def __init__(self, reader: StreamReader) -> None:
77
82
  """Initialize a new frame reader."""
78
83
  self._reader = reader
79
84
 
80
- async def _read_header(self) -> Header:
85
+ async def _read_header(self) -> tuple[Header, bytes]:
81
86
  """Locate and read a frame header.
82
87
 
83
88
  Raise pyplumio.ReadError if header size is too small and
84
89
  OSError if serial connection is broken.
85
90
  """
86
- while buffer := await self._reader.read(1):
91
+ while buffer := await self._reader.read(DELIMITER_SIZE):
87
92
  if FRAME_START not in buffer:
88
93
  continue
89
94
 
90
- buffer += await self._reader.read(struct_header.size - 1)
91
- if len(buffer) < struct_header.size:
92
- raise ReadError(f"Header can't be less than {struct_header.size} bytes")
95
+ try:
96
+ buffer += await self._reader.readexactly(HEADER_SIZE - DELIMITER_SIZE)
97
+ except IncompleteReadError as e:
98
+ raise ReadError(
99
+ f"Got incomplete header, while trying to read {e.expected} bytes"
100
+ ) from e
93
101
 
94
- return Header(buffer, *struct_header.unpack_from(buffer))
102
+ return Header(*struct_header.unpack_from(buffer)[DELIMITER_SIZE:]), buffer
95
103
 
96
104
  raise OSError("Serial connection broken")
97
105
 
@@ -99,21 +107,16 @@ class FrameReader:
99
107
  async def read(self) -> Frame | None:
100
108
  """Read the frame and return corresponding handler object.
101
109
 
102
- Raise pyplumio.ReadError on unexpected frame length or
103
- incomplete frame and pyplumio. Raise ChecksumError on incorrect
104
- frame checksum.
110
+ Raise pyplumio.UnknownDeviceError when sender device has an
111
+ unknown address, raise pyplumio.ReadError on unexpected frame
112
+ length or incomplete frame, raise pyplumio.ChecksumError on
113
+ incorrect frame checksum.
105
114
  """
106
- (
107
- header_bytes,
108
- _,
109
- frame_length,
110
- recipient,
111
- sender,
112
- econet_type,
113
- econet_version,
114
- ) = await self._read_header()
115
+ header, buffer = await self._read_header()
116
+ frame_length, recipient, sender, econet_type, econet_version = header
115
117
 
116
118
  if recipient not in (DeviceType.ECONET, DeviceType.ALL):
119
+ # Not an intended recipient, ignore the frame.
117
120
  return None
118
121
 
119
122
  if not is_known_device_type(sender):
@@ -123,25 +126,24 @@ class FrameReader:
123
126
  raise ReadError(f"Unexpected frame length ({frame_length})")
124
127
 
125
128
  try:
126
- payload = await self._reader.readexactly(frame_length - struct_header.size)
129
+ buffer += await self._reader.readexactly(frame_length - HEADER_SIZE)
127
130
  except IncompleteReadError as e:
128
131
  raise ReadError(
129
- "Got an incomplete frame while trying to read "
130
- + f"'{frame_length - struct_header.size}' bytes"
132
+ f"Got incomplete frame, while trying to read {e.expected} bytes"
131
133
  ) from e
132
134
 
133
- if (checksum := bcc(header_bytes + payload[:-2])) and checksum != payload[-2]:
135
+ if (checksum := bcc(buffer[:-2])) and checksum != buffer[-2]:
134
136
  raise ChecksumError(
135
- f"Incorrect frame checksum ({checksum} != {payload[-2]})"
137
+ f"Incorrect frame checksum ({checksum} != {buffer[-2]})"
136
138
  )
137
139
 
138
140
  frame = await Frame.create(
139
- frame_type=payload[0],
141
+ frame_type=buffer[HEADER_SIZE],
140
142
  recipient=DeviceType(recipient),
141
143
  sender=DeviceType(sender),
142
144
  econet_type=econet_type,
143
145
  econet_version=econet_version,
144
- message=payload[1:-2],
146
+ message=buffer[HEADER_SIZE + 1 : -2],
145
147
  )
146
148
  _LOGGER.debug("Received frame: %s", frame)
147
149
 
@@ -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,19 +55,20 @@ 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:
60
62
  """Create a request to change the parameter."""
61
63
  handler = partial(Request.create, recipient=self.device.address)
62
64
  if self.description.name == ATTR_ECOMAX_CONTROL:
63
- request = await handler(
65
+ return await handler(
64
66
  frame_type=FrameType.REQUEST_ECOMAX_CONTROL,
65
67
  data={ATTR_VALUE: self.values.value},
66
68
  )
67
- elif self.description.name == ATTR_THERMOSTAT_PROFILE:
68
- request = await handler(
69
+
70
+ if self.description.name == ATTR_THERMOSTAT_PROFILE:
71
+ return await handler(
69
72
  frame_type=FrameType.REQUEST_SET_THERMOSTAT_PARAMETER,
70
73
  data={
71
74
  ATTR_INDEX: self._index,
@@ -74,13 +77,22 @@ class EcomaxParameter(Parameter):
74
77
  ATTR_SIZE: 1,
75
78
  },
76
79
  )
77
- else:
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
80
 
83
- return request
81
+ return await handler(
82
+ frame_type=FrameType.REQUEST_SET_ECOMAX_PARAMETER,
83
+ data={ATTR_INDEX: self._index, ATTR_VALUE: self.values.value},
84
+ )
85
+
86
+ async def create_refresh_request(self) -> Request:
87
+ """Create a request to refresh the parameter."""
88
+ return await Request.create(
89
+ FrameType.REQUEST_ECOMAX_PARAMETERS, recipient=self.device.address
90
+ )
91
+
92
+ @property
93
+ def is_tracking_changes(self) -> bool:
94
+ """Return True if remote's tracking changes, False otherwise."""
95
+ return self.device.has_frame_version(FrameType.REQUEST_ECOMAX_PARAMETERS)
84
96
 
85
97
 
86
98
  @dataslots
@@ -90,6 +102,7 @@ class EcomaxNumberDescription(EcomaxParameterDescription, NumberDescription):
90
102
 
91
103
  multiplier: float = 1.0
92
104
  offset: int = 0
105
+ precision: int = 6
93
106
 
94
107
 
95
108
  class EcomaxNumber(EcomaxParameter, Number):
@@ -103,29 +116,27 @@ class EcomaxNumber(EcomaxParameter, Number):
103
116
  self, value: float | int, retries: int = 5, timeout: float = 5.0
104
117
  ) -> bool:
105
118
  """Set a parameter value."""
106
- value = (value + self.description.offset) / self.description.multiplier
119
+ value += self.description.offset
120
+ value = round(value / self.description.multiplier, self.description.precision)
107
121
  return await super().set(value, retries, timeout)
108
122
 
109
123
  @property
110
124
  def value(self) -> float:
111
125
  """Return the value."""
112
- return (
113
- self.values.value - self.description.offset
114
- ) * self.description.multiplier
126
+ value = self.values.value - self.description.offset
127
+ return round(value * self.description.multiplier, self.description.precision)
115
128
 
116
129
  @property
117
130
  def min_value(self) -> float:
118
131
  """Return the minimum allowed value."""
119
- return (
120
- self.values.min_value - self.description.offset
121
- ) * self.description.multiplier
132
+ value = self.values.min_value - self.description.offset
133
+ return round(value * self.description.multiplier, self.description.precision)
122
134
 
123
135
  @property
124
136
  def max_value(self) -> float:
125
137
  """Return the maximum allowed value."""
126
- return (
127
- self.values.max_value - self.description.offset
128
- ) * self.description.multiplier
138
+ value = self.values.max_value - self.description.offset
139
+ return round(value * self.description.multiplier, self.description.precision)
129
140
 
130
141
 
131
142
  @dataslots
@@ -292,11 +303,11 @@ ECOMAX_PARAMETERS: dict[ProductType, tuple[EcomaxParameterDescription, ...]] = {
292
303
  unit_of_measurement=UnitOfMeasurement.CELSIUS,
293
304
  ),
294
305
  EcomaxNumberDescription(
295
- name="supervision_airflow_work",
306
+ name="grate_fan_work",
296
307
  unit_of_measurement=UnitOfMeasurement.SECONDS,
297
308
  ),
298
309
  EcomaxNumberDescription(
299
- name="supervision_airflow_pause",
310
+ name="grate_fan_pause",
300
311
  unit_of_measurement=UnitOfMeasurement.MINUTES,
301
312
  ),
302
313
  EcomaxNumberDescription(
@@ -416,10 +427,10 @@ ECOMAX_PARAMETERS: dict[ProductType, tuple[EcomaxParameterDescription, ...]] = {
416
427
  unit_of_measurement=PERCENTAGE,
417
428
  ),
418
429
  EcomaxNumberDescription(
419
- name="burning_off_airflow_work",
430
+ name="burning_off_fan_work",
420
431
  ),
421
432
  EcomaxNumberDescription(
422
- name="burning_off_airflow_pause",
433
+ name="burning_off_fan_pause",
423
434
  ),
424
435
  EcomaxNumberDescription(
425
436
  name="start_burning_off",
@@ -65,6 +65,17 @@ class MixerParameter(Parameter):
65
65
  },
66
66
  )
67
67
 
68
+ async def create_refresh_request(self) -> Request:
69
+ """Create a request to refresh the parameter."""
70
+ return await Request.create(
71
+ FrameType.REQUEST_MIXER_PARAMETERS, recipient=self.device.parent.address
72
+ )
73
+
74
+ @property
75
+ def is_tracking_changes(self) -> bool:
76
+ """Return True if remote's tracking changes, False otherwise."""
77
+ return self.device.parent.has_frame_version(FrameType.REQUEST_MIXER_PARAMETERS)
78
+
68
79
 
69
80
  @dataslots
70
81
  @dataclass
@@ -73,6 +84,7 @@ class MixerNumberDescription(MixerParameterDescription, NumberDescription):
73
84
 
74
85
  multiplier: float = 1.0
75
86
  offset: int = 0
87
+ precision: int = 6
76
88
 
77
89
 
78
90
  class MixerNumber(MixerParameter, Number):
@@ -86,29 +98,27 @@ class MixerNumber(MixerParameter, Number):
86
98
  self, value: int | float, retries: int = 5, timeout: float = 5.0
87
99
  ) -> bool:
88
100
  """Set a parameter value."""
89
- value = (value + self.description.offset) / self.description.multiplier
101
+ value += self.description.offset
102
+ value = round(value / self.description.multiplier, self.description.precision)
90
103
  return await super().set(value, retries, timeout)
91
104
 
92
105
  @property
93
106
  def value(self) -> float:
94
107
  """Return the parameter value."""
95
- return (
96
- self.values.value - self.description.offset
97
- ) * self.description.multiplier
108
+ value = self.values.value - self.description.offset
109
+ return round(value * self.description.multiplier, self.description.precision)
98
110
 
99
111
  @property
100
112
  def min_value(self) -> float:
101
113
  """Return the minimum allowed value."""
102
- return (
103
- self.values.min_value - self.description.offset
104
- ) * self.description.multiplier
114
+ value = self.values.min_value - self.description.offset
115
+ return round(value * self.description.multiplier, self.description.precision)
105
116
 
106
117
  @property
107
118
  def max_value(self) -> float:
108
119
  """Return the maximum allowed value."""
109
- return (
110
- self.values.max_value - self.description.offset
111
- ) * self.description.multiplier
120
+ value = self.values.max_value - self.description.offset
121
+ return round(value * self.description.multiplier, self.description.precision)
112
122
 
113
123
 
114
124
  @dataslots
@@ -53,7 +53,7 @@ class RegulatorDataStructure(StructureDecoder):
53
53
  message, offset + 2, data
54
54
  )
55
55
 
56
- if (device := self.frame.sender_device) is not None and (
56
+ if (device := self.frame.handler) is not None and (
57
57
  schema := device.get_nowait(ATTR_REGDATA_SCHEMA, [])
58
58
  ):
59
59
  self._bitarray_index = 0
@@ -100,13 +100,24 @@ 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,
107
107
  data=collect_schedule_data(schedule_name, self.device),
108
108
  )
109
109
 
110
+ async def create_refresh_request(self) -> Request:
111
+ """Create a request to refresh the parameter."""
112
+ return await Request.create(
113
+ FrameType.REQUEST_SCHEDULES, recipient=self.device.address
114
+ )
115
+
116
+ @property
117
+ def is_tracking_changes(self) -> bool:
118
+ """Return True if remote's tracking changes, False otherwise."""
119
+ return self.device.has_frame_version(FrameType.REQUEST_SCHEDULES)
120
+
110
121
 
111
122
  @dataslots
112
123
  @dataclass
@@ -163,7 +174,7 @@ def _split_byte(byte: int) -> list[bool]:
163
174
 
164
175
  def _join_bits(bits: Sequence[int | bool]) -> int:
165
176
  """Join eight bits into a single byte."""
166
- return reduce(lambda x, y: (x << 1) | y, bits)
177
+ return reduce(lambda bit, byte: (bit << 1) | byte, bits)
167
178
 
168
179
 
169
180
  class SchedulesStructure(Structure):
@@ -66,7 +66,7 @@ class ThermostatParameter(Parameter):
66
66
  values: ParameterValues | None = None,
67
67
  index: int = 0,
68
68
  offset: int = 0,
69
- ):
69
+ ) -> None:
70
70
  """Initialize a new thermostat parameter."""
71
71
  self.offset = offset
72
72
  super().__init__(device, description, values, index)
@@ -86,6 +86,20 @@ class ThermostatParameter(Parameter):
86
86
  },
87
87
  )
88
88
 
89
+ async def create_refresh_request(self) -> Request:
90
+ """Create a request to refresh the parameter."""
91
+ return await Request.create(
92
+ FrameType.REQUEST_THERMOSTAT_PARAMETERS,
93
+ recipient=self.device.parent.address,
94
+ )
95
+
96
+ @property
97
+ def is_tracking_changes(self) -> bool:
98
+ """Return True if remote's tracking changes, False otherwise."""
99
+ return self.device.parent.has_frame_version(
100
+ FrameType.REQUEST_THERMOSTAT_PARAMETERS
101
+ )
102
+
89
103
 
90
104
  @dataslots
91
105
  @dataclass
@@ -93,6 +107,7 @@ class ThermostatNumberDescription(ThermostatParameterDescription, NumberDescript
93
107
  """Represent a thermostat number description."""
94
108
 
95
109
  multiplier: float = 1.0
110
+ precision: int = 6
96
111
 
97
112
 
98
113
  class ThermostatNumber(ThermostatParameter, Number):
@@ -106,23 +121,26 @@ class ThermostatNumber(ThermostatParameter, Number):
106
121
  self, value: int | float, retries: int = 5, timeout: float = 5.0
107
122
  ) -> bool:
108
123
  """Set a parameter value."""
109
- value = value / self.description.multiplier
124
+ value = round(value / self.description.multiplier, self.description.precision)
110
125
  return await super().set(value, retries, timeout)
111
126
 
112
127
  @property
113
128
  def value(self) -> float:
114
129
  """Return the value."""
115
- return self.values.value * self.description.multiplier
130
+ value = self.values.value * self.description.multiplier
131
+ return round(value, self.description.precision)
116
132
 
117
133
  @property
118
134
  def min_value(self) -> float:
119
135
  """Return the minimum allowed value."""
120
- return self.values.min_value * self.description.multiplier
136
+ value = self.values.min_value * self.description.multiplier
137
+ return round(value, self.description.precision)
121
138
 
122
139
  @property
123
140
  def max_value(self) -> float:
124
141
  """Return the maximum allowed value."""
125
- return self.values.max_value * self.description.multiplier
142
+ value = self.values.max_value * self.description.multiplier
143
+ return round(value, self.description.precision)
126
144
 
127
145
 
128
146
  @dataslots
@@ -247,7 +265,7 @@ class ThermostatParametersStructure(StructureDecoder):
247
265
  self, message: bytearray, offset: int = 0, data: dict[str, Any] | None = None
248
266
  ) -> tuple[dict[str, Any], int]:
249
267
  """Decode bytes and return message data and offset."""
250
- if (device := self.frame.sender_device) is not None and (
268
+ if (device := self.frame.handler) is not None and (
251
269
  thermostats := device.get_nowait(ATTR_THERMOSTATS_AVAILABLE, 0)
252
270
  ) == 0:
253
271
  return (