PyPlumIO 0.5.36__py3-none-any.whl → 0.5.38__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.36
3
+ Version: 0.5.38
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,15 +27,15 @@ 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.4.0; extra == "test"
31
- Requires-Dist: coverage==7.6.10; extra == "test"
32
- Requires-Dist: mypy==1.14.1; extra == "test"
30
+ Requires-Dist: codespell==2.4.1; extra == "test"
31
+ Requires-Dist: coverage==7.6.12; extra == "test"
32
+ Requires-Dist: mypy==1.15.0; 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.3; extra == "test"
35
+ Requires-Dist: pytest-asyncio==0.25.3; extra == "test"
36
+ Requires-Dist: ruff==0.9.6; extra == "test"
37
37
  Requires-Dist: tox==4.24.1; extra == "test"
38
- Requires-Dist: types-pyserial==3.5.0.20250124; 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"
@@ -49,8 +49,8 @@ Requires-Dist: tomli==2.2.1; extra == "dev"
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,8 +1,8 @@
1
1
  pyplumio/__init__.py,sha256=3ibJ43RIdfFrWp1PAsQixybAA--NPRw43B5OdLOwsU8,3319
2
2
  pyplumio/__main__.py,sha256=3IwHHSq-iay5FaeMc95klobe-xv82yydSKcBE7BFZ6M,500
3
- pyplumio/_version.py,sha256=gzGL-qBNqAXMnFJc3Sr0_AdQLS33_rosqhjOurMUizQ,413
3
+ pyplumio/_version.py,sha256=6vN0GSSFIfFF69VcXgY3lPccfoHuRW4krgc-Rsw04Kc,513
4
4
  pyplumio/connection.py,sha256=-dbrIK6ewoYNeBQod9ZmXT8JkxMKbcS6nosINFsg9RI,5972
5
- pyplumio/const.py,sha256=LyXa5aVy2KxnZq7H7F8s5SYsAgEC2UzZYMMRauliB2E,5502
5
+ pyplumio/const.py,sha256=26s1TJF7IJa6o1pjDmHaAzPgMJ5c-fb0jeSkzDQ6Bic,5577
6
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
@@ -10,7 +10,7 @@ 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
12
  pyplumio/devices/__init__.py,sha256=Erjd3DeEop_yelnLtRRaPbwMIuD1NwVh7dMM1_2KxtI,8155
13
- pyplumio/devices/ecomax.py,sha256=hb4QULpyg5KrCWeQDNnwy7IA9k5oRETK5AWllVgA0Kg,15806
13
+ pyplumio/devices/ecomax.py,sha256=0LCVeTMzC1isu0HE_MHp7bEXJXUCinXNWVVFTn4k92E,15855
14
14
  pyplumio/devices/ecoster.py,sha256=jNWli7ye9T6yfkcFJZhhUHH7KOv-L6AgYFp_dKyv3OM,263
15
15
  pyplumio/devices/mixer.py,sha256=HdJNsvX3obYyLsuDhERX4IkodX3hGv3veP9ymjQnoUk,3108
16
16
  pyplumio/devices/thermostat.py,sha256=-CZNRyywoDU6csFu85KSmQ5woVXY0x6peXkeOsi_fqg,2617
