python-selve-new 2.5.7__tar.gz → 2.5.9__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 (110) hide show
  1. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/PKG-INFO +1 -1
  2. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/python_selve_new.egg-info/PKG-INFO +1 -1
  3. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/python_selve_new.egg-info/SOURCES.txt +1 -0
  4. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/_version.py +3 -3
  5. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/util/serial_transport.py +75 -13
  6. python_selve_new-2.5.9/tests/unit/test_serial_transport.py +143 -0
  7. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.github/FUNDING.yml +0 -0
  8. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.github/architect.chatmode.md +0 -0
  9. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.github/ask.chatmode.md +0 -0
  10. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.github/code.chatmode.md +0 -0
  11. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.github/debug.chatmode.md +0 -0
  12. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.github/workflows/python-publish.yml +0 -0
  13. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.github/workflows/tests.yml +0 -0
  14. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.gitignore +0 -0
  15. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.idea/.gitignore +0 -0
  16. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  17. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.idea/misc.xml +0 -0
  18. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.idea/modules.xml +0 -0
  19. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.idea/python-selve-new.iml +0 -0
  20. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/.idea/vcs.xml +0 -0
  21. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/CHANGELOG.md +0 -0
  22. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/LICENSE +0 -0
  23. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/README.md +0 -0
  24. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/coverage_xdist_example.txt +0 -0
  25. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/debug_response.py +0 -0
  26. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/debug_test.bat +0 -0
  27. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/direct_hardware_test.bat +0 -0
  28. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/direct_hardware_test.py +0 -0
  29. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/generate_coverage.bat +0 -0
  30. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/package.sh +0 -0
  31. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/pyproject.toml +0 -0
  32. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/python_selve_new.egg-info/dependency_links.txt +0 -0
  33. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/python_selve_new.egg-info/requires.txt +0 -0
  34. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/python_selve_new.egg-info/top_level.txt +0 -0
  35. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/release.py +0 -0
  36. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/run_all_tests.bat +0 -0
  37. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/run_hardware_tests.bat +0 -0
  38. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/run_integration_tests.bat +0 -0
  39. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/run_mock_tests.bat +0 -0
  40. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/run_single_test.bat +0 -0
  41. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/run_tests.bat +0 -0
  42. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/__init__.py +0 -0
  43. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/__init__.py +0 -0
  44. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/command.py +0 -0
  45. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/device.py +0 -0
  46. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/event.py +0 -0
  47. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/firmware.py +0 -0
  48. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/group.py +0 -0
  49. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/iveo.py +0 -0
  50. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/param.py +0 -0
  51. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/senSim.py +0 -0
  52. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/sender.py +0 -0
  53. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/sensor.py +0 -0
  54. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/commands/service.py +0 -0
  55. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/device.py +0 -0
  56. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/gateway.py +0 -0
  57. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/group.py +0 -0
  58. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/iveo.py +0 -0
  59. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/senSim.py +0 -0
  60. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/sender.py +0 -0
  61. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/sensor.py +0 -0
  62. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/util/__init__.py +0 -0
  63. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/util/errors.py +0 -0
  64. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/selve/util/protocol.py +0 -0
  65. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/setup.cfg +0 -0
  66. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/setup.py +0 -0
  67. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/setup_and_test.bat +0 -0
  68. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/__init__.py +0 -0
  69. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/conftest.py +0 -0
  70. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/integration/README.md +0 -0
  71. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/integration/__init__.py +0 -0
  72. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/integration/conftest.py +0 -0
  73. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/integration/test_device_integration.py +0 -0
  74. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/integration/test_selve_gateway_integration.py +0 -0
  75. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/integration/test_selve_hardware.py +0 -0
  76. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/integration/test_selve_integration.py +0 -0
  77. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/test_import.py +0 -0
  78. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/test_replacement.py +0 -0
  79. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/__init__.py +0 -0
  80. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/mock_utils.py +0 -0
  81. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_command_coverage.py +0 -0
  82. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_commands.py +0 -0
  83. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_device.py +0 -0
  84. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_device_classes_coverage.py +0 -0
  85. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_device_commands_extended.py +0 -0
  86. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_gateway_configuration_issues.py +0 -0
  87. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_gateway_error_handling_fixed.py +0 -0
  88. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_group.py +0 -0
  89. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_group_commands.py +0 -0
  90. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_missing_components.py +0 -0
  91. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_mock_commands.py +0 -0
  92. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_mock_devices_and_groups.py +0 -0
  93. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_mock_sensors_and_senders.py +0 -0
  94. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_param_commands_extended.py +0 -0
  95. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_port_discovery.py +0 -0
  96. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_selve_advanced_coverage.py +0 -0
  97. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_selve_core_coverage.py +0 -0
  98. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_selve_edge_cases.py +0 -0
  99. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_selve_gateway.py +0 -0
  100. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_selve_init_comprehensive.py +0 -0
  101. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_selve_init_response_coverage.py +0 -0
  102. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_selve_init_simple.py +0 -0
  103. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_selve_main_class_extensive.py +0 -0
  104. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_sender_commands_extended.py +0 -0
  105. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_sensim_commands_extended.py +0 -0
  106. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_sensor_commands_extended.py +0 -0
  107. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_service_command_errors.py +0 -0
  108. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_service_commands.py +0 -0
  109. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_util.py +0 -0
  110. {python_selve_new-2.5.7 → python_selve_new-2.5.9}/tests/unit/test_utility_coverage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-selve-new
