PyPlumIO 0.5.29__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.29
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 ==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'
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.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'
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,6 +1,6 @@
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=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
@@ -9,8 +9,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=YN09bGsyJ5WVmF8_-veUckqUl57eBwmfNr8M5qhyqy0,8149
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=yO3KSX8FtU-dMe5FzNW-RXs5yW-lYu2F5Po-4ghcSnM,12533
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,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=OYQZ0XJF-lqV_GdMaLTek4Gd6etIwhEJIyZyBS189O4,27959
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=idF3tYukfAz1EM1CE-hZBjjmGrNZN6X1MlcZr3FHrzA,9089
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
@@ -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=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=SewPHuw7f4PTvzKXcOQA8SNjQyuyBWNIk6jsUJX82BI,8321
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.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.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 (75.3.0)
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.29'
16
- __version_tuple__ = version_tuple = (0, 5, 29)
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:
@@ -125,11 +129,44 @@ 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
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."""
@@ -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()
@@ -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."""
@@ -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:
@@ -81,6 +83,17 @@ class EcomaxParameter(Parameter):
81
83
  data={ATTR_INDEX: self._index, ATTR_VALUE: self.values.value},
82
84
  )
83
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)
96
+
84
97
 
85
98
  @dataslots
86
99
  @dataclass
@@ -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
@@ -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):
@@ -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
@@ -113,25 +127,20 @@ class ThermostatNumber(ThermostatParameter, Number):
113
127
  @property
114
128
  def value(self) -> float:
115
129
  """Return the value."""
116
- return round(
117
- self.values.value * self.description.multiplier, self.description.precision
118
- )
130
+ value = self.values.value * self.description.multiplier
131
+ return round(value, self.description.precision)
119
132
 
120
133
  @property
121
134
  def min_value(self) -> float:
122
135
  """Return the minimum allowed value."""
123
- return round(
124
- self.values.min_value * self.description.multiplier,
125
- self.description.precision,
126
- )
136
+ value = self.values.min_value * self.description.multiplier
137
+ return round(value, self.description.precision)
127
138
 
128
139
  @property
129
140
  def max_value(self) -> float:
130
141
  """Return the maximum allowed value."""
131
- return round(
132
- self.values.max_value * self.description.multiplier,
133
- self.description.precision,
134
- )
142
+ value = self.values.max_value * self.description.multiplier
143
+ return round(value, self.description.precision)
135
144
 
136
145
 
137
146
  @dataslots