PyPlumIO 0.5.19__tar.gz → 0.5.21__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 (143) hide show
  1. {pyplumio-0.5.19 → pyplumio-0.5.21}/.pre-commit-config.yaml +2 -2
  2. {pyplumio-0.5.19 → pyplumio-0.5.21}/PKG-INFO +5 -5
  3. {pyplumio-0.5.19 → pyplumio-0.5.21}/PyPlumIO.egg-info/PKG-INFO +5 -5
  4. {pyplumio-0.5.19 → pyplumio-0.5.21}/PyPlumIO.egg-info/requires.txt +4 -4
  5. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/_version.py +2 -2
  6. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/connection.py +1 -4
  7. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/devices/__init__.py +6 -3
  8. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/devices/ecomax.py +33 -23
  9. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/devices/mixer.py +3 -2
  10. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/devices/thermostat.py +3 -2
  11. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/helpers/event_manager.py +9 -14
  12. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/helpers/task_manager.py +3 -4
  13. pyplumio-0.5.21/pyplumio/helpers/timeout.py +33 -0
  14. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/protocol.py +35 -48
  15. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/stream.py +27 -28
  16. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/ecomax_parameters.py +0 -2
  17. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/network_info.py +3 -3
  18. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyproject.toml +4 -4
  19. {pyplumio-0.5.19 → pyplumio-0.5.21}/requirements_test.txt +4 -4
  20. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/frames/test_messages.py +1 -1
  21. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/frames/test_responses.py +1 -1
  22. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/helpers/test_event_manager.py +25 -14
  23. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/helpers/test_timeout.py +10 -12
  24. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/test_protocol.py +28 -21
  25. pyplumio-0.5.19/pyplumio/helpers/timeout.py +0 -47
  26. {pyplumio-0.5.19 → pyplumio-0.5.21}/.gitattributes +0 -0
  27. {pyplumio-0.5.19 → pyplumio-0.5.21}/.github/CODE_OF_CONDUCT.md +0 -0
  28. {pyplumio-0.5.19 → pyplumio-0.5.21}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  29. {pyplumio-0.5.19 → pyplumio-0.5.21}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  30. {pyplumio-0.5.19 → pyplumio-0.5.21}/.github/dependabot.yml +0 -0
  31. {pyplumio-0.5.19 → pyplumio-0.5.21}/.github/workflows/ci.yml +0 -0
  32. {pyplumio-0.5.19 → pyplumio-0.5.21}/.github/workflows/codeql-analysis.yml +0 -0
  33. {pyplumio-0.5.19 → pyplumio-0.5.21}/.github/workflows/deploy.yml +0 -0
  34. {pyplumio-0.5.19 → pyplumio-0.5.21}/.github/workflows/documentation.yml +0 -0
  35. {pyplumio-0.5.19 → pyplumio-0.5.21}/.gitignore +0 -0
  36. {pyplumio-0.5.19 → pyplumio-0.5.21}/.vscode/settings.json +0 -0
  37. {pyplumio-0.5.19 → pyplumio-0.5.21}/LICENSE +0 -0
  38. {pyplumio-0.5.19 → pyplumio-0.5.21}/MANIFEST.in +0 -0
  39. {pyplumio-0.5.19 → pyplumio-0.5.21}/PyPlumIO.egg-info/SOURCES.txt +0 -0
  40. {pyplumio-0.5.19 → pyplumio-0.5.21}/PyPlumIO.egg-info/dependency_links.txt +0 -0
  41. {pyplumio-0.5.19 → pyplumio-0.5.21}/PyPlumIO.egg-info/top_level.txt +0 -0
  42. {pyplumio-0.5.19 → pyplumio-0.5.21}/README.md +0 -0
  43. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/Makefile +0 -0
  44. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/make.bat +0 -0
  45. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/source/callbacks.rst +0 -0
  46. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/source/conf.py +0 -0
  47. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/source/connecting.rst +0 -0
  48. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/source/index.rst +0 -0
  49. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/source/mixers_thermostats.rst +0 -0
  50. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/source/protocol.rst +0 -0
  51. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/source/reading.rst +0 -0
  52. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/source/schedules.rst +0 -0
  53. {pyplumio-0.5.19 → pyplumio-0.5.21}/docs/source/writing.rst +0 -0
  54. {pyplumio-0.5.19 → pyplumio-0.5.21}/images/ecomax.png +0 -0
  55. {pyplumio-0.5.19 → pyplumio-0.5.21}/images/rs485.png +0 -0
  56. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/__init__.py +0 -0
  57. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/__main__.py +0 -0
  58. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/const.py +0 -0
  59. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/devices/ecoster.py +0 -0
  60. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/exceptions.py +0 -0
  61. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/filters.py +0 -0
  62. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/frames/__init__.py +0 -0
  63. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/frames/messages.py +0 -0
  64. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/frames/requests.py +0 -0
  65. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/frames/responses.py +0 -0
  66. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/helpers/__init__.py +0 -0
  67. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/helpers/data_types.py +0 -0
  68. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/helpers/factory.py +0 -0
  69. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/helpers/parameter.py +0 -0
  70. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/helpers/schedule.py +0 -0
  71. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/helpers/typing.py +0 -0
  72. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/helpers/uid.py +0 -0
  73. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/py.typed +0 -0
  74. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/__init__.py +0 -0
  75. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/alerts.py +0 -0
  76. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/boiler_load.py +0 -0
  77. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/boiler_power.py +0 -0
  78. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/fan_power.py +0 -0
  79. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/frame_versions.py +0 -0
  80. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/fuel_consumption.py +0 -0
  81. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/fuel_level.py +0 -0
  82. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/lambda_sensor.py +0 -0
  83. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/mixer_parameters.py +0 -0
  84. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/mixer_sensors.py +0 -0
  85. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/modules.py +0 -0
  86. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/output_flags.py +0 -0
  87. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/outputs.py +0 -0
  88. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/pending_alerts.py +0 -0
  89. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/product_info.py +0 -0
  90. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/program_version.py +0 -0
  91. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/regulator_data.py +0 -0
  92. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/regulator_data_schema.py +0 -0
  93. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/schedules.py +0 -0
  94. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/statuses.py +0 -0
  95. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/temperatures.py +0 -0
  96. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/thermostat_parameters.py +0 -0
  97. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/structures/thermostat_sensors.py +0 -0
  98. {pyplumio-0.5.19 → pyplumio-0.5.21}/pyplumio/utils.py +0 -0
  99. {pyplumio-0.5.19 → pyplumio-0.5.21}/requirements.txt +0 -0
  100. {pyplumio-0.5.19 → pyplumio-0.5.21}/requirements_docs.txt +0 -0
  101. {pyplumio-0.5.19 → pyplumio-0.5.21}/setup.cfg +0 -0
  102. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/__init__.py +0 -0
  103. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/conftest.py +0 -0
  104. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/frames/test_init.py +0 -0
  105. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/frames/test_requests.py +0 -0
  106. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/helpers/__init__.py +0 -0
  107. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/helpers/test_data_types.py +0 -0
  108. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/helpers/test_factory.py +0 -0
  109. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/helpers/test_parameter.py +0 -0
  110. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/helpers/test_schedule.py +0 -0
  111. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/helpers/test_task_manager.py +0 -0
  112. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/helpers/test_uid.py +0 -0
  113. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/ruff.toml +0 -0
  114. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/test_connection.py +0 -0
  115. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/test_devices.py +0 -0
  116. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/test_filters.py +0 -0
  117. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/test_init.py +0 -0
  118. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/test_main.py +0 -0
  119. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/test_stream.py +0 -0
  120. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/test_utils.py +0 -0
  121. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/messages/regulator_data.json +0 -0
  122. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/messages/sensor_data.json +0 -0
  123. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/requests/alerts.json +0 -0
  124. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/requests/ecomax_control.json +0 -0
  125. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/requests/ecomax_parameters.json +0 -0
  126. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/requests/mixer_parameters.json +0 -0
  127. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/requests/set_ecomax_parameter.json +0 -0
  128. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/requests/set_mixer_parameter.json +0 -0
  129. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/requests/set_schedule.json +0 -0
  130. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/requests/set_thermostat_parameter.json +0 -0
  131. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/requests/thermostat_parameters.json +0 -0
  132. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/alerts.json +0 -0
  133. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/device_available.json +0 -0
  134. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/ecomax_parameters.json +0 -0
  135. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/mixer_parameters.json +0 -0
  136. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/password.json +0 -0
  137. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/program_version.json +0 -0
  138. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/regulator_data_schema.json +0 -0
  139. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/schedules.json +0 -0
  140. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/thermostat_parameters.json +0 -0
  141. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/responses/uid.json +0 -0
  142. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/unknown/unknown_ecomax_parameter.json +0 -0
  143. {pyplumio-0.5.19 → pyplumio-0.5.21}/tests/testdata/unknown/unknown_mixer_parameter.json +0 -0
