PyPlumIO 0.5.19__tar.gz → 0.5.20__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.20}/.pre-commit-config.yaml +2 -2
  2. {pyplumio-0.5.19 → pyplumio-0.5.20}/PKG-INFO +2 -2
  3. {pyplumio-0.5.19 → pyplumio-0.5.20}/PyPlumIO.egg-info/PKG-INFO +2 -2
  4. {pyplumio-0.5.19 → pyplumio-0.5.20}/PyPlumIO.egg-info/requires.txt +1 -1
  5. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/_version.py +2 -2
  6. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/connection.py +1 -4
  7. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/devices/__init__.py +6 -3
  8. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/helpers/event_manager.py +0 -5
  9. pyplumio-0.5.20/pyplumio/helpers/timeout.py +33 -0
  10. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/protocol.py +31 -39
  11. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/stream.py +27 -28
  12. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyproject.toml +1 -1
  13. {pyplumio-0.5.19 → pyplumio-0.5.20}/requirements_test.txt +1 -1
  14. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/helpers/test_timeout.py +10 -12
  15. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/test_protocol.py +22 -21
  16. pyplumio-0.5.19/pyplumio/helpers/timeout.py +0 -47
  17. {pyplumio-0.5.19 → pyplumio-0.5.20}/.gitattributes +0 -0
  18. {pyplumio-0.5.19 → pyplumio-0.5.20}/.github/CODE_OF_CONDUCT.md +0 -0
  19. {pyplumio-0.5.19 → pyplumio-0.5.20}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  20. {pyplumio-0.5.19 → pyplumio-0.5.20}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  21. {pyplumio-0.5.19 → pyplumio-0.5.20}/.github/dependabot.yml +0 -0
  22. {pyplumio-0.5.19 → pyplumio-0.5.20}/.github/workflows/ci.yml +0 -0
  23. {pyplumio-0.5.19 → pyplumio-0.5.20}/.github/workflows/codeql-analysis.yml +0 -0
  24. {pyplumio-0.5.19 → pyplumio-0.5.20}/.github/workflows/deploy.yml +0 -0
  25. {pyplumio-0.5.19 → pyplumio-0.5.20}/.github/workflows/documentation.yml +0 -0
  26. {pyplumio-0.5.19 → pyplumio-0.5.20}/.gitignore +0 -0
  27. {pyplumio-0.5.19 → pyplumio-0.5.20}/.vscode/settings.json +0 -0
  28. {pyplumio-0.5.19 → pyplumio-0.5.20}/LICENSE +0 -0
  29. {pyplumio-0.5.19 → pyplumio-0.5.20}/MANIFEST.in +0 -0
  30. {pyplumio-0.5.19 → pyplumio-0.5.20}/PyPlumIO.egg-info/SOURCES.txt +0 -0
  31. {pyplumio-0.5.19 → pyplumio-0.5.20}/PyPlumIO.egg-info/dependency_links.txt +0 -0
  32. {pyplumio-0.5.19 → pyplumio-0.5.20}/PyPlumIO.egg-info/top_level.txt +0 -0
  33. {pyplumio-0.5.19 → pyplumio-0.5.20}/README.md +0 -0
  34. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/Makefile +0 -0
  35. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/make.bat +0 -0
  36. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/source/callbacks.rst +0 -0
  37. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/source/conf.py +0 -0
  38. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/source/connecting.rst +0 -0
  39. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/source/index.rst +0 -0
  40. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/source/mixers_thermostats.rst +0 -0
  41. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/source/protocol.rst +0 -0
  42. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/source/reading.rst +0 -0
  43. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/source/schedules.rst +0 -0
  44. {pyplumio-0.5.19 → pyplumio-0.5.20}/docs/source/writing.rst +0 -0
  45. {pyplumio-0.5.19 → pyplumio-0.5.20}/images/ecomax.png +0 -0
  46. {pyplumio-0.5.19 → pyplumio-0.5.20}/images/rs485.png +0 -0
  47. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/__init__.py +0 -0
  48. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/__main__.py +0 -0
  49. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/const.py +0 -0
  50. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/devices/ecomax.py +0 -0
  51. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/devices/ecoster.py +0 -0
  52. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/devices/mixer.py +0 -0
  53. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/devices/thermostat.py +0 -0
  54. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/exceptions.py +0 -0
  55. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/filters.py +0 -0
  56. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/frames/__init__.py +0 -0
  57. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/frames/messages.py +0 -0
  58. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/frames/requests.py +0 -0
  59. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/frames/responses.py +0 -0
  60. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/helpers/__init__.py +0 -0
  61. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/helpers/data_types.py +0 -0
  62. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/helpers/factory.py +0 -0
  63. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/helpers/parameter.py +0 -0
  64. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/helpers/schedule.py +0 -0
  65. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/helpers/task_manager.py +0 -0
  66. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/helpers/typing.py +0 -0
  67. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/helpers/uid.py +0 -0
  68. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/py.typed +0 -0
  69. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/__init__.py +0 -0
  70. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/alerts.py +0 -0
  71. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/boiler_load.py +0 -0
  72. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/boiler_power.py +0 -0
  73. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/ecomax_parameters.py +0 -0
  74. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/fan_power.py +0 -0
  75. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/frame_versions.py +0 -0
  76. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/fuel_consumption.py +0 -0
  77. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/fuel_level.py +0 -0
  78. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/lambda_sensor.py +0 -0
  79. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/mixer_parameters.py +0 -0
  80. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/mixer_sensors.py +0 -0
  81. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/modules.py +0 -0
  82. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/network_info.py +0 -0
  83. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/output_flags.py +0 -0
  84. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/outputs.py +0 -0
  85. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/pending_alerts.py +0 -0
  86. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/product_info.py +0 -0
  87. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/program_version.py +0 -0
  88. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/regulator_data.py +0 -0
  89. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/regulator_data_schema.py +0 -0
  90. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/schedules.py +0 -0
  91. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/statuses.py +0 -0
  92. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/temperatures.py +0 -0
  93. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/thermostat_parameters.py +0 -0
  94. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/structures/thermostat_sensors.py +0 -0
  95. {pyplumio-0.5.19 → pyplumio-0.5.20}/pyplumio/utils.py +0 -0
  96. {pyplumio-0.5.19 → pyplumio-0.5.20}/requirements.txt +0 -0
  97. {pyplumio-0.5.19 → pyplumio-0.5.20}/requirements_docs.txt +0 -0
  98. {pyplumio-0.5.19 → pyplumio-0.5.20}/setup.cfg +0 -0
  99. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/__init__.py +0 -0
  100. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/conftest.py +0 -0
  101. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/frames/test_init.py +0 -0
  102. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/frames/test_messages.py +0 -0
  103. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/frames/test_requests.py +0 -0
  104. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/frames/test_responses.py +0 -0
  105. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/helpers/__init__.py +0 -0
  106. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/helpers/test_data_types.py +0 -0
  107. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/helpers/test_event_manager.py +0 -0
  108. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/helpers/test_factory.py +0 -0
  109. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/helpers/test_parameter.py +0 -0
  110. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/helpers/test_schedule.py +0 -0
  111. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/helpers/test_task_manager.py +0 -0
  112. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/helpers/test_uid.py +0 -0
  113. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/ruff.toml +0 -0
  114. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/test_connection.py +0 -0
  115. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/test_devices.py +0 -0
  116. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/test_filters.py +0 -0
  117. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/test_init.py +0 -0
  118. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/test_main.py +0 -0
  119. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/test_stream.py +0 -0
  120. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/test_utils.py +0 -0
  121. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/messages/regulator_data.json +0 -0
  122. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/messages/sensor_data.json +0 -0
  123. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/requests/alerts.json +0 -0
  124. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/requests/ecomax_control.json +0 -0
  125. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/requests/ecomax_parameters.json +0 -0
  126. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/requests/mixer_parameters.json +0 -0
  127. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/requests/set_ecomax_parameter.json +0 -0
  128. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/requests/set_mixer_parameter.json +0 -0
  129. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/requests/set_schedule.json +0 -0
  130. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/requests/set_thermostat_parameter.json +0 -0
  131. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/requests/thermostat_parameters.json +0 -0
  132. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/alerts.json +0 -0
  133. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/device_available.json +0 -0
  134. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/ecomax_parameters.json +0 -0
  135. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/mixer_parameters.json +0 -0
  136. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/password.json +0 -0
  137. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/program_version.json +0 -0
  138. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/regulator_data_schema.json +0 -0
  139. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/schedules.json +0 -0
  140. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/thermostat_parameters.json +0 -0
  141. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/responses/uid.json +0 -0
  142. {pyplumio-0.5.19 → pyplumio-0.5.20}/tests/testdata/unknown/unknown_ecomax_parameter.json +0 -0
  143. {pyplumio-0.5.19 → pyplumio-0.5.20}/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.20
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
@@ -30,7 +30,7 @@ Requires-Dist: mypy==1.10.0; extra == "test"
30
30
  Requires-Dist: pyserial-asyncio-fast==0.11; extra == "test"
