python-selve-new 2.5.1__tar.gz → 2.5.3__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.3/.github/workflows/python-publish.yml +45 -0
  2. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.github/workflows/tests.yml +2 -2
  3. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/PKG-INFO +2 -2
  4. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/pyproject.toml +1 -1
  5. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/python_selve_new.egg-info/PKG-INFO +2 -2
  6. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/python_selve_new.egg-info/requires.txt +1 -1
  7. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/__init__.py +86 -75
  8. python_selve_new-2.5.3/selve/_version.py +24 -0
  9. python_selve_new-2.5.3/selve/util/serial_transport.py +121 -0
  10. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/setup.py +1 -2
  11. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/conftest.py +22 -6
  12. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/integration/conftest.py +11 -11
  13. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/integration/test_device_integration.py +12 -12
  14. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/integration/test_selve_gateway_integration.py +3 -12
  15. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/integration/test_selve_integration.py +19 -32
  16. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/test_replacement.py +16 -17
  17. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_gateway_configuration_issues.py +2 -2
  18. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_gateway_error_handling_fixed.py +6 -8
  19. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_missing_components.py +18 -20
  20. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_mock_commands.py +23 -25
  21. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_mock_devices_and_groups.py +28 -30
  22. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_mock_sensors_and_senders.py +25 -27
  23. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_port_discovery.py +5 -5
  24. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_selve_advanced_coverage.py +5 -9
  25. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_selve_core_coverage.py +17 -99
  26. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_selve_edge_cases.py +3 -2
  27. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_selve_gateway.py +8 -10
  28. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_selve_init_comprehensive.py +22 -24
  29. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_selve_init_simple.py +9 -13
  30. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_selve_main_class_extensive.py +13 -26
  31. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_service_command_errors.py +2 -2
  32. python_selve_new-2.5.1/.github/workflows/python-publish.yml +0 -100
  33. python_selve_new-2.5.1/selve/_version.py +0 -34
  34. python_selve_new-2.5.1/selve/util/serial_transport.py +0 -129
  35. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.github/FUNDING.yml +0 -0
  36. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.github/architect.chatmode.md +0 -0
  37. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.github/ask.chatmode.md +0 -0
  38. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.github/code.chatmode.md +0 -0
  39. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.github/debug.chatmode.md +0 -0
  40. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.gitignore +0 -0
  41. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.idea/.gitignore +0 -0
  42. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  43. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.idea/misc.xml +0 -0
  44. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.idea/modules.xml +0 -0
  45. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.idea/python-selve-new.iml +0 -0
  46. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/.idea/vcs.xml +0 -0
  47. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/CHANGELOG.md +0 -0
  48. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/LICENSE +0 -0
  49. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/README.md +0 -0
  50. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/coverage_xdist_example.txt +0 -0
  51. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/debug_response.py +0 -0
  52. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/debug_test.bat +0 -0
  53. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/direct_hardware_test.bat +0 -0
  54. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/direct_hardware_test.py +0 -0
  55. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/generate_coverage.bat +0 -0
  56. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/package.sh +0 -0
  57. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/python_selve_new.egg-info/SOURCES.txt +0 -0
  58. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/python_selve_new.egg-info/dependency_links.txt +0 -0
  59. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/python_selve_new.egg-info/top_level.txt +0 -0
  60. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/release.py +0 -0
  61. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/run_all_tests.bat +0 -0
  62. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/run_hardware_tests.bat +0 -0
  63. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/run_integration_tests.bat +0 -0
  64. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/run_mock_tests.bat +0 -0
  65. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/run_single_test.bat +0 -0
  66. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/run_tests.bat +0 -0
  67. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/__init__.py +0 -0
  68. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/command.py +0 -0
  69. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/device.py +0 -0
  70. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/event.py +0 -0
  71. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/firmware.py +0 -0
  72. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/group.py +0 -0
  73. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/iveo.py +0 -0
  74. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/param.py +0 -0
  75. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/senSim.py +0 -0
  76. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/sender.py +0 -0
  77. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/sensor.py +0 -0
  78. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/commands/service.py +0 -0
  79. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/device.py +0 -0
  80. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/gateway.py +0 -0
  81. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/group.py +0 -0
  82. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/iveo.py +0 -0
  83. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/senSim.py +0 -0
  84. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/sender.py +0 -0
  85. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/sensor.py +0 -0
  86. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/util/__init__.py +0 -0
  87. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/util/errors.py +0 -0
  88. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/selve/util/protocol.py +0 -0
  89. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/setup.cfg +0 -0
  90. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/setup_and_test.bat +0 -0
  91. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/__init__.py +0 -0
  92. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/integration/README.md +0 -0
  93. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/integration/__init__.py +0 -0
  94. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/integration/test_selve_hardware.py +0 -0
  95. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/test_import.py +0 -0
  96. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/__init__.py +0 -0
  97. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/mock_utils.py +0 -0
  98. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_command_coverage.py +0 -0
  99. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_commands.py +0 -0
  100. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_device.py +0 -0
  101. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_device_classes_coverage.py +0 -0
  102. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_device_commands_extended.py +0 -0
  103. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_group.py +0 -0
  104. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_group_commands.py +0 -0
  105. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_param_commands_extended.py +0 -0
  106. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_selve_init_response_coverage.py +0 -0
  107. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_sender_commands_extended.py +0 -0
  108. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_sensim_commands_extended.py +0 -0
  109. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_sensor_commands_extended.py +0 -0
  110. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_service_commands.py +0 -0
  111. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/tests/unit/test_util.py +0 -0
  112. {python_selve_new-2.5.1 → python_selve_new-2.5.3}/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.3
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.3
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,20 @@ except ImportError:
12
12
  __version__ = "unknown"
