PyPlumIO 0.5.42__py3-none-any.whl → 0.5.44__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.
Files changed (61) hide show
  1. pyplumio/__init__.py +3 -2
  2. pyplumio/_version.py +2 -2
  3. pyplumio/connection.py +14 -14
  4. pyplumio/const.py +8 -3
  5. pyplumio/{helpers/data_types.py → data_types.py} +23 -21
  6. pyplumio/devices/__init__.py +42 -41
  7. pyplumio/devices/ecomax.py +202 -174
  8. pyplumio/devices/ecoster.py +5 -0
  9. pyplumio/devices/mixer.py +24 -34
  10. pyplumio/devices/thermostat.py +24 -31
  11. pyplumio/filters.py +188 -147
  12. pyplumio/frames/__init__.py +20 -8
  13. pyplumio/frames/messages.py +3 -0
  14. pyplumio/frames/requests.py +21 -0
  15. pyplumio/frames/responses.py +18 -0
  16. pyplumio/helpers/async_cache.py +48 -0
  17. pyplumio/helpers/event_manager.py +58 -3
  18. pyplumio/helpers/factory.py +5 -2
  19. pyplumio/helpers/schedule.py +8 -5
  20. pyplumio/helpers/task_manager.py +3 -0
  21. pyplumio/helpers/timeout.py +7 -6
  22. pyplumio/helpers/uid.py +8 -5
  23. pyplumio/{helpers/parameter.py → parameters/__init__.py} +105 -5
  24. pyplumio/parameters/ecomax.py +868 -0
  25. pyplumio/parameters/mixer.py +245 -0
  26. pyplumio/parameters/thermostat.py +197 -0
  27. pyplumio/protocol.py +21 -10
  28. pyplumio/stream.py +3 -0
  29. pyplumio/structures/__init__.py +3 -0
  30. pyplumio/structures/alerts.py +9 -6
  31. pyplumio/structures/boiler_load.py +3 -0
  32. pyplumio/structures/boiler_power.py +4 -1
  33. pyplumio/structures/ecomax_parameters.py +6 -800
  34. pyplumio/structures/fan_power.py +4 -1
  35. pyplumio/structures/frame_versions.py +4 -1
  36. pyplumio/structures/fuel_consumption.py +4 -1
  37. pyplumio/structures/fuel_level.py +3 -0
  38. pyplumio/structures/lambda_sensor.py +9 -1
  39. pyplumio/structures/mixer_parameters.py +8 -230
  40. pyplumio/structures/mixer_sensors.py +10 -1
  41. pyplumio/structures/modules.py +14 -0
  42. pyplumio/structures/network_info.py +12 -1
  43. pyplumio/structures/output_flags.py +10 -1
  44. pyplumio/structures/outputs.py +22 -1
  45. pyplumio/structures/pending_alerts.py +3 -0
  46. pyplumio/structures/product_info.py +6 -3
  47. pyplumio/structures/program_version.py +3 -0
  48. pyplumio/structures/regulator_data.py +5 -2
  49. pyplumio/structures/regulator_data_schema.py +4 -1
  50. pyplumio/structures/schedules.py +18 -1
  51. pyplumio/structures/statuses.py +9 -0
  52. pyplumio/structures/temperatures.py +23 -1
  53. pyplumio/structures/thermostat_parameters.py +18 -184
  54. pyplumio/structures/thermostat_sensors.py +10 -1
  55. pyplumio/utils.py +14 -12
  56. {pyplumio-0.5.42.dist-info → pyplumio-0.5.44.dist-info}/METADATA +32 -17
  57. pyplumio-0.5.44.dist-info/RECORD +64 -0
  58. {pyplumio-0.5.42.dist-info → pyplumio-0.5.44.dist-info}/WHEEL +1 -1
  59. pyplumio-0.5.42.dist-info/RECORD +0 -60
  60. {pyplumio-0.5.42.dist-info → pyplumio-0.5.44.dist-info}/licenses/LICENSE +0 -0
  61. {pyplumio-0.5.42.dist-info → pyplumio-0.5.44.dist-info}/top_level.txt +0 -0
@@ -221,3 +221,21 @@ class UIDResponse(Response):
221
221
  def decode_message(self, message: bytearray) -> dict[str, Any]:
222
222
  """Decode a frame message."""
223
223
  return ProductInfoStructure(self).decode(message)[0]
224
+
225
+
226
+ __all__ = [
227
+ "AlertsResponse",
228
+ "DeviceAvailableResponse",
229
+ "EcomaxControlResponse",
230
+ "EcomaxParametersResponse",
231
+ "MixerParametersResponse",
232
+ "PasswordResponse",
233
+ "ProgramVersionResponse",
234
+ "RegulatorDataSchemaResponse",
235
+ "SchedulesResponse",
236
+ "SetEcomaxParameterResponse",
237
+ "SetMixerParameterResponse",
238
+ "SetThermostatParameterResponse",
239
+ "ThermostatParametersResponse",
240
+ "UIDResponse",
241
+ ]
@@ -0,0 +1,48 @@
1
+ """Contains a simple async cache for caching results of async functions."""
2
+
3
+ from collections.abc import Awaitable
4
+ from functools import wraps
5
+ from typing import Any, Callable, TypeVar, cast
6
+
7
+ from typing_extensions import ParamSpec
8
+
9
+ T = TypeVar("T")
10
+ P = ParamSpec("P")
11
+
12
+
13
+ class AsyncCache:
14
+ """A simple cache for asynchronous functions."""
15
+
16
+ __slots__ = ("cache",)
17
+
18
+ cache: dict[str, Any]
19
+
20
+ def __init__(self) -> None:
21
+ """Initialize the cache."""
22
+ self.cache = {}
23
+
24
+ async def get(self, key: str, coro: Callable[..., Awaitable[Any]]) -> Any:
25
+ """Get a value from the cache or compute and store it."""
26
+ if key not in self.cache:
27
+ self.cache[key] = await coro()
28
+
29
+ return self.cache[key]
30
+
31
+
32
+ # Create a global cache instance
33
+ async_cache = AsyncCache()
34
+
35
+
36
+ def acache(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
37
+ """Cache the result of an async function."""
38
+
39
+ @wraps(func)
40
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
41
+ func_name = f"{func.__module__}.{func.__qualname__}"
42
+ key = f"{func_name}:{args}:{kwargs}"
43
+ return cast(T, await async_cache.get(key, lambda: func(*args, **kwargs)))
44
+
45
+ return wrapper
46
+
47
+
48
+ __all__ = ["acache", "async_cache"]
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections.abc import Callable, Coroutine
6
+ from collections.abc import Callable, Coroutine, Generator
7
+ import inspect
7
8
  from typing import Any, Generic, TypeVar, overload
8
9
 
9
10
  from typing_extensions import TypeAlias
@@ -11,7 +12,45 @@ from typing_extensions import TypeAlias
11
12
  from pyplumio.helpers.task_manager import TaskManager
12
13
 
13
14
  Callback: TypeAlias = Callable[[Any], Coroutine[Any, Any, Any]]
14
- CallbackT = TypeVar("CallbackT", bound=Callback)
15
+
16
+ _CallableT: TypeAlias = Callable[..., Any]
17
+ _CallbackT = TypeVar("_CallbackT", bound=Callback)
18
+
19
+
20
+ @overload
21
+ def event_listener(name: _CallableT, filter: None = None) -> Callback: ...
22
+
23
+
24
+ @overload
25
+ def event_listener(
26
+ name: str | None = None, filter: _CallableT | None = None
27
+ ) -> _CallableT: ...
28
+
29
+
30
+ def event_listener(name: Any = None, filter: _CallableT | None = None) -> Any:
31
+ """Mark a function as an event listener.
32
+
33
+ This decorator attaches metadata to the function, identifying it
34
+ as a subscriber for the specified event.
35
+ """
36
+
37
+ def decorator(func: _CallbackT) -> _CallbackT:
38
+ # Attach metadata to the function to mark it as a listener.
39
+ event = (
40
+ name
41
+ if isinstance(name, str)
42
+ else func.__qualname__.split("on_event_", 1)[1]
43
+ )
44
+ setattr(func, "_on_event", event)
45
+ setattr(func, "_on_event_filter", filter)
46
+ return func
47
+
48
+ if callable(name):
49
+ return decorator(name)
50
+ else:
51
+ return decorator
52
+
53
+
15
54
  T = TypeVar("T")
16
55
 
17
56
 
@@ -30,6 +69,7 @@ class EventManager(TaskManager, Generic[T]):
30
69
  self.data = {}
31
70
  self._events = {}
32
71
  self._callbacks = {}
72
+ self._register_event_listeners()
33
73
 
34
74
  def __getattr__(self, name: str) -> T:
35
75
  """Return attributes from the underlying data dictionary."""
@@ -38,6 +78,18 @@ class EventManager(TaskManager, Generic[T]):
38
78
  except KeyError as e:
39
79
  raise AttributeError from e
40
80
 
81
+ def _register_event_listeners(self) -> None:
82
+ """Register the event listeners."""
83
+ for event, callback in self.event_listeners():
84
+ filter_func = getattr(callback, "_on_event_filter", None)
85
+ self.subscribe(event, filter_func(callback) if filter_func else callback)
86
+
87
+ def event_listeners(self) -> Generator[tuple[str, Callback]]:
88
+ """Get the event listeners."""
89
+ for _, callback in inspect.getmembers(self, predicate=inspect.ismethod):
90
+ if event := getattr(callback, "_on_event", None):
91
+ yield (event, callback)
92
+
41
93
  async def wait_for(self, name: str, timeout: float | None = None) -> None:
42
94
  """Wait for the value to become available.
43
95
 
@@ -91,7 +143,7 @@ class EventManager(TaskManager, Generic[T]):
91
143
  except KeyError:
92
144
  return default
93
145
 
94
- def subscribe(self, name: str, callback: CallbackT) -> CallbackT:
146
+ def subscribe(self, name: str, callback: _CallbackT) -> _CallbackT:
95
147
  """Subscribe a callback to the event.
96
148
 
97
149
  :param name: Event name or ID
@@ -189,3 +241,6 @@ class EventManager(TaskManager, Generic[T]):
189
241
  def events(self) -> dict[str, asyncio.Event]:
190
242
  """Return the events."""
191
243
  return self._events
244
+
245
+
246
+ __all__ = ["Callback", "EventManager", "event_listener"]
@@ -13,7 +13,7 @@ _LOGGER = logging.getLogger(__name__)
13
13
  T = TypeVar("T")
14
14
 
15
15
 
16
- async def _import_module(name: str) -> ModuleType:
16
+ async def import_module(name: str) -> ModuleType:
17
17
  """Import module by name."""
18
18
  loop = asyncio.get_running_loop()
19
19
  return await loop.run_in_executor(None, importlib.import_module, f"pyplumio.{name}")
@@ -23,7 +23,7 @@ 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:
26
- module = await _import_module(module_name)
26
+ module = await import_module(module_name)
27
27
  instance = getattr(module, class_name)(**kwargs)
28
28
  if not isinstance(instance, cls):
29
29
  raise TypeError(
@@ -35,3 +35,6 @@ async def create_instance(class_path: str, /, cls: type[T], **kwargs: Any) -> T:
35
35
  except Exception:
36
36
  _LOGGER.exception("Failed to create instance for class path '%s'", class_path)
37
37
  raise
38
+
39
+
40
+ __all__ = ["create_instance"]
@@ -23,7 +23,7 @@ STEP = dt.timedelta(minutes=30)
23
23
  Time = Annotated[str, "Time string in %H:%M format"]
24
24
 
25
25
 
26
- def _get_time(
26
+ def get_time(
27
27
  index: int, start: dt.datetime = MIDNIGHT_DT, step: dt.timedelta = STEP
28
28
  ) -> Time:
29
29
  """Return time for a specific index."""
@@ -32,7 +32,7 @@ def _get_time(
32
32
 
33
33
 
34
34
  @lru_cache(maxsize=10)
35
- def _get_time_range(start: Time, end: Time, step: dt.timedelta = STEP) -> list[Time]:
35
+ def get_time_range(start: Time, end: Time, step: dt.timedelta = STEP) -> list[Time]:
36
36
  """Get a time range.
37
37
 
38
38
  Start and end boundaries should be specified in %H:%M format.
@@ -54,7 +54,7 @@ def _get_time_range(start: Time, end: Time, step: dt.timedelta = STEP) -> list[T
54
54
  seconds = (end_dt - start_dt).total_seconds()
55
55
  steps = seconds // step.total_seconds() + 1
56
56
 
57
- return [_get_time(index, start=start_dt, step=step) for index in range(int(steps))]
57
+ return [get_time(index, start=start_dt, step=step) for index in range(int(steps))]
58
58
 
59
59
 
60
60
  class ScheduleDay(MutableMapping):
@@ -104,7 +104,7 @@ class ScheduleDay(MutableMapping):
104
104
  self, state: State | bool, start: Time = MIDNIGHT, end: Time = MIDNIGHT
105
105
  ) -> None:
106
106
  """Set a schedule interval state."""
107
- for time in _get_time_range(start, end):
107
+ for time in get_time_range(start, end):
108
108
  self.__setitem__(time, state)
109
109
 
110
110
  def set_on(self, start: Time = MIDNIGHT, end: Time = MIDNIGHT) -> None:
@@ -123,7 +123,7 @@ class ScheduleDay(MutableMapping):
123
123
  @classmethod
124
124
  def from_iterable(cls: type[ScheduleDay], intervals: Iterable[bool]) -> ScheduleDay:
125
125
  """Make schedule day from iterable."""
126
- return cls({_get_time(index): state for index, state in enumerate(intervals)})
126
+ return cls({get_time(index): state for index, state in enumerate(intervals)})
127
127
 
128
128
 
129
129
  @dataclass
@@ -174,3 +174,6 @@ class Schedule(Iterable):
174
174
  data=collect_schedule_data(self.name, self.device),
175
175
  )
176
176
  )
177
+
178
+
179
+ __all__ = ["Schedule", "ScheduleDay"]
@@ -40,3 +40,6 @@ class TaskManager:
40
40
  def tasks(self) -> set[asyncio.Task]:
41
41
  """Return the tasks."""
42
42
  return self._tasks
43
+
44
+
45
+ __all__ = ["TaskManager"]
@@ -3,9 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from collections.abc import Awaitable, Callable, Coroutine
6
+ from collections.abc import Awaitable, Callable
7
7
  from functools import wraps
8
- from typing import Any, TypeVar
8
+ from typing import TypeVar
9
9
 
10
10
  from typing_extensions import ParamSpec
11
11
 
@@ -15,12 +15,10 @@ P = ParamSpec("P")
15
15
 
16
16
  def timeout(
17
17
  seconds: float,
18
- ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T]]]:
18
+ ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
19
19
  """Decorate a timeout for the awaitable."""
20
20
 
21
- def decorator(
22
- func: Callable[P, Awaitable[T]],
23
- ) -> Callable[P, Coroutine[Any, Any, T]]:
21
+ def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
24
22
  @wraps(func)
25
23
  async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
26
24
  return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds)
@@ -28,3 +26,6 @@ def timeout(
28
26
  return wrapper
29
27
 
30
28
  return decorator
29
+
30
+
31
+ __all__ = ["timeout"]
pyplumio/helpers/uid.py CHANGED
@@ -12,10 +12,10 @@ BASE5_KEY: Final = "0123456789ABCDEFGHIJKLMNZPQRSTUV"
12
12
 
13
13
  def unpack_uid(buffer: bytes) -> str:
14
14
  """Unpack UID from bytes."""
15
- return _base5(buffer + _crc16(buffer))
15
+ return base5(buffer + crc16(buffer))
16
16
 
17
17
 
18
- def _base5(buffer: bytes) -> str:
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
21
  output = []
@@ -26,16 +26,19 @@ def _base5(buffer: bytes) -> str:
26
26
  return "".join(reversed(output))
27
27
 
28
28
 
29
- def _crc16(buffer: bytes) -> bytes:
29
+ def crc16(buffer: bytes) -> bytes:
30
30
  """Return a CRC 16."""
31
- crc16 = reduce(_crc16_byte, buffer, CRC)
31
+ crc16 = reduce(crc16_byte, buffer, CRC)
32
32
  return crc16.to_bytes(length=2, byteorder="little")
33
33
 
34
34
 
35
- def _crc16_byte(crc: int, byte: int) -> int:
35
+ def crc16_byte(crc: int, byte: int) -> int:
36
36
  """Add a byte to the CRC."""
37
37
  crc ^= byte
38
38
  for _ in range(8):
39
39
  crc = (crc >> 1) ^ POLYNOMIAL if crc & 1 else crc >> 1
40
40
 
41
41
  return crc
42
+
43
+
44
+ __all__ = ["unpack_uid"]
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from abc import ABC, abstractmethod
6
6
  import asyncio
7
+ from collections.abc import Sequence
7
8
  from dataclasses import dataclass
8
9
  import logging
9
10
  from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, get_args
@@ -11,8 +12,16 @@ from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, get_args
11
12
  from dataslots import dataslots
12
13
  from typing_extensions import TypeAlias
13
14
 
14
- from pyplumio.const import BYTE_UNDEFINED, STATE_OFF, STATE_ON, State, UnitOfMeasurement
15
+ from pyplumio.const import (
16
+ BYTE_UNDEFINED,
17
+ STATE_OFF,
18
+ STATE_ON,
19
+ ProductModel,
20
+ State,
21
+ UnitOfMeasurement,
22
+ )
15
23
  from pyplumio.frames import Request
24
+ from pyplumio.structures.product_info import ProductInfo
16
25
  from pyplumio.utils import is_divisible
17
26
 
18
27
  if TYPE_CHECKING:
@@ -20,8 +29,6 @@ if TYPE_CHECKING:
20
29
 
21
30
  _LOGGER = logging.getLogger(__name__)
22
31
 
23
-
24
- NumericType: TypeAlias = Union[int, float]
25
32
  ParameterT = TypeVar("ParameterT", bound="Parameter")
26
33
 
27
34
 
@@ -68,6 +75,9 @@ class ParameterDescription:
68
75
  optimistic: bool = False
69
76
 
70
77
 
78
+ NumericType: TypeAlias = Union[int, float]
79
+
80
+
71
81
  class Parameter(ABC):
72
82
  """Represents a base parameter."""
73
83
 
@@ -186,7 +196,7 @@ class Parameter(ABC):
186
196
  self, value: int, retries: int = 5, timeout: float = 5.0
187
197
  ) -> bool:
188
198
  """Attempt to update a parameter value on the remote device."""
189
- _LOGGER.debug(
199
+ _LOGGER.info(
190
200
  "Attempting to update '%s' parameter to %d", self.description.name, value
191
201
  )
192
202
  if value == self.values.value:
@@ -360,6 +370,30 @@ class Number(Parameter):
360
370
  return self.description.unit_of_measurement
361
371
 
362
372
 
373
+ @dataslots
374
+ @dataclass
375
+ class OffsetNumberDescription(NumberDescription):
376
+ """Represents a parameter description."""
377
+
378
+ offset: int = 0
379
+
380
+
381
+ class OffsetNumber(Number):
382
+ """Represents a number with offset."""
383
+
384
+ __slots__ = ()
385
+
386
+ description: OffsetNumberDescription
387
+
388
+ def _pack_value(self, value: NumericType) -> int:
389
+ """Pack the parameter value."""
390
+ return super()._pack_value(value + self.description.offset)
391
+
392
+ def _unpack_value(self, value: int) -> NumericType:
393
+ """Unpack the parameter value."""
394
+ return super()._unpack_value(value - self.description.offset)
395
+
396
+
363
397
  @dataslots
364
398
  @dataclass
365
399
  class SwitchDescription(ParameterDescription):
@@ -387,7 +421,10 @@ class Switch(Parameter):
387
421
  def validate(self, value: Any) -> bool:
388
422
  """Validate a parameter value."""
389
423
  if not isinstance(value, bool) and value not in get_args(State):
390
- raise ValueError(f"Invalid switch value: {value}. Must be 'on' or 'off'.")
424
+ raise ValueError(
425
+ f"Invalid value: {value}. The value must be either 'on', 'off' or "
426
+ f"boolean."
427
+ )
391
428
 
392
429
  return True
393
430
 
@@ -447,3 +484,66 @@ class Switch(Parameter):
447
484
  def max_value(self) -> Literal["on"]:
448
485
  """Return the maximum allowed value."""
449
486
  return STATE_ON
487
+
488
+
489
+ @dataclass
490
+ class ParameterOverride:
491
+ """Represents a parameter override."""
492
+
493
+ __slot__ = ("original", "replacement", "product_model", "product_id")
494
+
495
+ original: str
496
+ replacement: ParameterDescription
497
+ product_model: ProductModel
498
+ product_id: int
499
+
500
+
501
+ _DescriptorT = TypeVar("_DescriptorT", bound=ParameterDescription)
502
+
503
+
504
+ def patch_parameter_types(
505
+ product_info: ProductInfo,
506
+ parameter_types: list[_DescriptorT],
507
+ parameter_overrides: Sequence[ParameterOverride],
508
+ ) -> list[_DescriptorT]:
509
+ """Patch the parameter types based on the provided overrides.
510
+
511
+ Note:
512
+ The `# type: ignore[assignment]` comment is used to suppress a
513
+ type-checking error caused by mypy bug. For more details, see:
514
+ https://github.com/python/mypy/issues/13596
515
+
516
+ """
517
+ replacements = {
518
+ override.original: override.replacement
519
+ for override in parameter_overrides
520
+ if override.product_model.value == product_info.model
521
+ and override.product_id == product_info.id
522
+ }
523
+ for index, description in enumerate(parameter_types):
524
+ if description.name in replacements:
525
+ _LOGGER.info(
526
+ "Replacing parameter description for '%s' with '%s' (%s)",
527
+ description.name,
528
+ replacements[description.name],
529
+ product_info.model,
530
+ )
531
+ parameter_types[index] = replacements[description.name] # type: ignore[assignment]
532
+
533
+ return parameter_types
534
+
535
+
536
+ __all__ = [
537
+ "Number",
538
+ "NumberDescription",
539
+ "NumericType",
540
+ "OffsetNumber",
541
+ "OffsetNumberDescription",
542
+ "Parameter",
543
+ "ParameterDescription",
544
+ "ParameterValues",
545
+ "patch_parameter_types",
546
+ "State",
547
+ "Switch",
548
+ "SwitchDescription",
549
+ ]