3
- Version: 2.5.7
3
+ Version: 2.5.9
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-selve-new
3
- Version: 2.5.7
3
+ Version: 2.5.9
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
@@ -101,6 +101,7 @@ tests/unit/test_selve_main_class_extensive.py
101
101
  tests/unit/test_sender_commands_extended.py
102
102
  tests/unit/test_sensim_commands_extended.py
103
103
  tests/unit/test_sensor_commands_extended.py
104
+ tests/unit/test_serial_transport.py
104
105
  tests/unit/test_service_command_errors.py
105
106
  tests/unit/test_service_commands.py
106
107
  tests/unit/test_util.py
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.5.7'
22
- __version_tuple__ = version_tuple = (2, 5, 7)
21
+ __version__ = version = '2.5.9'
22
+ __version_tuple__ = version_tuple = (2, 5, 9)
23
23
 
24
- __commit_id__ = commit_id = 'gaf58df608'
24
+ __commit_id__ = commit_id = 'gc04157d25'
@@ -5,6 +5,38 @@ from typing import Optional
5
5
  import serialx
6
6
  from serialx import Parity
7
7
 
8
+ # Any data chunk arriving within this window extends the deadline.
9
+ # Only a truly dead/disconnected port triggers a reconnect.
10
+ _IDLE_TIMEOUT = 60.0
11
+
12
+ # The gateway protocol uses XML-RPC framing. Every message ends with one of
13
+ # these two tags; there are no other root-level closing tags in the protocol.
14
+ _END_TAGS = (b"</methodResponse>", b"</methodCall>")
15
+
16
+
17
+ def _extract_messages(buffer: bytes) -> tuple:
18
+ """Extract all complete XML messages from *buffer*.
19
+
20
+ Returns (messages, remaining_bytes) where *messages* is a list of decoded
21
+ strings and *remaining_bytes* is the unconsumed tail of the buffer.
22
+ """
23
+ messages: list = []
24
+ while True:
25
+ earliest_end = None
26
+ for tag in _END_TAGS:
27
+ pos = buffer.find(tag)
28
+ if pos >= 0:
29
+ end = pos + len(tag)
30
+ if earliest_end is None or end < earliest_end:
31
+ earliest_end = end
32
+ if earliest_end is None:
33
+ break
34
+ msg = buffer[:earliest_end].decode(errors="ignore").strip()
35
+ buffer = buffer[earliest_end:]
36
+ if msg:
37
+ messages.append(msg)
38
+ return messages, buffer
39
+
8
40
 
9
41
  class SerialTransport:
10
42
  """Async serial transport using serialx (replaces pyserial + background thread)."""
@@ -36,6 +68,7 @@ class SerialTransport:
36
68
 
37
69
  async def ensure_open(self) -> None:
38
70
  if not self.is_open:
71
+ self._logger.info("Serial: opening %s", self._port)
39
72
  self._reader, self._writer = await serialx.open_serial_connection(
40
73
  url=self._port,
41
74
  baudrate=self._baudrate,
@@ -46,8 +79,10 @@ class SerialTransport:
46
79
  rtscts=False,
47
80
  dsrdtr=False,
48
81
  )
82
+ self._logger.info("Serial: %s opened", self._port)
49
83
 
50
84
  async def close(self) -> None:
85
+ self._logger.info("Serial: closing %s", self._port)
51
86
  try:
52
87
  if self._writer and not self._writer.is_closing():
53
88
  self._writer.close()
@@ -56,34 +91,63 @@ class SerialTransport:
56
91
  self._logger.debug("Serial close failed", exc_info=True)
57
92
  self._reader = None
58
93
  self._writer = None
94
+ self._logger.info("Serial: %s closed", self._port)
59
95
 
60
96
  async def start_reader(self, rx_queue: asyncio.Queue) -> None:
61
97
  """Opens the serial port and starts the async reader task."""
62
98
  await self.ensure_open()
63
99
  self._rx_queue = rx_queue
