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.
- {replx-1.2/replx.egg-info → replx-1.4}/PKG-INFO +1 -1
- {replx-1.2 → replx-1.4}/replx/__init__.py +1 -1
- {replx-1.2 → replx-1.4}/replx/cli/agent/client/core.py +14 -13
- {replx-1.2 → replx-1.4}/replx/cli/agent/client/session.py +0 -25
- {replx-1.2 → replx-1.4}/replx/cli/agent/protocol.py +0 -3
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/connection_manager.py +18 -91
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/core.py +249 -139
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/exec.py +8 -35
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/filesystem.py +16 -21
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/repl.py +53 -7
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/session.py +6 -9
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/transfer.py +1 -15
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/session_manager.py +3 -13
- {replx-1.2 → replx-1.4}/replx/cli/app.py +2 -65
- {replx-1.2 → replx-1.4}/replx/cli/commands/device.py +56 -20
- {replx-1.2 → replx-1.4}/replx/cli/commands/exec.py +42 -12
- {replx-1.2 → replx-1.4}/replx/cli/commands/package.py +124 -163
- {replx-1.2 → replx-1.4}/replx/cli/config.py +3 -179
- {replx-1.2 → replx-1.4}/replx/cli/connection.py +4 -78
- {replx-1.2 → replx-1.4}/replx/cli/helpers/__init__.py +4 -17
- {replx-1.2 → replx-1.4}/replx/cli/helpers/compiler.py +0 -29
- {replx-1.2 → replx-1.4}/replx/cli/helpers/environment.py +0 -13
- {replx-1.2 → replx-1.4}/replx/cli/helpers/output.py +0 -58
- {replx-1.2 → replx-1.4}/replx/cli/helpers/registry.py +31 -146
- {replx-1.2 → replx-1.4}/replx/cli/helpers/scanner.py +3 -83
- {replx-1.2 → replx-1.4}/replx/cli/helpers/updater.py +0 -4
- {replx-1.2 → replx-1.4}/replx/commands.py +3 -3
- {replx-1.2 → replx-1.4}/replx/protocol/repl.py +46 -5
- {replx-1.2 → replx-1.4}/replx/terminal.py +47 -3
- replx-1.4/replx/tests/test_agent_asyncio.py +319 -0
- replx-1.4/replx/tests/test_agent_thread_pool.py +216 -0
- replx-1.4/replx/tests/test_disconnect_cleanup.py +57 -0
- replx-1.4/replx/tests/test_lock_cleanup.py +194 -0
- replx-1.4/replx/tests/test_repl_reader_task.py +313 -0
- replx-1.4/replx/tests/test_session_disconnect_releases_shared_port.py +47 -0
- {replx-1.2 → replx-1.4}/replx/transport/__init__.py +0 -11
- {replx-1.2 → replx-1.4}/replx/transport/serial.py +13 -18
- {replx-1.2 → replx-1.4}/replx/utils/__init__.py +0 -1
- {replx-1.2 → replx-1.4}/replx/utils/device_info.py +9 -23
- {replx-1.2 → replx-1.4/replx.egg-info}/PKG-INFO +1 -1
- {replx-1.2 → replx-1.4}/replx.egg-info/SOURCES.txt +7 -1
- replx-1.4/test/test_termio.py +62 -0
- replx-1.2/replx/tests/test_disconnect_cleanup.py +0 -46
- {replx-1.2 → replx-1.4}/LICENSE +0 -0
- {replx-1.2 → replx-1.4}/README.md +0 -0
- {replx-1.2 → replx-1.4}/pyproject.toml +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/__init__.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/agent/__init__.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/agent/client/__init__.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/__init__.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/__main__.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/command_dispatcher.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/agent/server/handlers/__init__.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/commands/__init__.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/commands/file.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/commands/firmware.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/commands/utility.py +0 -0
- {replx-1.2 → replx-1.4}/replx/cli/helpers/store.py +0 -0
- {replx-1.2 → replx-1.4}/replx/protocol/__init__.py +0 -0
- {replx-1.2 → replx-1.4}/replx/protocol/storage.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/__init__.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/test_agent_port_canonicalization.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/test_compiler_arch.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/test_connection_info_lookup.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/test_device_info_esp_multi_core.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/test_pkg_local_version.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/test_pkg_search_scope_filter.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/test_session_id_fallback.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/test_shutdown_status_message.py +0 -0
- {replx-1.2 → replx-1.4}/replx/tests/test_windows_com_port_normalization.py +0 -0
- {replx-1.2 → replx-1.4}/replx/transport/base.py +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/_thread.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/aioble/__init__.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/array.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/asyncio/__init__.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/binascii.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/bluetooth.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/builtins.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/cmath.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/collections.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/cryptolib.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/deflate.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/errno.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/framebuf.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/gc.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/hashlib.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/heapq.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/io.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/json.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/lwip.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/machine.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/math.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/micropython.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/mip/__init__.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/network.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/ntptime.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/os.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/platform.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/random.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/re.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/requests/__init__.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/select.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/socket.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/ssl.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/struct.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/sys.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/time.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/tls.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/uasyncio.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/uctypes.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/urequests.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm/vfs.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/binascii.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/errno.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/hashlib.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/io.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/json.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/machine.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/math.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/micropython.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/network.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/os.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/select.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/socket.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ssl.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/struct.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/sys.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/time.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ubinascii.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ucryptolib.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uerrno.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uhashlib.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uio.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ujson.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/umachine.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uos.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/uselect.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/usocket.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ussl.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/ustruct.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/comm_separate/EFR32MG/utime.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/core/ESP32/aioespnow.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/core/ESP32/esp.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/core/ESP32/esp32.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/core/ESP32/espnow.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/core/MIMXRT1062DVJ6A/mimxrt.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/typehints/core/RP2350/rp2.pyi +0 -0
- {replx-1.2 → replx-1.4}/replx/utils/constants.py +0 -0
- {replx-1.2 → replx-1.4}/replx/utils/exceptions.py +0 -0
- {replx-1.2 → replx-1.4}/replx.egg-info/dependency_links.txt +0 -0
- {replx-1.2 → replx-1.4}/replx.egg-info/entry_points.txt +0 -0
- {replx-1.2 → replx-1.4}/replx.egg-info/requires.txt +0 -0
- {replx-1.2 → replx-1.4}/replx.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
63
|
-
|
|
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
|
|
189
|
-
|
|
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)
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
498
|
-
|
|
499
|
-
transport.write(b'\x03')
|
|
436
|
+
transport = conn.repl_protocol.transport
|
|
437
|
+
transport.write(CTRL_C)
|
|
500
438
|
time.sleep(0.05)
|
|
501
|
-
|
|
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.
|
|
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
|