replx-agent 2.0.0__py3-none-any.whl
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.
Potentially problematic release.
This version of replx-agent might be problematic. Click here for more details.
- replx_agent/__init__.py +3 -0
- replx_agent/__main__.py +5 -0
- replx_agent/commands.py +131 -0
- replx_agent/helpers/__init__.py +3 -0
- replx_agent/helpers/scanner.py +308 -0
- replx_agent/pkg/__init__.py +11 -0
- replx_agent/pkg/compiler.py +153 -0
- replx_agent/pkg/registry.py +387 -0
- replx_agent/pkg/service.py +690 -0
- replx_agent/pkg/store.py +83 -0
- replx_agent/protocol/__init__.py +11 -0
- replx_agent/protocol/repl.py +1029 -0
- replx_agent/protocol/storage.py +1018 -0
- replx_agent/rpc/__init__.py +3 -0
- replx_agent/rpc/protocol.py +220 -0
- replx_agent/server/__init__.py +3 -0
- replx_agent/server/__main__.py +4 -0
- replx_agent/server/command_dispatcher.py +49 -0
- replx_agent/server/connection_manager.py +654 -0
- replx_agent/server/core.py +1221 -0
- replx_agent/server/handlers/__init__.py +24 -0
- replx_agent/server/handlers/exec.py +553 -0
- replx_agent/server/handlers/filesystem.py +383 -0
- replx_agent/server/handlers/firmware.py +118 -0
- replx_agent/server/handlers/i2c.py +23 -0
- replx_agent/server/handlers/package.py +182 -0
- replx_agent/server/handlers/repl.py +188 -0
- replx_agent/server/handlers/session.py +205 -0
- replx_agent/server/handlers/spi.py +23 -0
- replx_agent/server/handlers/transfer.py +600 -0
- replx_agent/server/handlers/uart.py +32 -0
- replx_agent/server/session_manager.py +391 -0
- replx_agent/server/tcp.py +207 -0
- replx_agent/terminal.py +566 -0
- replx_agent/transport/__init__.py +32 -0
- replx_agent/transport/serial.py +220 -0
- replx_agent/utils/__init__.py +95 -0
- replx_agent/utils/constants.py +78 -0
- replx_agent/utils/device_info.py +183 -0
- replx_agent/utils/exceptions.py +31 -0
- replx_agent-2.0.0.dist-info/METADATA +74 -0
- replx_agent-2.0.0.dist-info/RECORD +46 -0
- replx_agent-2.0.0.dist-info/WHEEL +5 -0
- replx_agent-2.0.0.dist-info/entry_points.txt +2 -0
- replx_agent-2.0.0.dist-info/licenses/LICENSE +21 -0
- replx_agent-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import threading
|
|
6
|
+
from collections import deque
|
|
7
|
+
from concurrent.futures import Future as ConcurrentFuture
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Optional, Dict, Any, Deque, List, Tuple, Hashable
|
|
10
|
+
|
|
11
|
+
from replx_agent.protocol import ReplProtocol, create_storage
|
|
12
|
+
from replx_agent.utils import parse_device_banner, canon_port as _shared_canon_port
|
|
13
|
+
from replx_agent.utils.constants import (
|
|
14
|
+
CTRL_B, CTRL_C, CONNECT_DETECT_ROUNDS, CONNECT_DETECT_RETRY_DELAY,
|
|
15
|
+
)
|
|
16
|
+
from replx_agent.utils.exceptions import TransportError
|
|
17
|
+
from replx_agent.utils.device_info import get_root_fs_for_core
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _detect_device_info(transport, core: str, device: str = None) -> Tuple[str, str, str, str]:
|
|
23
|
+
delay1 = 0.05 if sys.platform != "win32" else 0.1
|
|
24
|
+
delay2 = 0.1 if sys.platform != "win32" else 0.2
|
|
25
|
+
delay3 = 0.1 if sys.platform != "win32" else 0.2
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
transport.write(b'\r' + CTRL_C)
|
|
29
|
+
time.sleep(delay1)
|
|
30
|
+
transport.reset_input_buffer()
|
|
31
|
+
|
|
32
|
+
transport.write(b'\r' + CTRL_B)
|
|
33
|
+
time.sleep(delay2)
|
|
34
|
+
|
|
35
|
+
res = transport.read_available()
|
|
36
|
+
if res:
|
|
37
|
+
res_str = res.decode('utf-8', errors='replace') if isinstance(res, bytes) else res
|
|
38
|
+
result = parse_device_banner(res_str)
|
|
39
|
+
if result:
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
transport.write(b'import sys; print(sys.version.split()[0])\r\n')
|
|
43
|
+
time.sleep(delay3)
|
|
44
|
+
ver_res = transport.read_available()
|
|
45
|
+
version = "?"
|
|
46
|
+
if ver_res:
|
|
47
|
+
ver_str = ver_res.decode('utf-8', errors='replace') if isinstance(ver_res, bytes) else ver_res
|
|
48
|
+
match = re.search(r'(\d+\.\d+(?:\.\d+)?)', ver_str)
|
|
49
|
+
if match:
|
|
50
|
+
version = match.group(1)
|
|
51
|
+
|
|
52
|
+
return (version, core, device or core, "Unknown")
|
|
53
|
+
|
|
54
|
+
except TransportError as e:
|
|
55
|
+
logger.warning("Transport error during device detection: %s", e)
|
|
56
|
+
return ("?", core, device or core, "Unknown")
|
|
57
|
+
except (OSError, IOError) as e:
|
|
58
|
+
logger.warning("I/O error during device detection: %s", e)
|
|
59
|
+
return ("?", core, device or core, "Unknown")
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class InteractiveSessionState:
|
|
63
|
+
active: bool = False
|
|
64
|
+
ppid: Optional[Hashable] = None
|
|
65
|
+
seq: int = 0
|
|
66
|
+
client_addr: Optional[tuple] = None
|
|
67
|
+
echo: bool = True
|
|
68
|
+
input_queue: Deque[bytes] = field(default_factory=deque)
|
|
69
|
+
stop_requested: bool = False
|
|
70
|
+
thread: Optional[threading.Thread] = None
|
|
71
|
+
error: Optional[str] = None
|
|
72
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
73
|
+
|
|
74
|
+
def start(self, ppid: Hashable, seq: int, client_addr: tuple, echo: bool = True):
|
|
75
|
+
with self.lock:
|
|
76
|
+
self.active = True
|
|
77
|
+
self.ppid = ppid
|
|
78
|
+
self.seq = seq
|
|
79
|
+
self.client_addr = client_addr
|
|
80
|
+
self.echo = echo
|
|
81
|
+
self.input_queue.clear()
|
|
82
|
+
self.stop_requested = False
|
|
83
|
+
self.error = None
|
|
84
|
+
|
|
85
|
+
def stop(self):
|
|
86
|
+
thread_to_join = None
|
|
87
|
+
with self.lock:
|
|
88
|
+
self.active = False
|
|
89
|
+
self.ppid = None
|
|
90
|
+
self.seq = 0
|
|
91
|
+
self.client_addr = None
|
|
92
|
+
self.input_queue.clear()
|
|
93
|
+
self.stop_requested = False
|
|
94
|
+
thread_to_join = self.thread
|
|
95
|
+
self.thread = None
|
|
96
|
+
|
|
97
|
+
if thread_to_join and thread_to_join.is_alive() and thread_to_join is not threading.current_thread():
|
|
98
|
+
thread_to_join.join(timeout=1)
|
|
99
|
+
|
|
100
|
+
def is_owner(self, ppid: Hashable) -> bool:
|
|
101
|
+
with self.lock:
|
|
102
|
+
if ppid is None:
|
|
103
|
+
return False
|
|
104
|
+
if self.ppid is None:
|
|
105
|
+
return True
|
|
106
|
+
return self.ppid == ppid
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class ReplSessionState:
|
|
110
|
+
active: bool = False
|
|
111
|
+
ppid: Optional[Hashable] = None
|
|
112
|
+
reader_future: Optional[ConcurrentFuture] = None
|
|
113
|
+
reader_thread: Optional[threading.Thread] = None
|
|
114
|
+
output_buffer: bytearray = field(default_factory=bytearray)
|
|
115
|
+
buffer_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
116
|
+
|
|
117
|
+
def start(self, ppid: Hashable):
|
|
118
|
+
self.active = True
|
|
119
|
+
self.ppid = ppid
|
|
120
|
+
with self.buffer_lock:
|
|
121
|
+
self.output_buffer.clear()
|
|
122
|
+
|
|
123
|
+
def stop(self):
|
|
124
|
+
self.active = False
|
|
125
|
+
self.ppid = None
|
|
126
|
+
if self.reader_future is not None:
|
|
127
|
+
self.reader_future.cancel()
|
|
128
|
+
self.reader_future = None
|
|
129
|
+
if self.reader_thread:
|
|
130
|
+
self.reader_thread.join(timeout=1)
|
|
131
|
+
self.reader_thread = None
|
|
132
|
+
with self.buffer_lock:
|
|
133
|
+
self.output_buffer.clear()
|
|
134
|
+
|
|
135
|
+
def is_owner(self, ppid: Hashable) -> bool:
|
|
136
|
+
if ppid is None:
|
|
137
|
+
return False
|
|
138
|
+
if self.ppid is None:
|
|
139
|
+
return True
|
|
140
|
+
return self.ppid == ppid
|
|
141
|
+
|
|
142
|
+
def append_output(self, data: bytes):
|
|
143
|
+
with self.buffer_lock:
|
|
144
|
+
self.output_buffer.extend(data)
|
|
145
|
+
|
|
146
|
+
def read_output(self) -> bytes:
|
|
147
|
+
with self.buffer_lock:
|
|
148
|
+
data = self.output_buffer
|
|
149
|
+
self.output_buffer = bytearray()
|
|
150
|
+
return bytes(data)
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class BoardConnection:
|
|
154
|
+
port: str
|
|
155
|
+
repl_protocol: Optional[ReplProtocol] = None
|
|
156
|
+
file_system: Any = None
|
|
157
|
+
|
|
158
|
+
core: str = ""
|
|
159
|
+
device: str = ""
|
|
160
|
+
manufacturer: str = "?"
|
|
161
|
+
version: str = "?"
|
|
162
|
+
device_root_fs: str = "/"
|
|
163
|
+
|
|
164
|
+
board_id: Optional[str] = None
|
|
165
|
+
|
|
166
|
+
busy: bool = False
|
|
167
|
+
busy_command: Optional[str] = None
|
|
168
|
+
busy_session: Optional[Hashable] = None
|
|
169
|
+
busy_client: Optional[tuple] = None
|
|
170
|
+
last_command_time: float = field(default_factory=time.time)
|
|
171
|
+
# Single state lock guards busy_* and detached_running together so that
|
|
172
|
+
# callers can atomically check "not detached AND not busy" without races.
|
|
173
|
+
_busy_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
174
|
+
|
|
175
|
+
detached_running: bool = False
|
|
176
|
+
_drain_thread: Optional[threading.Thread] = field(default=None, repr=False)
|
|
177
|
+
|
|
178
|
+
# Counter incremented each heartbeat when the transport fails a liveness
|
|
179
|
+
# check; reset to 0 on a successful check. The heartbeat disconnects the
|
|
180
|
+
# port after this reaches a threshold (see AgentServer._heartbeat_tick).
|
|
181
|
+
_liveness_fail_count: int = 0
|
|
182
|
+
_logical_liveness_fail_count: int = 0
|
|
183
|
+
_last_logical_probe_time: float = field(default_factory=time.monotonic)
|
|
184
|
+
|
|
185
|
+
interactive: InteractiveSessionState = field(default_factory=InteractiveSessionState)
|
|
186
|
+
repl: ReplSessionState = field(default_factory=ReplSessionState)
|
|
187
|
+
|
|
188
|
+
# --- Sentinel session ids -------------------------------------------------
|
|
189
|
+
# Real client sessions are OS PIDs (>= 1). Negative ids are reserved for
|
|
190
|
+
# internal callers so they can never alias with a real client.
|
|
191
|
+
HEARTBEAT_SESSION_ID = -1
|
|
192
|
+
|
|
193
|
+
# Force-release a busy that has been held longer than this without any
|
|
194
|
+
# observable progress. Heartbeat applies this only when no REPL/interactive
|
|
195
|
+
# session and no detached script holds the connection.
|
|
196
|
+
BUSY_TIMEOUT_S = 60.0
|
|
197
|
+
|
|
198
|
+
def is_connected(self) -> bool:
|
|
199
|
+
return self.repl_protocol is not None
|
|
200
|
+
|
|
201
|
+
def busy_snapshot(self) -> tuple[bool, Optional[str], Optional[Hashable], Optional[tuple], bool]:
|
|
202
|
+
with self._busy_lock:
|
|
203
|
+
return (
|
|
204
|
+
self.busy,
|
|
205
|
+
self.busy_command,
|
|
206
|
+
self.busy_session,
|
|
207
|
+
self.busy_client,
|
|
208
|
+
self.detached_running,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def is_detached(self) -> bool:
|
|
212
|
+
with self._busy_lock:
|
|
213
|
+
return self.detached_running
|
|
214
|
+
|
|
215
|
+
def set_detached(self, running: bool):
|
|
216
|
+
with self._busy_lock:
|
|
217
|
+
self.detached_running = running
|
|
218
|
+
|
|
219
|
+
def acquire_for_command(self, session_id: Hashable, command: str,
|
|
220
|
+
client_addr: tuple = None,
|
|
221
|
+
allow_when_detached: bool = False) -> bool:
|
|
222
|
+
"""Try to mark the board as busy for ``session_id``.
|
|
223
|
+
|
|
224
|
+
Concurrent acquires from the same session are rejected so that two
|
|
225
|
+
in-flight commands cannot believe they each own the board (the previous
|
|
226
|
+
re-entrant behaviour caused premature release when the inner command
|
|
227
|
+
finished first). Returns ``True`` only if this call took the lock.
|
|
228
|
+
|
|
229
|
+
When ``allow_when_detached`` is True (e.g. for ``reset``), the command
|
|
230
|
+
may take over ownership even when the busy flag is currently held by a
|
|
231
|
+
detached script, superseding that script's ownership.
|
|
232
|
+
"""
|
|
233
|
+
with self._busy_lock:
|
|
234
|
+
if self.detached_running:
|
|
235
|
+
if not allow_when_detached:
|
|
236
|
+
return False
|
|
237
|
+
# Detached-aware commands (e.g. reset) are allowed to take over
|
|
238
|
+
# even while the detached-script holds busy=True.
|
|
239
|
+
elif self.busy:
|
|
240
|
+
return False
|
|
241
|
+
self.busy = True
|
|
242
|
+
self.busy_session = session_id
|
|
243
|
+
self.busy_command = command
|
|
244
|
+
self.busy_client = client_addr
|
|
245
|
+
self.last_command_time = time.time()
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
def acquire_for_heartbeat(self) -> bool:
|
|
249
|
+
with self._busy_lock:
|
|
250
|
+
if self.busy or self.detached_running or self.repl.active or self.interactive.active:
|
|
251
|
+
return False
|
|
252
|
+
self.busy = True
|
|
253
|
+
self.busy_session = self.HEARTBEAT_SESSION_ID
|
|
254
|
+
self.busy_command = 'heartbeat'
|
|
255
|
+
self.busy_client = None
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
def release(self, *, reset_liveness: bool = True, update_last_command_time: bool = True):
|
|
259
|
+
with self._busy_lock:
|
|
260
|
+
self.busy = False
|
|
261
|
+
self.busy_session = None
|
|
262
|
+
self.busy_command = None
|
|
263
|
+
self.busy_client = None
|
|
264
|
+
if update_last_command_time:
|
|
265
|
+
self.last_command_time = time.time()
|
|
266
|
+
if reset_liveness:
|
|
267
|
+
self._liveness_fail_count = 0
|
|
268
|
+
self._logical_liveness_fail_count = 0
|
|
269
|
+
self._last_logical_probe_time = time.monotonic()
|
|
270
|
+
|
|
271
|
+
def release_if_owned(self, session_id: Hashable) -> bool:
|
|
272
|
+
with self._busy_lock:
|
|
273
|
+
if not self.busy or self.busy_session != session_id:
|
|
274
|
+
return False
|
|
275
|
+
self.busy = False
|
|
276
|
+
self.busy_session = None
|
|
277
|
+
self.busy_command = None
|
|
278
|
+
self.busy_client = None
|
|
279
|
+
self.last_command_time = time.time()
|
|
280
|
+
self._liveness_fail_count = 0
|
|
281
|
+
self._logical_liveness_fail_count = 0
|
|
282
|
+
self._last_logical_probe_time = time.monotonic()
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
def set_busy_state(self, busy: bool, command: str = None, session: Hashable = None,
|
|
286
|
+
client_addr: tuple = None) -> None:
|
|
287
|
+
with self._busy_lock:
|
|
288
|
+
self.busy = busy
|
|
289
|
+
self.busy_command = command if busy else None
|
|
290
|
+
self.busy_session = session if busy else None
|
|
291
|
+
self.busy_client = client_addr if busy else None
|
|
292
|
+
if busy:
|
|
293
|
+
self.last_command_time = time.time()
|
|
294
|
+
|
|
295
|
+
def force_release_if_stale(self, timeout_s: float = None) -> bool:
|
|
296
|
+
"""Forcefully release a busy lock that has been held too long.
|
|
297
|
+
|
|
298
|
+
Skips REPL / interactive / detached owners since those have separate
|
|
299
|
+
lifecycles managed elsewhere. Returns ``True`` if a stale lock was
|
|
300
|
+
cleared.
|
|
301
|
+
"""
|
|
302
|
+
if timeout_s is None:
|
|
303
|
+
timeout_s = self.BUSY_TIMEOUT_S
|
|
304
|
+
with self._busy_lock:
|
|
305
|
+
if not self.busy:
|
|
306
|
+
return False
|
|
307
|
+
if self.detached_running:
|
|
308
|
+
return False
|
|
309
|
+
if self.repl.active or self.interactive.active:
|
|
310
|
+
return False
|
|
311
|
+
if time.time() - self.last_command_time < timeout_s:
|
|
312
|
+
return False
|
|
313
|
+
self.busy = False
|
|
314
|
+
self.busy_session = None
|
|
315
|
+
self.busy_command = None
|
|
316
|
+
self.busy_client = None
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
def stop_detached(self):
|
|
320
|
+
with self._busy_lock:
|
|
321
|
+
self.detached_running = False
|
|
322
|
+
|
|
323
|
+
if self._drain_thread and self._drain_thread.is_alive():
|
|
324
|
+
self._drain_thread.join(timeout=0.2)
|
|
325
|
+
self._drain_thread = None
|
|
326
|
+
|
|
327
|
+
class ConnectionManager:
|
|
328
|
+
def __init__(self):
|
|
329
|
+
self._connections: Dict[str, BoardConnection] = {}
|
|
330
|
+
self._connections_lock = threading.RLock()
|
|
331
|
+
# Per-port events used to serialize concurrent create_serial_connection
|
|
332
|
+
# calls for the same port (prevents double serial-open race).
|
|
333
|
+
self._port_creating: Dict[str, threading.Event] = {}
|
|
334
|
+
self._scan_lock = threading.Lock()
|
|
335
|
+
self._default_port: Optional[str] = None
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def default_port(self) -> Optional[str]:
|
|
339
|
+
return self._default_port
|
|
340
|
+
|
|
341
|
+
@default_port.setter
|
|
342
|
+
def default_port(self, value: Optional[str]):
|
|
343
|
+
self._default_port = self._canon_port(value) if value else None
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def _is_windows() -> bool:
|
|
347
|
+
return sys.platform == "win32" or sys.platform.startswith("win")
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _canon_port(port: Optional[str]) -> Optional[str]:
|
|
351
|
+
return _shared_canon_port(port)
|
|
352
|
+
|
|
353
|
+
def _resolve_existing_key(self, port: str) -> Optional[str]:
|
|
354
|
+
if port is None:
|
|
355
|
+
return None
|
|
356
|
+
p = self._canon_port(port)
|
|
357
|
+
if p in self._connections:
|
|
358
|
+
return p
|
|
359
|
+
if self._is_windows() and p and re.match(r"(?i)^com\d+$", p):
|
|
360
|
+
needle = p.lower()
|
|
361
|
+
for k in self._connections.keys():
|
|
362
|
+
if isinstance(k, str) and k.lower() == needle:
|
|
363
|
+
return k
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
def get_connection(self, port: str) -> Optional[BoardConnection]:
|
|
367
|
+
with self._connections_lock:
|
|
368
|
+
key = self._resolve_existing_key(port)
|
|
369
|
+
return self._connections.get(key) if key else None
|
|
370
|
+
|
|
371
|
+
def get_all_connections(self) -> Dict[str, BoardConnection]:
|
|
372
|
+
with self._connections_lock:
|
|
373
|
+
return dict(self._connections)
|
|
374
|
+
|
|
375
|
+
def get_connected_ports(self) -> List[str]:
|
|
376
|
+
with self._connections_lock:
|
|
377
|
+
return list(self._connections.keys())
|
|
378
|
+
|
|
379
|
+
def has_connection(self, port: str) -> bool:
|
|
380
|
+
with self._connections_lock:
|
|
381
|
+
return self._resolve_existing_key(port) is not None
|
|
382
|
+
|
|
383
|
+
def add_connection(self, port: str, connection: BoardConnection) -> None:
|
|
384
|
+
with self._connections_lock:
|
|
385
|
+
key = self._canon_port(port)
|
|
386
|
+
connection.port = key
|
|
387
|
+
self._connections[key] = connection
|
|
388
|
+
|
|
389
|
+
def remove_connection(self, port: str) -> Optional[BoardConnection]:
|
|
390
|
+
with self._connections_lock:
|
|
391
|
+
key = self._resolve_existing_key(port)
|
|
392
|
+
return self._connections.pop(key, None) if key else None
|
|
393
|
+
|
|
394
|
+
def connection_count(self) -> int:
|
|
395
|
+
with self._connections_lock:
|
|
396
|
+
return len(self._connections)
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def scan_lock(self) -> threading.Lock:
|
|
400
|
+
return self._scan_lock
|
|
401
|
+
|
|
402
|
+
def reserve_probe_port(self, port: str) -> Optional[Tuple[str, threading.Event]]:
|
|
403
|
+
port_key = self._canon_port(port)
|
|
404
|
+
if not port_key:
|
|
405
|
+
return None
|
|
406
|
+
with self._connections_lock:
|
|
407
|
+
if port_key in self._connections or port_key in self._port_creating:
|
|
408
|
+
return None
|
|
409
|
+
event = threading.Event()
|
|
410
|
+
self._port_creating[port_key] = event
|
|
411
|
+
return port_key, event
|
|
412
|
+
|
|
413
|
+
def release_probe_port(self, reservation: Tuple[str, threading.Event]) -> None:
|
|
414
|
+
port_key, event = reservation
|
|
415
|
+
with self._connections_lock:
|
|
416
|
+
if self._port_creating.get(port_key) is event:
|
|
417
|
+
self._port_creating.pop(port_key, None)
|
|
418
|
+
event.set()
|
|
419
|
+
|
|
420
|
+
def create_serial_connection(
|
|
421
|
+
self,
|
|
422
|
+
port: str,
|
|
423
|
+
core: str = "RP2350",
|
|
424
|
+
device: str = None,
|
|
425
|
+
baudrate: Optional[int] = None
|
|
426
|
+
) -> Tuple[BoardConnection, Optional[str]]:
|
|
427
|
+
original_port = str(port).strip() if port is not None else ""
|
|
428
|
+
port_key = self._canon_port(original_port)
|
|
429
|
+
|
|
430
|
+
# --- Phase 1: serialize opens and scan probes for this port ---
|
|
431
|
+
deadline = time.monotonic() + 30.0
|
|
432
|
+
transport = None
|
|
433
|
+
while True:
|
|
434
|
+
with self._connections_lock:
|
|
435
|
+
conn = self._connections.get(port_key)
|
|
436
|
+
if conn and conn.is_connected():
|
|
437
|
+
return conn, None
|
|
438
|
+
wait_event = self._port_creating.get(port_key)
|
|
439
|
+
if wait_event is None:
|
|
440
|
+
our_event = threading.Event()
|
|
441
|
+
self._port_creating[port_key] = our_event
|
|
442
|
+
break
|
|
443
|
+
|
|
444
|
+
remaining = deadline - time.monotonic()
|
|
445
|
+
if remaining <= 0 or not wait_event.wait(timeout=remaining):
|
|
446
|
+
return None, "Connection creation timed out (concurrent attempt)"
|
|
447
|
+
|
|
448
|
+
# --- Phase 2: we are the creator ---
|
|
449
|
+
try:
|
|
450
|
+
from replx_agent.transport import create_transport, repl_baud_candidates
|
|
451
|
+
|
|
452
|
+
rates = [baudrate] if baudrate else repl_baud_candidates()
|
|
453
|
+
transport = create_transport(f"serial:{original_port}", baudrate=rates[0])
|
|
454
|
+
|
|
455
|
+
# A board that was just reset (e.g. by esptool or a USB-UART bridge's
|
|
456
|
+
# DTR/RTS lines) may still be booting, so a failed sweep is retried.
|
|
457
|
+
version = "?"
|
|
458
|
+
current_rate = rates[0]
|
|
459
|
+
for attempt in range(CONNECT_DETECT_ROUNDS):
|
|
460
|
+
if attempt:
|
|
461
|
+
time.sleep(CONNECT_DETECT_RETRY_DELAY)
|
|
462
|
+
for rate in rates:
|
|
463
|
+
if rate != current_rate:
|
|
464
|
+
transport.set_baudrate(rate)
|
|
465
|
+
current_rate = rate
|
|
466
|
+
version, detected_core, detected_device, manufacturer = _detect_device_info(
|
|
467
|
+
transport, core, device
|
|
468
|
+
)
|
|
469
|
+
if version != "?":
|
|
470
|
+
break
|
|
471
|
+
if version != "?":
|
|
472
|
+
break
|
|
473
|
+
else:
|
|
474
|
+
# No rate produced a recognisable REPL; keep the primary rate as before.
|
|
475
|
+
if current_rate != rates[0]:
|
|
476
|
+
transport.set_baudrate(rates[0])
|
|
477
|
+
|
|
478
|
+
device_root_fs = get_root_fs_for_core(detected_core)
|
|
479
|
+
repl_protocol = ReplProtocol(
|
|
480
|
+
transport, core=detected_core, device_root_fs=device_root_fs
|
|
481
|
+
)
|
|
482
|
+
if version != "?":
|
|
483
|
+
try:
|
|
484
|
+
result = repl_protocol.exec("import os; print(os.getcwd())")
|
|
485
|
+
if result:
|
|
486
|
+
if isinstance(result, bytes):
|
|
487
|
+
result = result.decode('utf-8', errors='ignore')
|
|
488
|
+
cwd = result.strip() if result else "/"
|
|
489
|
+
else:
|
|
490
|
+
cwd = "/"
|
|
491
|
+
if cwd == '/flash' or cwd.startswith('/flash/'):
|
|
492
|
+
device_root_fs = '/flash/'
|
|
493
|
+
except Exception as exc:
|
|
494
|
+
logger.warning(
|
|
495
|
+
"Could not query filesystem root on %s; using %s from the %s profile: %s",
|
|
496
|
+
original_port,
|
|
497
|
+
device_root_fs,
|
|
498
|
+
detected_core,
|
|
499
|
+
exc,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
repl_protocol.device_root_fs = device_root_fs
|
|
503
|
+
|
|
504
|
+
file_system = create_storage(
|
|
505
|
+
repl_protocol,
|
|
506
|
+
core=detected_core,
|
|
507
|
+
device=detected_device,
|
|
508
|
+
device_root_fs=device_root_fs
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
conn = BoardConnection(
|
|
512
|
+
port=port_key,
|
|
513
|
+
repl_protocol=repl_protocol,
|
|
514
|
+
file_system=file_system,
|
|
515
|
+
core=detected_core,
|
|
516
|
+
device=detected_device,
|
|
517
|
+
manufacturer=manufacturer,
|
|
518
|
+
version=version,
|
|
519
|
+
device_root_fs=device_root_fs
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
with self._connections_lock:
|
|
523
|
+
self._connections[port_key] = conn
|
|
524
|
+
self._port_creating.pop(port_key, None)
|
|
525
|
+
|
|
526
|
+
our_event.set() # wake any threads that raced with us
|
|
527
|
+
return conn, None
|
|
528
|
+
|
|
529
|
+
except Exception as e:
|
|
530
|
+
if transport is not None:
|
|
531
|
+
try:
|
|
532
|
+
transport.close()
|
|
533
|
+
except Exception as close_error:
|
|
534
|
+
logger.warning(
|
|
535
|
+
"Failed to close serial port %s after connection failure: %s",
|
|
536
|
+
original_port,
|
|
537
|
+
close_error,
|
|
538
|
+
)
|
|
539
|
+
with self._connections_lock:
|
|
540
|
+
self._port_creating.pop(port_key, None)
|
|
541
|
+
our_event.set() # unblock waiting threads so they get the error
|
|
542
|
+
return None, str(e)
|
|
543
|
+
|
|
544
|
+
def disconnect(self, port: str) -> bool:
|
|
545
|
+
key = None
|
|
546
|
+
with self._connections_lock:
|
|
547
|
+
key = self._resolve_existing_key(port)
|
|
548
|
+
if not key:
|
|
549
|
+
return False
|
|
550
|
+
|
|
551
|
+
conn = self._connections.pop(key)
|
|
552
|
+
|
|
553
|
+
was_detached = conn.is_detached()
|
|
554
|
+
conn.stop_detached()
|
|
555
|
+
|
|
556
|
+
# Signal _read_ex() loops to exit immediately (checked every ~1ms).
|
|
557
|
+
# Without this, interactive.stop()'s join() waits the full 1s timeout
|
|
558
|
+
# because the thread has no way to know it should exit.
|
|
559
|
+
if conn.repl_protocol:
|
|
560
|
+
try:
|
|
561
|
+
conn.repl_protocol._stop_event.set()
|
|
562
|
+
conn.repl_protocol.interrupt()
|
|
563
|
+
except Exception as exc:
|
|
564
|
+
logger.warning("Failed to interrupt REPL on %s: %s", conn.port, exc)
|
|
565
|
+
|
|
566
|
+
if conn.repl.active:
|
|
567
|
+
conn.repl.stop()
|
|
568
|
+
if conn.interactive.active:
|
|
569
|
+
conn.interactive.stop()
|
|
570
|
+
|
|
571
|
+
conn.release()
|
|
572
|
+
|
|
573
|
+
if conn.repl_protocol:
|
|
574
|
+
try:
|
|
575
|
+
transport = conn.repl_protocol.transport
|
|
576
|
+
if transport:
|
|
577
|
+
try:
|
|
578
|
+
if not was_detached:
|
|
579
|
+
transport.write(CTRL_B)
|
|
580
|
+
except Exception as exc:
|
|
581
|
+
logger.warning("Failed to send disconnect signal to %s: %s", conn.port, exc)
|
|
582
|
+
try:
|
|
583
|
+
transport.close()
|
|
584
|
+
except Exception as exc:
|
|
585
|
+
logger.warning("Failed to close serial transport for %s: %s", conn.port, exc)
|
|
586
|
+
except Exception as exc:
|
|
587
|
+
logger.warning("Failed to disconnect transport for %s: %s", conn.port, exc)
|
|
588
|
+
finally:
|
|
589
|
+
conn.repl_protocol = None
|
|
590
|
+
|
|
591
|
+
return True
|
|
592
|
+
|
|
593
|
+
def disconnect_all(self):
|
|
594
|
+
with self._connections_lock:
|
|
595
|
+
ports = list(self._connections.keys())
|
|
596
|
+
|
|
597
|
+
for port in ports:
|
|
598
|
+
self.disconnect(port)
|
|
599
|
+
|
|
600
|
+
def get_connection_info(self, port: str) -> Optional[Dict[str, Any]]:
|
|
601
|
+
conn = self.get_connection(port)
|
|
602
|
+
if not conn:
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
busy, busy_command, _busy_session, _busy_client, _detached = conn.busy_snapshot()
|
|
606
|
+
return {
|
|
607
|
+
'port': conn.port,
|
|
608
|
+
'connected': conn.is_connected(),
|
|
609
|
+
'core': conn.core,
|
|
610
|
+
'device': conn.device,
|
|
611
|
+
'manufacturer': conn.manufacturer,
|
|
612
|
+
'version': conn.version,
|
|
613
|
+
'device_root_fs': conn.device_root_fs,
|
|
614
|
+
'busy': busy,
|
|
615
|
+
'busy_command': busy_command,
|
|
616
|
+
'board_id': conn.board_id
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
def get_all_connection_info(self) -> List[Dict[str, Any]]:
|
|
620
|
+
result = []
|
|
621
|
+
with self._connections_lock:
|
|
622
|
+
for port in self._connections:
|
|
623
|
+
info = self.get_connection_info(port)
|
|
624
|
+
if info:
|
|
625
|
+
result.append(info)
|
|
626
|
+
return result
|
|
627
|
+
|
|
628
|
+
def set_busy(self, port: str, busy: bool, command: str = None, session: int = None):
|
|
629
|
+
conn = self.get_connection(port)
|
|
630
|
+
if conn:
|
|
631
|
+
conn.set_busy_state(busy, command=command, session=session)
|
|
632
|
+
|
|
633
|
+
def is_busy(self, port: str) -> bool:
|
|
634
|
+
conn = self.get_connection(port)
|
|
635
|
+
return conn.busy_snapshot()[0] if conn else False
|
|
636
|
+
|
|
637
|
+
def check_health(self, port: str) -> bool:
|
|
638
|
+
conn = self.get_connection(port)
|
|
639
|
+
if not conn or not conn.repl_protocol:
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
transport = conn.repl_protocol.transport
|
|
644
|
+
if not transport:
|
|
645
|
+
return False
|
|
646
|
+
|
|
647
|
+
if hasattr(transport, 'check_connection'):
|
|
648
|
+
return transport.check_connection()
|
|
649
|
+
|
|
650
|
+
return transport.is_open
|
|
651
|
+
except TransportError:
|
|
652
|
+
return False
|
|
653
|
+
except Exception:
|
|
654
|
+
return False
|