@@ -21,17 +21,17 @@ pyplumio/frames/responses.py,sha256=dzrL0Yx7SoJuJAQyjOE8_ARfy7yvOqk2uq4kdnH5t1U,
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
- pyplumio/helpers/factory.py,sha256=v07s9DyihfkNUzt7ndyJbNd_DLS8UpRkut_xkGrbi6c,1123
25
- pyplumio/helpers/parameter.py,sha256=ubqgleTPT-m3yxhJkQWoLjkbjCdwOeaNhdA7O66l47Q,12425
26
- pyplumio/helpers/schedule.py,sha256=0lkghnnpQRdRtgqoNv7PnHMYYJpJNMHl9PR4_SaHB8w,5374
24
+ pyplumio/helpers/factory.py,sha256=9uXUmVRvPkg9IyrfYYVbz9wsYAXltMTXkm1x82dhMyA,1126
25
+ pyplumio/helpers/parameter.py,sha256=zfcSJK-55uwJTcLYMY5e7Zwr4M4SQXq8bsM4pnHZ7aQ,12689
26
+ pyplumio/helpers/schedule.py,sha256=Dl28p3iz8okr5AT5v78WiJv6ggYlO-f2Jk6r5t1wY0A,5266
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=qcE8sx8YwrUX3xEfL0cgjNP65rOZmv-M3fDlgFezUwc,989
29
+ pyplumio/helpers/uid.py,sha256=-7OCw7fJjd6kGVZmYU8nHl2WQpNvSIj-m-VtlngDZz4,990
30
30
  pyplumio/structures/__init__.py,sha256=EjK-5qJZ0F7lpP2b6epvTMg9cIBl4Kn91nqNkEcLwTc,1299
31
- pyplumio/structures/alerts.py,sha256=8ievMl5_tUBlnTLCiZoIloucIngCcoAYy6uI9sSXrt0,3664
31
+ pyplumio/structures/alerts.py,sha256=O4P0sbBu1g7AN_AApcViy9CcrY5Vry_LZJgidNUF7Co,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
@@ -42,9 +42,9 @@ pyplumio/structures/mixer_sensors.py,sha256=-cN7U-Fr2fmAQ5McQL7bZUC8CFlb1y8TN0f_
42
42
  pyplumio/structures/modules.py,sha256=oXUIqrOAV1dZzBV5zUH3HDUSFvNOjpUSx0TF9nZVnbs,2569
43
43
  pyplumio/structures/network_info.py,sha256=kPxmIaDGm5SyLRKVFzcrODlUtB0u5JjiZqekoKSyDpA,4159
44
44
  pyplumio/structures/output_flags.py,sha256=07N0kxlvR5WZAURuChk_BqSiXR8eaQrtI5qlkgCf4Yc,1345
45
- pyplumio/structures/outputs.py,sha256=1xsJPkjN643-aFawqVoupGatUIUJfQG_g252n051Qi0,1916
45
+ pyplumio/structures/outputs.py,sha256=pZ8AoTFLSs4aRh2-4CVI29GWNyLCGo4JviqQTmrPPak,1889
46
46
  pyplumio/structures/pending_alerts.py,sha256=Uq9WpB4MW9AhDkqmDhk-g0J0h4pVq0Q50z12dYEv6kY,739
47
- pyplumio/structures/product_info.py,sha256=uiEN6DFQlzmBvQByTirFzXQShoex0YGdFS9WI-MAxPc,2405
47
+ pyplumio/structures/product_info.py,sha256=ex_1DIiVzqHdBGaagV9Gy1ZN8JxxkzVxRTNYRddjdhw,2405
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
@@ -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.36.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
- PyPlumIO-0.5.36.dist-info/METADATA,sha256=F7biRxVKU0EPVYWi2uJXZYfVmvwM5j3C_o-O2aoJjQI,5510
58
- PyPlumIO-0.5.36.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
59
- PyPlumIO-0.5.36.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
- PyPlumIO-0.5.36.dist-info/RECORD,,
56
+ PyPlumIO-0.5.38.dist-info/LICENSE,sha256=m-UuZFjXJ22uPTGm9kSHS8bqjsf5T8k2wL9bJn1Y04o,1088
57
+ PyPlumIO-0.5.38.dist-info/METADATA,sha256=LFnrK5JuhPzZzRwJPosDefBIYItqrBs3YToUz2iuw_k,5510
58
+ PyPlumIO-0.5.38.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
59
+ PyPlumIO-0.5.38.dist-info/top_level.txt,sha256=kNBz9UPPkPD9teDn3U_sEy5LjzwLm9KfADCXtBlbw8A,9
60
+ PyPlumIO-0.5.38.dist-info/RECORD,,
pyplumio/_version.py CHANGED
@@ -1,8 +1,13 @@
1
- # file generated by setuptools_scm
1
+ # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
3
6
  TYPE_CHECKING = False