@@ -2,13 +2,13 @@
2
2
  # See https://pre-commit.com/hooks.html for more hooks
3
3
  repos:
4
4
  - repo: https://github.com/astral-sh/ruff-pre-commit
5
- rev: v0.4.2
5
+ rev: v0.4.7
6
6
  hooks:
7
7
  - id: ruff
8
8
  args:
9
9
  - --fix
10
10
  - repo: https://github.com/codespell-project/codespell
11
- rev: v2.2.6
11
+ rev: v2.3.0
12
12
  hooks:
13
13
  - id: codespell
14
14
  - repo: https://github.com/pre-commit/mirrors-mypy
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyPlumIO
3
- Version: 0.5.19
3
+ Version: 0.5.21
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,11 +27,11 @@ Provides-Extra: test
27
27
  Requires-Dist: codespell==2.3.0; extra == "test"
28
28
  Requires-Dist: coverage==7.5.3; extra == "test"
29
29
  Requires-Dist: mypy==1.10.0; extra == "test"
30
- Requires-Dist: pyserial-asyncio-fast==0.11; extra == "test"
31
- Requires-Dist: pytest==8.2.1; extra == "test"
30
+ Requires-Dist: pyserial-asyncio-fast==0.12; extra == "test"
31
+ Requires-Dist: pytest==8.2.2; extra == "test"
32
32
  Requires-Dist: pytest-asyncio==0.23.7; extra == "test"
