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.

Files changed (46) hide show
  1. replx_agent/__init__.py +3 -0
  2. replx_agent/__main__.py +5 -0
  3. replx_agent/commands.py +131 -0
  4. replx_agent/helpers/__init__.py +3 -0
  5. replx_agent/helpers/scanner.py +308 -0
  6. replx_agent/pkg/__init__.py +11 -0
  7. replx_agent/pkg/compiler.py +153 -0
  8. replx_agent/pkg/registry.py +387 -0
  9. replx_agent/pkg/service.py +690 -0
  10. replx_agent/pkg/store.py +83 -0
  11. replx_agent/protocol/__init__.py +11 -0
  12. replx_agent/protocol/repl.py +1029 -0
  13. replx_agent/protocol/storage.py +1018 -0
  14. replx_agent/rpc/__init__.py +3 -0
  15. replx_agent/rpc/protocol.py +220 -0
  16. replx_agent/server/__init__.py +3 -0
  17. replx_agent/server/__main__.py +4 -0
  18. replx_agent/server/command_dispatcher.py +49 -0
  19. replx_agent/server/connection_manager.py +654 -0
  20. replx_agent/server/core.py +1221 -0
  21. replx_agent/server/handlers/__init__.py +24 -0
  22. replx_agent/server/handlers/exec.py +553 -0
  23. replx_agent/server/handlers/filesystem.py +383 -0
  24. replx_agent/server/handlers/firmware.py +118 -0
  25. replx_agent/server/handlers/i2c.py +23 -0
  26. replx_agent/server/handlers/package.py +182 -0
  27. replx_agent/server/handlers/repl.py +188 -0
  28. replx_agent/server/handlers/session.py +205 -0
  29. replx_agent/server/handlers/spi.py +23 -0
  30. replx_agent/server/handlers/transfer.py +600 -0
  31. replx_agent/server/handlers/uart.py +32 -0
  32. replx_agent/server/session_manager.py +391 -0
  33. replx_agent/server/tcp.py +207 -0
  34. replx_agent/terminal.py +566 -0
  35. replx_agent/transport/__init__.py +32 -0
  36. replx_agent/transport/serial.py +220 -0
  37. replx_agent/utils/__init__.py +95 -0
  38. replx_agent/utils/constants.py +78 -0
  39. replx_agent/utils/device_info.py +183 -0
  40. replx_agent/utils/exceptions.py +31 -0
  41. replx_agent-2.0.0.dist-info/METADATA +74 -0
  42. replx_agent-2.0.0.dist-info/RECORD +46 -0
  43. replx_agent-2.0.0.dist-info/WHEEL +5 -0
  44. replx_agent-2.0.0.dist-info/entry_points.txt +2 -0
  45. replx_agent-2.0.0.dist-info/licenses/LICENSE +21 -0
  46. 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