PyPlumIO 0.4.0.post1__tar.gz → 0.4.2__tar.gz

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 (112) hide show
  1. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.vscode/settings.json +5 -2
  2. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/PKG-INFO +1 -1
  3. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/PyPlumIO.egg-info/PKG-INFO +1 -1
  4. PyPlumIO-0.4.2/pyplumio/_version.py +4 -0
  5. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/const.py +3 -0
  6. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/devices/__init__.py +19 -18
  7. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/devices/ecomax.py +16 -3
  8. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/filters.py +63 -27
  9. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/typing.py +9 -1
  10. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/ecomax_parameters.py +25 -12
  11. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/mixer_parameters.py +28 -34
  12. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/thermostat_parameters.py +49 -51
  13. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/test_devices.py +14 -3
  14. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/test_filters.py +58 -0
  15. PyPlumIO-0.4.0.post1/pyplumio/_version.py +0 -4
  16. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.gitattributes +0 -0
  17. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.github/CODE_OF_CONDUCT.md +0 -0
  18. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.github/workflows/ci.yml +0 -0
  21. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.github/workflows/codeql-analysis.yml +0 -0
  22. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.github/workflows/deploy.yml +0 -0
  23. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.github/workflows/documentation.yml +0 -0
  24. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.gitignore +0 -0
  25. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/.pre-commit-config.yaml +0 -0
  26. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/LICENSE +0 -0
  27. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/MANIFEST.in +0 -0
  28. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/PyPlumIO.egg-info/SOURCES.txt +0 -0
  29. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/PyPlumIO.egg-info/dependency_links.txt +0 -0
  30. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/PyPlumIO.egg-info/requires.txt +0 -0
  31. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/PyPlumIO.egg-info/top_level.txt +0 -0
  32. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/README.md +0 -0
  33. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/docs/Makefile +0 -0
  34. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/docs/make.bat +0 -0
  35. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/docs/source/conf.py +0 -0
  36. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/docs/source/index.rst +0 -0
  37. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/docs/source/protocol.rst +0 -0
  38. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/docs/source/usage.rst +0 -0
  39. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/images/ecomax.png +0 -0
  40. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/images/rs485.png +0 -0
  41. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/__init__.py +0 -0
  42. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/__main__.py +0 -0
  43. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/connection.py +0 -0
  44. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/devices/ecoster.py +0 -0
  45. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/devices/mixer.py +0 -0
  46. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/devices/thermostat.py +0 -0
  47. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/exceptions.py +0 -0
  48. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/frames/__init__.py +0 -0
  49. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/frames/messages.py +0 -0
  50. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/frames/requests.py +0 -0
  51. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/frames/responses.py +0 -0
  52. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/__init__.py +0 -0
  53. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/data_types.py +0 -0
  54. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/event_manager.py +0 -0
  55. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/factory.py +0 -0
  56. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/parameter.py +0 -0
  57. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/schedule.py +0 -0
  58. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/task_manager.py +0 -0
  59. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/timeout.py +0 -0
  60. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/helpers/uid.py +0 -0
  61. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/protocol.py +0 -0
  62. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/stream.py +0 -0
  63. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/__init__.py +0 -0
  64. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/alerts.py +0 -0
  65. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/data_schema.py +0 -0
  66. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/fan_power.py +0 -0
  67. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/frame_versions.py +0 -0
  68. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/fuel_consumption.py +0 -0
  69. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/fuel_level.py +0 -0
  70. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/lambda_sensor.py +0 -0
  71. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/load.py +0 -0
  72. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/mixer_sensors.py +0 -0
  73. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/modules.py +0 -0
  74. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/network_info.py +0 -0
  75. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/output_flags.py +0 -0
  76. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/outputs.py +0 -0
  77. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/pending_alerts.py +0 -0
  78. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/power.py +0 -0
  79. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/product_info.py +0 -0
  80. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/program_version.py +0 -0
  81. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/regulator_data.py +0 -0
  82. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/schedules.py +0 -0
  83. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/statuses.py +0 -0
  84. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/temperatures.py +0 -0
  85. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/structures/thermostat_sensors.py +0 -0
  86. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyplumio/util.py +0 -0
  87. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/pyproject.toml +0 -0
  88. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/requirements.txt +0 -0
  89. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/requirements_test.txt +0 -0
  90. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/setup.cfg +0 -0
  91. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/__init__.py +0 -0
  92. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/conftest.py +0 -0
  93. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/frames/test_init.py +0 -0
  94. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/frames/test_messages.py +0 -0
  95. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/frames/test_requests.py +0 -0
  96. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/frames/test_responses.py +0 -0
  97. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/helpers/__init__.py +0 -0
  98. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/helpers/test_data_types.py +0 -0
  99. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/helpers/test_event_manager.py +0 -0
  100. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/helpers/test_factory.py +0 -0
  101. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/helpers/test_parameter.py +0 -0
  102. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/helpers/test_schedule.py +0 -0
  103. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/helpers/test_task_manager.py +0 -0
  104. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/helpers/test_timeout.py +0 -0
  105. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/helpers/test_uid.py +0 -0
  106. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/test_connection.py +0 -0
  107. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/test_init.py +0 -0
  108. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/test_main.py +0 -0
  109. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/test_protocol.py +0 -0
  110. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/test_stream.py +0 -0
  111. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tests/test_util.py +0 -0
  112. {PyPlumIO-0.4.0.post1 → PyPlumIO-0.4.2}/tox.ini +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "python.testing.unittestEnabled": false,