33
- Requires-Dist: ruff==0.4.5; extra == "test"
34
- Requires-Dist: tox==4.15.0; extra == "test"
33
+ Requires-Dist: ruff==0.4.9; extra == "test"
34
+ Requires-Dist: tox==4.15.1; extra == "test"
35
35
  Requires-Dist: types-pyserial==3.5.0.20240527; extra == "test"
36
36
  Provides-Extra: docs
37
37
  Requires-Dist: sphinx==7.3.7; extra == "docs"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyPlumIO
3
- Version: 0.5.19
3
+ Version: 0.5.21
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,11 +27,11 @@ Provides-Extra: test
27
27
  Requires-Dist: codespell==2.3.0; extra == "test"
28
28
  Requires-Dist: coverage==7.5.3; extra == "test"
29
29
  Requires-Dist: mypy==1.10.0; extra == "test"
30
- Requires-Dist: pyserial-asyncio-fast==0.11; extra == "test"
31
- Requires-Dist: pytest==8.2.1; extra == "test"
30
+ Requires-Dist: pyserial-asyncio-fast==0.12; extra == "test"
31
+ Requires-Dist: pytest==8.2.2; extra == "test"
32
32
  Requires-Dist: pytest-asyncio==0.23.7; extra == "test"
33
- Requires-Dist: ruff==0.4.5; extra == "test"
34
- Requires-Dist: tox==4.15.0; extra == "test"
33
+ Requires-Dist: ruff==0.4.9; extra == "test"
34
+ Requires-Dist: tox==4.15.1; extra == "test"
35
35
  Requires-Dist: types-pyserial==3.5.0.20240527; extra == "test"
36
36
  Provides-Extra: docs
37
37
  Requires-Dist: sphinx==7.3.7; extra == "docs"
@@ -14,9 +14,9 @@ readthedocs-sphinx-search==0.3.2
14
14
  codespell==2.3.0
15
15
  coverage==7.5.3
16
16
  mypy==1.10.0
17
- pyserial-asyncio-fast==0.11
18
- pytest==8.2.1
17
+ pyserial-asyncio-fast==0.12
18
+ pytest==8.2.2
19
19
  pytest-asyncio==0.23.7
20
- ruff==0.4.5
21
- tox==4.15.0
20
+ ruff==0.4.9
21
+ tox==4.15.1
22
22
  types-pyserial==3.5.0.20240527
@@ -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.19'
16
- __version_tuple__ = version_tuple = (0, 5, 19)
15
+ __version__ = version = '0.5.21'
16
+ __version_tuple__ = version_tuple = (0, 5, 21)
@@ -72,10 +72,7 @@ class Connection(ABC, TaskManager):
72
72
  async def _connect(self) -> None:
73
73
  """Establish connection and initialize the protocol object."""
74
74
  try:
75
- reader, writer = cast(
76
- tuple[asyncio.StreamReader, asyncio.StreamWriter],
77
- await self._open_connection(),
78
- )
75
+ reader, writer = await self._open_connection()
79
76
  self.protocol.connection_established(reader, writer)
80
77
  except (OSError, SerialException, asyncio.TimeoutError) as err:
81
78
  raise ConnectionFailedError from err
@@ -111,6 +111,11 @@ class Device(ABC, EventManager):
111
111
  """
112
112
  self.create_task(self.set(name, value, timeout, retries))
113
113
 
114
+ async def shutdown(self) -> None:
115
+ """Cancel device tasks."""
116
+ self.cancel_tasks()
117
+ await self.wait_until_done()
118
+
114
119
 
115
120
  class AddressableDevice(Device, ABC):
116
121
  """Represents an addressable device."""
@@ -139,9 +144,7 @@ class AddressableDevice(Device, ABC):
139
144
  """Set up addressable device."""
140
145
  results = await asyncio.gather(
141
146
  *{
142
- self.create_task(
143
- self.request(description.provides, description.frame_type)
144
- )
147
+ self.request(description.provides, description.frame_type)
145
148
  for description in self._setup_frames
146
149
  },
147
150
  return_exceptions=True,
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  from collections.abc import Generator, Iterable, Sequence
7
- from contextlib import suppress
8
7
  import logging
9
8
  import time
10
9
  from typing import Any, ClassVar, Final
@@ -18,7 +17,7 @@ from pyplumio.const import (
18
17
  DeviceType,
19
18
  FrameType,
20
19
  )
21
- from pyplumio.devices import AddressableDevice
20
+ from pyplumio.devices import AddressableDevice, SubDevice
22
21
  from pyplumio.devices.mixer import Mixer
23
22
  from pyplumio.devices.thermostat import Thermostat
24
23
  from pyplumio.filters import on_change
@@ -263,8 +262,12 @@ class EcoMAX(AddressableDevice):
263
262
  if not parameters:
264
263
  return False
265
264
 
266
- for mixer in self._mixers(parameters.keys()):
267
- await mixer.dispatch(ATTR_MIXER_PARAMETERS, parameters[mixer.index])
265
+ await asyncio.gather(
266
+ *[
267
+ mixer.dispatch(ATTR_MIXER_PARAMETERS, parameters[mixer.index])
268
+ for mixer in self._mixers(indexes=parameters.keys())
269
+ ]
270
+ )
268
271
 
269
272
  return True
270
273
 
@@ -278,14 +281,18 @@ class EcoMAX(AddressableDevice):
278
281
  if not sensors:
279
282
  return False
280
283
 
281
- for mixer in self._mixers(sensors.keys()):
282
- await mixer.dispatch(ATTR_MIXER_SENSORS, sensors[mixer.index])
284
+ await asyncio.gather(
285
+ *[
286
+ mixer.dispatch(ATTR_MIXER_SENSORS, sensors[mixer.index])
287
+ for mixer in self._mixers(indexes=sensors.keys())
288
+ ]
289
+ )
283
290
 
284
291
  return True
285
292
 
286
293
  async def _add_schedules(
287
294
  self, schedules: list[tuple[int, list[list[bool]]]]
288
- ) -> dict[str, Any]:
295
+ ) -> dict[str, Schedule]:
289
296
  """Add schedules to the dataset."""
290
297
  return {
291
298
  SCHEDULES[index]: Schedule(
@@ -333,8 +340,9 @@ class EcoMAX(AddressableDevice):
333
340
  For each sensor dispatch an event with the sensor's name and
334
341
  value.
335
342
  """
336
- for name, value in sensors.items():
337
- await self.dispatch(name, value)
343
+ await asyncio.gather(
344
+ *[self.dispatch(name, value) for name, value in sensors.items()]
345
+ )
338
346
 
339
347
  return True
340
348
 
@@ -371,10 +379,14 @@ class EcoMAX(AddressableDevice):
371
379
  if not parameters:
372
380
  return False
373
381
 
374
- for thermostat in self._thermostats(parameters.keys()):
375
- await thermostat.dispatch(
376
- ATTR_THERMOSTAT_PARAMETERS, parameters[thermostat.index]
377
- )
382
+ await asyncio.gather(
383
+ *[
384
+ thermostat.dispatch(
385
+ ATTR_THERMOSTAT_PARAMETERS, parameters[thermostat.index]
386
+ )
387
+ for thermostat in self._thermostats(indexes=parameters.keys())
388
+ ]
389
+ )
378
390
 