4
7
  if TYPE_CHECKING:
5
- from typing import Tuple, Union
8
+ from typing import Tuple
9
+ from typing import Union
10
+
6
11
  VERSION_TUPLE = Tuple[Union[int, str], ...]
7
12
  else:
8
13
  VERSION_TUPLE = object
@@ -12,5 +17,5 @@ __version__: str
12
17
  __version_tuple__: VERSION_TUPLE
13
18
  version_tuple: VERSION_TUPLE
14
19
 
15
- __version__ = version = '0.5.36'
16
- __version_tuple__ = version_tuple = (0, 5, 36)
20
+ __version__ = version = '0.5.38'
21
+ __version_tuple__ = version_tuple = (0, 5, 38)
pyplumio/const.py CHANGED
@@ -3,11 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from enum import Enum, IntEnum, unique
6
- from typing import Any, Final
6
+ from typing import Any, Final, Literal
7
7
 
8
- # Binary states.
9
- STATE_ON: Final = "on"
10
- STATE_OFF: Final = "off"
8
+ from typing_extensions import TypeAlias
11
9
 
12
10
  # General attributes.
13
11
  ATTR_CONNECTED: Final = "connected"
@@ -221,3 +219,9 @@ class UnitOfMeasurement(Enum):
221
219
 
222
220
 
223
221
  PERCENTAGE: Final = "%"
222
+
223
+ STATE_ON: Final = "on"
224
+ STATE_OFF: Final = "off"
225
+
226
+
227
+ State: TypeAlias = Literal["on", "off"]
@@ -21,7 +21,7 @@ from pyplumio.devices.mixer import Mixer
21
21
  from pyplumio.devices.thermostat import Thermostat
22
22
  from pyplumio.filters import on_change
23
23
  from pyplumio.frames import DataFrameDescription, Frame, Request
24
- from pyplumio.helpers.parameter import ParameterValues
24
+ from pyplumio.helpers.parameter import STATE_OFF, STATE_ON, ParameterValues, State
25
25
  from pyplumio.helpers.schedule import Schedule, ScheduleDay
26
26
  from pyplumio.structures.alerts import ATTR_TOTAL_ALERTS
27
27
  from pyplumio.structures.ecomax_parameters import (
@@ -99,8 +99,6 @@ SETUP_FRAME_TYPES: tuple[DataFrameDescription, ...] = (
99
99
 
100
100
  _LOGGER = logging.getLogger(__name__)
101
101
 
102
- ecomax_control_error = "ecoMAX control is not available. Please try again later."
103
-
104
102
 
105
103
  class EcoMAX(PhysicalDevice):
106
104
  """Represents an ecoMAX controller."""
@@ -282,13 +280,13 @@ class EcoMAX(PhysicalDevice):
282
280
  SCHEDULES[index]: Schedule(
283
281
  name=SCHEDULES[index],
284
282
  device=self,
285
- monday=ScheduleDay(schedule[1]),
286
- tuesday=ScheduleDay(schedule[2]),
287
- wednesday=ScheduleDay(schedule[3]),
288
- thursday=ScheduleDay(schedule[4]),
289
- friday=ScheduleDay(schedule[5]),
290
- saturday=ScheduleDay(schedule[6]),
291
- sunday=ScheduleDay(schedule[0]),
283
+ monday=ScheduleDay.from_iterable(schedule[1]),
284
+ tuesday=ScheduleDay.from_iterable(schedule[2]),
285
+ wednesday=ScheduleDay.from_iterable(schedule[3]),
286
+ thursday=ScheduleDay.from_iterable(schedule[4]),
287
+ friday=ScheduleDay.from_iterable(schedule[5]),
288
+ saturday=ScheduleDay.from_iterable(schedule[6]),
289
+ sunday=ScheduleDay.from_iterable(schedule[0]),
292
290
  )
293
291
  for index, schedule in schedules
294
292
  }
@@ -398,23 +396,23 @@ class EcoMAX(PhysicalDevice):
398
396
 
399
397
  return False
400
398
 
401
- async def turn_on(self) -> bool:
402
- """Turn on the ecoMAX controller."""
399
+ async def _set_ecomax_state(self, state: State) -> bool:
400
+ """Try to set the ecoMAX control state."""
403
401
  try:
404
- ecomax_control: EcomaxSwitch = self.data[ATTR_ECOMAX_CONTROL]
405
- return await ecomax_control.turn_on()
402
+ switch: EcomaxSwitch = self.data[ATTR_ECOMAX_CONTROL]
403
+ return await switch.set(state)
406
404
  except KeyError:
407
- _LOGGER.error(ecomax_control_error)
408
- return False
405
+ _LOGGER.error("ecoMAX control is not available. Please try again later.")
406
+
407
+ return False
408
+
409
+ async def turn_on(self) -> bool:
410
+ """Turn on the ecoMAX controller."""
411
+ return await self._set_ecomax_state(STATE_ON)
409
412
 
410
413
  async def turn_off(self) -> bool:
411
414
  """Turn off the ecoMAX controller."""
412
- try:
413
- ecomax_control: EcomaxSwitch = self.data[ATTR_ECOMAX_CONTROL]
414
- return await ecomax_control.turn_off()
415
- except KeyError:
416
- _LOGGER.error(ecomax_control_error)
417
- return False
415
+ return await self._set_ecomax_state(STATE_OFF)
418
416
 
419
417
  def turn_on_nowait(self) -> None:
420
418
  """Turn on the ecoMAX controller without waiting."""
@@ -19,7 +19,7 @@ async def _import_module(name: str) -> ModuleType:
19
19
  return await loop.run_in_executor(None, importlib.import_module, f"pyplumio.{name}")
20
20
 
21
21
 
22
- async def create_instance(class_path: str, cls: type[T], **kwargs: Any) -> T:
22
+ async def create_instance(class_path: str, /, cls: type[T], **kwargs: Any) -> T:
23
23
  """Return a class instance from the class path."""
24
24
  module_name, class_name = class_path.rsplit(".", 1)
25
25
  try:
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, get_args
11
11
  from dataslots import dataslots
12
12
  from typing_extensions import TypeAlias
13
13
 
14
- from pyplumio.const import BYTE_UNDEFINED, STATE_OFF, STATE_ON, UnitOfMeasurement
14
+ from pyplumio.const import BYTE_UNDEFINED, STATE_OFF, STATE_ON, State, UnitOfMeasurement
15
15
  from pyplumio.frames import Request
16
16
 
17
17
  if TYPE_CHECKING:
@@ -19,8 +19,8 @@ if TYPE_CHECKING:
19
19
 
20
20
  _LOGGER = logging.getLogger(__name__)
21
21
 
22
+
22
23
  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 validate_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,7 +42,7 @@ def unpack_parameter(
42
42
  )
43
43
 
44
44
 
45
- def validate_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
 
@@ -75,6 +75,7 @@ class ParameterDescription:
75
75
  """Represents a parameter description."""
76
76
 
77
77
  name: str
78
+ optimistic: bool = False
78
79
 
79
80
 
80
81
  class Parameter(ABC):
@@ -107,7 +108,7 @@ class Parameter(ABC):
107
108
  """Return a serializable string representation."""
108
109
  return (
109
110
  f"{self.__class__.__name__}("
110
- f"device={self.device.__class__.__name__}, "
111
+ f"device={self.device}, "
111
112
  f"description={self.description}, "
112
113
  f"values={self.values}, "
113
114
  f"index={self._index})"
@@ -204,13 +205,21 @@ class Parameter(ABC):
204
205
  self, value: int, retries: int = 5, timeout: float = 5.0
205
206
  ) -> bool:
206
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
+ )
207
211
  if value == self.values.value:
208
212
  # Value is unchanged
209
213
  return True
210
214
 
211
- self._pending_update = True
212
215
  self._values.value = value
