replx 1.2__tar.gz → 1.4__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 (154) hide show
  1. {replx-1.2/replx.egg-info → replx-1.4}/PKG-INFO +1 -1
  2. {replx-1.2 → replx-1.4}/replx/__init__.py +1 -1
  3. {replx-1.2 → replx-1.4}/replx/cli/agent/client/core.py +14 -13
  4. {replx-1.2 → replx-1.4}/replx/cli/agent/client/session.py +0 -25
  5. {replx-1.2 → replx-1.4}/replx/cli/agent/protocol.py +0 -3
  6. {replx-1.2 → replx-1.4}/replx/cli/agent/server/connection_manager.py +18 -91
  7. {replx-1.2 → replx-1.4}/replx/cli/agent/server/core.py +249 -139
  8. {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/exec.py +8 -35
  9. {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/filesystem.py +16 -21
  10. {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/repl.py +53 -7
  11. {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/session.py +6 -9
  12. {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/transfer.py +1 -15
  13. {replx-1.2 → replx-1.4}/replx/cli/agent/server/session_manager.py +3 -13
  14. {replx-1.2 → replx-1.4}/replx/cli/app.py +2 -65
  15. {replx-1.2 → replx-1.4}/replx/cli/commands/device.py +56 -20
  16. {replx-1.2 → replx-1.4}/replx/cli/commands/exec.py +42 -12
  17. {replx-1.2 → replx-1.4}/replx/cli/commands/package.py +124 -163
  18. {replx-1.2 → replx-1.4}/replx/cli/config.py +3 -179
  19. {replx-1.2 → replx-1.4}/replx/cli/connection.py +4 -78
  20. {replx-1.2 → replx-1.4}/replx/cli/helpers/__init__.py +4 -17
  21. {replx-1.2 → replx-1.4}/replx/cli/helpers/compiler.py +0 -29
  22. {replx-1.2 → replx-1.4}/replx/cli/helpers/environment.py +0 -13
  23. {replx-1.2 → replx-1.4}/replx/cli/helpers/output.py +0 -58
  24. {replx-1.2 → replx-1.4}/replx/cli/helpers/registry.py +31 -146
  25. {replx-1.2 → replx-1.4}/replx/cli/helpers/scanner.py +3 -83
  26. {replx-1.2 → replx-1.4}/replx/cli/helpers/updater.py +0 -4
  27. {replx-1.2 → replx-1.4}/replx/commands.py +3 -3
  28. {replx-1.2 → replx-1.4}/replx/protocol/repl.py +46 -5
  29. {replx-1.2 → replx-1.4}/replx/terminal.py +47 -3
  30. replx-1.4/replx/tests/test_agent_asyncio.py +319 -0
  31. replx-1.4/replx/tests/test_agent_thread_pool.py +216 -0
  32. replx-1.4/replx/tests/test_disconnect_cleanup.py +57 -0
  33. replx-1.4/replx/tests/test_lock_cleanup.py +194 -0
  34. replx-1.4/replx/tests/test_repl_reader_task.py +313 -0
  35. replx-1.4/replx/tests/test_session_disconnect_releases_shared_port.py +47 -0
  36. {replx-1.2 → replx-1.4}/replx/transport/__init__.py +0 -11
  37. {replx-1.2 → replx-1.4}/replx/transport/serial.py +13 -18
  38. {replx-1.2 → replx-1.4}/replx/utils/__init__.py +0 -1
  39. {replx-1.2 → replx-1.4}/replx/utils/device_info.py +9 -23
  40. {replx-1.2 → replx-1.4/replx.egg-info}/PKG-INFO +1 -1
  41. {replx-1.2 → replx-1.4}/replx.egg-info/SOURCES.txt +7 -1
  42. replx-1.4/test/test_termio.py +62 -0
  43. replx-1.2/replx/tests/test_disconnect_cleanup.py +0 -46
  44. {replx-1.2 → replx-1.4}/LICENSE +0 -0
  45. {replx-1.2 → replx-1.4}/README.md +0 -0
  46. {replx-1.2 → replx-1.4}/pyproject.toml +0 -0
  47. {replx-1.2 → replx-1.4}/replx/cli/__init__.py +0 -0
  48. {replx-1.2 → replx-1.4}/replx/cli/agent/__init__.py +0 -0
  49. {replx-1.2 → replx-1.4}/replx/cli/agent/client/__init__.py +0 -0
  50. {replx-1.2 → replx-1.4}/replx/cli/agent/server/__init__.py +0 -0
  51. {replx-1.2 → replx-1.4}/replx/cli/agent/server/__main__.py +0 -0
  52. {replx-1.2 → replx-1.4}/replx/cli/agent/server/command_dispatcher.py +0 -0
  53. {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/__init__.py +0 -0
  54. {replx-1.2 → replx-1.4}/replx/cli/commands/__init__.py +0 -0
  55. {replx-1.2 → replx-1.4}/replx/cli/commands/file.py +0 -0
  56. {replx-1.2 → replx-1.4}/replx/cli/commands/firmware.py +0 -0
  57. {replx-1.2 → replx-1.4}/replx/cli/commands/utility.py +0 -0
  58. {replx-1.2 → replx-1.4}/replx/cli/helpers/store.py +0 -0
  59. {replx-1.2 → replx-1.4}/replx/protocol/__init__.py +0 -0
  60. {replx-1.2 → replx-1.4}/replx/protocol/storage.py +0 -0
  61. {replx-1.2 → replx-1.4}/replx/tests/__init__.py +0 -0
  62. {replx-1.2 → replx-1.4}/replx/tests/test_agent_port_canonicalization.py +0 -0
  63. {replx-1.2 → replx-1.4}/replx/tests/test_compiler_arch.py +0 -0
  64. {replx-1.2 → replx-1.4}/replx/tests/test_connection_info_lookup.py +0 -0
  65. {replx-1.2 → replx-1.4}/replx/tests/test_device_info_esp_multi_core.py +0 -0
  66. {replx-1.2 → replx-1.4}/replx/tests/test_pkg_local_version.py +0 -0
  67. {replx-1.2 → replx-1.4}/replx/tests/test_pkg_search_scope_filter.py +0 -0
  68. {replx-1.2 → replx-1.4}/replx/tests/test_session_id_fallback.py +0 -0
  69. {replx-1.2 → replx-1.4}/replx/tests/test_shutdown_status_message.py +0 -0
  70. {replx-1.2 → replx-1.4}/replx/tests/test_windows_com_port_normalization.py +0 -0
  71. {replx-1.2 → replx-1.4}/replx/transport/base.py +0 -0
  72. {replx-1.2 → replx-1.4}/replx/typehints/comm/_thread.pyi +0 -0
  73. {replx-1.2 → replx-1.4}/replx/typehints/comm/aioble/__init__.pyi +0 -0
  74. {replx-1.2 → replx-1.4}/replx/typehints/comm/array.pyi +0 -0
  75. {replx-1.2 → replx-1.4}/replx/typehints/comm/asyncio/__init__.pyi +0 -0
  76. {replx-1.2 → replx-1.4}/replx/typehints/comm/binascii.pyi +0 -0
  77. {replx-1.2 → replx-1.4}/replx/typehints/comm/bluetooth.pyi +0 -0
  78. {replx-1.2 → replx-1.4}/replx/typehints/comm/builtins.pyi +0 -0
  79. {replx-1.2 → replx-1.4}/replx/typehints/comm/cmath.pyi +0 -0
  80. {replx-1.2 → replx-1.4}/replx/typehints/comm/collections.pyi +0 -0
  81. {replx-1.2 → replx-1.4}/replx/typehints/comm/cryptolib.pyi +0 -0
  82. {replx-1.2 → replx-1.4}/replx/typehints/comm/deflate.pyi +0 -0
  83. {replx-1.2 → replx-1.4}/replx/typehints/comm/errno.pyi +0 -0
  84. {replx-1.2 → replx-1.4}/replx/typehints/comm/framebuf.pyi +0 -0
  85. {replx-1.2 → replx-1.4}/replx/typehints/comm/gc.pyi +0 -0
  86. {replx-1.2 → replx-1.4}/replx/typehints/comm/hashlib.pyi +0 -0
  87. {replx-1.2 → replx-1.4}/replx/typehints/comm/heapq.pyi +0 -0
  88. {replx-1.2 → replx-1.4}/replx/typehints/comm/io.pyi +0 -0
  89. {replx-1.2 → replx-1.4}/replx/typehints/comm/json.pyi +0 -0
  90. {replx-1.2 → replx-1.4}/replx/typehints/comm/lwip.pyi +0 -0
  91. {replx-1.2 → replx-1.4}/replx/typehints/comm/machine.pyi +0 -0
  92. {replx-1.2 → replx-1.4}/replx/typehints/comm/math.pyi +0 -0
  93. {replx-1.2 → replx-1.4}/replx/typehints/comm/micropython.pyi +0 -0
  94. {replx-1.2 → replx-1.4}/replx/typehints/comm/mip/__init__.pyi +0 -0
  95. {replx-1.2 → replx-1.4}/replx/typehints/comm/network.pyi +0 -0
  96. {replx-1.2 → replx-1.4}/replx/typehints/comm/ntptime.pyi +0 -0
  97. {replx-1.2 → replx-1.4}/replx/typehints/comm/os.pyi +0 -0
  98. {replx-1.2 → replx-1.4}/replx/typehints/comm/platform.pyi +0 -0
  99. {replx-1.2 → replx-1.4}/replx/typehints/comm/random.pyi +0 -0
  100. {replx-1.2 → replx-1.4}/replx/typehints/comm/re.pyi +0 -0
  101. {replx-1.2 → replx-1.4}/replx/typehints/comm/requests/__init__.pyi +0 -0
  102. {replx-1.2 → replx-1.4}/replx/typehints/comm/select.pyi +0 -0
  103. {replx-1.2 → replx-1.4}/replx/typehints/comm/socket.pyi +0 -0
  104. {replx-1.2 → replx-1.4}/replx/typehints/comm/ssl.pyi +0 -0
  105. {replx-1.2 → replx-1.4}/replx/typehints/comm/struct.pyi +0 -0
  106. {replx-1.2 → replx-1.4}/replx/typehints/comm/sys.pyi +0 -0
  107. {replx-1.2 → replx-1.4}/replx/typehints/comm/time.pyi +0 -0
  108. {replx-1.2 → replx-1.4}/replx/typehints/comm/tls.pyi +0 -0
  109. {replx-1.2 → replx-1.4}/replx/typehints/comm/uasyncio.pyi +0 -0
  110. {replx-1.2 → replx-1.4}/replx/typehints/comm/uctypes.pyi +0 -0
  111. {replx-1.2 → replx-1.4}/replx/typehints/comm/urequests.pyi +0 -0
  112. {replx-1.2 → replx-1.4}/replx/typehints/comm/vfs.pyi +0 -0
  113. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/binascii.pyi +0 -0
  114. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/errno.pyi +0 -0
  115. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/hashlib.pyi +0 -0
  116. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/io.pyi +0 -0
  117. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/json.pyi +0 -0
  118. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/machine.pyi +0 -0
  119. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/math.pyi +0 -0
  120. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/micropython.pyi +0 -0
  121. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/network.pyi +0 -0
  122. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/os.pyi +0 -0
  123. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/select.pyi +0 -0
  124. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/socket.pyi +0 -0
  125. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ssl.pyi +0 -0
  126. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/struct.pyi +0 -0
  127. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/sys.pyi +0 -0
  128. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/time.pyi +0 -0
  129. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ubinascii.pyi +0 -0
  130. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ucryptolib.pyi +0 -0
  131. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uerrno.pyi +0 -0
  132. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uhashlib.pyi +0 -0
  133. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uio.pyi +0 -0
  134. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ujson.pyi +0 -0
  135. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/umachine.pyi +0 -0
  136. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uos.pyi +0 -0
  137. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uselect.pyi +0 -0
  138. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/usocket.pyi +0 -0
  139. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ussl.pyi +0 -0
  140. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ustruct.pyi +0 -0
  141. {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/utime.pyi +0 -0
  142. {replx-1.2 → replx-1.4}/replx/typehints/core/ESP32/aioespnow.pyi +0 -0
  143. {replx-1.2 → replx-1.4}/replx/typehints/core/ESP32/esp.pyi +0 -0
  144. {replx-1.2 → replx-1.4}/replx/typehints/core/ESP32/esp32.pyi +0 -0
  145. {replx-1.2 → replx-1.4}/replx/typehints/core/ESP32/espnow.pyi +0 -0
  146. {replx-1.2 → replx-1.4}/replx/typehints/core/MIMXRT1062DVJ6A/mimxrt.pyi +0 -0
  147. {replx-1.2 → replx-1.4}/replx/typehints/core/RP2350/rp2.pyi +0 -0
  148. {replx-1.2 → replx-1.4}/replx/utils/constants.py +0 -0
  149. {replx-1.2 → replx-1.4}/replx/utils/exceptions.py +0 -0
  150. {replx-1.2 → replx-1.4}/replx.egg-info/dependency_links.txt +0 -0
  151. {replx-1.2 → replx-1.4}/replx.egg-info/entry_points.txt +0 -0
  152. {replx-1.2 → replx-1.4}/replx.egg-info/requires.txt +0 -0
  153. {replx-1.2 → replx-1.4}/replx.egg-info/top_level.txt +0 -0
  154. {replx-1.2 → replx-1.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: replx
3
- Version: 1.2
3
+ Version: 1.4
4
4
  Summary: replx is a fast, modern MicroPython CLI: turbo REPL, robust file sync (put/get), project install, mpy-cross integration, and smart port discovery.
5
5
  Author-email: "chanmin.park" <devcamp@gmail.com>
6
6
  License-Expression: MIT
@@ -1,5 +1,5 @@
1
1
  __all__ = ["__version__", "get_version", "__description__"]
2
- __version__ = "1.2"
2
+ __version__ = "1.4"
3
3
  __description__ = "Fast, modern MicroPython CLI with REPL, file sync, install, and smart port detection."
4
4
  __author__ = "PlanX Lab Development Team"
5
5
 
@@ -33,7 +33,7 @@ class AgentClient:
33
33
  self.sock.close()
34
34
  self.sock = None
35
35
 
36
- def send_command(self, command: str, timeout: float = None, **args) -> Dict[str, Any]:
36
+ def send_command(self, command: str, timeout: float = None, max_retries: int = None, **args) -> Dict[str, Any]:
37
37
  if not self.sock:
38
38
  self.connect()
39
39
 
@@ -58,9 +58,11 @@ class AgentClient:
58
58
  request_data = AgentProtocol.encode_message(request)
59
59
 
60
60
  response = None
61
-
62
- # For short timeouts (< 1s), don't retry to fail fast (e.g., ping/agent check)
63
- max_attempts = 1 if effective_timeout < 1.0 else self.MAX_RETRIES
61
+
62
+ if max_retries is not None:
63
+ max_attempts = max(1, max_retries)
64
+ else:
65
+ max_attempts = 1 if effective_timeout < 1.0 else self.MAX_RETRIES
64
66
 
65
67
  for attempt in range(max_attempts):
66
68
  try:
@@ -155,7 +157,6 @@ class AgentClient:
155
157
  self.sock.settimeout(0.01)
156
158
  input_interval = 0.001
157
159
  last_input_time = 0
158
- error_check_until = time.time() + 0.1
159
160
 
160
161
  try:
161
162
  while True:
@@ -180,15 +181,15 @@ class AgentClient:
180
181
  except Exception:
181
182
  pass
182
183
 
184
+ _pending_error = None
183
185
  try:
184
186
  data, addr = self.sock.recvfrom(MAX_UDP_SIZE)
185
187
  msg = AgentProtocol.decode_message(data)
186
188
 
187
189
  if msg and msg.get('seq') == seq:
188
- if now < error_check_until and msg.get('type') == 'response' and msg.get('error'):
189
- raise RuntimeError(msg['error'])
190
-
191
- if msg.get('type') == 'stream':
190
+ if msg.get('type') == 'response' and msg.get('error'):
191
+ _pending_error = msg['error']
192
+ elif msg.get('type') == 'stream':
192
193
  output = msg.get('output', '')
193
194
  if output and output_callback:
194
195
  output_callback(output.encode('utf-8'), 'stdout')
@@ -204,6 +205,9 @@ class AgentClient:
204
205
  except Exception:
205
206
  pass
206
207
 
208
+ if _pending_error:
209
+ raise RuntimeError(_pending_error)
210
+
207
211
  except KeyboardInterrupt:
208
212
  try:
209
213
  self.send_command(Cmd.RUN_STOP, timeout=0.5)
@@ -324,7 +328,6 @@ class AgentClient:
324
328
 
325
329
  if background:
326
330
  if sys.platform == 'win32':
327
- # Windows: Use DETACHED_PROCESS to run without console
328
331
  startupinfo = subprocess.STARTUPINFO()
329
332
  startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
330
333
  startupinfo.wShowWindow = 0
@@ -337,7 +340,6 @@ class AgentClient:
337
340
  startupinfo=startupinfo
338
341
  )
339
342
  else:
340
- # Unix: Use start_new_session for proper daemon behavior
341
343
  subprocess.Popen(
342
344
  cmd,
343
345
  stdout=subprocess.DEVNULL,
@@ -367,10 +369,9 @@ class AgentClient:
367
369
  except Exception:
368
370
  pass
369
371
 
370
- # Wait for agent to stop, but with faster polling
371
372
  start_time = time.time()
372
373
  while time.time() - start_time < timeout:
373
- time.sleep(0.05) # Shorter sleep for faster response
374
+ time.sleep(0.05)
374
375
  if not AgentClient.is_agent_running(port=port):
375
376
  return True
376
377
 
@@ -3,7 +3,6 @@ from typing import Optional
3
3
  import psutil
4
4
 
5
5
  def _find_terminal_process() -> Optional[dict]:
6
- # Prefer actual shell processes (per-terminal) over host IDE processes (shared).
7
6
  shell_names = {
8
7
  'powershell.exe', 'pwsh.exe', 'cmd.exe', 'bash.exe', 'zsh.exe', 'sh.exe', 'fish.exe',
9
8
  'windowsterminal.exe',
@@ -15,7 +14,6 @@ def _find_terminal_process() -> Optional[dict]:
15
14
  'pycharm.exe', 'pycharm64.exe', 'idea.exe', 'idea64.exe',
16
15
  }
17
16
 
18
- # Fast path: parent pid is typically the actual terminal/shell.
19
17
  try:
20
18
  parent_pid = os.getppid()
21
19
  if parent_pid and parent_pid > 0:
@@ -30,7 +28,6 @@ def _find_terminal_process() -> Optional[dict]:
30
28
  'level': 0,
31
29
  }
32
30
  except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, OSError):
33
- # Fall back to a broader traversal.
34
31
  pass
35
32
 
36
33
  try:
@@ -56,7 +53,6 @@ def _find_terminal_process() -> Optional[dict]:
56
53
  }
57
54
 
58
55
  if name in ide_names and best_ide is None:
59
- # Keep as last resort (may be shared across terminals).
60
56
  try:
61
57
  best_ide = {
62
58
  'pid': current.pid,
@@ -87,7 +83,6 @@ def _find_terminal_process() -> Optional[dict]:
87
83
  return best_ide
88
84
 
89
85
  except Exception:
90
- # Never allow session id discovery to fail hard.
91
86
  pass
92
87
 
93
88
  return None
@@ -122,26 +117,6 @@ def _find_jupyter_kernel() -> Optional[dict]:
122
117
 
123
118
  return None
124
119
 
125
- def _detect_environment() -> str:
126
- try:
127
- __IPYTHON__
128
- return 'ipython'
129
- except NameError:
130
- pass
131
-
132
- env = os.environ
133
-
134
- if any(key.startswith('JPY_') or 'JUPYTER' in key for key in env):
135
- return 'jupyter'
136
-
137
- if env.get('TERM_PROGRAM') == 'vscode':
138
- return 'vscode_terminal'
139
-
140
- if env.get('SHELL') or env.get('TERM'):
141
- return 'terminal'
142
-
143
- return 'unknown'
144
-
145
120
  def get_session_id() -> int:
146
121
  terminal = _find_terminal_process()
147
122
  if terminal:
@@ -6,14 +6,11 @@ from typing import Dict, Any, Optional, List
6
6
 
7
7
  from replx.utils.constants import MAX_PAYLOAD_SIZE
8
8
 
9
- # Protocol constants
10
9
  _MAGIC = b'RPLX'
11
10
  _VERSION = 1
12
11
 
13
12
 
14
13
  class AgentProtocol:
15
- """UDP protocol for agent client-server communication."""
16
-
17
14
  @staticmethod
18
15
  def encode_message(msg: Dict[str, Any]) -> bytes:
19
16
  payload = json.dumps(msg).encode('utf-8')
@@ -2,31 +2,27 @@ import re
2
2
  import sys
3
3
  import time
4
4
  import threading
5
+ from concurrent.futures import Future as ConcurrentFuture
5
6
  from dataclasses import dataclass, field
6
7
  from typing import Optional, Dict, Any, List, Tuple
7
8
 
8
9
  from replx.protocol import ReplProtocol, create_storage
9
10
  from replx.utils import parse_device_banner
11
+ from replx.utils.constants import CTRL_B, CTRL_C
10
12
  from replx.utils.exceptions import TransportError
11
13
 
12
14
 
13
15
  def _detect_device_info(transport, core: str, device: str = None) -> Tuple[str, str, str, str]:
14
- """Detect device information from transport.
15
-
16
- Returns:
17
- Tuple of (version, core, device, manufacturer)
18
- """
19
- # Use shorter delays on Unix for faster failure on invalid ports
20
16
  delay1 = 0.05 if sys.platform != "win32" else 0.1
21
17
  delay2 = 0.1 if sys.platform != "win32" else 0.2
22
18
  delay3 = 0.1 if sys.platform != "win32" else 0.2
23
19
 
24
20
  try:
25
- transport.write(b'\r\x03')
21
+ transport.write(b'\r' + CTRL_C)
26
22
  time.sleep(delay1)
27
23
  transport.reset_input_buffer()
28
24
 
29
- transport.write(b'\r\x02')
25
+ transport.write(b'\r' + CTRL_B)
30
26
  time.sleep(delay2)
31
27
 
32
28
  res = transport.read_available()
@@ -110,6 +106,7 @@ class InteractiveSessionState:
110
106
  class ReplSessionState:
111
107
  active: bool = False
112
108
  ppid: Optional[int] = None
109
+ reader_future: Optional[ConcurrentFuture] = None
113
110
  reader_thread: Optional[threading.Thread] = None
114
111
  output_buffer: bytes = b""
115
112
  buffer_lock: threading.Lock = field(default_factory=threading.Lock)
@@ -123,6 +120,9 @@ class ReplSessionState:
123
120
  def stop(self):
124
121
  self.active = False
125
122
  self.ppid = None
123
+ if self.reader_future is not None:
124
+ self.reader_future.cancel()
125
+ self.reader_future = None
126
126
  if self.reader_thread:
127
127
  self.reader_thread.join(timeout=1)
128
128
  self.reader_thread = None
@@ -158,7 +158,6 @@ class BoardConnection:
158
158
  version: str = "?"
159
159
  device_root_fs: str = "/"
160
160
 
161
- # Unique board ID from machine.unique_id().hex() - lazy evaluated on first command
162
161
  board_id: Optional[str] = None
163
162
 
164
163
  busy: bool = False
@@ -168,7 +167,6 @@ class BoardConnection:
168
167
  last_command_time: float = field(default_factory=time.time)
169
168
  _busy_lock: threading.Lock = field(default_factory=threading.Lock)
170
169
 
171
- # Detached script state (per-connection)
172
170
  detached_running: bool = False
173
171
  _detached_lock: threading.Lock = field(default_factory=threading.Lock)
174
172
  _drain_thread: Optional[threading.Thread] = field(default=None, repr=False)
@@ -208,7 +206,6 @@ class BoardConnection:
208
206
  self.busy_client = None
209
207
 
210
208
  def stop_detached(self):
211
- """Stop detached script and drain thread for this connection."""
212
209
  with self._detached_lock:
213
210
  self.detached_running = False
214
211
 
@@ -221,7 +218,6 @@ class ConnectionManager:
221
218
  self._connections: Dict[str, BoardConnection] = {}
222
219
  self._connections_lock = threading.RLock()
223
220
  self._default_port: Optional[str] = None
224
- # Cache: maps board_id -> {serial_port}
225
221
  self._board_id_cache: Dict[str, Dict[str, str]] = {}
226
222
  self._cache_lock = threading.Lock()
227
223
 
@@ -239,11 +235,6 @@ class ConnectionManager:
239
235
 
240
236
  @staticmethod
241
237
  def _canon_port(port: Optional[str]) -> Optional[str]:
242
- """Canonicalize port keys for internal storage/lookup.
243
-
244
- On Windows, COM ports are case-insensitive; we canonicalize COMx to
245
- uppercase so all commands behave consistently.
246
- """
247
238
  if port is None:
248
239
  return None
249
240
  p = str(port).strip()
@@ -254,7 +245,6 @@ class ConnectionManager:
254
245
  return p
255
246
 
256
247
  def _resolve_existing_key(self, port: str) -> Optional[str]:
257
- """Return the stored key matching `port`, possibly case-insensitively on Windows."""
258
248
  if port is None:
259
249
  return None
260
250
  p = self._canon_port(port)
@@ -306,7 +296,6 @@ class ConnectionManager:
306
296
  device: str = None,
307
297
  baudrate: int = 115200
308
298
  ) -> Tuple[BoardConnection, Optional[str]]:
309
- # Store port
310
299
  original_port = str(port).strip() if port is not None else ""
311
300
  port_key = self._canon_port(original_port)
312
301
 
@@ -328,13 +317,10 @@ class ConnectionManager:
328
317
  repl_protocol = ReplProtocol(transport)
329
318
 
330
319
  device_root_fs = "/"
331
- # Skip filesystem check if detection already failed (version is "?")
332
- # This speeds up failure on invalid ports
333
320
  if version != "?":
334
321
  try:
335
322
  result = repl_protocol.exec("import os; print(os.getcwd())")
336
323
  if result:
337
- # exec() returns bytes, decode to string
338
324
  if isinstance(result, bytes):
339
325
  result = result.decode('utf-8', errors='ignore')
340
326
  cwd = result.strip() if result else "/"
@@ -357,7 +343,7 @@ class ConnectionManager:
357
343
  )
358
344
 
359
345
  conn = BoardConnection(
360
- port=port_key, # Canonical key (Windows COM ports uppercased)
346
+ port=port_key,
361
347
  repl_protocol=repl_protocol,
362
348
  file_system=file_system,
363
349
  core=detected_core,
@@ -376,35 +362,21 @@ class ConnectionManager:
376
362
  return None, str(e)
377
363
 
378
364
  def ensure_board_id(self, port: str) -> Optional[str]:
379
- """
380
- Lazily query and cache the board's unique ID.
381
- Uses machine.unique_id().hex() to get a consistent ID.
382
-
383
- Args:
384
- port: The connection key
385
-
386
- Returns:
387
- The board_id string, or None if query failed
388
- """
389
365
  conn = self.get_connection(port)
390
366
  if not conn or not conn.repl_protocol:
391
367
  return None
392
368
 
393
- # Return cached value if already queried
394
369
  if conn.board_id is not None:
395
370
  return conn.board_id
396
371
 
397
372
  try:
398
- # Query machine.unique_id().hex()
399
373
  result = conn.repl_protocol.exec("import machine; print(machine.unique_id().hex())")
400
374
  if result:
401
- # exec() returns bytes, decode to string
402
375
  if isinstance(result, bytes):
403
376
  result = result.decode('utf-8', errors='ignore')
404
377
  result = result.strip()
405
378
  if result:
406
379
  conn.board_id = result
407
- # Update board_id cache
408
380
  self._update_board_id_cache(port, conn.board_id)
409
381
  return conn.board_id
410
382
  except Exception:
@@ -413,9 +385,6 @@ class ConnectionManager:
413
385
  return None
414
386
 
415
387
  def _update_board_id_cache(self, port: str, board_id: str) -> None:
416
- """
417
- Update the board_id cache with port mapping.
418
- """
419
388
  with self._cache_lock:
420
389
  if board_id not in self._board_id_cache:
421
390
  self._board_id_cache[board_id] = {}
@@ -423,37 +392,22 @@ class ConnectionManager:
423
392
  self._board_id_cache[board_id]["serial_port"] = self._canon_port(port)
424
393
 
425
394
  def find_conflicting_by_board_id(self, port: str) -> Optional[str]:
426
- """
427
- Find conflicting connection by comparing board IDs.
428
- This is called after ensure_board_id() to detect if the same board
429
- is already connected via a different transport.
430
-
431
- Args:
432
- port: The current connection key
433
-
434
- Returns:
435
- The conflicting connection's port key, or None if no conflict
436
- """
437
395
  conn = self.get_connection(port)
438
396
  if not conn or not conn.board_id:
439
397
  return None
440
398
 
441
399
  with self._connections_lock:
442
400
  for key, other_conn in self._connections.items():
443
- # Skip self
444
401
  if key == port:
445
402
  continue
446
403
 
447
- # Skip disconnected
448
404
  if not other_conn.is_connected():
449
405
  continue
450
406
 
451
- # Ensure other connection's board_id is queried
452
407
  if other_conn.board_id is None:
453
408
  try:
454
409
  result = other_conn.repl_protocol.exec("import machine; print(machine.unique_id().hex())")
455
410
  if result:
456
- # exec() returns bytes, decode to string
457
411
  if isinstance(result, bytes):
458
412
  result = result.decode('utf-8', errors='ignore')
459
413
  result = result.strip()
@@ -462,46 +416,28 @@ class ConnectionManager:
462
416
  except Exception:
463
417
  continue
464
418
 
465
- # Compare board IDs
466
419
  if other_conn.board_id and other_conn.board_id == conn.board_id:
467
420
  return key
468
421
 
469
422
  return None
470
423
 
471
424
  def resolve_board_conflict(self, port: str) -> Optional[str]:
472
- """
473
- Check for board conflicts using board_id and automatically disconnect
474
- the conflicting connection.
475
-
476
- Args:
477
- port: The current connection key
478
-
479
- Returns:
480
- The disconnected port key, or None if no conflict was found/resolved
481
- """
482
- # First, ensure board_id is queried for this connection
483
425
  board_id = self.ensure_board_id(port)
484
426
  if not board_id:
485
427
  return None
486
428
 
487
- # Find any conflicting connection
488
429
  conflicting_port = self.find_conflicting_by_board_id(port)
489
430
  if conflicting_port:
490
- # Auto-disconnect the older connection
491
431
  self.disconnect(conflicting_port)
492
432
 
493
- # Reset REPL state of the new connection
494
433
  conn = self.get_connection(port)
495
434
  if conn and conn.repl_protocol:
496
435
  try:
497
- transport = conn.repl_protocol._transport
498
- # Send Ctrl+C to interrupt any ongoing operation
499
- transport.write(b'\x03')
436
+ transport = conn.repl_protocol.transport
437
+ transport.write(CTRL_C)
500
438
  time.sleep(0.05)
501
- # Send Ctrl+B to enter Normal mode (exit Raw REPL if in it)
502
- transport.write(b'\x02')
439
+ transport.write(CTRL_B)
503
440
  time.sleep(0.1)
504
- # Clear any pending data
505
441
  transport.reset_input_buffer()
506
442
  except Exception:
507
443
  pass
@@ -519,23 +455,24 @@ class ConnectionManager:
519
455
 
520
456
  conn = self._connections.pop(key)
521
457
 
522
- # Stop detached script first (this waits for drain thread)
523
458
  conn.stop_detached()
524
459
 
525
- # Stop REPL/interactive workers before transport close to release serial handle promptly.
526
460
  if conn.repl.active:
527
461
  conn.repl.stop()
528
462
  if conn.interactive.active:
529
463
  conn.interactive.stop()
530
464
 
531
- # Release busy state
532
465
  conn.release()
533
466
 
534
- # Close transport outside the lock to avoid potential deadlocks
535
467
  if conn.repl_protocol:
536
468
  try:
537
- transport = conn.repl_protocol._transport
469
+ transport = conn.repl_protocol.transport
538
470
  if transport:
471
+ try:
472
+ transport.write(CTRL_B)
473
+ time.sleep(0.1 if sys.platform == 'win32' else 0.05)
474
+ except Exception:
475
+ pass
539
476
  transport.close()
540
477
  except Exception:
541
478
  pass
@@ -592,13 +529,6 @@ class ConnectionManager:
592
529
  return conn.busy if conn else False
593
530
 
594
531
  def check_health(self, port: str) -> bool:
595
- """Check if the connection to the specified port is still healthy.
596
-
597
- This method actively tests the serial port to detect disconnection.
598
-
599
- Returns:
600
- True if the connection is healthy, False otherwise.
601
- """
602
532
  conn = self.get_connection(port)
603
533
  if not conn or not conn.repl_protocol:
604
534
  return False
@@ -608,14 +538,11 @@ class ConnectionManager:
608
538
  if not transport:
609
539
  return False
610
540
 
611
- # Use check_connection which actively probes the port
612
541
  if hasattr(transport, 'check_connection'):
613
542
  return transport.check_connection()
614
543
 
615
- # Fallback: check is_open property
616
544
  return transport.is_open
617
545
  except TransportError:
618
- # Serial port has been disconnected
619
546
  return False
620
547
  except Exception:
621
548
  return False