PyPlumIO 0.5.35__py3-none-any.whl → 0.5.37__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.2
2
2
  Name: PyPlumIO
3
- Version: 0.5.35
3
+ Version: 0.5.37
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
@@ -27,30 +27,30 @@ Requires-Dist: dataslots==1.2.0
27
27
  Requires-Dist: pyserial-asyncio==0.6
28
28
  Requires-Dist: typing-extensions==4.12.2
29
29
  Provides-Extra: test
30
- Requires-Dist: codespell==2.3.0; extra == "test"
30
+ Requires-Dist: codespell==2.4.1; extra == "test"
31
31
  Requires-Dist: coverage==7.6.10; extra == "test"
32
32
  Requires-Dist: mypy==1.14.1; extra == "test"
33
33
  Requires-Dist: pyserial-asyncio-fast==0.14; extra == "test"
34
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"
35
+ Requires-Dist: pytest-asyncio==0.25.3; extra == "test"
36
+ Requires-Dist: ruff==0.9.4; extra == "test"
37
+ Requires-Dist: tox==4.24.1; extra == "test"
38
+ Requires-Dist: types-pyserial==3.5.0.20250130; extra == "test"
39
39
  Provides-Extra: docs
40
40
  Requires-Dist: sphinx==8.1.3; extra == "docs"
41
41
  Requires-Dist: sphinx_rtd_theme==3.0.2; extra == "docs"
42
42
  Requires-Dist: readthedocs-sphinx-search==0.3.2; extra == "docs"
43
43
  Provides-Extra: dev
44
44
  Requires-Dist: pyplumio[docs,test]; extra == "dev"
45
- Requires-Dist: pre-commit==4.0.1; extra == "dev"
45
+ Requires-Dist: pre-commit==4.1.0; extra == "dev"
46
46
  Requires-Dist: tomli==2.2.1; extra == "dev"
47
47
 
48
48
  # PyPlumIO is a native ecoNET library for Plum ecoMAX controllers.