213
- initial_retries = retries
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
+
222
+ self._pending_update = True
214
223
  while self.pending_update:
215
224
  if retries <= 0:
216
225
  _LOGGER.warning(
@@ -220,7 +229,7 @@ class Parameter(ABC):
220
229
  )
221
230
  return False
222
231
 
223
- await self.device.queue.put(await self.create_request())
232
+ await self.device.queue.put(request)
224
233
  await asyncio.sleep(timeout)
225
234
  retries -= 1
226
235
 
@@ -2,130 +2,128 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Generator, Iterable, Iterator, MutableMapping
5
+ from collections.abc import Iterable, Iterator, MutableMapping
6
6
  from dataclasses import dataclass
7
7
  import datetime as dt
8
8
  from functools import lru_cache
9
- import math
10
- from typing import Annotated, Final, Literal, get_args
9
+ from typing import Annotated, Final, get_args
11
10
 
12
- from typing_extensions import TypeAlias
13
-
14
- from pyplumio.const import STATE_OFF, STATE_ON, FrameType
11
+ from pyplumio.const import STATE_OFF, STATE_ON, FrameType, State
15
12
  from pyplumio.devices import PhysicalDevice
16
13
  from pyplumio.frames import Request
17
14
  from pyplumio.structures.schedules import collect_schedule_data
18
15
 
19
16
  TIME_FORMAT: Final = "%H:%M"
20
17
 
21
- STATE_NIGHT: Final = "night"
22
- STATE_DAY: Final = "day"
18
+ MIDNIGHT: Final = "00:00"
19
+ MIDNIGHT_DT = dt.datetime.strptime(MIDNIGHT, TIME_FORMAT)
20
+
21
+ STEP = dt.timedelta(minutes=30)
23
22
 
24
- _ON_STATES: Final = {STATE_ON, STATE_DAY}
23
+ Time = Annotated[str, "Time string in %H:%M format"]
25
24
 
26
- ScheduleState: TypeAlias = Literal["on", "off", "day", "night"]
27
- Time = Annotated[str, "time in HH:MM format"]
28
25
 
29
- start_of_day_dt = dt.datetime.strptime("00:00", TIME_FORMAT)
26
+ def _get_time(
27
+ index: int, start: dt.datetime = MIDNIGHT_DT, step: dt.timedelta = STEP
28
+ ) -> Time:
29
+ """Return time for a specific index."""
30
+ time_dt = start + (step * index)
31
+ return time_dt.strftime(TIME_FORMAT)
30
32
 
31
33
 
32
- def _get_time_range(
33
- start: Time, end: Time, step: int = 30
34
- ) -> Generator[int, None, None]:
34
+ @lru_cache(maxsize=10)
35
+ def _get_time_range(start: Time, end: Time, step: dt.timedelta = STEP) -> list[Time]:
35
36
  """Get a time range.
36
37
 
37
- Start and end times should be specified in HH:MM format, step in
38
- minutes.
38
+ Start and end boundaries should be specified in %H:%M format.
39
+ Both are inclusive.
39
40
  """
41
+ start_dt = dt.datetime.strptime(start, TIME_FORMAT)
42
+ end_dt = dt.datetime.strptime(end, TIME_FORMAT)
40
43
 
41
- @lru_cache(maxsize=10)
42
- def _get_time_range_cached(start: Time, end: Time, step: int = 30) -> range:
43
- """Get a time range and cache it using LRU cache."""
44
- start_dt = dt.datetime.strptime(start, TIME_FORMAT)
45
- end_dt = dt.datetime.strptime(end, TIME_FORMAT)
46
- if end_dt == start_of_day_dt:
47
- # Upper boundary of the interval is midnight.
48
- end_dt += dt.timedelta(hours=24) - dt.timedelta(minutes=step)
49
-
50
- if end_dt <= start_dt:
51
- raise ValueError(
52
- f"Invalid time range: start time ({start}) must be earlier "
53
- f"than end time ({end})."
54
- )
44
+ if end_dt == MIDNIGHT_DT:
45
+ # Upper boundary of the interval is midnight.
46
+ end_dt += dt.timedelta(hours=24) - step
55
47
 