31
31
  Requires-Dist: pytest==8.2.1; extra == "test"
32
32
  Requires-Dist: pytest-asyncio==0.23.7; extra == "test"
33
- Requires-Dist: ruff==0.4.5; extra == "test"
33
+ Requires-Dist: ruff==0.4.7; extra == "test"
34
34
  Requires-Dist: tox==4.15.0; extra == "test"
35
35
  Requires-Dist: types-pyserial==3.5.0.20240527; extra == "test"
36
36
  Provides-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.20
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
@@ -30,7 +30,7 @@ Requires-Dist: mypy==1.10.0; extra == "test"
30
30
  Requires-Dist: pyserial-asyncio-fast==0.11; extra == "test"
31
31
  Requires-Dist: pytest==8.2.1; extra == "test"
32
32
  Requires-Dist: pytest-asyncio==0.23.7; extra == "test"
33
- Requires-Dist: ruff==0.4.5; extra == "test"
33
+ Requires-Dist: ruff==0.4.7; extra == "test"
34
34
  Requires-Dist: tox==4.15.0; extra == "test"
35
35
  Requires-Dist: types-pyserial==3.5.0.20240527; extra == "test"
36
36
  Provides-Extra: docs
@@ -17,6 +17,6 @@ mypy==1.10.0
17
17
  pyserial-asyncio-fast==0.11
18
18
  pytest==8.2.1
19
19
  pytest-asyncio==0.23.7
20
- ruff==0.4.5
20
+ ruff==0.4.7
21
21
  tox==4.15.0
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.20'
16
+ __version_tuple__ = version_tuple = (0, 5, 20)
@@ -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,
@@ -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."""
@@ -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,20 @@ 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
+ for queue in (self.read, self.write):
111
+ await queue.join()
112
+
110
113
 
111
114
  class AsyncProtocol(Protocol, EventManager):
112
115
  """Represents an async protocol.
@@ -117,11 +120,11 @@ class AsyncProtocol(Protocol, EventManager):
117
120
  The frame producer tries to read frames from the write queue.
118
121
  If any is available, it sends them to the device via frame writer.
119
122
 
120
- It then reads stream via frame reader, creates device entry and puts
121
- received frame into the read queue.
123
+ It then reads stream via frame reader and puts received frame
124
+ into the read queue.
122
125
 
123
- Frame consumers read frames from the read queue and send frame to
124
- their respective device class for further processing.
126
+ Frame consumers read frames from the read queue, create device
127
+ entry, if needed, and send frame to the entry for the processing.
125
128
  """
126
129
 
127
130
  consumers_count: int
@@ -139,18 +142,10 @@ class AsyncProtocol(Protocol, EventManager):
139
142
  super().__init__()
140
143
  self.consumers_count = consumers_count
141
144
  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
- ),
145
+ eth=ethernet_parameters or EthernetParameters(status=False),
146
+ wlan=wireless_parameters or WirelessParameters(status=False),
152
147
  )
153
- self._queues = Queues(asyncio.Queue(), asyncio.Queue())
148
+ self._queues = Queues(read=asyncio.Queue(), write=asyncio.Queue())
154
149
 
155
150
  def connection_established(
156
151
  self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
@@ -159,7 +154,9 @@ class AsyncProtocol(Protocol, EventManager):
159
154
  self.reader = FrameReader(reader)
160
155
  self.writer = FrameWriter(writer)
161
156
  self._queues.write.put_nowait(StartMasterRequest(recipient=DeviceType.ECOMAX))
162
- self.create_task(self.frame_producer(self._queues))
157
+ self.create_task(
158
+ self.frame_producer(self._queues, reader=self.reader, writer=self.writer)
159
+ )
163
160
  for _ in range(self.consumers_count):
164
161
  self.create_task(self.frame_consumer(self._queues.read))
165
162
 
@@ -184,8 +181,9 @@ class AsyncProtocol(Protocol, EventManager):
184
181
 
185
182
  async def shutdown(self) -> None:
186
183
  """Shutdown protocol tasks."""
187
- await asyncio.gather(*[queue.join() for queue in self._queues])
188
- await super(Protocol, self).shutdown()
184
+ await self._queues.join()
185
+ self.cancel_tasks()
186
+ await self.wait_until_done()
189
187
  for device in self.data.values():
190
188
  await device.shutdown()
191
189
 
@@ -193,33 +191,27 @@ class AsyncProtocol(Protocol, EventManager):
193
191
  self.connected.clear()
194
192
  await self.close_writer()
195
193
 
196
- async def frame_producer(self, queues: Queues) -> None:
194
+ async def frame_producer(
195
+ self, queues: Queues, reader: FrameReader, writer: FrameWriter
196
+ ) -> None:
197
197
  """Handle frame reads and writes."""
198
198
  await self.connected.wait()
199
- reader = cast(FrameReader, self.reader)
200
- writer = cast(FrameWriter, self.writer)
201
199
  while self.connected.is_set():
202
200
  try:
203
- if queues.write.qsize() > 0:
201
+ if not queues.write.empty():
204
202
  await writer.write(await queues.write.get())
205
203
  queues.write.task_done()
206
204
 
207
205
  if (response := await reader.read()) is not None:
208
206
  queues.read.put_nowait(response)
209
207
 
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:
208
+ except (ReadError, UnknownDeviceError, FrameError) as e:
217
209
  _LOGGER.debug("Can't process received frame: %s", e)
218
210
  except (OSError, asyncio.TimeoutError):
219
211
  self.create_task(self.connection_lost())
220
212
  break
221
- except Exception as e: # pylint: disable=broad-except
222
- _LOGGER.exception(e)
213
+ except Exception:
214
+ _LOGGER.exception("Unexpected exception")
223
215
 
224
216
  async def frame_consumer(self, queue: asyncio.Queue) -> None:
225
217
  """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],