13
13
 
14
14
  import asyncio
15
- import threading
15
+ import logging
16
16
  import time
17
17
  from collections import deque
18
18
  from itertools import chain
19
19
  from typing import Callable, Optional
20
20
 
21
- import serial
22
- from serial.tools import list_ports
23
- from serial import SerialException
21
+ try:
22
+ from serial.tools import list_ports as _serial_list_ports
23
+ def _comports():
24
+ return _serial_list_ports.comports()
25
+ except ImportError:
26
+ def _comports(): # type: ignore[misc]
27
+ return []
28
+
24
29
  import untangle
25
30
 
26
31
  from selve.commands import param, service
@@ -105,7 +110,7 @@ class Selve:
105
110
  self.reversedStopPosition = 0
106
111
 
107
112
  #Logger
108
- self._LOGGER = logger
113
+ self._LOGGER: logging.Logger = logger or logging.getLogger(__name__)
109
114
 
110
115
 
111
116
  # Legacy worker was removed in favor of dedicated TX/RX tasks.
@@ -113,21 +118,21 @@ class Selve:
113
118
  # Kept for backward compatibility in tests/mocks.
114
119
  return True
115
120
 
116
- def _build_transport(self, port: str):
121
+ async def _build_transport(self, port: str):
117
122
  self._transport = SerialTransport(port=port, logger=self._LOGGER)
118
- self._transport.ensure_open()
119
- self._serial = self._transport.serial
123
+ await self._transport.ensure_open()
124
+ self._serial = None
120
125
 
121
- def _teardown_transport(self):
126
+ async def _teardown_transport(self):
122
127
  if self._transport:
123
- self._transport.shutdown()
128
+ await self._transport.shutdown()
124
129
  self._transport = None
125
130
  self._serial = None
126
131
 
127
132
  async def _probe_port(self, port: str, fromConfigFlow: bool = False) -> bool:
128
133
  """Attempt to connect and verify a Selve gateway on the given port."""
129
134
  try:
130
- self._build_transport(port)
135
+ await self._build_transport(port)
131
136
  ok = await self.pingGateway(fromConfigFlow=fromConfigFlow)
132
137
  if ok:
133
138
  try:
@@ -142,13 +147,12 @@ class Selve:
142
147
  self._LOGGER.debug(f"Probe failed on {port}: {e}")
143
148
 
144
149
  await self.stopWorker()
145
- self._teardown_transport()
150
+ await self._teardown_transport()
146
151
  return False
147
152
 
148
153
 
149
154
  def list_ports(self):
150
- available_ports = list_ports.comports()
151
- return available_ports
155
+ return _comports()
152
156
 
153
157
  async def check_port(self, port):
154
158
  if port is not None:
@@ -172,22 +176,14 @@ class Selve:
172
176
  await self.discover()
173
177
  await self.startWorker()
174
178
  return
175
- except (serial.SerialException, IOError) as e:
179
+ except (OSError, IOError) as e:
176
180
  self._LOGGER.debug("Configured port not valid! " + str(e))
177
181
  except Exception as e:
178
182
  self._LOGGER.error("Unknown exception: " + str(e))
179
183
 
180
184
 
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()
185
+ loop = asyncio.get_running_loop()
186
+ available_ports = await loop.run_in_executor(None, _comports)
191
187
 
192
188
  self._LOGGER.debug("available comports: " + str(available_ports))
193
189
 
@@ -216,31 +212,25 @@ class Selve:
216
212
  await asyncio.sleep(5)