379
391
  return True
380
392
 
@@ -401,10 +413,12 @@ class EcoMAX(AddressableDevice):
401
413
  if not sensors:
402
414
  return False
403
415
 
404
- for thermostat in self._thermostats(sensors.keys()):
405
- await thermostat.dispatch(
406
- ATTR_THERMOSTAT_SENSORS, sensors[thermostat.index]
407
- )
416
+ await asyncio.gather(
417
+ *[
418
+ thermostat.dispatch(ATTR_THERMOSTAT_SENSORS, sensors[thermostat.index])
419
+ for thermostat in self._thermostats(indexes=sensors.keys())
420
+ ]
421
+ )
408
422
 
409
423
  return True
410
424
 
@@ -438,10 +452,6 @@ class EcoMAX(AddressableDevice):
438
452
  """Shutdown tasks for the ecoMAX controller and sub-devices."""
439
453
  mixers = self.get_nowait(ATTR_MIXERS, {})
440
454
  thermostats = self.get_nowait(ATTR_THERMOSTATS, {})
441
- for subdevice in (mixers | thermostats).values():
442
- await subdevice.shutdown()
443
-
444
- with suppress(AttributeError):
445
- await self.regdata.shutdown()
446
-
455
+ devices: Iterable[SubDevice] = (mixers | thermostats).values()
456
+ await asyncio.gather(*[device.shutdown() for device in devices])
447
457
  await super().shutdown()
@@ -37,8 +37,9 @@ class Mixer(SubDevice):
37
37
  For each sensor dispatch an event with the
38
38
  sensor's name and value.
39
39
  """
40
- for name, value in sensors.items():
41
- await self.dispatch(name, value)
40
+ await asyncio.gather(
41
+ *[self.dispatch(name, value) for name, value in sensors.items()]
42
+ )
42
43
 
43
44
  return True
44
45
 
@@ -33,8 +33,9 @@ class Thermostat(SubDevice):
33
33
  For each sensor dispatch an event with the
34
34
  sensor's name and value.
35
35
  """
36
- for name, value in sensors.items():
37
- await self.dispatch(name, value)
36
+ await asyncio.gather(
37
+ *[self.dispatch(name, value) for name, value in sensors.items()]
38
+ )
38
39
 
39
40
  return True
40
41
 
@@ -138,16 +138,16 @@ class EventManager(TaskManager):
138
138
  """Call a registered callbacks and dispatch the event without waiting."""
139
139
  self.create_task(self.dispatch(name, value))
140
140
 
141
- def load(self, data: dict[str, Any]) -> None:
142
- """Load an event data."""
143
-
144
- async def _dispatch_events(data: dict[str, Any]) -> None:
145
- """Dispatch events for a loaded data."""
146
- for key, value in data.items():
147
- await self.dispatch(key, value)
148
-
141
+ async def load(self, data: dict[str, Any]) -> None:
142
+ """Load event data."""
149
143
  self.data = data
150
- self.create_task(_dispatch_events(data))
144
+ await asyncio.gather(
145
+ *[self.dispatch(name, value) for name, value in data.items()]
146
+ )
147
+
148
+ def load_nowait(self, data: dict[str, Any]) -> None:
149
+ """Load event data without waiting."""
150
+ self.create_task(self.load(data))
151
151
 
152
152
  def create_event(self, name: str) -> asyncio.Event:
153
153
  """Create an event."""
@@ -165,11 +165,6 @@ class EventManager(TaskManager):
165
165
  if not event.is_set():
166
166
  event.set()
167
167
 
168
- async def shutdown(self) -> None:
169
- """Cancel scheduled tasks."""
170
- self.cancel_tasks()
171
- await self.wait_until_done()
172
-
173
168
  @property
174
169
  def events(self) -> dict[str, asyncio.Event]:
175
170
  """Return the events."""
@@ -18,15 +18,14 @@ class TaskManager:
18
18
 
19
19
  def create_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task:
20
20
  """Create asyncio task and store a reference for it."""
21
- task: asyncio.Task = asyncio.create_task(coro)
21
+ task = asyncio.create_task(coro)
22
22
  self._tasks.add(task)
23
23
  task.add_done_callback(self._tasks.discard)
24
24
  return task
25
25
 