3
3
  "python.testing.pytestEnabled": true,
4
- "python.formatting.provider": "black",
4
+ "python.formatting.provider": "none",
5
5
  "editor.formatOnSave": true,
6
6
  "editor.codeActionsOnSave": {
7
7
  "source.organizeImports": true
@@ -16,5 +16,8 @@
16
16
  "editor.rulers": [
17
17
  72,
18
18
  88
19
- ]
19
+ ],
20
+ "[python]": {
21
+ "editor.defaultFormatter": "ms-python.black-formatter"
22
+ }
20
23
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyPlumIO
3
- Version: 0.4.0.post1
3
+ Version: 0.4.2
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyPlumIO
3
- Version: 0.4.0.post1
3
+ Version: 0.4.2
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
@@ -0,0 +1,4 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ __version__ = version = '0.4.2'
4
+ __version_tuple__ = version_tuple = (0, 4, 2)
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
  from enum import IntEnum, unique
5
5
  from typing import Final
6
6
 
7
+ UNDEFINED: Final = "undefined"
8
+
7
9
  # Binary states.
8
10
  STATE_ON: Final = "on"
9
11
  STATE_OFF: Final = "off"
@@ -12,6 +14,7 @@ STATE_OFF: Final = "off"
12
14
  ATTR_CONNECTED: Final = "connected"
13
15
  ATTR_CURRENT_TEMP: Final = "current_temp"
14
16
  ATTR_DEVICE_INDEX: Final = "device_index"
17
+ ATTR_FRAME_ERRORS: Final = "frame_errors"
15
18
  ATTR_INDEX: Final = "index"
16
19
  ATTR_LOADED: Final = "loaded"
17
20
  ATTR_OFFSET: Final = "offset"
@@ -6,7 +6,7 @@ import logging
6
6
  from typing import ClassVar
7
7
 
8
8
  from pyplumio import util
9
- from pyplumio.const import ATTR_LOADED, DeviceType, FrameType
9
+ from pyplumio.const import ATTR_FRAME_ERRORS, ATTR_LOADED, DeviceType, FrameType
10
10
  from pyplumio.exceptions import ParameterNotFoundError, UnknownDeviceError
11
11
  from pyplumio.frames import DataFrameDescription, Frame, Request, get_frame_handler
12
12
  from pyplumio.helpers.event_manager import EventManager
@@ -99,22 +99,23 @@ class Addressable(Device):
99
99
 
100
100
  async def async_setup(self) -> bool:
101
101
  """Setup addressable device object."""
102
- try:
103
- await asyncio.gather(
104
- *{
105
- self.create_task(
106
- self.request(description.provides, description.frame_type)
107
- )
108
- for description in self._frame_types
109
- },
110
- return_exceptions=False,
111
- )
112
- await self.dispatch(ATTR_LOADED, True)
113
- return True
114
- except ValueError as e:
115
- _LOGGER.error("Request failed: %s", e)
116
- await self.dispatch(ATTR_LOADED, False)
117
- return False
102
+ results = await asyncio.gather(
103
+ *{
104
+ self.create_task(
105
+ self.request(description.provides, description.frame_type)
106
+ )
107
+ for description in self._frame_types
108
+ },
109
+ return_exceptions=True,
110
+ )
111
+
112
+ errors = [
113
+ result.args[1] for result in results if isinstance(result, ValueError)
114
+ ]
115
+
116
+ await self.dispatch(ATTR_FRAME_ERRORS, errors)
117
+ await self.dispatch(ATTR_LOADED, True)
118
+ return True
118
119
 
