python-selve-new 2.5.8__tar.gz → 2.5.10__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.8 → python_selve_new-2.5.10}/PKG-INFO +1 -1
  2. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/python_selve_new.egg-info/PKG-INFO +1 -1
  3. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/python_selve_new.egg-info/SOURCES.txt +1 -0
  4. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/__init__.py +2 -2
  5. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/_version.py +3 -3
  6. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/util/serial_transport.py +59 -30
  7. python_selve_new-2.5.10/tests/unit/test_serial_transport.py +143 -0
  8. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.github/FUNDING.yml +0 -0
  9. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.github/architect.chatmode.md +0 -0
  10. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.github/ask.chatmode.md +0 -0
  11. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.github/code.chatmode.md +0 -0
  12. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.github/debug.chatmode.md +0 -0
  13. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.github/workflows/python-publish.yml +0 -0
  14. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.github/workflows/tests.yml +0 -0
  15. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.gitignore +0 -0
  16. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.idea/.gitignore +0 -0
  17. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  18. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.idea/misc.xml +0 -0
  19. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.idea/modules.xml +0 -0
  20. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.idea/python-selve-new.iml +0 -0
  21. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/.idea/vcs.xml +0 -0
  22. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/CHANGELOG.md +0 -0
  23. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/LICENSE +0 -0
  24. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/README.md +0 -0
  25. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/coverage_xdist_example.txt +0 -0
  26. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/debug_response.py +0 -0
  27. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/debug_test.bat +0 -0
  28. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/direct_hardware_test.bat +0 -0
  29. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/direct_hardware_test.py +0 -0
  30. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/generate_coverage.bat +0 -0
  31. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/package.sh +0 -0
  32. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/pyproject.toml +0 -0
  33. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/python_selve_new.egg-info/dependency_links.txt +0 -0
  34. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/python_selve_new.egg-info/requires.txt +0 -0
  35. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/python_selve_new.egg-info/top_level.txt +0 -0
  36. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/release.py +0 -0
  37. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/run_all_tests.bat +0 -0
  38. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/run_hardware_tests.bat +0 -0
  39. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/run_integration_tests.bat +0 -0
  40. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/run_mock_tests.bat +0 -0
  41. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/run_single_test.bat +0 -0
  42. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/run_tests.bat +0 -0
  43. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/__init__.py +0 -0
  44. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/command.py +0 -0
  45. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/device.py +0 -0
  46. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/event.py +0 -0
  47. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/firmware.py +0 -0
  48. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/group.py +0 -0
  49. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/iveo.py +0 -0
  50. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/param.py +0 -0
  51. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/senSim.py +0 -0
  52. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/sender.py +0 -0
  53. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/sensor.py +0 -0
  54. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/commands/service.py +0 -0
  55. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/device.py +0 -0
  56. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/gateway.py +0 -0
  57. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/group.py +0 -0
  58. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/iveo.py +0 -0
  59. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/senSim.py +0 -0
  60. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/sender.py +0 -0
  61. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/sensor.py +0 -0
  62. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/util/__init__.py +0 -0
  63. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/util/errors.py +0 -0
  64. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/selve/util/protocol.py +0 -0
  65. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/setup.cfg +0 -0
  66. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/setup.py +0 -0
  67. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/setup_and_test.bat +0 -0
  68. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/__init__.py +0 -0
  69. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/conftest.py +0 -0
  70. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/integration/README.md +0 -0
  71. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/integration/__init__.py +0 -0
  72. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/integration/conftest.py +0 -0
  73. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/integration/test_device_integration.py +0 -0
  74. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/integration/test_selve_gateway_integration.py +0 -0
  75. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/integration/test_selve_hardware.py +0 -0
  76. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/integration/test_selve_integration.py +0 -0
  77. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/test_import.py +0 -0
  78. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/test_replacement.py +0 -0
  79. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/__init__.py +0 -0
  80. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/mock_utils.py +0 -0
  81. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_command_coverage.py +0 -0
  82. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_commands.py +0 -0
  83. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_device.py +0 -0
  84. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_device_classes_coverage.py +0 -0
  85. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_device_commands_extended.py +0 -0
  86. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_gateway_configuration_issues.py +0 -0
  87. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_gateway_error_handling_fixed.py +0 -0
  88. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_group.py +0 -0
  89. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_group_commands.py +0 -0
  90. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_missing_components.py +0 -0
  91. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_mock_commands.py +0 -0
  92. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_mock_devices_and_groups.py +0 -0
  93. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_mock_sensors_and_senders.py +0 -0
  94. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_param_commands_extended.py +0 -0
  95. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_port_discovery.py +0 -0
  96. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_selve_advanced_coverage.py +0 -0
  97. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_selve_core_coverage.py +0 -0
  98. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_selve_edge_cases.py +0 -0
  99. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_selve_gateway.py +0 -0
  100. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_selve_init_comprehensive.py +0 -0
  101. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_selve_init_response_coverage.py +0 -0
  102. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_selve_init_simple.py +0 -0
  103. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_selve_main_class_extensive.py +0 -0
  104. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_sender_commands_extended.py +0 -0
  105. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_sensim_commands_extended.py +0 -0
  106. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_sensor_commands_extended.py +0 -0
  107. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_service_command_errors.py +0 -0
  108. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_service_commands.py +0 -0
  109. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/tests/unit/test_util.py +0 -0
  110. {python_selve_new-2.5.8 → python_selve_new-2.5.10}/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.8
3
+ Version: 2.5.10
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.8
3
+ Version: 2.5.10
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
@@ -1106,9 +1106,9 @@ class Selve:
1106
1106
  dev.state = MovementState.UP_ON
1107
1107
  elif response.command is DriveCommandCommeo.STOP:
1108
1108
  dev.state = MovementState.STOPPED_OFF
1109
- elif response.command is DriveCommandCommeo.DRIVESTEPDOWN:
1109
+ elif response.command is DriveCommandCommeo.STEPDOWN:
1110
1110
  dev.state = MovementState.DOWN_ON
1111
- elif response.command is DriveCommandCommeo.DRIVESTEPUP:
1111
+ elif response.command is DriveCommandCommeo.STEPUP:
1112
1112
  dev.state = MovementState.UP_ON
1113
1113
  self.addOrUpdateDevice(dev, SelveTypes.DEVICE)
1114
1114
  for id in response.failedIds:
@@ -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.8'
22
- __version_tuple__ = version_tuple = (2, 5, 8)
21
+ __version__ = version = '2.5.10'
22
+ __version_tuple__ = version_tuple = (2, 5, 10)
23
23
 
24
- __commit_id__ = commit_id = 'g2562749e3'
24
+ __commit_id__ = commit_id = 'gb6507990c'
@@ -5,7 +5,37 @@ from typing import Optional
5
5
  import serialx
6
6
  from serialx import Parity
7
7
 
8
- _READLINE_TIMEOUT = 12.0 # seconds gateway goes silent during RF motor ops
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
9
39
 
10
40
 
11
41
  class SerialTransport:
@@ -49,7 +79,7 @@ class SerialTransport:
49
79
  rtscts=False,
50
80
  dsrdtr=False,
51
81
  )
52
- self._logger.info("Serial: %s opened, reader=%r writer=%r", self._port, self._reader, self._writer)
82
+ self._logger.info("Serial: %s opened", self._port)
53
83
 
54
84
  async def close(self) -> None:
55
85
  self._logger.info("Serial: closing %s", self._port)
@@ -70,7 +100,7 @@ class SerialTransport:
70
100
  if self._reader_task and not self._reader_task.done():
71
101
  self._logger.info("Serial: reader task already running")
72
102
  return
73
- self._logger.info("Serial: starting reader task, reader=%r", self._reader)
103
+ self._logger.info("Serial: starting reader task")
74
104
  self._reader_task = asyncio.create_task(
75
105
  self._reader_loop(), name="selve-serial-reader"
76
106
  )
@@ -87,28 +117,31 @@ class SerialTransport:
87
117
  self._logger.info("Serial: reader task stopped")
88
118
 
89
119
  async def _reader_loop(self) -> None:
90
- self._logger.info("Serial: reader loop started, reader=%r at_eof=%s",
91
- self._reader,
92
- self._reader.at_eof() if self._reader else "N/A")
93
- buffer = ""
94
- iteration = 0
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""
95
133
  while True:
96
134
  try:
97
- if self._reader is None:
98
- self._logger.warning("Serial: reader is None in loop, reopening")
99
- await self.ensure_open()
100
- self._logger.debug("Serial: awaiting readline (iter=%d, buf=%r)", iteration, buffer)
101
- line_bytes = await asyncio.wait_for(
102
- self._reader.readline(), timeout=_READLINE_TIMEOUT
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
103
139
  )
104
- self._logger.debug("Serial: readline returned %d bytes (iter=%d)", len(line_bytes), iteration)
105
- iteration += 1
106
140
  except asyncio.TimeoutError:
107
141
  self._logger.warning(
108
- "Serial: readline timeout after %.0fs (iter=%d, buf=%r) — reconnecting",
109
- _READLINE_TIMEOUT, iteration, buffer,
142
+ "Serial: no data for %.0fs — reconnecting", _IDLE_TIMEOUT
110
143
  )
111
- buffer = ""
144
+ buffer = b""
112
145
  await self.close()
113
146
  await asyncio.sleep(1.0)
114
147
  await self.ensure_open()
@@ -125,21 +158,17 @@ class SerialTransport:
125
158
  await asyncio.sleep(1)
126
159
  continue
127
160
 
128
- if not line_bytes:
129
- self._logger.debug("Serial: readline returned empty bytes (EOF?), reader.at_eof=%s",
130
- self._reader.at_eof() if self._reader else "N/A")
161
+ if not chunk:
131
162
  await asyncio.sleep(0.01)
132
163
  continue
133
164
 
134
- decoded = line_bytes.decode(errors="ignore").strip()
135
- if decoded == "":
136
- if buffer and self._rx_queue:
137
- self._logger.debug("Serial RX: %s", buffer)
138
- await self._rx_queue.put(buffer)
139
- buffer = ""
140
- continue
165
+ buffer += chunk
141
166
 
142
- 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)
143
172
 
144
173
  async def write(self, payload: bytes) -> None:
145
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"