26
- def cancel_tasks(self) -> None:
26
+ def cancel_tasks(self) -> bool:
27
27
  """Cancel all tasks."""
28
- for task in self._tasks:
29
- task.cancel()
28
+ return all(task.cancel() for task in self._tasks)
30
29
 
31
30
  async def wait_until_done(self, return_exceptions: bool = True) -> None:
32
31
  """Wait for all tasks to complete."""
@@ -0,0 +1,33 @@
1
+ """Contains a timeout decorator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable, Coroutine
7
+ from functools import wraps
8
+ import logging
9
+ from typing import Any, TypeVar
10
+
11
+ from typing_extensions import ParamSpec
12
+
13
+ T = TypeVar("T")
14
+ P = ParamSpec("P")
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ def timeout(
20
+ seconds: int,
21
+ ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T]]]:
22
+ """Decorate a timeout for the awaitable."""
23
+
24
+ def decorator(
25
+ func: Callable[P, Awaitable[T]],
26
+ ) -> Callable[P, Coroutine[Any, Any, T]]:
27
+ @wraps(func)
28
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
29
+ return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds)
30
+
31
+ return wrapper
32
+
33
+ return decorator
@@ -5,17 +5,12 @@ from __future__ import annotations
5
5
  from abc import ABC, abstractmethod
6
6
  import asyncio
7
7
  from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass
8
9
  import logging
9
- from typing import NamedTuple, cast
10
10
 
11
11
  from pyplumio.const import ATTR_CONNECTED, DeviceType
12
12
  from pyplumio.devices import AddressableDevice
13
- from pyplumio.exceptions import (
14
- FrameDataError,
15
- FrameError,
16
- ReadError,
17
- UnknownDeviceError,
18
- )
13
+ from pyplumio.exceptions import FrameError, ReadError, UnknownDeviceError
19
14
  from pyplumio.frames import Frame
20
15
  from pyplumio.frames.requests import StartMasterRequest
21
16
  from pyplumio.helpers.event_manager import EventManager
@@ -101,12 +96,19 @@ class DummyProtocol(Protocol):
101
96
  await self.close_writer()
102
97
 
103
98
 
104
- class Queues(NamedTuple):
99
+ @dataclass
100
+ class Queues:
105
101
  """Represents asyncio queues."""
106
102
 
103
+ __slots__ = ("read", "write")
104
+
107
105
  read: asyncio.Queue
108
106
  write: asyncio.Queue
109
107
 
108
+ async def join(self) -> None:
109
+ """Wait for queues to finish."""
110
+ await asyncio.gather(self.read.join(), self.write.join())
111
+
110
112
 
111
113
  class AsyncProtocol(Protocol, EventManager):
112
114
  """Represents an async protocol.
@@ -117,11 +119,11 @@ class AsyncProtocol(Protocol, EventManager):
117
119
  The frame producer tries to read frames from the write queue.
118
120
  If any is available, it sends them to the device via frame writer.
119
121
 
120
- It then reads stream via frame reader, creates device entry and puts
121
- received frame into the read queue.
122
+ It then reads stream via frame reader and puts received frame
123
+ into the read queue.
122
124
 