56
- def _dt_to_index(dt: dt.datetime) -> int:
57
- """Convert datetime to index in schedule list."""
58
- return math.floor((dt - start_of_day_dt).total_seconds() // (60 * step))
48
+ if end_dt <= start_dt:
49
+ raise ValueError(
50
+ f"Invalid time range: start time ({start}) must be earlier "
51
+ f"than end time ({end})."
52
+ )
59
53
 
60
- return range(_dt_to_index(start_dt), _dt_to_index(end_dt) + 1)
54
+ seconds = (end_dt - start_dt).total_seconds()
55
+ steps = seconds // step.total_seconds() + 1
61
56
 
62
- yield from _get_time_range_cached(start, end, step)
57
+ return [_get_time(index, start=start_dt, step=step) for index in range(int(steps))]
63
58
 
64
59
 
65
60
  class ScheduleDay(MutableMapping):
66
61
  """Represents a single day of schedule."""
67
62
 
68
- __slots__ = ("_intervals",)
63
+ __slots__ = ("_schedule",)
69
64
 
70
- _intervals: list[bool]
65
+ _schedule: dict[Time, bool]
71
66
 
72
- def __init__(self, intervals: list[bool]) -> None:
67
+ def __init__(self, schedule: dict[Time, bool]) -> None:
73
68
  """Initialize a new schedule day."""
74
- self._intervals = intervals
69
+ self._schedule = schedule
75
70
 
76
71
  def __repr__(self) -> str:
77
72
  """Return serializable representation of the class."""
78
- return f"ScheduleDay({self._intervals})"
73
+ return f"ScheduleDay({self._schedule})"
79
74
 
80
75
  def __len__(self) -> int:
81
76
  """Return a schedule length."""
82
- return len(self._intervals)
77
+ return self._schedule.__len__()
83
78
 
84
- def __iter__(self) -> Iterator[bool]:
79
+ def __iter__(self) -> Iterator[Time]:
85
80
  """Return an iterator."""
86
- return self._intervals.__iter__()
81
+ return self._schedule.__iter__()
87
82
 
88
- def __getitem__(self, index: int) -> bool:
83
+ def __getitem__(self, time: Time) -> State:
89
84
  """Return a schedule item."""
90
- return self._intervals.__getitem__(index)
85
+ state = self._schedule.__getitem__(time)
86
+ return STATE_ON if state else STATE_OFF
91
87
 
92
- def __delitem__(self, index: int) -> None:
88
+ def __delitem__(self, time: Time) -> None:
93
89
  """Delete a schedule item."""
94
- return self._intervals.__delitem__(index)
90
+ self._schedule.__delitem__(time)
95
91
 
96
- def __setitem__(self, index: int, value: bool) -> None:
92
+ def __setitem__(self, time: Time, state: State | bool) -> None:
97
93
  """Set a schedule item."""
98
- return self._intervals.__setitem__(index, value)
99
-
100
- def append(self, item: bool) -> None:
101
- """Append a value to the interval."""
102
- self._intervals.append(item)
94
+ if state in get_args(State):
95
+ state = True if state == STATE_ON else False
96
+ if isinstance(state, bool):
97
+ self._schedule.__setitem__(time, state)
98
+ else:
99
+ raise TypeError(
100
+ f"Expected boolean value or one of: {', '.join(get_args(State))}."
101
+ )
103
102
 
104
103
  def set_state(
105
- self, state: ScheduleState, start: Time = "00:00", end: Time = "00:00"
104
+ self, state: State | bool, start: Time = MIDNIGHT, end: Time = MIDNIGHT
106
105
  ) -> None:
107
106
  """Set a schedule interval state."""
108
- if state not in get_args(ScheduleState):
109
- raise ValueError(
110
- f"Invalid state '{state}'. Allowed states are: "
111
- f"{', '.join(get_args(ScheduleState))}"
112
- )
113
-
114
- for index in _get_time_range(start, end):
115
- self._intervals[index] = True if state in _ON_STATES else False
107
+ for time in _get_time_range(start, end):
108
+ self.__setitem__(time, state)
116
109
 
117
- def set_on(self, start: Time = "00:00", end: Time = "00:00") -> None:
110
+ def set_on(self, start: Time = MIDNIGHT, end: Time = MIDNIGHT) -> None:
118
111
  """Set a schedule interval state to 'on'."""
119
112
  self.set_state(STATE_ON, start, end)
120
113
 
121
- def set_off(self, start: Time = "00:00", end: Time = "00:00") -> None:
114
+ def set_off(self, start: Time = MIDNIGHT, end: Time = MIDNIGHT) -> None:
122
115
  """Set a schedule interval state to 'off'."""
123
116
  self.set_state(STATE_OFF, start, end)
124
117
 
125
118
  @property
126
- def intervals(self) -> list[bool]:
127
- """Return the schedule intervals."""
128
- return self._intervals
119
+ def schedule(self) -> dict[Time, bool]:
120
+ """Return the schedule."""
121
+ return self._schedule
122
+
123
+ @classmethod
124
+ def from_iterable(cls: type[ScheduleDay], intervals: Iterable[bool]) -> ScheduleDay:
125
+ """Make schedule day from iterable."""
126
+ return cls({_get_time(index): state for index, state in enumerate(intervals)})
129
127
 
130
128
 
131
129
  @dataclass
pyplumio/helpers/uid.py CHANGED
@@ -10,8 +10,8 @@ POLYNOMIAL: Final = 0xA001
10
10
  BASE5_KEY: Final = "0123456789ABCDEFGHIJKLMNZPQRSTUV"
11
11
 
12
12
 
13
- def decode_uid(buffer: bytes) -> str:
14
- """Decode an UID string."""
13
+ def unpack_uid(buffer: bytes) -> str:
14
+ """Unpack UID from bytes."""
15
15
  return _base5(buffer + _crc16(buffer))
16
16
 
17
17
 
@@ -17,7 +17,7 @@ from pyplumio.utils import ensure_dict
17
17
  ATTR_ALERTS: Final = "alerts"
18
18
  ATTR_TOTAL_ALERTS: Final = "total_alerts"
19
19
 
20
- MAX_UINT32: Final = 4294967295
20
+ MAX_UINT32: Final = 0xFFFFFFFF
21
21
 
22
22
 
23
23
  class DateTimeInterval(NamedTuple):
@@ -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
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import math
6
5
  from typing import Any, Final
7
6
 
8
7
  from pyplumio.helpers.data_types import UnsignedInt
@@ -60,7 +59,7 @@ class OutputsStructure(StructureDecoder):
60
59
  ensure_dict(
61
60
  data,
62
61
  {
63
- output: bool(outputs.value & int(math.pow(2, index)))
62
+ output: bool(outputs.value & 2**index)
64
63
  for index, output in enumerate(OUTPUTS)
65
64
  },
66
65
  ),
@@ -10,7 +10,7 @@ from typing import Any, Final
10
10
 
11
11
  from pyplumio.const import ProductType
12
12
  from pyplumio.helpers.data_types import UnsignedShort, VarBytes, VarString
13
- from pyplumio.helpers.uid import decode_uid
13
+ from pyplumio.helpers.uid import unpack_uid
14
14
  from pyplumio.structures import StructureDecoder
15
15
  from pyplumio.utils import ensure_dict
16
16
 
@@ -69,7 +69,7 @@ class ProductInfoStructure(StructureDecoder):
69
69
  ATTR_PRODUCT: ProductInfo(
70
70
  type=ProductType(product_type),
71
71
  id=product_id,
72
- uid=decode_uid(uid.value),
72
+ uid=unpack_uid(uid.value),
73
73
  logo=logo.value,
74
74
  image=image.value,
75
75
  model=format_model_name(model_name.value),