64
100
  if self._reader_task and not self._reader_task.done():
101
+ self._logger.info("Serial: reader task already running")
65
102
  return
103
+ self._logger.info("Serial: starting reader task")
66
104
  self._reader_task = asyncio.create_task(
67
105
  self._reader_loop(), name="selve-serial-reader"
68
106
  )
69
107
 
70
108
  async def stop_reader(self) -> None:
71
109
  if self._reader_task:
110
+ self._logger.info("Serial: stopping reader task")
72
111
  self._reader_task.cancel()
73
112
  try:
74
113
  await self._reader_task
75
114
  except asyncio.CancelledError:
76
115
  pass
77
116
  self._reader_task = None
117
+ self._logger.info("Serial: reader task stopped")
78
118
 
79
119
  async def _reader_loop(self) -> None:
80
- buffer = ""
120
+ """Read raw bytes and dispatch complete XML messages by framing on closing tags.
121
+
122
+ Using read() instead of readline() avoids depending on \\n as a message
123
+ delimiter. The Selve protocol is XML-RPC: every gateway message ends with
124
+ either </methodResponse> or </methodCall>. We scan the byte buffer for
125
+ these tags and dispatch as soon as a complete message is available.
126
+
127
+ A 60-second idle timeout (no bytes at all) triggers a reconnect. This is
128
+ far more conservative than the old 12-second readline hack and only fires
129
+ when the serial port is truly dead.
130
+ """
131
+ self._logger.info("Serial: reader loop started")
132
+ buffer = b""
81
133
  while True:
82
134
  try:
83
- if self._reader is None:
84
- await self.ensure_open()
85
- line_bytes = await self._reader.readline()
135
+ await self.ensure_open()
136
+ assert self._reader is not None
137
+ chunk = await asyncio.wait_for(
138
+ self._reader.read(4096), timeout=_IDLE_TIMEOUT
139
+ )
140
+ except asyncio.TimeoutError:
141
+ self._logger.warning(
142
+ "Serial: no data for %.0fs — reconnecting", _IDLE_TIMEOUT
143
+ )
144
+ buffer = b""
145
+ await self.close()
146
+ await asyncio.sleep(1.0)
147
+ await self.ensure_open()
148
+ continue
86
149
  except asyncio.CancelledError:
150
+ self._logger.info("Serial: reader loop cancelled")
87
151
  break
88
152
  except (OSError, EOFError) as exc:
89
153
  self._logger.error("Serial read error: %s", exc)
@@ -94,19 +158,17 @@ class SerialTransport:
94
158
  await asyncio.sleep(1)
95
159
  continue
96
160
 
97
- if not line_bytes:
161
+ if not chunk:
98
162
  await asyncio.sleep(0.01)
99
163
  continue
100
164
 
101
- decoded = line_bytes.decode(errors="ignore").strip()
102
- if decoded == "":
103
- if buffer and self._rx_queue:
104
- self._logger.debug("Serial RX: %s", buffer)
105
- await self._rx_queue.put(buffer)
106
- buffer = ""
107
- continue
165
+ buffer += chunk
108
166
 
109
- buffer += decoded
167
+ new_messages, buffer = _extract_messages(buffer)
168
+ for msg in new_messages:
169
+ self._logger.debug("Serial RX: %s", msg)
170
+ if self._rx_queue:
171
+ await self._rx_queue.put(msg)
110
172
 
111
173
  async def write(self, payload: bytes) -> None:
112
174
  await self.ensure_open()