123
- Frame consumers read frames from the read queue and send frame to
124
- their respective device class for further processing.
125
+ Frame consumers read frames from the read queue, create device
126
+ entry, if needed, and send frame to the entry for the processing.
125
127
  """
126
128
 
127
129
  consumers_count: int
@@ -139,18 +141,10 @@ class AsyncProtocol(Protocol, EventManager):
139
141
  super().__init__()
140
142
  self.consumers_count = consumers_count
141
143
  self._network = NetworkInfo(
142
- eth=(
143
- EthernetParameters(status=False)
144
- if ethernet_parameters is None
145
- else ethernet_parameters
146
- ),
147
- wlan=(
148
- WirelessParameters(status=False)
149
- if wireless_parameters is None
150
- else wireless_parameters
151
- ),
144
+ eth=ethernet_parameters or EthernetParameters(status=False),
145
+ wlan=wireless_parameters or WirelessParameters(status=False),
152
146
  )
153
- self._queues = Queues(asyncio.Queue(), asyncio.Queue())
147
+ self._queues = Queues(read=asyncio.Queue(), write=asyncio.Queue())
154
148
 
155
149
  def connection_established(
156
150
  self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
@@ -159,7 +153,9 @@ class AsyncProtocol(Protocol, EventManager):
159
153
  self.reader = FrameReader(reader)
160
154
  self.writer = FrameWriter(writer)
161
155
  self._queues.write.put_nowait(StartMasterRequest(recipient=DeviceType.ECOMAX))
162
- self.create_task(self.frame_producer(self._queues))
156
+ self.create_task(
157
+ self.frame_producer(self._queues, reader=self.reader, writer=self.writer)
158
+ )
163
159
  for _ in range(self.consumers_count):
164
160
  self.create_task(self.frame_consumer(self._queues.read))
165
161
 
@@ -174,52 +170,43 @@ class AsyncProtocol(Protocol, EventManager):
174
170
  return
175
171
 
176
172
  self.connected.clear()
177
- for device in self.data.values():
178
- # Notify devices about connection loss.
179
- await device.dispatch(ATTR_CONNECTED, False)
180
-
181
173
  await self.close_writer()
182
- for callback in self.on_connection_lost:
183
- await callback()
174
+ await asyncio.gather(
175
+ *[device.dispatch(ATTR_CONNECTED, False) for device in self.data.values()]
176
+ )
177
+ await asyncio.gather(*[callback() for callback in self.on_connection_lost])
184
178
 
185
179
  async def shutdown(self) -> None:
186
180
  """Shutdown protocol tasks."""
187
- await asyncio.gather(*[queue.join() for queue in self._queues])
188
- await super(Protocol, self).shutdown()
189
- for device in self.data.values():
190
- await device.shutdown()
191
-
181
+ await self._queues.join()
182
+ self.cancel_tasks()
183
+ await self.wait_until_done()
184
+ await asyncio.gather(*[device.shutdown() for device in self.data.values()])
192
185
  if self.connected.is_set():
193
186
  self.connected.clear()
194
187
  await self.close_writer()
195
188
 
196
- async def frame_producer(self, queues: Queues) -> None:
189
+ async def frame_producer(
190
+ self, queues: Queues, reader: FrameReader, writer: FrameWriter
191
+ ) -> None:
197
192
  """Handle frame reads and writes."""
198
193
  await self.connected.wait()
199
- reader = cast(FrameReader, self.reader)
200
- writer = cast(FrameWriter, self.writer)
201
194
  while self.connected.is_set():
202
195
  try:
203
- if queues.write.qsize() > 0:
196
+ if not queues.write.empty():
204
197
  await writer.write(await queues.write.get())
205
198
  queues.write.task_done()
206
199
 
207
200
  if (response := await reader.read()) is not None:
208
201
  queues.read.put_nowait(response)
209
202
 
210
- except FrameDataError as e:
211
- _LOGGER.warning("Incorrect payload: %s", e)
212
- except ReadError as e:
213
- _LOGGER.debug("Read error: %s", e)
214
- except UnknownDeviceError as e:
215
- _LOGGER.debug("Unknown device: %s", e)
216
- except FrameError as e:
203
+ except (ReadError, UnknownDeviceError, FrameError) as e:
217
204
  _LOGGER.debug("Can't process received frame: %s", e)
218
205
  except (OSError, asyncio.TimeoutError):
219
206
  self.create_task(self.connection_lost())
220
207
  break
221
- except Exception as e: # pylint: disable=broad-except
222
- _LOGGER.exception(e)
208
+ except Exception:
209
+ _LOGGER.exception("Unexpected exception")
223
210
 
224
211
  async def frame_consumer(self, queue: asyncio.Queue) -> None:
225
212
  """Handle frame processing."""
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  from asyncio import IncompleteReadError, StreamReader, StreamWriter
7
7
  import logging
8
- from typing import Final
8
+ from typing import Final, NamedTuple
9
9
 
10
10
  from pyplumio.const import DeviceType
11
11
  from pyplumio.devices import is_known_device_type
@@ -54,6 +54,18 @@ class FrameWriter:
54
54
  await self._writer.wait_closed()
55
55
 
56
56
 
57
+ class Header(NamedTuple):
58
+ """Represents a frame header."""
59
+
60
+ bytes: bytes
61
+ frame_start: int
62
+ frame_length: int
63
+ recipient: int
64
+ sender: int
65
+ econet_type: int
66
+ econet_version: int
67
+
68
+
57
69
  class FrameReader:
58
70
  """Represents a frame reader."""
59
71
 
@@ -65,11 +77,11 @@ class FrameReader:
65
77
  """Initialize a new frame reader."""
66
78
  self._reader = reader
67
79
 
68
- async def _read_header(self) -> tuple[bytes, int, int, int, int, int]:
80
+ async def _read_header(self) -> Header:
69
81
  """Locate and read a frame header.