119
120
  async def request(
120
121
  self,
@@ -136,7 +137,7 @@ class Addressable(Device):
136
137
  except asyncio.TimeoutError:
137
138
  retries -= 1
138
139
 
139
- raise ValueError(f'could not request "{name}" with "{frame_type.name}"')
140
+ raise ValueError(f'could not request "{name}"', frame_type)
140
141
 
141
142
 
142
143
  class SubDevice(Device):
@@ -8,6 +8,7 @@ import time
8
8
  from typing import ClassVar, Final
9
9
 
10
10
  from pyplumio.const import (
11
+ ATTR_FRAME_ERRORS,
11
12
  ATTR_PASSWORD,
12
13
  ATTR_SENSORS,
13
14
  ATTR_STATE,
@@ -140,12 +141,24 @@ class EcoMAX(Addressable):
140
141
 
141
142
  super().handle_frame(frame)
142
143
 
144
+ def _has_frame_version(self, frame_type: FrameType | int, version: int) -> bool:
145
+ """Check if device instance has a version of the frame."""
146
+ return (
147
+ frame_type in self._frame_versions
148
+ and self._frame_versions[frame_type] == version
149
+ )
150
+
151
+ def _frame_is_supported(self, frame_type: FrameType | int) -> bool:
152
+ """Check if frame is supported by the device."""
153
+ return frame_type not in self.data.get(ATTR_FRAME_ERRORS, [])
154
+
143
155
  async def _update_frame_versions(self, versions: dict[int, int]) -> None:
144
156
  """Check versions and fetch outdated frames."""
145
157
  for frame_type, version in versions.items():
146
- if is_known_frame_type(frame_type) and (
147
- frame_type not in self._frame_versions
148
- or self._frame_versions[frame_type] != version
158
+ if (
159
+ is_known_frame_type(frame_type)
160
+ and self._frame_is_supported(frame_type)
161
+ and not self._has_frame_version(frame_type, version)
149
162
  ):
150
163
  # We don't have this frame or it's version has changed.
151
164
  request = factory(get_frame_handler(frame_type), recipient=self.address)
@@ -4,34 +4,70 @@ from __future__ import annotations
4
4
  from abc import ABC, abstractmethod
5
5
  import math
6
6
  import time
7
- from typing import Any, Final
7
+ from typing import Any, Final, SupportsFloat, SupportsIndex, overload
8
8
 
9
+ from pyplumio.const import UNDEFINED
9
10
  from pyplumio.helpers.parameter import Parameter
10
- from pyplumio.helpers.typing import EventCallbackType
11
+ from pyplumio.helpers.typing import EventCallbackType, SupportsSubtraction
11
12
 
12
13
  TOLERANCE: Final = 0.1
13
14
 
14
15
 
15
- def _significantly_changed(old_value, new_value) -> bool:
16
+ @overload
17
+ def _significantly_changed(old: Parameter, new: Parameter) -> bool:
18
+ """Check if parameter is significantly changed."""
19
+
20
+
21
+ @overload
22
+ def _significantly_changed(
23
+ old: SupportsFloat | SupportsIndex, new: SupportsFloat | SupportsIndex
24
+ ) -> bool:
25
+ """Check if float value is significantly changed."""
26
+
27
+
28
+ def _significantly_changed(old, new) -> bool:
16
29
  """Check if value is significantly changed."""
17
- if old_value is None or (isinstance(old_value, Parameter) and old_value.is_changed):
30
+ if old == UNDEFINED:
18
31
  return True
19
32
 
33
+ if isinstance(old, Parameter) and old.is_changed:
34
+ return True
35
+
36
+ if isinstance(old, Parameter) and isinstance(new, Parameter):
37
+ return (
38
+ old.value != new.value
39
+ or old.min_value != new.min_value
40
+ or old.max_value != new.max_value
41
+ )
42
+
20
43
  try:
21
- return not math.isclose(old_value, new_value, abs_tol=TOLERANCE)
44
+ return not math.isclose(old, new, abs_tol=TOLERANCE)
22
45
  except TypeError:
23
46
  pass
24
47
 
25
- return old_value != new_value
48
+ return old != new
49
+
50
+
51
+ @overload
52
+ def _diffence_between(old: list, new: list) -> list:
53
+ """Return the difference between lists."""
26
54
 
27
55
 
28
- def _diffence_between(old_value, new_value):
56
+ @overload
57
+ def _diffence_between(old: SupportsSubtraction, new: SupportsSubtraction) -> list:
58
+ """Return the difference between lists."""
59
+
60
+
61
+ def _diffence_between(old, new):
29
62
  """Return the difference between values."""
30
- if isinstance(old_value, list) and isinstance(new_value, list):
31
- return [x for x in new_value if x not in old_value]
63
+ if old == UNDEFINED:
64
+ return None
65
+
66
+ if isinstance(old, list) and isinstance(new, list):
67
+ return [x for x in new if x not in old]
32
68
 
33
- if hasattr(old_value, "__sub__") and hasattr(new_value, "__sub__"):
34
- return new_value - old_value
69
+ if hasattr(old, "__sub__") and hasattr(new, "__sub__"):
70
+ return new - old
35
71
 
36
72
  return None
37
73
 
@@ -40,12 +76,12 @@ class Filter(ABC):
40
76
  """Represents base for value callback modifiers."""
41
77
 
42
78
  _callback: Any
43
- _value: Any
79
+ _value: Any = UNDEFINED
44
80
 
45
81
  def __init__(self, callback: EventCallbackType):
46
82
  """Initialize new Filter object."""
47
83
  self._callback = callback
48
- self._value = None
84
+ self._value = UNDEFINED
49
85
 
50
86
  def __eq__(self, other) -> bool:
51
87
  """Compare debounced callbacks."""
@@ -59,21 +95,21 @@ class Filter(ABC):
59
95
 
60
96
  @abstractmethod
61
97
  async def __call__(self, new_value):
62
- """Set new value for the callback."""
98
+ """Set a new value for the callback."""
63
99
 
64
100
 
65
101
  class _OnChange(Filter):
66
102
  """Provides changed functionality to the callback."""
67
103
 
68
104
  async def __call__(self, new_value):
69
- """Set new value for the callback."""
105
+ """Set a new value for the callback."""
70
106
  if _significantly_changed(self._value, new_value):
71
107
  self._value = new_value
72
108
  return await self._callback(new_value)
73
109
 
74
110
 
75
111
  def on_change(callback: EventCallbackType) -> _OnChange:
76
- """Helper for change callback filter."""
112
+ """Helper for a change callback filter."""
77
113
  return _OnChange(callback)
78
114
 
79
115
 
@@ -90,20 +126,20 @@ class _Debounce(Filter):
90
126
  self._min_calls = min_calls
91
127
 
92
128
  async def __call__(self, new_value):
93
- """Set new value for the callback."""
129
+ """Set a new value for the callback."""
94
130
  if _significantly_changed(self._value, new_value):
95
131
  self._calls += 1
96
132
  else:
97
133
  self._calls = 0
98
134
 
99
- if self._calls >= self._min_calls or self._value is None:
135
+ if self._value == UNDEFINED or self._calls >= self._min_calls:
100
136
  self._value = new_value
101
137
  self._calls = 0
102
138
  return await self._callback(new_value)
103
139
 
104
140
 
105
141
  def debounce(callback: EventCallbackType, min_calls) -> _Debounce:
106
- """Helper method for debounce callback filter."""
142
+ """Helper method for a debounce callback filter."""
107
143
  return _Debounce(callback, min_calls)
108
144
 
109
145
 
@@ -120,7 +156,7 @@ class _Throttle(Filter):
120
156
  self._timeout = seconds
121
157
 
122
158
  async def __call__(self, new_value):
123
- """set new value for the callback."""
159
+ """Set a new value for the callback."""
124
160
  current_timestamp = time.monotonic()
125
161
  if (
126
162
  self._last_called is None
@@ -131,7 +167,7 @@ class _Throttle(Filter):
131
167
 
132
168
 
133
169
  def throttle(callback: EventCallbackType, seconds: float) -> _Throttle:
134
- """Helper method for throttle callback filter."""
170
+ """Helper method for a throttle callback filter."""
135
171
  return _Throttle(callback, seconds)
136
172
 
137
173
 
@@ -139,16 +175,16 @@ class _Delta(Filter):
139
175
  """Provides ability to pass call difference to the callback."""
140
176
 
141
177
  async def __call__(self, new_value):
142
- """set new value for the callback."""
143
- old_value = self._value
144
- if _significantly_changed(old_value, new_value):
178
+ """Set new value for the callback."""
179
+ if _significantly_changed(self._value, new_value):
180
+ old_value = self._value
145
181
  self._value = new_value
146
182
  if (difference := _diffence_between(old_value, new_value)) is not None:
147
183
  return await self._callback(difference)
148
184
 
149
185
 
150
186
  def delta(callback: EventCallbackType) -> _Delta:
151
- """Helper method for delta callback filter."""
187
+ """Helper method for a delta callback filter."""
152
188
  return _Delta(callback)
153
189
 
154
190
 
@@ -168,7 +204,7 @@ class _Aggregate(Filter):
168
204
  self._sum = 0.0
169
205
 
170
206
  async def __call__(self, new_value):
171
- """Set new value for the callback."""
207
+ """Set a new value for the callback."""
172
208
  current_timestamp = time.monotonic()
173
209
  try:
174
210
  self._sum += new_value
@@ -185,5 +221,5 @@ class _Aggregate(Filter):
185
221
 
186
222
 
187
223
  def aggregate(callback: EventCallbackType, seconds: float) -> _Aggregate:
188
- """Helper method for total callback filter."""
224
+ """Helper method for a total callback filter."""
189
225
  return _Aggregate(callback, seconds)
@@ -2,9 +2,17 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from collections.abc import Awaitable, Callable
5
- from typing import Any, Literal, Union
5
+ from typing import Any, Literal, Protocol, Union
6
6
 
7
7
  ParameterDataType = tuple[int, int, int]
8
8
  ParameterValueType = Union[int, float, bool, Literal["off"], Literal["on"]]
9
9
  EventDataType = dict[Union[str, int], Any]
10
10
  EventCallbackType = Callable[[Any], Awaitable[Any]]
11
+ UndefinedType = Literal["undefined"]
12
+
13
+
14
+ class SupportsSubtraction(Protocol):
15
+ """Supports subtraction operation."""
16
+
17
+ def __sub__(self, other):
18
+ """Subtracts a value."""
@@ -10,13 +10,15 @@ from pyplumio.devices import Addressable
10
10
  from pyplumio.frames import Request
11
11
  from pyplumio.helpers.factory import factory
12
12
  from pyplumio.helpers.parameter import BinaryParameter, Parameter, ParameterDescription
13
- from pyplumio.helpers.typing import EventDataType, ParameterDataType, ParameterValueType
13
+ from pyplumio.helpers.typing import EventDataType, ParameterValueType
14
14
  from pyplumio.structures import StructureDecoder, ensure_device_data
15
15
  from pyplumio.structures.thermostat_parameters import ATTR_THERMOSTAT_PROFILE
16
16
 
17
17
  ATTR_ECOMAX_CONTROL: Final = "ecomax_control"
18
18
  ATTR_ECOMAX_PARAMETERS: Final = "ecomax_parameters"
19
19
 
20
+ ECOMAX_PARAMETER_SIZE: Final = 3
21
+
20
22
 
21
23
  class EcomaxParameter(Parameter):
22
24
  """Represents ecoMAX parameter."""
@@ -301,22 +303,33 @@ THERMOSTAT_PROFILE_PARAMETER = EcomaxParameterDescription(name=ATTR_THERMOSTAT_P
301
303
  class EcomaxParametersStructure(StructureDecoder):
302
304
  """Represents ecoMAX parameters data structure."""
303
305
 
306
+ _offset: int
307
+
308
+ def _ecomax_parameter(self, message: bytearray, start: int, end: int):
309
+ """Yields ecoMAX parameters."""
310
+ for index in range(start, start + end):
311
+ if parameter := util.unpack_parameter(message, self._offset):
312
+ yield (index, parameter)
313
+
314
+ self._offset += ECOMAX_PARAMETER_SIZE
315
+
304
316
  def decode(
305
317
  self, message: bytearray, offset: int = 0, data: EventDataType | None = None
306
318
  ) -> tuple[EventDataType, int]:
307
319
  """Decode bytes and return message data and offset."""
308
- first_index = message[offset + 1]
309
- last_index = message[offset + 2]
310
- offset += 3
311
- ecomax_parameters: list[tuple[int, ParameterDataType]] = []
312
- for index in range(first_index, first_index + last_index):
313
- parameter = util.unpack_parameter(message, offset)
314
- if parameter is not None:
315
- ecomax_parameters.append((index, parameter))
316
320
 
317
- offset += 3
321
+ start = message[offset + 1]
322
+ end = message[offset + 2]
323
+ self._offset = offset + 3
318
324
 
319
325
  return (
320
- ensure_device_data(data, {ATTR_ECOMAX_PARAMETERS: ecomax_parameters}),
321
- offset,
326
+ ensure_device_data(
327
+ data,
328
+ {
329
+ ATTR_ECOMAX_PARAMETERS: list(
330
+ self._ecomax_parameter(message, start, end)
331
+ )
332
+ },
333
+ ),
334
+ self._offset,
322
335
  )
@@ -1,7 +1,6 @@
1
1
  """Contains mixer parameter structure decoder."""
2
2
  from __future__ import annotations
3
3
 
4
- from collections.abc import Iterable
5
4
  from dataclasses import dataclass
6
5
  from typing import TYPE_CHECKING, Final
7
6
 
@@ -18,6 +17,8 @@ if TYPE_CHECKING:
18
17
 
19
18
  ATTR_MIXER_PARAMETERS: Final = "mixer_parameters"
20
19
 
20
+ MIXER_PARAMETER_SIZE: Final = 3
21
+
21
22
 
22
23
  class MixerParameter(Parameter):
23
24
  """Represents mixer parameter."""
@@ -117,48 +118,41 @@ ECOMAX_I_MIXER_PARAMETERS: tuple[MixerParameterDescription, ...] = (
117
118
  )
118
119
 
119
120
 
120
- def _decode_mixer_parameters(
121
- message: bytearray, offset: int, indexes: Iterable
122
- ) -> tuple[list[tuple[int, ParameterDataType]], int]:
123
- """Decode parameters for a single mixer."""
124
- parameters: list[tuple[int, ParameterDataType]] = []
125
- for index in indexes:
126
- parameter = util.unpack_parameter(message, offset)
127
- if parameter is not None:
128
- parameters.append((index, parameter))
121
+ class MixerParametersStructure(StructureDecoder):
122
+ """Represent mixer parameters data structure."""
129
123
 
130
- offset += 3
124
+ _offset: int
131
125
 
132
- return parameters, offset
126
+ def _mixer_parameter(self, message: bytearray, start: int, end: int):
127
+ """Yields mixer parameters."""
128
+ for index in range(start, start + end):
129
+ if (parameter := util.unpack_parameter(message, self._offset)) is not None:
130
+ yield (index, parameter)
133
131
 
134
-
135
- class MixerParametersStructure(StructureDecoder):
136
- """Represent mixer parameters data structure."""
132
+ self._offset += MIXER_PARAMETER_SIZE
137
133
 
138
134
  def decode(
139
135
  self, message: bytearray, offset: int = 0, data: EventDataType | None = None
140
136
  ) -> tuple[EventDataType, int]:
141
137
  """Decode bytes and return message data and offset."""
142
- first_index = message[offset + 1]
143
- last_index = message[offset + 2]
144
- mixer_count = message[offset + 3]
145
- parameter_count_per_mixer = first_index + last_index
146
- offset += 4
138
+ start = message[offset + 1]
139
+ end = message[offset + 2]
140
+ mixers = message[offset + 3]
141
+ self._offset = offset + 4
142
+
147
143
  mixer_parameters: dict[int, list[tuple[int, ParameterDataType]]] = {}
148
- for index in range(mixer_count):
149
- parameters, offset = _decode_mixer_parameters(
150
- message,
151
- offset,
152
- range(first_index, parameter_count_per_mixer),
153
- )
154
- if parameters:
155
- mixer_parameters[index] = parameters
156
-
157
- if not mixer_parameters:
158
- # No mixer parameters detected.
159
- return ensure_device_data(data, {ATTR_MIXER_PARAMETERS: None}), offset
144
+ for mixer in range(mixers):
145
+ if parameters := list(self._mixer_parameter(message, start, end)):
146
+ mixer_parameters[mixer] = parameters
160
147
 
161
148
  return (
162
- ensure_device_data(data, {ATTR_MIXER_PARAMETERS: mixer_parameters}),
163
- offset,
149
+ ensure_device_data(
150
+ data,
151
+ {
152
+ ATTR_MIXER_PARAMETERS: (
153
+ None if not mixer_parameters else mixer_parameters
154
+ )
155
+ },
156
+ ),
157
+ self._offset,
164
158
  )
@@ -1,7 +1,6 @@
1
1
  """Contains thermostat parameter structure decoder."""
2
2
  from __future__ import annotations
3
3
 
4
- from collections.abc import Iterable
5
4
  from dataclasses import dataclass
6
5
  from typing import TYPE_CHECKING, Final
7
6
 
@@ -14,12 +13,15 @@ from pyplumio.helpers.typing import EventDataType, ParameterDataType, ParameterV
14
13
  from pyplumio.structures import StructureDecoder, ensure_device_data
15
14
  from pyplumio.structures.thermostat_sensors import ATTR_THERMOSTAT_COUNT
16
15
 
16
+ if TYPE_CHECKING:
17
+ from pyplumio.devices.thermostat import Thermostat
18
+
19
+
17
20
  ATTR_THERMOSTAT_PROFILE: Final = "thermostat_profile"
18
21
  ATTR_THERMOSTAT_PARAMETERS: Final = "thermostat_parameters"
19
22
  ATTR_THERMOSTAT_PARAMETERS_DECODER: Final = "thermostat_parameters_decoder"
20
23
 
21
- if TYPE_CHECKING:
22
- from pyplumio.devices.thermostat import Thermostat
24
+ THERMOSTAT_PARAMETER_SIZE: Final = 3
23
25
 
24
26
 
25
27
  class ThermostatParameter(Parameter):
@@ -107,72 +109,68 @@ THERMOSTAT_PARAMETERS: tuple[ThermostatParameterDescription, ...] = (
107
109
  )
108
110
 
109
111
 
110
- def _decode_thermostat_parameters(
111
- message: bytearray, offset: int, indexes: Iterable
112
- ) -> tuple[list[tuple[int, ParameterDataType]], int]:
113
- """Decode parameters for a single thermostat."""
114
- parameters: list[tuple[int, ParameterDataType]] = []
115
- for index in indexes:
116
- description = THERMOSTAT_PARAMETERS[index]
117
- parameter = util.unpack_parameter(message, offset, size=description.size)
118
- if parameter is not None:
119
- parameters.append((index, parameter))
120
-
121
- offset += 3 * description.size
122
-
123
- return parameters, offset
112
+ def _empty_response(
113
+ offset: int, data: EventDataType | None = None
114
+ ) -> tuple[EventDataType, int]:
115
+ """Return empty response."""
116
+ return (
117
+ ensure_device_data(
118
+ data,
119
+ {ATTR_THERMOSTAT_PARAMETERS: None, ATTR_THERMOSTAT_PROFILE: None},
120
+ ),
121
+ offset,
122
+ )
124
123
 
125
124
 
126
125
  class ThermostatParametersStructure(StructureDecoder):
127
126
  """Represent thermostat parameters data structure."""
128
127
 
128
+ _offset: int
129
+
130
+ def _thermostat_parameter(
131
+ self, message: bytearray, thermostats: int, start: int, end: int
132
+ ):
133
+ """Yields thermostat parameters."""
134
+ for index in range(start, (start + end) // thermostats):
135
+ description = THERMOSTAT_PARAMETERS[index]
136
+ if (
137
+ parameter := util.unpack_parameter(
138
+ message, self._offset, size=description.size
139
+ )
140
+ ) is not None:
141
+ yield (index, parameter)
142
+
143
+ self._offset += THERMOSTAT_PARAMETER_SIZE * description.size
144
+
129
145
  def decode(
130
146
  self, message: bytearray, offset: int = 0, data: EventDataType | None = None
131
147
  ) -> tuple[EventDataType, int]:
132
148
  """Decode bytes and return message data and offset."""
133
149
  data = ensure_device_data(data)
134
- thermostat_count = data.get(ATTR_THERMOSTAT_COUNT, 0)
135
- if thermostat_count == 0:
136
- return (
137
- ensure_device_data(
138
- data,
139
- {ATTR_THERMOSTAT_PARAMETERS: None, ATTR_THERMOSTAT_PROFILE: None},
140
- ),
141
- offset,
142
- )
143
-
144
- first_index = message[offset + 1]
145
- last_index = message[offset + 2]
150
+ thermostats = data.get(ATTR_THERMOSTAT_COUNT, 0)
151
+ if thermostats == 0:
152
+ return _empty_response(offset, data)
153
+
154
+ start = message[offset + 1]
155
+ end = message[offset + 2]
146
156
  thermostat_profile = util.unpack_parameter(message, offset + 3)
147
- parameter_count_per_thermostat = (first_index + last_index) // thermostat_count
148
- offset += 6
157
+ self._offset = offset + 6
149
158
  thermostat_parameters: dict[int, list[tuple[int, ParameterDataType]]] = {}
150
- for index in range(thermostat_count):
151
- parameters, offset = _decode_thermostat_parameters(
152
- message,
153
- offset,
154
- range(first_index, parameter_count_per_thermostat),
155
- )
156
- if parameters:
157
- thermostat_parameters[index] = parameters
158
-
159
- if not thermostat_parameters:
160
- # No thermostat parameters detected.
161
- return (
162
- ensure_device_data(
163
- data,
164
- {ATTR_THERMOSTAT_PARAMETERS: None, ATTR_THERMOSTAT_PROFILE: None},
165
- ),
166
- offset,
167
- )
159
+ for thermostat in range(thermostats):
160
+ if parameters := list(
161
+ self._thermostat_parameter(message, thermostats, start, end)
162
+ ):
163
+ thermostat_parameters[thermostat] = parameters
168
164
 
169
165
  return (
170
166
  ensure_device_data(
171
167
  data,
172
168
  {
173
169
  ATTR_THERMOSTAT_PROFILE: thermostat_profile,
174
- ATTR_THERMOSTAT_PARAMETERS: thermostat_parameters,
170
+ ATTR_THERMOSTAT_PARAMETERS: None
171
+ if not thermostat_parameters
172
+ else thermostat_parameters,
175
173
  },
176
174
  ),
177
- offset,
175
+ self._offset,
178
176
  )
@@ -7,6 +7,7 @@ import pytest
7
7
 
8
8
  from pyplumio.const import (
9
9
  ATTR_DEVICE_INDEX,
10
+ ATTR_FRAME_ERRORS,
10
11
  ATTR_INDEX,
11
12
  ATTR_LOADED,
12
13
  ATTR_OFFSET,
@@ -117,6 +118,7 @@ async def test_async_setup() -> None:
117
118
  await ecomax.wait_until_done()
118
119
 
119
120
  assert await ecomax.get(ATTR_LOADED)
121
+ assert not ecomax.data[ATTR_FRAME_ERRORS]
120
122
  assert mock_request.await_count == len(DATA_FRAME_TYPES)
121
123
 
122
124
 
@@ -126,13 +128,22 @@ async def test_async_setup_error(caplog) -> None:
126
128
 
127
129
  with patch("pyplumio.devices.ecomax.EcoMAX.wait_for"), patch(
128
130
  "pyplumio.devices.ecomax.EcoMAX.request",
129
- side_effect=(ValueError("test"), True, True, True, True, True, True, True),
131
+ side_effect=(
132
+ ValueError("test", FrameType.REQUEST_ALERTS),
133
+ True,
134
+ True,
135
+ True,
136
+ True,
137
+ True,
138
+ True,
139
+ True,
140
+ ),
130
141
  ) as mock_request:
131
142
  await ecomax.async_setup()
132
143
  await ecomax.wait_until_done()
133
144
 
134
- assert "Request failed" in caplog.text
135
- assert not await ecomax.get(ATTR_LOADED)
145
+ assert await ecomax.get(ATTR_LOADED)
146
+ assert ecomax.data[ATTR_FRAME_ERRORS][0] == FrameType.REQUEST_ALERTS
136
147
  assert mock_request.await_count == len(DATA_FRAME_TYPES)
137
148
 
138
149
 
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
6
6
  import pytest
7
7
 
8
8
  from pyplumio.filters import aggregate, debounce, delta, on_change, throttle
9
+ from pyplumio.helpers.parameter import Parameter
9
10
  from pyplumio.structures.alerts import Alert
10
11
 
11
12
 
@@ -31,6 +32,63 @@ async def test_on_change() -> None:
31
32
  _ = wrapped_callback == "you shall not pass"
32
33
 
33
34
 
35
+ async def test_on_change_parameter() -> None:
36
+ """Test on change filter with parameters."""
37
+ test_callback = AsyncMock()
38
+ test_parameter = AsyncMock(spec=Parameter)
39
+ test_parameter.value = 0
40
+ test_parameter.min_value = 0
41
+ test_parameter.max_value = 1
42
+ test_parameter.is_changed = False
43
+ wrapped_callback = on_change(test_callback)
44
+ await wrapped_callback(test_parameter)
45
+ test_callback.assert_awaited_once_with(test_parameter)
46
+ test_callback.reset_mock()
47
+
48
+ # Check that callback is not awaited with no change.
49
+ await wrapped_callback(test_parameter)
50
+ test_callback.assert_not_awaited()
51
+
52
+ # Check that callback is awaited on local value change.
53
+ test_parameter = AsyncMock(spec=Parameter)
54
+ test_parameter.value = 1
55
+ test_parameter.min_value = 0
56
+ test_parameter.max_value = 1
57
+ test_parameter.is_changed = True
58
+ await wrapped_callback(test_parameter)
59
+ test_callback.assert_awaited_once_with(test_parameter)
60
+ test_callback.reset_mock()
61
+
62
+ # Check that callback is awaited on value change.
63
+ test_parameter = AsyncMock(spec=Parameter)
64
+ test_parameter.value = 1
65
+ test_parameter.min_value = 0
66
+ test_parameter.max_value = 1
67
+ test_parameter.is_changed = False
68
+ await wrapped_callback(test_parameter)
69
+ test_callback.assert_awaited_once_with(test_parameter)
70
+ test_callback.reset_mock()
71
+
72
+ # Check that callback is awaited on min value change.
73
+ test_parameter = AsyncMock(spec=Parameter)
74
+ test_parameter.value = 1
75
+ test_parameter.min_value = 1
76
+ test_parameter.max_value = 1
77
+ test_parameter.is_changed = False
78
+ await wrapped_callback(test_parameter)
79
+ test_callback.assert_awaited_once_with(test_parameter)
80
+ test_callback.reset_mock()
81
+
82
+ # Check that callback is awaited on max value change.
83
+ test_parameter = AsyncMock(spec=Parameter)
84
+ test_parameter.value = 1
85
+ test_parameter.min_value = 1
86
+ test_parameter.max_value = 2
87
+ test_parameter.is_changed = False
88
+ await wrapped_callback(test_parameter)
89
+ test_callback.assert_awaited_once_with(test_parameter)
90
+
91
+
34
92
  async def test_debounce() -> None:
35
93
  """Test debounce filter."""
36
94
  test_callback = AsyncMock()
@@ -1,4 +0,0 @@
1
- # file generated by setuptools_scm
2
- # don't change, don't track in version control
3
- __version__ = version = '0.4.0.post1'
4
- __version_tuple__ = version_tuple = (0, 4, 0)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes