python-selve-new 2.5.1__tar.gz → 2.5.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. python_selve_new-2.5.2/.github/workflows/python-publish.yml +45 -0
  2. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.github/workflows/tests.yml +2 -2
  3. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/PKG-INFO +2 -2
  4. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/pyproject.toml +1 -1
  5. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/python_selve_new.egg-info/PKG-INFO +2 -2
  6. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/python_selve_new.egg-info/requires.txt +1 -1
  7. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/__init__.py +38 -52
  8. python_selve_new-2.5.2/selve/_version.py +24 -0
  9. python_selve_new-2.5.2/selve/util/serial_transport.py +121 -0
  10. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/setup.py +1 -2
  11. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/conftest.py +22 -6
  12. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/integration/conftest.py +11 -11
  13. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/integration/test_device_integration.py +12 -12
  14. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/integration/test_selve_gateway_integration.py +2 -11
  15. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/integration/test_selve_integration.py +19 -32
  16. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/test_replacement.py +16 -17
  17. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_gateway_configuration_issues.py +2 -2
  18. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_gateway_error_handling_fixed.py +6 -8
  19. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_missing_components.py +18 -20
  20. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_mock_commands.py +23 -25
  21. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_mock_devices_and_groups.py +28 -30
  22. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_mock_sensors_and_senders.py +25 -27
  23. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_port_discovery.py +5 -5
  24. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_selve_advanced_coverage.py +5 -9
  25. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_selve_core_coverage.py +17 -99
  26. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_selve_gateway.py +8 -10
  27. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_selve_init_comprehensive.py +20 -23
  28. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_selve_init_simple.py +9 -13
  29. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_selve_main_class_extensive.py +13 -26
  30. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_service_command_errors.py +2 -2
  31. python_selve_new-2.5.1/.github/workflows/python-publish.yml +0 -100
  32. python_selve_new-2.5.1/selve/_version.py +0 -34
  33. python_selve_new-2.5.1/selve/util/serial_transport.py +0 -129
  34. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.github/FUNDING.yml +0 -0
  35. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.github/architect.chatmode.md +0 -0
  36. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.github/ask.chatmode.md +0 -0
  37. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.github/code.chatmode.md +0 -0
  38. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.github/debug.chatmode.md +0 -0
  39. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.gitignore +0 -0
  40. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.idea/.gitignore +0 -0
  41. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  42. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.idea/misc.xml +0 -0
  43. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.idea/modules.xml +0 -0
  44. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.idea/python-selve-new.iml +0 -0
  45. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/.idea/vcs.xml +0 -0
  46. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/CHANGELOG.md +0 -0
  47. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/LICENSE +0 -0
  48. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/README.md +0 -0
  49. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/coverage_xdist_example.txt +0 -0
  50. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/debug_response.py +0 -0
  51. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/debug_test.bat +0 -0
  52. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/direct_hardware_test.bat +0 -0
  53. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/direct_hardware_test.py +0 -0
  54. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/generate_coverage.bat +0 -0
  55. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/package.sh +0 -0
  56. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/python_selve_new.egg-info/SOURCES.txt +0 -0
  57. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/python_selve_new.egg-info/dependency_links.txt +0 -0
  58. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/python_selve_new.egg-info/top_level.txt +0 -0
  59. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/release.py +0 -0
  60. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/run_all_tests.bat +0 -0
  61. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/run_hardware_tests.bat +0 -0
  62. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/run_integration_tests.bat +0 -0
  63. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/run_mock_tests.bat +0 -0
  64. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/run_single_test.bat +0 -0
  65. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/run_tests.bat +0 -0
  66. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/__init__.py +0 -0
  67. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/command.py +0 -0
  68. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/device.py +0 -0
  69. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/event.py +0 -0
  70. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/firmware.py +0 -0
  71. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/group.py +0 -0
  72. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/iveo.py +0 -0
  73. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/param.py +0 -0
  74. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/senSim.py +0 -0
  75. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/sender.py +0 -0
  76. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/sensor.py +0 -0
  77. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/commands/service.py +0 -0
  78. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/device.py +0 -0
  79. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/gateway.py +0 -0
  80. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/group.py +0 -0
  81. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/iveo.py +0 -0
  82. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/senSim.py +0 -0
  83. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/sender.py +0 -0
  84. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/sensor.py +0 -0
  85. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/util/__init__.py +0 -0
  86. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/util/errors.py +0 -0
  87. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/selve/util/protocol.py +0 -0
  88. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/setup.cfg +0 -0
  89. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/setup_and_test.bat +0 -0
  90. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/__init__.py +0 -0
  91. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/integration/README.md +0 -0
  92. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/integration/__init__.py +0 -0
  93. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/integration/test_selve_hardware.py +0 -0
  94. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/test_import.py +0 -0
  95. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/__init__.py +0 -0
  96. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/mock_utils.py +0 -0
  97. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_command_coverage.py +0 -0
  98. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_commands.py +0 -0
  99. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_device.py +0 -0
  100. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_device_classes_coverage.py +0 -0
  101. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_device_commands_extended.py +0 -0
  102. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_group.py +0 -0
  103. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_group_commands.py +0 -0
  104. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_param_commands_extended.py +0 -0
  105. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_selve_edge_cases.py +0 -0
  106. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_selve_init_response_coverage.py +0 -0
  107. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_sender_commands_extended.py +0 -0
  108. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_sensim_commands_extended.py +0 -0
  109. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_sensor_commands_extended.py +0 -0
  110. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_service_commands.py +0 -0
  111. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_util.py +0 -0
  112. {python_selve_new-2.5.1 → python_selve_new-2.5.2}/tests/unit/test_utility_coverage.py +0 -0
@@ -0,0 +1,45 @@
1
+ # Publishes to PyPI after the "Run Tests" workflow succeeds on a version tag.
2
+
3
+ name: Publish Python Package
4
+
5
+ on:
6
+ workflow_run:
7
+ workflows: ["Run Tests"]
8
+ types: [completed]
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ deploy:
15
+ runs-on: ubuntu-latest
16
+ # Only publish when tests passed AND the trigger was a version tag (e.g. v2.5.2)
17
+ if: |
18
+ github.event.workflow_run.conclusion == 'success' &&
19
+ startsWith(github.event.workflow_run.head_branch, 'v')
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ with:
24
+ fetch-depth: 0 # Fetch all history for setuptools_scm
25
+ ref: ${{ github.event.workflow_run.head_sha }}
26
+
27
+ - name: Set up Python
28
+ uses: actions/setup-python@v5
29
+ with:
30
+ python-version: '3.x'
31
+
32
+ - name: Install build dependencies
33
+ run: |
34
+ python -m pip install --upgrade pip
35
+ pip install build setuptools-scm
36
+
37
+ - name: Build package
38
+ run: |
39
+ python -m build
40
+
41
+ - name: Publish package to PyPI
42
+ uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
43
+ with:
44
+ user: __token__
45
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -15,7 +15,7 @@ jobs:
15
15
  runs-on: ubuntu-latest
16
16
  strategy:
17
17
  matrix:
18
- python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
18
+ python-version: ['3.10', '3.11', '3.12', '3.13']
19
19
 
20
20
  steps:
21
21
  - uses: actions/checkout@v4
@@ -48,7 +48,7 @@ jobs:
48
48
 
49
49
  - name: Run integration tests (without hardware)
50
50
  run: |
51
- pytest tests/integration/ -v -m "not hardware" --timeout=30
51
+ pytest tests/integration/ -v -m "not hardware" --timeout=30 --ignore=tests/integration/test_selve_hardware.py
52
52
 
53
53
  - name: Generate coverage report (Python 3.11 only)
54
54
  if: matrix.python-version == '3.11'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-selve-new
3
- Version: 2.5.1
3
+ Version: 2.5.2
4
4
  Summary: Python library for interfacing with selve devices using the USB-RF controller. Written completely new.
5
5
  Home-page: https://github.com/Kannix2005/python-selve-new