49
49
  [![PyPI version](https://badge.fury.io/py/PyPlumIO.svg)](https://badge.fury.io/py/PyPlumIO)
50
50
  [![PyPI Supported Python Versions](https://img.shields.io/pypi/pyversions/pyplumio.svg)](https://pypi.python.org/pypi/pyplumio/)
51
51
  [![PyPlumIO CI](https://github.com/denpamusic/PyPlumIO/actions/workflows/ci.yml/badge.svg)](https://github.com/denpamusic/PyPlumIO/actions/workflows/ci.yml)
52
- [![Maintainability](https://api.codeclimate.com/v1/badges/9f275fbc50fe9082a909/maintainability)](https://codeclimate.com/github/denpamusic/PyPlumIO/maintainability)
53
- [![Test Coverage](https://api.codeclimate.com/v1/badges/9f275fbc50fe9082a909/test_coverage)](https://codeclimate.com/github/denpamusic/PyPlumIO/test_coverage)
52
+ [![Maintainability](https://api.codeclimate.com/v1/badges/e802127770476b7ba6fd/maintainability)](https://codeclimate.com/github/denpamusic/PyPlumIO/maintainability)
53
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/e802127770476b7ba6fd/test_coverage)](https://codeclimate.com/github/denpamusic/PyPlumIO/test_coverage)
54
54
  [![stability-release-candidate](https://img.shields.io/badge/stability-pre--release-48c9b0.svg)](https://guidelines.denpa.pro/stability#release-candidate)
55
55
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
56
56
 
@@ -1,37 +1,37 @@
1
- pyplumio/__init__.py,sha256=ditJTIOFGJDg60atHzOpiggdUrZHpSynno7MtpZUGVk,3299
1
+ pyplumio/__init__.py,sha256=3ibJ43RIdfFrWp1PAsQixybAA--NPRw43B5OdLOwsU8,3319
2
2
  pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
- pyplumio/_version.py,sha256=J8xgWxzK8uRktLMJfG0M8fVxK4sfrTuU-akVgfoP1wU,413
4
- pyplumio/connection.py,sha256=6mUbcjGxxEhMVIbzZgCqH-Ez-fcYoRj7ZbVSzpikpNA,5949
3
+ pyplumio/_version.py,sha256=orZLgglRSt7G3Yf4YysqzFGUuXQI2XkO9JT2vDRdeJc,413
4
+ pyplumio/connection.py,sha256=-dbrIK6ewoYNeBQod9ZmXT8JkxMKbcS6nosINFsg9RI,5972
5
5
  pyplumio/const.py,sha256=LyXa5aVy2KxnZq7H7F8s5SYsAgEC2UzZYMMRauliB2E,5502
6
- pyplumio/exceptions.py,sha256=lzZFzq9aszav_pmAbWbN4IfzAAqZe0nE0mRFsscJ3rw,772
6
+ pyplumio/exceptions.py,sha256=_B_0EgxDxd2XyYv3WpZM733q0cML5m6J-f55QOvYRpI,996
7
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=Ne-mWkO6FpILAjGdagbAh_VL3QEla-eDiT2N-kOc5o4,4883
11
11
  pyplumio/utils.py,sha256=TnBzRopinyp92wruguijxcIYmaeyNVTFX0dygI5FCMU,823
12
- pyplumio/devices/__init__.py,sha256=v3EwUf2cGj9Mbxwa6kRMYqUIiREVIbxaVZaR2p5jmaE,8123
13
- pyplumio/devices/ecomax.py,sha256=LWDIlnC2lIOF2L5obLwpHKY0KTxp81TbrMGAzk7GlSw,15528
12
+ pyplumio/devices/__init__.py,sha256=Erjd3DeEop_yelnLtRRaPbwMIuD1NwVh7dMM1_2KxtI,8155
13
+ pyplumio/devices/ecomax.py,sha256=hb4QULpyg5KrCWeQDNnwy7IA9k5oRETK5AWllVgA0Kg,15806
14
14
  pyplumio/devices/ecoster.py,sha256=jNWli7ye9T6yfkcFJZhhUHH7KOv-L6AgYFp_dKyv3OM,263
15
- pyplumio/devices/mixer.py,sha256=CnHWrJELtFgs2YTHGpQwKr2UTRdetX76OvLBA2PH-fs,3207
15
+ pyplumio/devices/mixer.py,sha256=HdJNsvX3obYyLsuDhERX4IkodX3hGv3veP9ymjQnoUk,3108
16
16
  pyplumio/devices/thermostat.py,sha256=-CZNRyywoDU6csFu85KSmQ5woVXY0x6peXkeOsi_fqg,2617
17
17
  pyplumio/frames/__init__.py,sha256=30ECFT_5IneUrpOJGxjHyeuX-i4S1ikX8Pg1HO8Yxkg,7686
18
18
  pyplumio/frames/messages.py,sha256=iDwZOPdVOZaIcEHYnkwtCazH_N6BjyEDtiJBjTRaePY,3570
19
- pyplumio/frames/requests.py,sha256=nbSuOLue2rI4WgtXslqTGfFnWBlwzLE6I9wraKC1uqg,6854
20
- pyplumio/frames/responses.py,sha256=Ch1AVBmD6Ek7BazoEMDDEa6ad_fUdUXf4bNssQOu0sI,6228
19
+ pyplumio/frames/requests.py,sha256=X9P0TdCd8z7pf2XspuontEJsLy00EZfxxue6pg-_MT8,6854
20
+ pyplumio/frames/responses.py,sha256=dzrL0Yx7SoJuJAQyjOE8_ARfy7yvOqk2uq4kdnH5t1U,6228
21
21
  pyplumio/helpers/__init__.py,sha256=H2xxdkF-9uADLwEbfBUoxNTdwru3L5Z2cfJjgsuRsn0,31
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=v07s9DyihfkNUzt7ndyJbNd_DLS8UpRkut_xkGrbi6c,1123
25
- pyplumio/helpers/parameter.py,sha256=lolBkPp69cD1ME8SVQf5VVd_R7nOtrZuhUvKCz1amIg,12390
26
- pyplumio/helpers/schedule.py,sha256=rlq3O2Xflqks6V1rMoHKy_lww8zJJpuHFFouM2pikJw,5417
25
+ pyplumio/helpers/parameter.py,sha256=LoTYAtSLv2bGjEMABn7S1Ycqd_DzcMt_6UPG8frFZ-8,12740
26
+ pyplumio/helpers/schedule.py,sha256=0lkghnnpQRdRtgqoNv7PnHMYYJpJNMHl9PR4_SaHB8w,5374
27
27
  pyplumio/helpers/task_manager.py,sha256=HAd69yGTRL0zQsu-ywnbLu1UXiJzgHWuhYWA--vs4lQ,1181
28
28
  pyplumio/helpers/timeout.py,sha256=JAhWNtIpcXyVILIwHWVy5mYofqbbRDGKLdTUKkQuajs,772
29
- pyplumio/helpers/uid.py,sha256=J7gN8i8LE0g6tfL66BJbwsQQqzBBxWx7giyvqaJh4BM,976
29
+ pyplumio/helpers/uid.py,sha256=qcE8sx8YwrUX3xEfL0cgjNP65rOZmv-M3fDlgFezUwc,989
30
30
  pyplumio/structures/__init__.py,sha256=EjK-5qJZ0F7lpP2b6epvTMg9cIBl4Kn91nqNkEcLwTc,1299
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=wLwhTdKee-UtCU5NuRfOHRPvnjDOtDNUDeUgeO0VH8w,27974
34
+ pyplumio/structures/ecomax_parameters.py,sha256=4hsLM8pgcLrfYL0loLqTH4kMSdVzOThu5SL_QTodSYs,27997
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
@@ -53,8 +53,8 @@ pyplumio/structures/statuses.py,sha256=wkoynyMRr1VREwfBC6vU48kPA8ZQ83pcXuciy2xHJ
53
53
  pyplumio/structures/temperatures.py,sha256=1CDzehNmbALz1Jyt_9gZNIk52q6Wv-xQXjijVDCVYec,2337
54
54
  pyplumio/structures/thermostat_parameters.py,sha256=QA-ZyulBG3P10sqgdI7rmpQYlKm9SJIXxBxAXs8Bwow,8295
55
55
  pyplumio/structures/thermostat_sensors.py,sha256=8e1TxYIJTQKT0kIGO9gG4hGdLOBUpIhiPToQyOMyeNE,3237
56
- PyPlumIO-0.5.35.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
- PyPlumIO-0.5.35.dist-info/METADATA,sha256=4QXHmKbKDme-A4wWibnMQdeopxLDsZtKHswzhoChzZ0,5510
58
- PyPlumIO-0.5.35.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
59
- PyPlumIO-0.5.35.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
- PyPlumIO-0.5.35.dist-info/RECORD,,
56
+ PyPlumIO-0.5.37.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
+ PyPlumIO-0.5.37.dist-info/METADATA,sha256=CNXGeqKEbJVnM1LlpzV33S7TDJXCJYlrY8pU8n_4w1w,5510
58
+ PyPlumIO-0.5.37.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
59
+ PyPlumIO-0.5.37.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
+ PyPlumIO-0.5.37.dist-info/RECORD,,
pyplumio/__init__.py CHANGED
@@ -101,6 +101,7 @@ __all__ = [
101
101
  "ProtocolError",
102
102
  "PyPlumIOError",
103
103
  "ReadError",
104
+ "RequestError",
104
105
  "SerialConnection",
105
106
  "TcpConnection",
106
107
  "UnknownDeviceError",
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.35'
16
- __version_tuple__ = version_tuple = (0, 5, 35)
15
+ __version__ = version = '0.5.37'
16
+ __version_tuple__ = version_tuple = (0, 5, 37)
pyplumio/connection.py CHANGED
@@ -86,7 +86,7 @@ class Connection(ABC, TaskManager):
86
86
  RECONNECT_TIMEOUT,
87
87
  )
88
88
  await asyncio.sleep(RECONNECT_TIMEOUT)
89
- self.create_task(self._reconnect())
89
+ self.create_task(self._reconnect(), name="reconnect_task")
90
90
 
91
91
  async def connect(self) -> None:
92
92
  """Open the connection.
@@ -13,7 +13,7 @@ from pyplumio.exceptions import RequestError, UnknownDeviceError
13
13
  from pyplumio.frames import DataFrameDescription, Frame, Request, is_known_frame_type
14
14
  from pyplumio.helpers.event_manager import EventManager
15
15
  from pyplumio.helpers.factory import create_instance
16
- from pyplumio.helpers.parameter import Parameter, ParameterValue
16
+ from pyplumio.helpers.parameter import NumericType, Parameter, State
17
17
  from pyplumio.structures.frame_versions import ATTR_FRAME_VERSIONS
18
18
  from pyplumio.structures.network_info import NetworkInfo
19
19
  from pyplumio.utils import to_camelcase
@@ -57,7 +57,7 @@ class Device(ABC, EventManager):
57
57
  async def set(
58
58
  self,
59
59
  name: str,
60
- value: ParameterValue,
60
+ value: NumericType | State | bool,
61
61
  retries: int = 5,
62
62
  timeout: float | None = None,
63
63
  ) -> bool:
@@ -89,7 +89,7 @@ class Device(ABC, EventManager):
89
89
  def set_nowait(
90
90
  self,
91
91
  name: str,
92
- value: ParameterValue,
92
+ value: NumericType | State | bool,
93
93
  retries: int = 5,
94
94
  timeout: float | None = None,
95
95
  ) -> None:
@@ -180,7 +180,7 @@ class PhysicalDevice(Device, ABC):
180
180
  )
181
181
 
182
182
  errors = [
183
- result.args[1] for result in results if isinstance(result, RequestError)
183
+ result.frame_type for result in results if isinstance(result, RequestError)
184
184
  ]
185
185
 
186
186
  await asyncio.gather(
@@ -204,9 +204,9 @@ class PhysicalDevice(Device, ABC):
204
204
  retries -= 1
205
205
 
206
206
  raise RequestError(
207
- f"Failed to request parameter '{name}' with frame type '{frame_type}' "
208
- f"after {retries} retries.",
209
- frame_type,
207
+ f"Failed to request '{name}' with frame type '{frame_type}' after "
208
+ f"{retries} retries.",
209
+ frame_type=frame_type,
210
210
  )
211
211
 
212
212
  @classmethod
@@ -59,7 +59,8 @@ ATTR_MIXERS: Final = "mixers"
59
59
  ATTR_THERMOSTATS: Final = "thermostats"
60
60
  ATTR_FUEL_BURNED: Final = "fuel_burned"
61
61
 
62
- MAX_TIME_SINCE_LAST_FUEL_UPDATE_NS: Final = 300 * 1000000000
62
+ NANOSECONDS_IN_SECOND: Final = 1_000_000_000
63
+ MAX_TIME_SINCE_LAST_FUEL_UPDATE_NS: Final = 300 * NANOSECONDS_IN_SECOND
63
64
 
64
65
  SETUP_FRAME_TYPES: tuple[DataFrameDescription, ...] = (
65
66
  DataFrameDescription(
@@ -105,16 +106,15 @@ class EcoMAX(PhysicalDevice):
105
106
  """Represents an ecoMAX controller."""
106
107
 
107
108
  address = DeviceType.ECOMAX
108
-
109
- _fuel_burned_timestamp_ns: int
110
109
  _setup_frames = SETUP_FRAME_TYPES
111
110
 
111
+ _fuel_burned_time_ns: int
112
+
112
113
  def __init__(self, queue: asyncio.Queue[Frame], network: NetworkInfo) -> None:
113
114
  """Initialize a new ecoMAX controller."""
114
115
  super().__init__(queue, network)
115
- self._fuel_burned_timestamp_ns = time.perf_counter_ns()
116
116
  self.subscribe(ATTR_ECOMAX_PARAMETERS, self._handle_ecomax_parameters)
117
- self.subscribe(ATTR_FUEL_CONSUMPTION, self._add_burned_fuel_counter)
117
+ self.subscribe(ATTR_FUEL_CONSUMPTION, self._add_burned_fuel_meter)
118
118
  self.subscribe(ATTR_MIXER_PARAMETERS, self._handle_mixer_parameters)
119
119
  self.subscribe(ATTR_MIXER_SENSORS, self._handle_mixer_sensors)
120
120
  self.subscribe(ATTR_SCHEDULES, self._add_schedules)
@@ -124,6 +124,7 @@ class EcoMAX(PhysicalDevice):
124
124
  self.subscribe(ATTR_THERMOSTAT_PARAMETERS, self._handle_thermostat_parameters)
125
125
  self.subscribe(ATTR_THERMOSTAT_PROFILE, self._add_thermostat_profile_parameter)
126
126
  self.subscribe(ATTR_THERMOSTAT_SENSORS, self._handle_thermostat_sensors)
127
+ self._fuel_burned_time_ns = time.perf_counter_ns()
127
128
 
128
129
  async def async_setup(self) -> bool:
129
130
  """Set up an ecoMAX controller."""
@@ -183,13 +184,10 @@ class EcoMAX(PhysicalDevice):
183
184
  description = ECOMAX_PARAMETERS[product.type][index]
184
185
  except IndexError:
185
186
  _LOGGER.warning(
186
- (
187
- "Encountered unknown ecoMAX parameter (%i): %s. "
188
- "Your device isn't fully compatible with this software and "
189
- "may not work properly. "
190
- "Please visit the issue tracker and open a feature "
191
- "request to support %s"
192
- ),
187
+ "Encountered unknown ecoMAX parameter (%i): %s. "
188
+ "Your device isn't fully compatible with this software "
189
+ "and may not work properly. Please visit the issue tracker "
190
+ "and open a feature request to support %s",
193
191
  index,
194
192
  values,
195
193
  product.model,
@@ -211,19 +209,29 @@ class EcoMAX(PhysicalDevice):
211
209
  await asyncio.gather(*_ecomax_parameter_events())
212
210
  return True
213
211
 
214
- async def _add_burned_fuel_counter(self, fuel_consumption: float) -> None:
215
- """Calculate fuel burned since last sensor's data message."""
216
- current_timestamp_ns = time.perf_counter_ns()
217
- time_passed_ns = current_timestamp_ns - self._fuel_burned_timestamp_ns
218
- self._fuel_burned_timestamp_ns = current_timestamp_ns
219
- if time_passed_ns >= MAX_TIME_SINCE_LAST_FUEL_UPDATE_NS:
220
- _LOGGER.warning(
221
- "Skipping outdated fuel consumption data, was %i seconds old",
222
- time_passed_ns / 1000000000,
212
+ async def _add_burned_fuel_meter(self, fuel_consumption: float) -> None:
213
+ """Calculate and dispatch the amount of fuel burned.
214
+
215
+ This method calculates the fuel burned based on the time
216
+ elapsed since the last sensor message, which contains fuel
217
+ consumption data. If the elapsed time is within the acceptable
218
+ range, it dispatches the fuel burned data. Otherwise, it logs a
219
+ warning and skips the outdated data.
220
+ """
221
+ time_ns = time.perf_counter_ns()
222
+ nanoseconds_passed = time_ns - self._fuel_burned_time_ns
223
+ self._fuel_burned_time_ns = time_ns
224
+ if nanoseconds_passed < MAX_TIME_SINCE_LAST_FUEL_UPDATE_NS:
225
+ return self.dispatch_nowait(
226
+ ATTR_FUEL_BURNED,
227
+ fuel_consumption * nanoseconds_passed / (3600 * NANOSECONDS_IN_SECOND),
223
228
  )
224
- else:
225
- fuel_burned = fuel_consumption * time_passed_ns / (3600 * 1000000000)
226
- await self.dispatch(ATTR_FUEL_BURNED, fuel_burned)
229
+
230
+ _LOGGER.warning(
231
+ "Skipping outdated fuel consumption data: %f (was %i seconds old)",
232
+ fuel_consumption,
233
+ nanoseconds_passed / NANOSECONDS_IN_SECOND,
234
+ )
227
235
 
228
236
  async def _handle_mixer_parameters(
229
237
  self,
pyplumio/devices/mixer.py CHANGED
@@ -64,13 +64,10 @@ class Mixer(VirtualDevice):
64
64
  description = MIXER_PARAMETERS[product.type][index]
65
65
  except IndexError:
66
66
  _LOGGER.warning(
67
- (
68
- "Encountered unknown mixer parameter (%i): %s. "
69
- "Your device isn't fully compatible with this software and "
70
- "may not work properly. "
71
- "Please visit the issue tracker and open a feature "
72
- "request to support %s"
73
- ),
67
+ "Encountered unknown mixer parameter (%i): %s. "
68
+ "Your device isn't fully compatible with this software "
69
+ "and may not work properly. Please visit the issue tracker "
70
+ "and open a feature request to support %s",
74
71
  index,
75
72
  values,
76
73
  product.model,
pyplumio/exceptions.py CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from pyplumio.const import FrameType
6
+
5
7
 
6
8
  class PyPlumIOError(Exception):
7
9
  """Base PyPlumIO error class."""
@@ -11,13 +13,18 @@ class ConnectionFailedError(PyPlumIOError):
11
13
  """Raised on connection failure."""
12
14
 
13
15
 
14
- class ProtocolError(PyPlumIOError):
15
- """Base class for protocol-related errors."""
16
-
17
-
18
16
  class RequestError(PyPlumIOError):
19
17
  """Raised on request error."""
20
18
 
19
+ def __init__(self, message: str, frame_type: FrameType) -> None:
20
+ """Initialize a new RequestError."""
21
+ super().__init__(message)
22
+ self.frame_type = frame_type
23
+
24
+
25
+ class ProtocolError(PyPlumIOError):
26
+ """Base class for protocol-related errors."""
27
+
21
28
 
22
29
  class ReadError(ProtocolError):
23
30
  """Raised on read error."""
@@ -20,16 +20,20 @@ from pyplumio.frames.responses import DeviceAvailableResponse, ProgramVersionRes
20
20
  from pyplumio.structures.schedules import SchedulesStructure
21
21
 
22
22
 
23
- class ProgramVersionRequest(Request):
24
- """Represents a program version request."""
23
+ class AlertsRequest(Request):
24
+ """Represents an alerts request.
25
+
26
+ Contains number of alerts to get and index of the first
27
+ alert.
28
+ """
25
29
 
26
30
  __slots__ = ()
27
31
 
28
- frame_type = FrameType.REQUEST_PROGRAM_VERSION
32
+ frame_type = FrameType.REQUEST_ALERTS
29
33
 
30
- def response(self, **kwargs: Any) -> Response | None:
31
- """Return a response frame."""
32
- return ProgramVersionResponse(recipient=self.sender, **kwargs)
34
+ def create_message(self, data: dict[str, Any]) -> bytearray:
35
+ """Create a frame message."""
36
+ return bytearray([data.get(ATTR_START, 0), data.get(ATTR_COUNT, 10)])
33
37
 
34
38
 
35
39
  class CheckDeviceRequest(Request):
@@ -44,20 +48,23 @@ class CheckDeviceRequest(Request):
44
48
  return DeviceAvailableResponse(recipient=self.sender, **kwargs)
45
49
 
46
50
 
47
- class UIDRequest(Request):
48
- """Represents an UID request."""
49
-
50
- __slots__ = ()
51
-
52
- frame_type = FrameType.REQUEST_UID
53
-
51
+ class EcomaxControlRequest(Request):
52
+ """Represents an ecoMAX control request.
54
53
 
55
- class PasswordRequest(Request):
56
- """Represents a password request."""
54
+ Contains single binary value. 0 - means that controller should
55
+ be turned off, 1 - means that it should be turned on.
56
+ """
57
57
 
58
58
  __slots__ = ()
59
59
 
60
- frame_type = FrameType.REQUEST_PASSWORD
60
+ frame_type = FrameType.REQUEST_ECOMAX_CONTROL
61
+
62
+ def create_message(self, data: dict[str, Any]) -> bytearray:
63
+ """Create a frame message."""
64
+ try:
65
+ return bytearray([data[ATTR_VALUE]])
66
+ except KeyError as e:
67
+ raise FrameDataError from e
61
68
 
62
69
 
63
70
  class EcomaxParametersRequest(Request):
@@ -91,20 +98,24 @@ class MixerParametersRequest(Request):
91
98
  return bytearray([data.get(ATTR_COUNT, 255), data.get(ATTR_START, 0)])
92
99
 
93
100
 
94
- class ThermostatParametersRequest(Request):
95
- """Represents a thermostat parameters request.
101
+ class PasswordRequest(Request):
102
+ """Represents a password request."""
96
103
 
97
- Contains number of parameters to get and index of the first
98
- parameter.
99
- """
104
+ __slots__ = ()
105
+
106
+ frame_type = FrameType.REQUEST_PASSWORD
107
+
108
+
109
+ class ProgramVersionRequest(Request):
110
+ """Represents a program version request."""
100
111
 
101
112
  __slots__ = ()
102
113
 
103
- frame_type = FrameType.REQUEST_THERMOSTAT_PARAMETERS
114
+ frame_type = FrameType.REQUEST_PROGRAM_VERSION
104
115
 
105
- def create_message(self, data: dict[str, Any]) -> bytearray:
106
- """Create a frame message."""
107
- return bytearray([data.get(ATTR_COUNT, 255), data.get(ATTR_START, 0)])
116
+ def response(self, **kwargs: Any) -> Response | None:
117
+ """Return a response frame."""
118
+ return ProgramVersionResponse(recipient=self.sender, **kwargs)
108
119
 
109
120
 
110
121
  class RegulatorDataSchemaRequest(Request):
@@ -115,6 +126,14 @@ class RegulatorDataSchemaRequest(Request):
115
126
  frame_type = FrameType.REQUEST_REGULATOR_DATA_SCHEMA
116
127
 
117
128
 
129
+ class SchedulesRequest(Request):
130
+ """Represents a schedules request."""
131
+
132
+ __slots__ = ()
133
+
134
+ frame_type = FrameType.REQUEST_SCHEDULES
135
+
136
+
118
137
  class SetEcomaxParameterRequest(Request):
119
138
  """Represents a request to set an ecoMAX parameter.
120
139
 
@@ -153,6 +172,18 @@ class SetMixerParameterRequest(Request):
153
172
  raise FrameDataError from e
154
173
 
155
174
 
175
+ class SetScheduleRequest(Request):
176
+ """Represents a request to set a schedule."""
177
+
178
+ __slots__ = ()
179
+
180
+ frame_type = FrameType.REQUEST_SET_SCHEDULE
181
+
182
+ def create_message(self, data: dict[str, Any]) -> bytearray:
183
+ """Create a frame message."""
184
+ return SchedulesStructure(self).encode(data)
185
+
186
+
156
187
  class SetThermostatParameterRequest(Request):
157
188
  """Represents a request to set a thermostat parameter.
158
189
 
@@ -183,25 +214,6 @@ class SetThermostatParameterRequest(Request):
183
214
  raise FrameDataError from e
184
215
 
185
216
 
186
- class EcomaxControlRequest(Request):
187
- """Represents an ecoMAX control request.
188
-
189
- Contains single binary value. 0 - means that controller should
190
- be turned off, 1 - means that it should be turned on.
191
- """
192
-
193
- __slots__ = ()
194
-
195
- frame_type = FrameType.REQUEST_ECOMAX_CONTROL
196
-
197
- def create_message(self, data: dict[str, Any]) -> bytearray:
198
- """Create a frame message."""
199
- try:
200
- return bytearray([data[ATTR_VALUE]])
201
- except KeyError as e:
202
- raise FrameDataError from e
203
-
204
-
205
217
  class StartMasterRequest(Request):
206
218
  """Represents a request to become a master.
207
219
 
@@ -226,37 +238,25 @@ class StopMasterRequest(Request):
226
238
  frame_type = FrameType.REQUEST_STOP_MASTER
227
239
 
228
240
 
229
- class AlertsRequest(Request):
230
- """Represents an alerts request.
241
+ class ThermostatParametersRequest(Request):
242
+ """Represents a thermostat parameters request.
231
243
 
232
- Contains number of alerts to get and index of the first
233
- alert.
244
+ Contains number of parameters to get and index of the first
245
+ parameter.
234
246
  """
235
247
 
236
248
  __slots__ = ()
237
249
 
238
- frame_type = FrameType.REQUEST_ALERTS
250
+ frame_type = FrameType.REQUEST_THERMOSTAT_PARAMETERS
239
251
 
240
252
  def create_message(self, data: dict[str, Any]) -> bytearray:
241
253
  """Create a frame message."""
242
- return bytearray([data.get(ATTR_START, 0), data.get(ATTR_COUNT, 10)])
243
-
244
-
245
- class SchedulesRequest(Request):
246
- """Represents a schedules request."""
247
-
248
- __slots__ = ()
249
-
250
- frame_type = FrameType.REQUEST_SCHEDULES
254
+ return bytearray([data.get(ATTR_COUNT, 255), data.get(ATTR_START, 0)])
251
255
 
252
256
 
253
- class SetScheduleRequest(Request):
254
- """Represents a request to set a schedule."""
257
+ class UIDRequest(Request):
258
+ """Represents an UID request."""
255
259
 
256
260
  __slots__ = ()
257
261
 
258
- frame_type = FrameType.REQUEST_SET_SCHEDULE
259
-
260
- def create_message(self, data: dict[str, Any]) -> bytearray:
261
- """Create a frame message."""
262
- return SchedulesStructure(self).encode(data)
262
+ frame_type = FrameType.REQUEST_UID
@@ -17,23 +17,16 @@ from pyplumio.structures.schedules import SchedulesStructure
17
17
  from pyplumio.structures.thermostat_parameters import ThermostatParametersStructure
18
18
 
19
19
 
20
- class ProgramVersionResponse(Response):
21
- """Represents a program version response.
22
-
23
- Contains software version info.
24
- """
20
+ class AlertsResponse(Response):
21
+ """Represents response to a device alerts request."""
25
22
 
26
23
  __slots__ = ()
27
24
 
28
- frame_type = FrameType.RESPONSE_PROGRAM_VERSION
29
-
30
- def create_message(self, data: dict[str, Any]) -> bytearray:
31
- """Create a frame message."""
32
- return ProgramVersionStructure(self).encode(data)
25
+ frame_type = FrameType.RESPONSE_ALERTS
33
26
 
34
27
  def decode_message(self, message: bytearray) -> dict[str, Any]:
35
28
  """Decode a frame message."""
36
- return ProgramVersionStructure(self).decode(message)[0]
29
+ return AlertsStructure(self).decode(message)[0]
37
30
 
38
31
 
39
32
  class DeviceAvailableResponse(Response):
@@ -55,39 +48,16 @@ class DeviceAvailableResponse(Response):
55
48
  return NetworkInfoStructure(self).decode(message, offset=1)[0]
56
49
 
57
50
 
58
- class UIDResponse(Response):
59
- """Represents an UID response.
60
-
61
- Contains product info and product UID.
62
- """
63
-
64
- __slots__ = ()
65
-
66
- frame_type = FrameType.RESPONSE_UID
67
-
68
- def create_message(self, data: dict[str, Any]) -> bytearray:
69
- """Create a frame message."""
70
- return ProductInfoStructure(self).encode(data)
71
-
72
- def decode_message(self, message: bytearray) -> dict[str, Any]:
73
- """Decode a frame message."""
74
- return ProductInfoStructure(self).decode(message)[0]
75
-
76
-
77
- class PasswordResponse(Response):
78
- """Represents a password response.
51
+ class EcomaxControlResponse(Response):
52
+ """Represents response to an ecoMAX control request.
79
53
 
80
- Contains device service password as plaintext.
54
+ Empty response acknowledges, that ecoMAX control request was
55
+ successfully processed.
81
56
  """
82
57
 
83
58
  __slots__ = ()
84
59
 
85
- frame_type = FrameType.RESPONSE_PASSWORD
86
-
87
- def decode_message(self, message: bytearray) -> dict[str, Any]:
88
- """Decode a frame message."""
89
- password = message[1:].decode() if message[1:] else None
90
- return {ATTR_PASSWORD: password}
60
+ frame_type = FrameType.RESPONSE_ECOMAX_CONTROL
91
61
 
92
62
 
93
63
  class EcomaxParametersResponse(Response):
@@ -120,19 +90,39 @@ class MixerParametersResponse(Response):
120
90
  return MixerParametersStructure(self).decode(message)[0]
121
91
 
122
92
 
123
- class ThermostatParametersResponse(Response):
124
- """Represents a thermostat parameters response.
93
+ class PasswordResponse(Response):
94
+ """Represents a password response.
125
95
 
126
- Contains editable thermostat parameters.
96
+ Contains device service password as plaintext.
127
97
  """
128
98
 
129
99
  __slots__ = ()
130
100
 
131
- frame_type = FrameType.RESPONSE_THERMOSTAT_PARAMETERS
101
+ frame_type = FrameType.RESPONSE_PASSWORD
132
102
 
133
103
  def decode_message(self, message: bytearray) -> dict[str, Any]:
134
104
  """Decode a frame message."""
135
- return ThermostatParametersStructure(self).decode(message)[0]
105
+ password = message[1:].decode() if message[1:] else None
106
+ return {ATTR_PASSWORD: password}
107
+
108
+
109
+ class ProgramVersionResponse(Response):
110
+ """Represents a program version response.
111
+
112
+ Contains software version info.
113
+ """
114
+
115
+ __slots__ = ()
116
+
117
+ frame_type = FrameType.RESPONSE_PROGRAM_VERSION
118
+
119
+ def create_message(self, data: dict[str, Any]) -> bytearray:
120
+ """Create a frame message."""
121
+ return ProgramVersionStructure(self).encode(data)
122
+
123
+ def decode_message(self, message: bytearray) -> dict[str, Any]:
124
+ """Decode a frame message."""
125
+ return ProgramVersionStructure(self).decode(message)[0]
136
126
 
137
127
 
138
128
  class RegulatorDataSchemaResponse(Response):
@@ -151,6 +141,18 @@ class RegulatorDataSchemaResponse(Response):
151
141
  return RegulatorDataSchemaStructure(self).decode(message)[0]
152
142
 
153
143
 
144
+ class SchedulesResponse(Response):
145
+ """Represents response to a device schedules request."""
146
+
147
+ __slots__ = ()
148
+
149
+ frame_type = FrameType.RESPONSE_SCHEDULES
150
+
151
+ def decode_message(self, message: bytearray) -> dict[str, Any]:
152
+ """Decode a frame message."""
153
+ return SchedulesStructure(self).decode(message)[0]
154
+
155
+
154
156
  class SetEcomaxParameterResponse(Response):
155
157
  """Represents response to a set ecoMAX parameter request.
156
158
 
@@ -187,37 +189,35 @@ class SetThermostatParameterResponse(Response):
187
189
  frame_type = FrameType.RESPONSE_SET_THERMOSTAT_PARAMETER
188
190
 
189
191
 
190
- class EcomaxControlResponse(Response):
191
- """Represents response to an ecoMAX control request.
192
+ class ThermostatParametersResponse(Response):
193
+ """Represents a thermostat parameters response.
192
194
 
193
- Empty response acknowledges, that ecoMAX control request was
194
- successfully processed.
195
+ Contains editable thermostat parameters.
195
196
  """
196
197
 
197
198
  __slots__ = ()
198
199
 
199
- frame_type = FrameType.RESPONSE_ECOMAX_CONTROL
200
-
201
-
202
- class AlertsResponse(Response):
203
- """Represents response to a device alerts request."""
204
-
205
- __slots__ = ()
206
-
207
- frame_type = FrameType.RESPONSE_ALERTS
200
+ frame_type = FrameType.RESPONSE_THERMOSTAT_PARAMETERS
208
201
 
209
202
  def decode_message(self, message: bytearray) -> dict[str, Any]:
210
203
  """Decode a frame message."""
211
- return AlertsStructure(self).decode(message)[0]
204
+ return ThermostatParametersStructure(self).decode(message)[0]
212
205
 
213
206
 
214
- class SchedulesResponse(Response):
215
- """Represents response to a device schedules request."""
207
+ class UIDResponse(Response):
208
+ """Represents an UID response.
209
+
210
+ Contains product info and product UID.
211
+ """
216
212
 
217
213
  __slots__ = ()
218
214
 
219
- frame_type = FrameType.RESPONSE_SCHEDULES
215
+ frame_type = FrameType.RESPONSE_UID
216
+
217
+ def create_message(self, data: dict[str, Any]) -> bytearray:
218
+ """Create a frame message."""
219
+ return ProductInfoStructure(self).encode(data)
220
220
 
221
221
  def decode_message(self, message: bytearray) -> dict[str, Any]:
222
222
  """Decode a frame message."""
223
- return SchedulesStructure(self).decode(message)[0]
223
+ return ProductInfoStructure(self).decode(message)[0]
@@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
6
6
  import asyncio
7
7
  from dataclasses import dataclass
8
8
  import logging
9
- from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union
9
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, get_args
10
10
 
11
11
  from dataslots import dataslots
12
12
  from typing_extensions import TypeAlias
@@ -19,8 +19,8 @@ if TYPE_CHECKING:
19
19
 
20
20
  _LOGGER = logging.getLogger(__name__)
21
21
 
22
-
23
- ParameterValue: TypeAlias = Union[int, float, bool, Literal["off", "on"]]
22
+ NumericType: TypeAlias = Union[int, float]
23
+ State: TypeAlias = Literal["on", "off"]
24
24
  ParameterT = TypeVar("ParameterT", bound="Parameter")
25
25
 
26
26
 
@@ -28,7 +28,7 @@ def unpack_parameter(
28
28
  data: bytearray, offset: int = 0, size: int = 1
29
29
  ) -> ParameterValues | None:
30
30
  """Unpack a device parameter."""
31
- if not check_parameter(data[offset : offset + size * 3]):
31
+ if not is_valid_parameter(data[offset : offset + size * 3]):
32
32
  return None
33
33
 
34
34
  value = data[offset : offset + size]
@@ -42,14 +42,17 @@ def unpack_parameter(
42
42
  )
43
43
 
44
44
 
45
- def check_parameter(data: bytearray) -> bool:
45
+ def is_valid_parameter(data: bytearray) -> bool:
46
46
  """Check if parameter contains any bytes besides 0xFF."""
47
47
  return any(x for x in data if x != BYTE_UNDEFINED)
48
48
 
49
49
 
50
- def _normalize_parameter_value(value: ParameterValue) -> int:
51
- """Normalize a parameter value."""
52
- if value in (STATE_OFF, STATE_ON):
50
+ def parameter_value_to_int(value: NumericType | State | bool) -> int:
51
+ """Convert a parameter value to an integer.
52
+
53
+ If the value is STATE_OFF or STATE_ON, it returns 0 or 1 respectively.
54
+ """
55
+ if value in get_args(State):
53
56
  return 1 if value == STATE_ON else 0
54
57
 
55
58
  return int(value)
@@ -72,24 +75,17 @@ class ParameterDescription:
72
75
  """Represents a parameter description."""
73
76
 
74
77
  name: str
78
+ optimistic: bool = False
75
79
 
76
80
 
77
81
  class Parameter(ABC):
78
82
  """Represents a base parameter."""
79
83
 
80
- __slots__ = (
81
- "device",
82
- "description",
83
- "_pending_update",
84
- "_previous_value",
85
- "_index",
86
- "_values",
87
- )
84
+ __slots__ = ("device", "description", "_pending_update", "_index", "_values")
88
85
 
89
86
  device: Device
90
87
  description: ParameterDescription
91
88
  _pending_update: bool
92
- _previous_value: int
93
89
  _index: int
94
90
  _values: ParameterValues
95
91
 
@@ -103,8 +99,8 @@ class Parameter(ABC):
103
99
  """Initialize a new parameter."""
104
100
  self.device = device
105
101
  self.description = description
102
+ self._index = index
106
103
  self._pending_update = False
107
- self._previous_value = 0
108
104
  self._index = index
109
105
  self._values = values if values else ParameterValues(0, 0, 0)
110
106
 
@@ -127,9 +123,9 @@ class Parameter(ABC):
127
123
  handler = getattr(self.values, method_to_call)
128
124
  return handler(other)
129
125
 
130
- if isinstance(other, (int, float, bool)) or other in (STATE_OFF, STATE_ON):
126
+ if isinstance(other, (int, float, bool)) or other in get_args(State):
131
127
  handler = getattr(self.values.value, method_to_call)
132
- return handler(_normalize_parameter_value(other))
128
+ return handler(parameter_value_to_int(other))
133
129
  else:
134
130
  return NotImplemented
135
131
 
@@ -184,46 +180,56 @@ class Parameter(ABC):
184
180
  )
185
181
  return type(self)(self.device, self.description, values)
186
182
 
187
- def validate(self, value: ParameterValue) -> int:
183
+ def validate(self, value: NumericType | State | bool) -> int:
188
184
  """Validate a parameter value."""
189
- value = _normalize_parameter_value(value)
190
- if value < self.values.min_value or value > self.values.max_value:
185
+ int_value = parameter_value_to_int(value)
186
+ if int_value < self.values.min_value or int_value > self.values.max_value:
191
187
  raise ValueError(
192
188
  f"Invalid value: {value}. Must be between "
193
189
  f"{self.min_value} and {self.max_value}."
194
190
  )
195
191
 
196
- return value
192
+ return int_value
197
193
 
198
194
  async def set(self, value: Any, retries: int = 5, timeout: float = 5.0) -> bool:
199
195
  """Set a parameter value."""
200
- return await self._try_set(self.validate(value), retries, timeout)
196
+ return await self._attempt_update(self.validate(value), retries, timeout)
201
197
 
202
198
  def set_nowait(self, value: Any, retries: int = 5, timeout: float = 5.0) -> None:
203
199
  """Set a parameter value without waiting."""
204
- self.device.create_task(self._try_set(self.validate(value), retries, timeout))
200
+ self.device.create_task(
201
+ self._attempt_update(self.validate(value), retries, timeout)
202
+ )
205
203
 
206
- async def _try_set(
207
- self, value: Any, retries: int = 5, timeout: float = 5.0
204
+ async def _attempt_update(
205
+ self, value: int, retries: int = 5, timeout: float = 5.0
208
206
  ) -> bool:
209
- """Try to set a parameter value."""
207
+ """Attempt to update a parameter value on the remote device."""
208
+ _LOGGER.debug(
209
+ "Attempting to update '%s' parameter to %d", self.description.name, value
210
+ )
210
211
  if value == self.values.value:
211
212
  # Value is unchanged
212
213
  return True
213
214
 
214
- self._previous_value = self._values.value
215
215
  self._values.value = value
216
+ request = await self.create_request()
217
+ if self.description.optimistic or not (initial_retries := retries):
218
+ # No retries
219
+ await self.device.queue.put(request)
220
+ return True
221
+
216
222
  self._pending_update = True
217
223
  while self.pending_update:
218
224
  if retries <= 0:
219
225
  _LOGGER.warning(
220
- "Unable to confirm that parameter '%s' was set after %d retries",
226
+ "Unable to confirm update of '%s' parameter after %d retries",
221
227
  self.description.name,
222
- retries,
228
+ initial_retries,
223
229
  )
224
230
  return False
225
231
 
226
- await self.device.queue.put(await self.create_request())
232
+ await self.device.queue.put(request)
227
233
  await asyncio.sleep(timeout)
228
234
  retries -= 1
229
235
 
@@ -231,9 +237,7 @@ class Parameter(ABC):
231
237
 
232
238
  def update(self, values: ParameterValues) -> None:
233
239
  """Update the parameter values."""
234
- if self.pending_update and self._previous_value != values.value:
235
- self._pending_update = False
236
-
240
+ self._pending_update = False
237
241
  self._values = values
238
242
 
239
243
  @property
@@ -267,17 +271,17 @@ class Parameter(ABC):
267
271
 
268
272
  @property
269
273
  @abstractmethod
270
- def value(self) -> Any:
274
+ def value(self) -> NumericType | State | bool:
271
275
  """Return the value."""
272
276
 
273
277
  @property
274
278
  @abstractmethod
275
- def min_value(self) -> Any:
279
+ def min_value(self) -> NumericType | State | bool:
276
280
  """Return the minimum allowed value."""
277
281
 
278
282
  @property
279
283
  @abstractmethod
280
- def max_value(self) -> Any:
284
+ def max_value(self) -> NumericType | State | bool:
281
285
  """Return the maximum allowed value."""
282
286
 
283
287
  @abstractmethod
@@ -301,13 +305,13 @@ class Number(Parameter):
301
305
  description: NumberDescription
302
306
 
303
307
  async def set(
304
- self, value: int | float, retries: int = 5, timeout: float = 5.0
308
+ self, value: NumericType, retries: int = 5, timeout: float = 5.0
305
309
  ) -> bool:
306
310
  """Set a parameter value."""
307
311
  return await super().set(value, retries, timeout)
308
312
 
309
313
  def set_nowait(
310
- self, value: int | float, retries: int = 5, timeout: float = 5.0
314
+ self, value: NumericType, retries: int = 5, timeout: float = 5.0
311
315
  ) -> None:
312
316
  """Set a parameter value without waiting."""
313
317
  super().set_nowait(value, retries, timeout)
@@ -317,17 +321,17 @@ class Number(Parameter):
317
321
  return Request()
318
322
 
319
323
  @property
320
- def value(self) -> int | float:
324
+ def value(self) -> NumericType:
321
325
  """Return the value."""
322
326
  return self.values.value
323
327
 
324
328
  @property
325
- def min_value(self) -> int | float:
329
+ def min_value(self) -> NumericType:
326
330
  """Return the minimum allowed value."""
327
331
  return self.values.min_value
328
332
 
329
333
  @property
330
- def max_value(self) -> int | float:
334
+ def max_value(self) -> NumericType:
331
335
  """Return the maximum allowed value."""
332
336
  return self.values.max_value
333
337
 
@@ -351,13 +355,13 @@ class Switch(Parameter):
351
355
  description: SwitchDescription
352
356
 
353
357
  async def set(
354
- self, value: bool | Literal["off", "on"], retries: int = 5, timeout: float = 5.0
358
+ self, value: State | bool, retries: int = 5, timeout: float = 5.0
355
359
  ) -> bool:
356
360
  """Set a parameter value."""
357
361
  return await super().set(value, retries, timeout)
358
362
 
359
363
  def set_nowait(
360
- self, value: bool | Literal["off", "on"], retries: int = 5, timeout: float = 5.0
364
+ self, value: State | bool, retries: int = 5, timeout: float = 5.0
361
365
  ) -> None:
362
366
  """Set a switch value without waiting."""
363
367
  super().set_nowait(value, retries, timeout)
@@ -393,7 +397,7 @@ class Switch(Parameter):
393
397
  return Request()
394
398
 
395
399
  @property
396
- def value(self) -> Literal["off", "on"]:
400
+ def value(self) -> State:
397
401
  """Return the value."""
398
402
  return STATE_ON if self.values.value == 1 else STATE_OFF
399
403
 
@@ -21,8 +21,7 @@ TIME_FORMAT: Final = "%H:%M"
21
21
  STATE_NIGHT: Final = "night"
22
22
  STATE_DAY: Final = "day"
23
23
 
24
- ON_STATES: Final = (STATE_ON, STATE_DAY)
25
- OFF_STATES: Final = (STATE_OFF, STATE_NIGHT)
24
+ _ON_STATES: Final = {STATE_ON, STATE_DAY}
26
25
 
27
26
  ScheduleState: TypeAlias = Literal["on", "off", "day", "night"]
28
27
  Time = Annotated[str, "time in HH:MM format"]
@@ -113,7 +112,7 @@ class ScheduleDay(MutableMapping):
113
112
  )
114
113
 
115
114
  for index in _get_time_range(start, end):
116
- self._intervals[index] = True if state in ON_STATES else False
115
+ self._intervals[index] = True if state in _ON_STATES else False
117
116
 
118
117
  def set_on(self, start: Time = "00:00", end: Time = "00:00") -> None:
119
118
  """Set a schedule interval state to 'on'."""
pyplumio/helpers/uid.py CHANGED
@@ -1,4 +1,4 @@
1
- """Contains an UID helpers."""
1
+ """Contains UID helpers."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -18,18 +18,18 @@ def decode_uid(buffer: bytes) -> str:
18
18
  def _base5(buffer: bytes) -> str:
19
19
  """Encode bytes to a base5 encoded string."""
20
20
  number = int.from_bytes(buffer, byteorder="little")
21
- output = ""
21
+ output = []
22
22
  while number:
23
- output = BASE5_KEY[number & 0b00011111] + output
23
+ output.append(BASE5_KEY[number & 0b00011111])
24
24
  number >>= 5
25
25
 
26
- return output
26
+ return "".join(reversed(output))
27
27
 
28
28
 
29
29
  def _crc16(buffer: bytes) -> bytes:
30
30
  """Return a CRC 16."""
31
31
  crc16 = reduce(_crc16_byte, buffer, CRC)
32
- return crc16.to_bytes(byteorder="little", length=2)
32
+ return crc16.to_bytes(length=2, byteorder="little")
33
33
 
34
34
 
35
35
  def _crc16_byte(crc: int, byte: int) -> int:
@@ -825,7 +825,9 @@ ECOMAX_PARAMETERS: dict[ProductType, tuple[EcomaxParameterDescription, ...]] = {
825
825
  ),
826
826
  }
827
827
 
828
- ECOMAX_CONTROL_PARAMETER = EcomaxSwitchDescription(name=ATTR_ECOMAX_CONTROL)
828
+ ECOMAX_CONTROL_PARAMETER = EcomaxSwitchDescription(
829
+ name=ATTR_ECOMAX_CONTROL, optimistic=True
830
+ )
829
831
  THERMOSTAT_PROFILE_PARAMETER = EcomaxNumberDescription(name=ATTR_THERMOSTAT_PROFILE)
830
832
 
831
833