@@ -38,7 +38,7 @@ test = [
38
38
  "pyserial-asyncio-fast==0.11",
39
39
  "pytest==8.2.1",
40
40
  "pytest-asyncio==0.23.7",
41
- "ruff==0.4.5",
41
+ "ruff==0.4.7",
42
42
  "tox==4.15.0",
43
43
  "types-pyserial==3.5.0.20240527"
44
44
  ]
@@ -5,7 +5,7 @@ pre-commit==3.7.1
5
5
  pyserial-asyncio-fast==0.11
6
6
  pytest==8.2.1
7
7
  pytest-asyncio==0.23.7
8
- ruff==0.4.5
8
+ ruff==0.4.7
9
9
  tomli==2.0.1
10
10
  tox==4.15.0
11
11
  types-pyserial==3.5.0.20240527
@@ -1,7 +1,6 @@
1
1
  """Contains tests for the timeout decorator class."""
2
2
 
3
3
  import asyncio
4
- import logging
5
4
  from unittest.mock import AsyncMock, Mock, patch
6
5
 
7
6
  import pytest
@@ -9,28 +8,27 @@ import pytest
9
8
  from pyplumio.helpers.timeout import timeout
10
9
 
11
10
 
12
- @patch("asyncio.wait_for", new_callable=AsyncMock, side_effect=(asyncio.TimeoutError))
13
- async def test_timeout(mock_wait_for, caplog) -> None:
11
+ @patch(
12
+ "asyncio.wait_for",
13
+ new_callable=AsyncMock,
14
+ side_effect=("test", asyncio.TimeoutError),
15
+ )
16
+ async def test_timeout(mock_wait_for) -> None:
14
17
  """Test a timeout decorator."""