@@ -0,0 +1,143 @@
1
+ import asyncio
2
+ import pytest
3
+ from unittest.mock import AsyncMock, MagicMock, patch
4
+
5
+ from selve.util.serial_transport import _extract_messages
6
+
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Pure synchronous tests for _extract_messages
10
+ # ---------------------------------------------------------------------------
11
+
12
+ def test_single_method_response():
13
+ xml = b"<methodResponse><array><string>selve.GW.service.ping</string></array></methodResponse>"
14
+ msgs, remaining = _extract_messages(xml)
15
+ assert len(msgs) == 1
16
+ assert "</methodResponse>" in msgs[0]
17
+ assert "selve.GW.service.ping" in msgs[0]
18
+ assert remaining == b""
19
+
20
+
21
+ def test_single_method_call():
22
+ xml = b"<methodCall><methodName>selve.GW.event.device</methodName><array></array></methodCall>"
23
+ msgs, remaining = _extract_messages(xml)
24
+ assert len(msgs) == 1
25
+ assert "</methodCall>" in msgs[0]
26
+ assert "event.device" in msgs[0]
27
+ assert remaining == b""
28
+
29
+
30
+ def test_two_messages_in_one_chunk():
31
+ chunk = (
32
+ b"<methodResponse><array><string>selve.GW.service.ping</string></array></methodResponse>"
33
+ b"<methodCall><methodName>selve.GW.event.dutyCycle</methodName><array></array></methodCall>"
34
+ )
35
+ msgs, remaining = _extract_messages(chunk)
36
+ assert len(msgs) == 2
37
+ assert any("methodResponse" in m for m in msgs)
38
+ assert any("methodCall" in m for m in msgs)
39
+ assert remaining == b""
40
+
41
+
42
+ def test_incomplete_message_stays_in_buffer():
43
+ partial = b"<methodResponse><array><string>selve.GW.service.ping</string></array>"
44
+ msgs, remaining = _extract_messages(partial)
45
+ assert msgs == []
46
+ assert remaining == partial
47
+
48
+
49
+ def test_message_split_across_chunks():
50
+ xml = b"<methodResponse><array><string>selve.GW.service.ping</string></array></methodResponse>"
51
+ mid = len(xml) // 2
52
+ # First chunk: no complete message yet
53
+ msgs1, buf = _extract_messages(xml[:mid])
54
+ assert msgs1 == []
55
+ # Second chunk completes the message
56
+ msgs2, remaining = _extract_messages(buf + xml[mid:])
57
+ assert len(msgs2) == 1
58
+ assert "</methodResponse>" in msgs2[0]
59
+ assert remaining == b""
60
+
61
+
62
+ def test_xml_preamble_included_in_message():
63
+ xml = (
64
+ b'<?xml version="1.0"? encoding="UTF-8">'
65
+ b"<methodResponse><array><string>selve.GW.service.ping</string></array></methodResponse>"
66
+ )
67
+ msgs, remaining = _extract_messages(xml)
68
+ assert len(msgs) == 1
69
+ assert "methodResponse" in msgs[0]
70
+
71
+
72
+ def test_garbage_before_message_is_included():
73
+ """Leading whitespace/garbage bytes before the opening tag stay in the message."""
74
+ xml = b"\r\n <methodResponse><array></array></methodResponse>"
75
+ msgs, remaining = _extract_messages(xml)
76
+ assert len(msgs) == 1
77
+ assert "methodResponse" in msgs[0]
78
+
79
+
80
+ def test_empty_buffer():
81
+ msgs, remaining = _extract_messages(b"")
82
+ assert msgs == []
83
+ assert remaining == b""
84
+
85
+
86
+ def test_fault_response_uses_method_response_tag():
87
+ """Fault responses are wrapped in <methodResponse>, same closing tag."""
88
+ xml = b"<methodResponse><fault><array><string>Error</string><int>-1</int></array></fault></methodResponse>"
89
+ msgs, remaining = _extract_messages(xml)
90
+ assert len(msgs) == 1
91
+ assert "fault" in msgs[0]
92
+ assert remaining == b""
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Async test: idle timeout triggers reconnect
97
+ # ---------------------------------------------------------------------------
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_idle_timeout_triggers_reconnect():
101
+ """If no data arrives for _IDLE_TIMEOUT seconds, close+ensure_open are called."""
102
+ import selve.util.serial_transport as st
103
+ from selve.util.serial_transport import SerialTransport
104
+
105
+ transport = SerialTransport(port="COM_TEST", logger=MagicMock())
106
+
107
+ # Reader that never produces data (simulates dead port)
108
+ reader = asyncio.StreamReader()
109
+ transport._reader = reader
110
+ transport._writer = MagicMock(is_closing=MagicMock(return_value=False))
111
+ transport._rx_queue = asyncio.Queue()
112
+
113
+ close_calls = []
114
+ ensure_calls = []
115
+
116
+ async def fake_close():
117
+ close_calls.append(1)
118
+ transport._reader = None
119
+ transport._writer = None
120
+
121
+ async def fake_ensure():
122
+ ensure_calls.append(1)
123
+ transport._reader = asyncio.StreamReader()
124
+ transport._writer = MagicMock(is_closing=MagicMock(return_value=False))
125
+
126
+ transport.close = fake_close
127
+ transport.ensure_open = fake_ensure
128
+
129
+ original = st._IDLE_TIMEOUT
130
+ st._IDLE_TIMEOUT = 0.05
131
+ try:
132
+ task = asyncio.create_task(transport._reader_loop())
133
+ await asyncio.sleep(0.25)
134
+ task.cancel()
135
+ try:
136
+ await task
137
+ except asyncio.CancelledError:
138
+ pass
139
+ finally:
140
+ st._IDLE_TIMEOUT = original
141
+
142
+ assert len(close_calls) >= 1, "close() should be called on idle timeout"
143
+ assert len(ensure_calls) >= 1, "ensure_open() should be called after reconnect"