70
82
 
71
83
  Raise pyplumio.ReadError if header size is too small and
72
- OSError on broken connection.
84
+ OSError if serial connection is broken.
73
85
  """
74
86
  while buffer := await self._reader.read(1):
75
87
  if FRAME_START not in buffer:
@@ -79,23 +91,7 @@ class FrameReader:
79
91
  if len(buffer) < struct_header.size:
80
92
  raise ReadError(f"Header can't be less than {struct_header.size} bytes")
81
93
 
82
- [
83
- _,
84
- length,
85
- recipient,
86
- sender,
87
- econet_type,
88
- econet_version,
89
- ] = struct_header.unpack_from(buffer)
90
-
91
- return (
92
- buffer,
93
- length,
94
- recipient,
95
- sender,
96
- econet_type,
97
- econet_version,
98
- )
94
+ return Header(buffer, *struct_header.unpack_from(buffer))
99
95
 
100
96
  raise OSError("Serial connection broken")
101
97
 
@@ -108,8 +104,9 @@ class FrameReader:
108
104
  checksum.
109
105
  """
110
106
  (
111
- header,
112
- length,
107
+ header_bytes,
108
+ _,
109
+ frame_length,
113
110
  recipient,
114
111
  sender,
115
112
  econet_type,
@@ -122,19 +119,21 @@ class FrameReader:
122
119
  if not is_known_device_type(sender):
123
120
  raise UnknownDeviceError(f"Unknown sender type ({sender})")
124
121
 
125
- if length > MAX_FRAME_LENGTH or length < MIN_FRAME_LENGTH:
126
- raise ReadError(f"Unexpected frame length ({length})")
122
+ if frame_length > MAX_FRAME_LENGTH or frame_length < MIN_FRAME_LENGTH:
123
+ raise ReadError(f"Unexpected frame length ({frame_length})")
127
124
 
128
125
  try:
129
- payload = await self._reader.readexactly(length - struct_header.size)
126
+ payload = await self._reader.readexactly(frame_length - struct_header.size)
130
127
  except IncompleteReadError as e:
131
128
  raise ReadError(
132
129
  "Got an incomplete frame while trying to read "
133
- + f"'{length - struct_header.size}' bytes"
130
+ + f"'{frame_length - struct_header.size}' bytes"
134
131
  ) from e
135
132
 
136
- if payload[-2] != bcc(header + payload[:-2]):
137
- raise ChecksumError(f"Incorrect frame checksum ({payload[-2]})")
133
+ if (checksum := bcc(header_bytes + payload[:-2])) and checksum != payload[-2]:
134
+ raise ChecksumError(
135
+ f"Incorrect frame checksum ({checksum} != {payload[-2]})"
136
+ )
138
137
 
139
138
  frame = await Frame.create(
140
139
  frame_type=payload[0],
@@ -116,8 +116,6 @@ class EcomaxBinaryParameterDescription(
116
116
  ):
117
117
  """Represents an ecoMAX binary parameter description."""
118
118
 
119
- __slots__ = ()
120
-
121
119
 
122
120
  ECOMAX_PARAMETERS: dict[ProductType, tuple[EcomaxParameterDescription, ...]] = {
123
121
  ProductType.ECOMAX_P: (
@@ -18,7 +18,7 @@ DEFAULT_NETMASK: Final = "255.255.255.0"
18
18
  NETWORK_INFO_SIZE: Final = 25
19
19
 
20
20
 
21
- @dataclass
21
+ @dataclass(frozen=True)
22
22
  class EthernetParameters:
23
23
  """Represents an ethernet parameters."""
24
24
 
@@ -35,7 +35,7 @@ class EthernetParameters:
35
35
  status: bool = True
36
36
 
37
37
 
38
- @dataclass
38
+ @dataclass(frozen=True)
39
39
  class WirelessParameters(EthernetParameters):
40
40
  """Represents a wireless network parameters."""
41
41
 
@@ -50,7 +50,7 @@ class WirelessParameters(EthernetParameters):
50
50
  signal_quality: int = 100
51
51
 
52
52
 
53
- @dataclass
53
+ @dataclass(frozen=True)
54
54
  class NetworkInfo:
55
55
  """Represents a network parameters."""
56
56