15
18
  # Mock function to pass to the decorator.
16
19
  mock_func = Mock()
17
20
  mock_func.return_value = "test"
18
- mock_func.__name__ = "func_name"
19
21
 
20
22
  # Call the decorator.
21
- decorator = timeout(10, raise_exception=False)
23
+ decorator = timeout(10)
22
24
  wrapper = decorator(mock_func)
23
- with caplog.at_level(logging.WARNING):
24
- result = await wrapper("test_arg", kwarg="test_kwarg")
25
-
26
- assert result is None
27
- assert "Function 'func_name' timed out" in caplog.text
28
- assert "func_name" in caplog.text
25
+ result = await wrapper("test_arg", kwarg="test_kwarg")
26
+ assert result == "test"
29
27
  mock_wait_for.assert_awaited_once_with("test", timeout=10)
30
28
  mock_func.assert_called_once_with("test_arg", kwarg="test_kwarg")
31
29
 
32
30
  # Check with raise_exception set to true.
33
- decorator = timeout(10, raise_exception=True)
31
+ decorator = timeout(10)
34
32
  wrapper = decorator(mock_func)
35
33
  with pytest.raises(asyncio.TimeoutError):
36
34
  await wrapper("test_arg", kwarg="test_kwarg")
@@ -205,8 +205,8 @@ async def test_async_protocol_shutdown(
205
205
  bypass_asyncio_events,
206
206
  ) -> None:
207
207
  """Test shutting down connection with an async protocol."""
208
- mock_read_queue = Mock()
209
- mock_write_queue = Mock()
208
+ mock_read_queue = AsyncMock()
209
+ mock_write_queue = AsyncMock()
210
210
 
211
211
  mock_writer = AsyncMock()
212
212
  mock_writer.close = AsyncMock()