6
6
  Author: Stefan Altheimer
@@ -21,7 +21,7 @@ Requires-Python: >=3.9
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: requests
24
- Requires-Dist: pyserial
24
+ Requires-Dist: serialx
25
25
  Requires-Dist: pybase64
26
26
  Requires-Dist: untangle
27
27
  Requires-Dist: nest_asyncio
@@ -31,7 +31,7 @@ classifiers = [
31
31
  ]
32
32
  dependencies = [
33
33
  "requests",
34
- "pyserial",
34
+ "serialx",
35
35
  "pybase64",
36
36
  "untangle",
37
37
  "nest_asyncio",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-selve-new
3
- Version: 2.5.1
3
+ Version: 2.5.2
4
4
  Summary: Python library for interfacing with selve devices using the USB-RF controller. Written completely new.
5
5
  Home-page: https://github.com/Kannix2005/python-selve-new
6
6
  Author: Stefan Altheimer
@@ -21,7 +21,7 @@ Requires-Python: >=3.9
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: requests
24
- Requires-Dist: pyserial
24
+ Requires-Dist: serialx
25
25
  Requires-Dist: pybase64
26
26
  Requires-Dist: untangle
27
27
  Requires-Dist: nest_asyncio
@@ -1,5 +1,5 @@
1
1
  requests
2
- pyserial
2
+ serialx
3
3
  pybase64
4
4
  untangle
5
5
  nest_asyncio
@@ -12,15 +12,19 @@ except ImportError:
12
12
  __version__ = "unknown"
13
13
 
14
14
  import asyncio
15
- import threading
16
15
  import time
17
16
  from collections import deque
18
17
  from itertools import chain
19
18
  from typing import Callable, Optional
20
19
 
21
- import serial
22
- from serial.tools import list_ports
23
- from serial import SerialException
20
+ try:
21
+ from serial.tools import list_ports as _serial_list_ports
22
+ def _comports():
23
+ return _serial_list_ports.comports()
24
+ except ImportError:
25
+ def _comports(): # type: ignore[misc]
26
+ return []
27
+
24
28
  import untangle
25
29
 
26
30
  from selve.commands import param, service
@@ -113,21 +117,21 @@ class Selve:
113
117
  # Kept for backward compatibility in tests/mocks.
114
118
  return True
115
119
 
116
- def _build_transport(self, port: str):
120
+ async def _build_transport(self, port: str):
117
121
  self._transport = SerialTransport(port=port, logger=self._LOGGER)
118
- self._transport.ensure_open()
119
- self._serial = self._transport.serial
122
+ await self._transport.ensure_open()
123
+ self._serial = None
120
124
 
121
- def _teardown_transport(self):
125
+ async def _teardown_transport(self):
122
126
  if self._transport:
123
- self._transport.shutdown()
127
+ await self._transport.shutdown()
124
128
  self._transport = None
125
129
  self._serial = None
126
130
 
127
131
  async def _probe_port(self, port: str, fromConfigFlow: bool = False) -> bool:
128
132
  """Attempt to connect and verify a Selve gateway on the given port."""
129
133
  try:
130
- self._build_transport(port)
134
+ await self._build_transport(port)
131
135
  ok = await self.pingGateway(fromConfigFlow=fromConfigFlow)
132
136
  if ok:
133
137
  try:
@@ -142,13 +146,12 @@ class Selve:
142
146
  self._LOGGER.debug(f"Probe failed on {port}: {e}")
143
147
 
144
148
  await self.stopWorker()
145
- self._teardown_transport()
149
+ await self._teardown_transport()
146
150
  return False
147
151
 
148
152
 
149
153
  def list_ports(self):
150
- available_ports = list_ports.comports()
151
- return available_ports
154
+ return _comports()
152
155
 
153
156
  async def check_port(self, port):
154
157
  if port is not None:
@@ -172,22 +175,14 @@ class Selve:
172
175
  await self.discover()
173
176
  await self.startWorker()
174
177
  return
175
- except (serial.SerialException, IOError) as e:
178
+ except (OSError, IOError) as e:
176
179
  self._LOGGER.debug("Configured port not valid! " + str(e))
177
180
  except Exception as e:
178
181
  self._LOGGER.error("Unknown exception: " + str(e))
179
182
 
180
183
 
181
- if self.loop is not None:
182
- # Use the current running loop to avoid "different loop" errors
183
- try:
184
- current_loop = asyncio.get_running_loop()
185
- available_ports = await current_loop.run_in_executor(None, list_ports.comports)
186
- except RuntimeError:
187
- # No running loop, use the instance loop
188
- available_ports = await self.loop.run_in_executor(None, list_ports.comports)
189
- else:
190
- available_ports = list_ports.comports()
184
+ loop = asyncio.get_running_loop()
185
+ available_ports = await loop.run_in_executor(None, _comports)
191
186
 
192
187
  self._LOGGER.debug("available comports: " + str(available_ports))
193
188
 
@@ -216,31 +211,22 @@ class Selve:
216
211
  await asyncio.sleep(5)
217
212
  self._LOGGER.debug("(Selve Worker): " + "Recovering")
218
213
 
219
- self._teardown_transport()
214
+ await self._teardown_transport()
220
215
 
221
216
  if self._port is not None:
222
217
  try:
223
218
  if await self._probe_port(self._port, fromConfigFlow=False):
224
219
  if self.rxQ is not None and self._transport is not None:
225
- loop = self.loop or asyncio.get_running_loop()
226
- self._transport.start_reader(loop, self.rxQ)
220
+ await self._transport.start_reader(self.rxQ)
227
221
  return
228
- except (serial.SerialException, IOError) as e:
222
+ except (OSError, IOError) as e:
229
223
  self._LOGGER.debug("(Selve Worker): " + "Configured port not valid, maybe it has changed, trying other ports...")
230
224
  except Exception as e:
231
225
  self._LOGGER.error("(Selve Worker): " + "Unknown exception: " + str(e))
232
226
 
233
- if self.loop is not None:
234
- # Use the current running loop to avoid "different loop" errors
235
- try:
236
- current_loop = asyncio.get_running_loop()
237
- available_ports = await current_loop.run_in_executor(None, list_ports.comports)
238
- except RuntimeError:
239
- # No running loop, use the instance loop
240
- available_ports = await self.loop.run_in_executor(None, list_ports.comports)
241
- else:
242
- available_ports = list_ports.comports()
243
-
227
+ loop = asyncio.get_running_loop()
228
+ available_ports = await loop.run_in_executor(None, _comports)
229
+
244
230
  self._LOGGER.debug("(Selve Worker): " + "available comports: " + str(available_ports))
245
231
 
246
232
  if len(available_ports) == 0:
@@ -251,8 +237,7 @@ class Selve:
251
237
  try:
252
238
  if await self._probe_port(p.device, fromConfigFlow=False):
253
239
  if self.rxQ is not None and self._transport is not None:
254
- loop = self.loop or asyncio.get_running_loop()
255
- self._transport.start_reader(loop, self.rxQ)
240
+ await self._transport.start_reader(self.rxQ)
256
241
  return
257
242
  except Exception as e:
258
243
  self._LOGGER.error("(Selve Worker): " + "Error at com port: " + str(e))
@@ -273,12 +258,11 @@ class Selve:
273
258
  if self._event_queue is None or not isinstance(self._event_queue, asyncio.Queue):
274
259
  self._event_queue = asyncio.Queue()
275
260
 
276
- # Ensure transport and reader thread are running
277
- loop = self.loop or asyncio.get_running_loop()
261
+ # Ensure transport and reader task are running
278
262
  if self._transport is None and self._port is not None:
279
- self._build_transport(self._port)
263
+ await self._build_transport(self._port)
280
264
  if self._transport is not None:
281
- self._transport.start_reader(loop, self.rxQ)
265
+ await self._transport.start_reader(self.rxQ)
282
266
 
283
267
  if self._tx_task is None or self._tx_task.done():
284
268
  self._tx_task = asyncio.create_task(self._tx_loop())
@@ -371,7 +355,7 @@ class Selve:
371
355
  self._dispatch_task = None
372
356
  self._pending_futures.clear()
373
357
  if self._transport:
374
- self._transport.stop_reader()
358
+ await self._transport.stop_reader()
375
359
  if self._event_queue is not None:
376
360
  while not self._event_queue.empty():
377
361
  try:
@@ -387,7 +371,7 @@ class Selve:
387
371
  self._LOGGER.debug("Preparing for termination")
388
372
  await self.stopWorker()
389
373
  # close the serial port, do the cleanup
390
- self._teardown_transport()
374
+ await self._teardown_transport()
391
375
  return True
392
376
 
393
377
 
@@ -428,20 +412,20 @@ class Selve:
428
412
  if self._transport is None:
429
413
  if self._port is None:
430
414
  raise PortError("No serial port configured")
431
- self._build_transport(self._port)
415
+ await self._build_transport(self._port)
432
416
 
433
417
  await self._transport.write(commandstr)
434
418
  # small pause to give the gateway time to answer
435
419
  await asyncio.sleep(0.1)
436
420
 
437
- except (serial.SerialException, IOError) as se:
421
+ except (OSError, IOError) as se:
438
422
  self._LOGGER.info('Serial error, trying to reconnect once... ' + str(se))
439
423
  await self.recover()
440
424
 
441
425
  try:
442
426
  self._LOGGER.debug('Trying again...')
443
427
  if self._transport is None and self._port is not None:
444
- self._build_transport(self._port)
428
+ await self._build_transport(self._port)
445
429
  await self._transport.write(commandstr)
446
430
  await asyncio.sleep(0.1)
447
431
 
@@ -1188,7 +1172,8 @@ class Selve:
1188
1172
  while await self.gatewayState() != ServiceState.READY:
1189
1173
  if time.time() - start_time >= 30:
1190
1174
  self._LOGGER.info("Error: Gateway could not be reset or loads too long")
1191
- pass
1175
+ break
1176
+ await asyncio.sleep(0.1)
1192
1177
  self._LOGGER.info("Gateway reset")
1193
1178
 
1194
1179
  async def factoryResetGateway(self):
@@ -1201,7 +1186,8 @@ class Selve:
1201
1186
  while await self.gatewayState() != ServiceState.READY:
1202
1187
  if time.time() - start_time >= 60:
1203
1188
  self._LOGGER.info("Error: Gateway could not be reset or loads too long")
1204
- pass
1189
+ break
1190
+ await asyncio.sleep(0.1)
1205
1191
  self._LOGGER.info("Gateway factory reset")
1206
1192
  return response.executed
1207
1193
 
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2.5.2'
22
+ __version_tuple__ = version_tuple = (2, 5, 2)
23
+
24
+ __commit_id__ = commit_id = 'g24c865c13'
@@ -0,0 +1,121 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Optional
4
+
5
+ import serialx
6
+ from serialx import Parity
7
+
8
+
9
+ class SerialTransport:
10
+ """Async serial transport using serialx (replaces pyserial + background thread)."""
11
+
12
+ def __init__(
13
+ self,
14
+ port: str,
15
+ baudrate: int = 115200,
16
+ read_timeout: float = 1.0,
17
+ logger: Optional[logging.Logger] = None,
18
+ ) -> None:
19
+ self._port = port
20
+ self._baudrate = baudrate
21
+ self._read_timeout = read_timeout
22
+ self._logger = logger or logging.getLogger(__name__)
23
+
24
+ self._reader: Optional[asyncio.StreamReader] = None
25
+ self._writer: Optional[asyncio.StreamWriter] = None
26
+ self._reader_task: Optional[asyncio.Task] = None
27
+ self._rx_queue: Optional[asyncio.Queue] = None
28
+
29
+ @property
30
+ def port(self) -> str:
31
+ return self._port
32
+
33
+ @property
34
+ def is_open(self) -> bool:
35
+ return self._writer is not None and not self._writer.is_closing()
36
+
37
+ async def ensure_open(self) -> None:
38
+ if not self.is_open:
39
+ self._reader, self._writer = await serialx.open_serial_connection(
40
+ url=self._port,
41
+ baudrate=self._baudrate,
42
+ bytesize=8,
43
+ parity=Parity.NONE,
44
+ stopbits=1,
45
+ xonxoff=False,
46
+ rtscts=False,
47
+ dsrdtr=False,
48
+ )
49
+
50
+ async def close(self) -> None:
51
+ try:
52
+ if self._writer and not self._writer.is_closing():
53
+ self._writer.close()
54
+ await self._writer.wait_closed()
55
+ except Exception:
56
+ self._logger.debug("Serial close failed", exc_info=True)
57
+ self._reader = None
58
+ self._writer = None
59
+
60
+ async def start_reader(self, rx_queue: asyncio.Queue) -> None:
61
+ """Opens the serial port and starts the async reader task."""
62
+ await self.ensure_open()
63
+ self._rx_queue = rx_queue
64
+ if self._reader_task and not self._reader_task.done():
65
+ return
66
+ self._reader_task = asyncio.create_task(
67
+ self._reader_loop(), name="selve-serial-reader"
68
+ )
69
+
70
+ async def stop_reader(self) -> None:
71
+ if self._reader_task:
72
+ self._reader_task.cancel()
73
+ try:
74
+ await self._reader_task
75
+ except asyncio.CancelledError:
76
+ pass
77
+ self._reader_task = None
78
+
79
+ async def _reader_loop(self) -> None:
80
+ buffer = ""
81
+ while True:
82
+ try:
83
+ if self._reader is None:
84
+ await self.ensure_open()
85
+ line_bytes = await self._reader.readline()
86
+ except asyncio.CancelledError:
87
+ break
88
+ except (OSError, EOFError) as exc:
89
+ self._logger.error("Serial read error: %s", exc)
90
+ await asyncio.sleep(1)
91
+ continue
92
+ except Exception:
93
+ self._logger.error("Unexpected serial read error", exc_info=True)
94
+ await asyncio.sleep(1)
95
+ continue
96
+
97
+ if not line_bytes:
98
+ await asyncio.sleep(0.01)
99
+ continue
100
+
101
+ decoded = line_bytes.decode(errors="ignore").strip()
102
+ if decoded == "":
103
+ if buffer and self._rx_queue:
104
+ await self._rx_queue.put(buffer)
105
+ buffer = ""
106
+ continue
107
+
108
+ buffer += decoded
109
+
110
+ async def write(self, payload: bytes) -> None:
111
+ await self.ensure_open()
112
+ try:
113
+ self._writer.write(payload)
114
+ await self._writer.drain()
115
+ except OSError as exc:
116
+ self._logger.error("Serial write error: %s", exc)
117
+ raise
118
+
119
+ async def shutdown(self) -> None:
120
+ await self.stop_reader()
121
+ await self.close()
@@ -39,7 +39,6 @@ setup(
39
39
  # Specify the Python versions you support here. In particular, ensure
40
40
  # that you indicate whether you support Python 2, Python 3 or both.
41
41
  'Programming Language :: Python :: 3',
42
- 'Programming Language :: Python :: 3.9',
43
42
  'Programming Language :: Python :: 3.10',
44
43
  'Programming Language :: Python :: 3.11',
45
44
  'Programming Language :: Python :: 3.12'
@@ -56,7 +55,7 @@ setup(
56
55
  # For an analysis of "install_requires" vs pip's requirements files see:
57
56
  # https://packaging.python.org/en/latest/requirements.html
58
57
  install_requires=[
59
- 'pyserial',
58
+ 'serialx',
60
59
  'pybase64',
61
60
  'untangle',
62
61
  'nest_asyncio',
@@ -3,7 +3,19 @@ import logging
3
3
  import sys
4
4
  import asyncio
5
5
  import nest_asyncio
6
- from unittest.mock import MagicMock, patch
6
+ from unittest.mock import MagicMock, AsyncMock, patch
7
+
8
+ # Stub out serialx before any selve import so the protobuf/aioesphomeapi
9
+ # version conflict in serialx's optional esphome platform never fires.
10
+ _serialx_mock = MagicMock()
11
+ _serialx_mock.Parity = MagicMock()
12
+ _serialx_mock.Parity.NONE = "N"
13
+ _serialx_mock.open_serial_connection = AsyncMock(
14
+ return_value=(MagicMock(), MagicMock())
15
+ )
16
+ sys.modules.setdefault("serialx", _serialx_mock)
17
+ sys.modules.setdefault("serialx.platforms", MagicMock())
18
+ sys.modules.setdefault("serialx.platforms.serial_esphome", MagicMock())
7
19
 
8
20
  # Ermöglicht das Ausführen von async-Funktionen in einem bereits laufenden Event-Loop
9
21
  # Dies ist wichtig für die Integration-Tests
@@ -34,19 +46,23 @@ def logger():
34
46
 
35
47
  @pytest.fixture
36
48
  def mock_serial():
37
- """Mock the serial port for testing."""
38
- with patch('selve.serial.Serial') as mock:
49
+ """Mock the SerialTransport for testing."""
50
+ with patch('selve.util.serial_transport.SerialTransport') as mock:
39
51
  mock_instance = MagicMock()
40
52
  mock.return_value = mock_instance
41
53
  mock_instance.is_open = True
42
- mock_instance.read_until.return_value = b'<methodResponse name="selve.GW.service.ping"></methodResponse>'
54
+ mock_instance.ensure_open = AsyncMock()
55
+ mock_instance.start_reader = AsyncMock()
56
+ mock_instance.stop_reader = AsyncMock()
57
+ mock_instance.write = AsyncMock()
58
+ mock_instance.shutdown = AsyncMock()
43
59
  yield mock
44
60
 
45
61
 
46
62
  @pytest.fixture
47
63
  def mock_list_ports():
48
- """Mock the list_ports.comports function."""
49
- with patch('selve.list_ports.comports') as mock:
64
+ """Mock the _comports function."""
65
+ with patch('selve._comports') as mock:
50
66
  # Create a mock port
51
67
  mock_port = MagicMock()
52
68
  mock_port.device = "COM3"
@@ -39,17 +39,17 @@ def event_loop():
39
39
 
40
40
  @pytest.fixture
41
41
  def mock_serial():
42
- """Provide a mocked serial interface."""
43
- with patch('selve.serial.Serial') as mock_serial:
44
- # Configure mock serial port
45
- mock_serial_instance = mock_serial.return_value
46
- mock_serial_instance.is_open = True
47
- mock_serial_instance.read_until.return_value = b'<methodResponse name="selve.GW.service.ping"></methodResponse>'
48
- mock_serial_instance.write = MagicMock()
49
- mock_serial_instance.in_waiting = 0
50
- mock_serial_instance.readline = MagicMock(return_value=b'<methodResponse name="selve.GW.command.result" result="true"></methodResponse>')
51
-
52
- yield mock_serial
42
+ """Provide a mocked serial transport."""
43
+ with patch('selve.util.serial_transport.SerialTransport') as mock_transport:
44
+ mock_instance = mock_transport.return_value
45
+ mock_instance.is_open = True
46
+ mock_instance.ensure_open = AsyncMock()
47
+ mock_instance.start_reader = AsyncMock()
48
+ mock_instance.stop_reader = AsyncMock()
49
+ mock_instance.write = AsyncMock()
50
+ mock_instance.shutdown = AsyncMock()
51
+
52
+ yield mock_transport
53
53
 
54
54
 
55
55
  @pytest.fixture
@@ -3,7 +3,6 @@ import logging
3
3
  import sys
4
4
  import asyncio
5
5
  import os
6
- import serial
7
6
  from unittest.mock import MagicMock, patch, AsyncMock
8
7
 
9
8
  # Import the Selve package
@@ -38,16 +37,17 @@ class TestDeviceIntegration(unittest.TestCase):
38
37
 
39
38
  def setUp(self):
40
39
  """Set up mock serial port and other mocks."""
41
- self.mock_serial_patcher = patch('selve.serial.Serial')
40
+ self.mock_serial_patcher = patch('selve.util.serial_transport.SerialTransport')
42
41
  self.mock_serial = self.mock_serial_patcher.start()
43
-
44
- # Configure mock serial port
45
- mock_serial_instance = self.mock_serial.return_value
46
- mock_serial_instance.is_open = True
47
- mock_serial_instance.read_until.return_value = b'<methodResponse name="selve.GW.service.ping"></methodResponse>'
48
- mock_serial_instance.write = MagicMock()
49
- mock_serial_instance.in_waiting = 0
50
- mock_serial_instance.readline = MagicMock(return_value=b'<methodResponse name="selve.GW.command.result" result="true"></methodResponse>')
42
+
43
+ # Configure mock transport
44
+ mock_transport_instance = self.mock_serial.return_value
45
+ mock_transport_instance.is_open = True
46
+ mock_transport_instance.ensure_open = AsyncMock()
47
+ mock_transport_instance.start_reader = AsyncMock()
48
+ mock_transport_instance.stop_reader = AsyncMock()
49
+ mock_transport_instance.write = AsyncMock()
50
+ mock_transport_instance.shutdown = AsyncMock()
51
51
 
52
52
  # Create the Selve instance
53
53
  self.selve = Selve(port="COM3", discover=False, develop=True,
@@ -241,7 +241,7 @@ class TestDeviceIntegration(unittest.TestCase):
241
241
  def test_connection_error_handling(self):
242
242
  """Test handling of connection errors in device commands."""
243
243
  # Make executeCommandSyncWithResponse raise an exception
244
- self.selve.executeCommandSyncWithResponse.side_effect = serial.SerialException("Connection failed")
244
+ self.selve.executeCommandSyncWithResponse.side_effect = OSError("Connection failed")
245
245
 
246
246
  # Setup the test
247
247
  async def test_async():
@@ -250,7 +250,7 @@ class TestDeviceIntegration(unittest.TestCase):
250
250
  result = await self.selve.moveDeviceUp(self.device)
251
251
  # The method should have caught the exception and returned False
252
252
  self.assertFalse(result, "Method should return False on serial exception")
253
- except serial.SerialException:
253
+ except OSError:
254
254
  # If the exception wasn't caught, we need to patch the method
255
255
  from unittest.mock import patch
256
256
 
@@ -42,9 +42,6 @@ class TestSelveGatewayIntegration:
42
42
 
43
43
  # Verify port was set correctly
44
44
  assert selve._port == "COM3"
45
-
46
- # Verify serial was initialized
47
- assert selve._serial is not None
48
45
 
49
46
  @pytest.mark.asyncio
50
47
  async def test_gateway_setup_with_invalid_port(self, mock_serial, logger):
@@ -85,14 +82,8 @@ class TestSelveGatewayIntegration:
85
82
  # Directly test the recover method with mocked components
86
83
  with patch.object(mock_selve_instance, '_LOGGER') as mock_logger:
87
84
  with patch('asyncio.sleep', return_value=None): # Skip the sleep delay
88
- with patch('selve.serial.Serial') as mock_serial_class:
89
- # Configure the mock serial to succeed
90
- mock_serial_instance = MagicMock()
91
- mock_serial_instance.is_open = True
92
- mock_serial_class.return_value = mock_serial_instance
93
-
94
- # Mock _probe_port to return True immediately
95
- mock_selve_instance._probe_port = AsyncMock(return_value=True)
85
+ # Mock _probe_port to return True immediately
86
+ with patch.object(mock_selve_instance, '_probe_port', AsyncMock(return_value=True)):
96
87
  mock_selve_instance._port = "COM3"
97
88
 
98
89
  # Call recover directly