217
213
  self._LOGGER.debug("(Selve Worker): " + "Recovering")
218
214
 
219
- self._teardown_transport()
215
+ # Tear down the broken transport, then rebuild it directly without calling
216
+ # _probe_port / stopWorker, which would kill the running TX/dispatch tasks.
217
+ await self._teardown_transport()
220
218
 
221
219
  if self._port is not None:
222
220
  try:
223
- if await self._probe_port(self._port, fromConfigFlow=False):
224
- 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)
227
- return
228
- except (serial.SerialException, IOError) as e:
229
- self._LOGGER.debug("(Selve Worker): " + "Configured port not valid, maybe it has changed, trying other ports...")
221
+ await self._build_transport(self._port)
222
+ if self.rxQ is not None and self._transport is not None:
223
+ await self._transport.start_reader(self.rxQ)
224
+ self._LOGGER.info("(Selve Worker): Recovery successful on " + str(self._port))
225
+ return
226
+ except (OSError, IOError) as e:
227
+ self._LOGGER.debug("(Selve Worker): " + "Configured port not valid, maybe it has changed, trying other ports... " + str(e))
230
228
  except Exception as e:
231
229
  self._LOGGER.error("(Selve Worker): " + "Unknown exception: " + str(e))
232
230
 
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
-
231
+ loop = asyncio.get_running_loop()
232
+ available_ports = await loop.run_in_executor(None, _comports)
233
+
244
234
  self._LOGGER.debug("(Selve Worker): " + "available comports: " + str(available_ports))
245
235
 
246
236
  if len(available_ports) == 0:
@@ -249,11 +239,12 @@ class Selve:
249
239
 
250
240
  for p in available_ports:
251
241
  try:
252
- if await self._probe_port(p.device, fromConfigFlow=False):
253
- 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)
256
- return
242
+ await self._build_transport(p.device)
243
+ self._port = p.device
244
+ if self.rxQ is not None and self._transport is not None:
245
+ await self._transport.start_reader(self.rxQ)
246
+ self._LOGGER.info("(Selve Worker): Recovery successful on " + str(p.device))
247
+ return
257
248
  except Exception as e:
258
249
  self._LOGGER.error("(Selve Worker): " + "Error at com port: " + str(e))
259
250
  else:
@@ -273,12 +264,11 @@ class Selve:
273
264
  if self._event_queue is None or not isinstance(self._event_queue, asyncio.Queue):
274
265
  self._event_queue = asyncio.Queue()
275
266
 
276
- # Ensure transport and reader thread are running
277
- loop = self.loop or asyncio.get_running_loop()
267
+ # Ensure transport and reader task are running
278
268
  if self._transport is None and self._port is not None:
279
- self._build_transport(self._port)
269
+ await self._build_transport(self._port)
280
270
  if self._transport is not None:
281
- self._transport.start_reader(loop, self.rxQ)
271
+ await self._transport.start_reader(self.rxQ)
282
272
 
283
273
  if self._tx_task is None or self._tx_task.done():
284
274
  self._tx_task = asyncio.create_task(self._tx_loop())
@@ -371,7 +361,7 @@ class Selve:
371
361
  self._dispatch_task = None
372
362
  self._pending_futures.clear()
373
363
  if self._transport:
374
- self._transport.stop_reader()
364
+ await self._transport.stop_reader()
375
365
  if self._event_queue is not None:
376
366
  while not self._event_queue.empty():
377
367
  try:
@@ -387,7 +377,7 @@ class Selve:
387
377
  self._LOGGER.debug("Preparing for termination")
388
378
  await self.stopWorker()
389
379
  # close the serial port, do the cleanup
390
- self._teardown_transport()
380
+ await self._teardown_transport()
391
381
  return True
392
382
 
393
383
 
@@ -428,20 +418,20 @@ class Selve:
428
418
  if self._transport is None:
429
419
  if self._port is None:
430
420
  raise PortError("No serial port configured")
431
- self._build_transport(self._port)
421
+ await self._build_transport(self._port)
432
422
 
433
423
  await self._transport.write(commandstr)
434
424
  # small pause to give the gateway time to answer
435
425
  await asyncio.sleep(0.1)
436
426
 
437
- except (serial.SerialException, IOError) as se:
427
+ except (OSError, IOError) as se:
438
428
  self._LOGGER.info('Serial error, trying to reconnect once... ' + str(se))
439
429
  await self.recover()
440
430
 
441
431
  try:
442
432
  self._LOGGER.debug('Trying again...')
443
433
  if self._transport is None and self._port is not None:
444
- self._build_transport(self._port)
434
+ await self._build_transport(self._port)
445
435
  await self._transport.write(commandstr)
446
436
  await asyncio.sleep(0.1)
447
437
 
@@ -1073,20 +1063,39 @@ class Selve:
1073
1063
  callback(response)
1074
1064
 
1075
1065
 
1076
- def _handleCommandResult(self, response: IveoResultResponse | CommandResultResponse):
1077
-
1078
- # if isinstance(response, IveoResultResponse):
1079
- # for id in response.executedIds:
1080
- # dev = self.getDevice(id, SelveTypes.IVEO)
1081
-
1082
- # if response.command is DriveCommandIveo.DOWN:
1083
- # dev.state = MovementState.DOWN_ON
1084
- # if response.command is DriveCommandIveo.UP:
1085
- # dev.state = MovementState.UP_ON
1086
- # if response.command is DriveCommandIveo.STOP:
1087
- # dev.state = MovementState.STOPPED_OFF
1088
-
1089
- # self.addOrUpdateDevice(dev, SelveTypes.IVEO)
1066
+ def _handleCommandResult(self, response):
1067
+ if isinstance(response, IveoResultResponse):
1068
+ for id in response.executedIds:
1069
+ dev = self.getDevice(id, SelveTypes.IVEO)
1070
+ if dev is None:
1071
+ continue
1072
+ if response.command is DriveCommandIveo.DOWN:
1073
+ dev.state = MovementState.DOWN_ON
1074
+ elif response.command is DriveCommandIveo.UP:
1075
+ dev.state = MovementState.UP_ON
1076
+ elif response.command is DriveCommandIveo.STOP:
1077
+ dev.state = MovementState.STOPPED_OFF
1078
+ self.addOrUpdateDevice(dev, SelveTypes.IVEO)
1079
+
1080
+ elif isinstance(response, CommandResultResponse):
1081
+ for id in response.successIds:
1082
+ dev = self.getDevice(id, SelveTypes.DEVICE)
1083
+ if dev is None:
1084
+ continue
1085
+ dev.unreachable = False
1086
+ if response.command is DriveCommandCommeo.DRIVEDOWN:
1087
+ dev.state = MovementState.DOWN_ON
1088
+ elif response.command is DriveCommandCommeo.DRIVEUP:
1089
+ dev.state = MovementState.UP_ON
1090
+ elif response.command is DriveCommandCommeo.STOP:
1091
+ dev.state = MovementState.STOPPED_OFF
1092
+ self.addOrUpdateDevice(dev, SelveTypes.DEVICE)
1093
+ for id in response.failedIds:
1094
+ dev = self.getDevice(id, SelveTypes.DEVICE)
1095
+ if dev is None:
1096
+ continue
1097
+ dev.unreachable = True
1098
+ self.addOrUpdateDevice(dev, SelveTypes.DEVICE)
1090
1099
 
1091
1100
  for callback in self._callbacks:
1092
1101
  callback()
@@ -1188,7 +1197,8 @@ class Selve:
1188
1197
  while await self.gatewayState() != ServiceState.READY:
1189
1198
  if time.time() - start_time >= 30:
1190
1199
  self._LOGGER.info("Error: Gateway could not be reset or loads too long")
1191
- pass
1200
+ break
1201
+ await asyncio.sleep(0.1)
1192
1202
  self._LOGGER.info("Gateway reset")
1193
1203
 
1194
1204
  async def factoryResetGateway(self):
@@ -1201,7 +1211,8 @@ class Selve:
1201
1211
  while await self.gatewayState() != ServiceState.READY:
1202
1212
  if time.time() - start_time >= 60:
1203
1213
  self._LOGGER.info("Error: Gateway could not be reset or loads too long")
1204
- pass
1214
+ break
1215
+ await asyncio.sleep(0.1)
1205
1216
  self._LOGGER.info("Gateway factory reset")
1206
1217
  return response.executed
1207
1218
 
@@ -1364,7 +1375,7 @@ class Selve:
1364
1375
  else:
1365
1376
  dev.targetValue = 100 - response.targetValue if response.targetValue else 0
1366
1377
 
1367
- dev.unreachable = response.unreachable if response.unreachable else True
1378
+ dev.unreachable = response.unreachable
1368
1379
  dev.overload = response.overload if response.overload else False
1369
1380
  dev.obstructed = response.obstructed if response.obstructed else False
1370
1381
  dev.alarm = response.alarm if response.alarm else False
@@ -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.3'
22
+ __version_tuple__ = version_tuple = (2, 5, 3)
23
+
24
+ __commit_id__ = commit_id = 'ge708968d7'
@@ -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