@@ -233,12 +233,7 @@ async def test_async_protocol_shutdown(
233
233
 
234
234
  mock_shutdown.assert_awaited_once()
235
235
  mock_cancel_tasks.assert_called_once()
236
- assert mock_gather.call_count == 2
237
- calls = [
238
- call(mock_read_queue.join(), mock_write_queue.join()),
239
- call(*async_protocol.tasks, return_exceptions=True),
240
- ]
241
- mock_gather.assert_has_awaits(calls)
236
+ mock_gather.assert_awaited_once_with(*async_protocol.tasks, return_exceptions=True)
242
237
  mock_writer.close.assert_awaited_once()
243
238
  assert async_protocol.writer is None
244
239
 
@@ -251,8 +246,8 @@ async def test_async_protocol_frame_producer(
251
246
  response = Response(sender=DeviceType.ECOMAX)
252
247
 
253
248
  # Create mock frame reader and writer.
254
- async_protocol.reader = AsyncMock(spec=FrameReader)
255
- async_protocol.reader.read = AsyncMock(
249
+ mock_reader = AsyncMock(spec=FrameReader)
250
+ mock_reader.read = AsyncMock(
256
251
  side_effect=(
257
252
  response,
258
253
  FrameError("test frame error"),
@@ -264,12 +259,14 @@ async def test_async_protocol_frame_producer(
264
259
  )
265
260
  )
266
261
 
267
- async_protocol.writer = AsyncMock(spec=FrameWriter)
262
+ mock_writer = AsyncMock(spec=FrameWriter)
268
263
 
269
264
  # Create mock queues.
270
265
  mock_read_queue = AsyncMock(spec=asyncio.Queue)
271
266
  mock_write_queue = AsyncMock(spec=asyncio.Queue)
272
- mock_write_queue.qsize = Mock(side_effect=(1, 0, 0, 0, 0, 0, 0))
267
+ mock_write_queue.empty = Mock(
268
+ side_effect=(False, True, True, True, True, True, True)
269
+ )
273
270
  mock_write_queue.get = AsyncMock(return_value="test_request")
274
271
 
275
272
  with (
@@ -279,7 +276,11 @@ async def test_async_protocol_frame_producer(
279
276
  ) as mock_connection_lost,
280
277
  caplog.at_level(logging.DEBUG),
281
278
  ):
282
- await async_protocol.frame_producer(Queues(mock_read_queue, mock_write_queue))
279
+ await async_protocol.frame_producer(
280
+ Queues(mock_read_queue, mock_write_queue),
281
+ reader=mock_reader,
282
+ writer=mock_writer,
283
+ )
283
284
 
284
285
  assert caplog.record_tuples == [
285
286
  (
@@ -290,32 +291,32 @@ async def test_async_protocol_frame_producer(
290
291
  (
291
292
  "pyplumio.protocol",
292
293
  logging.DEBUG,
293
- "Unknown device: test unknown device error",
294
+ "Can't process received frame: test unknown device error",
294
295
  ),
295
296
  (
296
297
  "pyplumio.protocol",
297
298
  logging.DEBUG,
298
- "Read error: test read error",
299
+ "Can't process received frame: test read error",
299
300
  ),
300
301
  (
301
302
  "pyplumio.protocol",
302
- logging.WARNING,
303
- "Incorrect payload: test frame data error",
303
+ logging.DEBUG,
304
+ "Can't process received frame: test frame data error",
304
305
  ),
305
306
  (
306
307
  "pyplumio.protocol",
307
308
  logging.ERROR,
308
- "test generic error",
309
+ "Unexpected exception",
309
310
  ),
310
311
  ]
311
312
 
312
- async_protocol.writer.write.assert_awaited_once_with("test_request")
313
+ mock_writer.write.assert_awaited_once_with("test_request")
313
314
  mock_write_queue.task_done.assert_called_once()
314
315
  mock_read_queue.put_nowait.assert_called_once_with(response)
315
316
  mock_connection_lost.assert_called_once()
316
317
  assert mock_write_queue.get.await_count == 1
317
- assert mock_write_queue.qsize.call_count == 7
318
- assert async_protocol.reader.read.await_count == 7
318
+ assert mock_write_queue.empty.call_count == 7
319
+ assert mock_reader.read.await_count == 7
319
320
 
320
321
 
321
322
  @patch("pyplumio.frames.requests.CheckDeviceRequest.response")
@@ -1,47 +0,0 @@
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, raise_exception: bool = True
21
- ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T | None]]]:
22
- """Decorate a timeout for the awaitable.
23
-
24
- Return None on exception if raise_exception parameter is set to false.
25
- """
26
-
27
- def decorator(
28
- func: Callable[P, Awaitable[T]],
29
- ) -> Callable[P, Coroutine[Any, Any, T | None]]:
30
- @wraps(func)
31
- async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | None:
32
- try:
33
- return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds)
34
- except asyncio.TimeoutError:
35
- if raise_exception:
36
- raise
37
-
38
- _LOGGER.warning(
39
- "Function '%s' timed out after %i seconds",
40
- func.__name__,
41
- seconds,
42
- )
43
- return None
44
-
45
- return wrapper
46
-
47
- return decorator
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
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
File without changes
File without changes