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,1029 @@
1
+ import sys
2
+ import time
3
+ import struct
4
+ import textwrap
5
+ import threading
6
+ from contextlib import contextmanager
7
+ from typing import Callable, Optional
8
+
9
+ from replx_agent.utils.exceptions import ProtocolError, TransportError
10
+ from replx_agent.utils.constants import (
11
+ CTRL_A, CTRL_B, CTRL_C, CTRL_D, CTRL_E,
12
+ EOF_MARKER,
13
+ RAW_REPL_PROMPT, SOFT_REBOOT_MSG, OK_RESPONSE,
14
+ RAW_PASTE_INIT, RAW_PASTE_SUPPORTED, RAW_PASTE_NOT_SUPPORTED,
15
+ RAW_PASTE_WINDOW_INC, RAW_PASTE_END_DATA,
16
+ RAW_PASTE_DEFAULT_WINDOW_SIZE,
17
+ RAW_PASTE_SAFE_BAUD, RAW_PASTE_HIGH_BAUD_MIN_COMMAND_SIZE,
18
+ ERROR_HEADER,
19
+ )
20
+ from replx_agent.utils.device_info import get_core_profile, normalize_core
21
+ from replx_agent.transport import create_transport
22
+ from replx_agent.terminal import (
23
+ IS_WINDOWS, CR, LF,
24
+ flush_outbuf as _flush_outbuf,
25
+ stdout_write_bytes as _stdout_write_bytes,
26
+ getch, putch, getch_nonblock,
27
+ )
28
+
29
+ _RAW_PASTE_MIN_COMMAND_SIZE = 4 * 1024
30
+
31
+
32
+ class ReplProtocol:
33
+ _active_instance = None
34
+ _sigint_handler_installed = False
35
+
36
+ def interrupt(self) -> None:
37
+ self.transport.write(CTRL_C)
38
+
39
+ def send_eof(self) -> None:
40
+ self.transport.write(CTRL_D)
41
+
42
+ def send_raw(self, data: bytes) -> None:
43
+ self.transport.write(data)
44
+
45
+ def drain(self) -> bytes:
46
+ return self.transport.read_available()
47
+
48
+ def enter_paste_mode(self) -> None:
49
+ self.transport.write(CTRL_E)
50
+
51
+ def exit_paste_mode(self) -> None:
52
+ self.transport.write(CTRL_B)
53
+
54
+ def in_waiting(self) -> int:
55
+ return self.transport.in_waiting()
56
+
57
+ def read_bytes(self, n: int) -> bytes:
58
+ return self.transport.read(n)
59
+
60
+ def __init__(self, connection_string, baudrate:int=115200, core:str="RP2350", device_root_fs:str="/"):
61
+ from replx_agent.transport import SerialTransport
62
+
63
+ try:
64
+ if isinstance(connection_string, SerialTransport):
65
+ self.transport = connection_string
66
+ else:
67
+ self.transport = create_transport(connection_string, baudrate=baudrate)
68
+ self._stop_event = threading.Event()
69
+ self._follow_thread = None
70
+ except Exception as e:
71
+ raise ProtocolError(f"failed to open {connection_string} ({e})")
72
+
73
+ self._rx_pushback = bytearray()
74
+ self._raw_paste_supported = None
75
+ self._raw_paste_window_size = RAW_PASTE_DEFAULT_WINDOW_SIZE
76
+
77
+ self._skip_error_output = False
78
+ self._error_header_buf = b""
79
+
80
+ self.core = normalize_core(core)
81
+ self.device_root_fs = device_root_fs
82
+
83
+ _profile = get_core_profile(core)
84
+ self._DEVICE_CHUNK_SIZES = _profile['chunk_size']
85
+ self._RAW_MODE_DELAY = _profile['raw_mode_delay']
86
+ self._PUT_BATCH_BYTES = _profile['put_batch_bytes']
87
+
88
+ baud = getattr(self.transport, 'baudrate', None)
89
+ if not isinstance(baud, int) or baud <= 0:
90
+ baud = RAW_PASTE_SAFE_BAUD
91
+ self._raw_paste_min_command_size = (
92
+ _RAW_PASTE_MIN_COMMAND_SIZE if baud <= RAW_PASTE_SAFE_BAUD
93
+ else RAW_PASTE_HIGH_BAUD_MIN_COMMAND_SIZE
94
+ )
95
+
96
+ self._init_repl()
97
+ self._install_sigint_handler()
98
+
99
+ @classmethod
100
+ def _install_sigint_handler(cls):
101
+ if IS_WINDOWS and not cls._sigint_handler_installed:
102
+ import signal
103
+
104
+ def win_sigint_handler(signum, frame):
105
+ if cls._active_instance is None:
106
+ raise KeyboardInterrupt
107
+ cls._active_instance.request_interrupt()
108
+
109
+ try:
110
+ signal.signal(signal.SIGINT, win_sigint_handler)
111
+ cls._sigint_handler_installed = True
112
+ except Exception:
113
+ pass
114
+
115
+ def _reset_error_filter(self):
116
+ self._skip_error_output = False
117
+ self._error_header_buf = b""
118
+
119
+ def _init_repl(self):
120
+ self._interrupt_requested = False
121
+ self._session_depth = 0
122
+ self._in_raw_repl = False
123
+ self._repl_prompt_detected = False
124
+
125
+ def _read(self, n:int=1) -> bytes:
126
+ if self._rx_pushback:
127
+ if len(self._rx_pushback) <= n:
128
+ b = bytes(self._rx_pushback)
129
+ self._rx_pushback.clear()
130
+ rem = n - len(b)
131
+ if rem > 0:
132
+ b += self.transport.read(rem)
133
+ else:
134
+ b = bytes(self._rx_pushback[:n])
135
+ del self._rx_pushback[:n]
136
+ else:
137
+ b = self.transport.read(n)
138
+
139
+ return b
140
+
141
+ def _read_ex(self, min_num_bytes:int, ending:bytes, timeout:float=5.0,
142
+ data_consumer:Optional[Callable[[bytes], None]]=None) -> bytes:
143
+ start = time.monotonic()
144
+ deadline = (start + timeout) if timeout > 0 else None
145
+
146
+ streaming_mode = (timeout == 0 and data_consumer is not None)
147
+
148
+ pat = ending
149
+ m = len(pat)
150
+ pi = [0] * m
151
+ k = 0
152
+ for i in range(1, m):
153
+ while k > 0 and pat[k] != pat[i]:
154
+ k = pi[k-1]
155
+ if pat[k] == pat[i]:
156
+ k += 1
157
+ pi[i] = k
158
+
159
+ matched = 0
160
+ crash_tail = b''
161
+
162
+ tail_size = m + 8
163
+ tail_buffer = bytearray(tail_size)
164
+ tail_len = 0
165
+ data = bytearray()
166
+
167
+ def _call_consumer(chunk: bytes):
168
+ if data_consumer and chunk:
169
+ data_consumer(chunk)
170
+
171
+ def _update_tail(chunk: bytes):
172
+ nonlocal tail_len
173
+ chunk_len = len(chunk)
174
+ if chunk_len >= tail_size:
175
+ tail_buffer[:] = chunk[-tail_size:]
176
+ tail_len = tail_size
177
+ elif tail_len + chunk_len <= tail_size:
178
+ tail_buffer[tail_len:tail_len + chunk_len] = chunk
179
+ tail_len += chunk_len
180
+ else:
181
+ keep = tail_size - chunk_len
182
+ tail_buffer[:keep] = tail_buffer[tail_len - keep:tail_len]
183
+ tail_buffer[keep:keep + chunk_len] = chunk
184
+ tail_len = tail_size
185
+
186
+ def _feed_chunk(chunk: bytes) -> int:
187
+ nonlocal matched, crash_tail
188
+
189
+ if not chunk:
190
+ return -1
191
+ if ending == EOF_MARKER and self.core.startswith('ESP32'):
192
+ crash_tail = (crash_tail + chunk)[-8192:]
193
+ if (b'\r\nGuru Meditation Error:' in crash_tail
194
+ or b'\r\nA fatal error occurred. The crash dump printed below' in crash_tail):
195
+ raise ProtocolError(
196
+ "ESP32 firmware crashed (native panic/core dump), not a REPL timeout. "
197
+ "Power-cycle the board. On P4/C6 check the MicroPython C6_WIFI build, "
198
+ "chip revision and ESP-Hosted coprocessor firmware; Python retries cannot fix this."
199
+ )
200
+
201
+ clen = len(chunk)
202
+ end_at = -1
203
+
204
+ if matched == 0:
205
+ pos = chunk.find(pat)
206
+ if pos >= 0:
207
+ end_at = pos + m - 1
208
+ matched = m
209
+ else:
210
+ tail_start = max(0, clen - m + 1)
211
+ for idx in range(tail_start, clen):
212
+ b = chunk[idx]
213
+ while matched > 0 and pat[matched] != b:
214
+ matched = pi[matched - 1]
215
+ if pat[matched] == b:
216
+ matched += 1
217
+ if matched == m:
218
+ end_at = idx
219
+ break
220
+ else:
221
+ remaining = m - matched
222
+ if clen >= remaining and chunk[:remaining] == pat[matched:]:
223
+ end_at = remaining - 1
224
+ matched = m
225
+ else:
226
+ for idx in range(clen):
227
+ b = chunk[idx]
228
+ while matched > 0 and pat[matched] != b:
229
+ matched = pi[matched - 1]
230
+ if pat[matched] == b:
231
+ matched += 1
232
+ if matched == m:
233
+ end_at = idx
234
+ break
235
+
236
+ if streaming_mode:
237
+ chunk_to_process = chunk[:end_at+1] if end_at >= 0 else chunk
238
+ _call_consumer(chunk_to_process)
239
+ _update_tail(chunk_to_process)
240
+ return end_at + 1 if end_at >= 0 else -1
241
+ else:
242
+ if end_at >= 0:
243
+ data.extend(chunk[:end_at+1])
244
+ _call_consumer(chunk[:end_at+1])
245
+ return end_at + 1
246
+
247
+ data.extend(chunk)
248
+ _call_consumer(chunk)
249
+ return -1
250
+
251
+ def _read_some(max_n: int) -> bytes:
252
+ if self._rx_pushback:
253
+ # Return buffered bytes without blocking for the rest of max_n;
254
+ # self._read() would wait a full serial timeout for them.
255
+ b = bytes(self._rx_pushback[:max(1, max_n)])
256
+ del self._rx_pushback[:len(b)]
257
+ return b
258
+ try:
259
+ avail = self.transport.read_available()
260
+ if avail:
261
+ return avail
262
+ except TransportError:
263
+ raise
264
+
265
+ if streaming_mode:
266
+ return b''
267
+ return self._read(1)
268
+
269
+ def _get_result() -> bytes:
270
+ if streaming_mode:
271
+ return bytes(tail_buffer[:tail_len])
272
+ else:
273
+ return bytes(data)
274
+
275
+ try:
276
+ if min_num_bytes > 0:
277
+ chunk = _read_some(min_num_bytes)
278
+ if chunk:
279
+ pos = _feed_chunk(chunk)
280
+ if pos >= 0:
281
+ tail = chunk[pos:]
282
+ if tail:
283
+ self._rx_pushback[:] = tail + self._rx_pushback
284
+ return _get_result()
285
+
286
+ last_activity = time.monotonic()
287
+ last_keepalive = time.monotonic()
288
+ keepalive_interval = 30.0
289
+
290
+ while True:
291
+ if self._stop_event.is_set():
292
+ break
293
+
294
+ if deadline is not None and time.monotonic() >= deadline:
295
+ break
296
+
297
+ now = time.monotonic()
298
+ if streaming_mode and (now - last_keepalive) >= keepalive_interval:
299
+ if hasattr(self.transport, 'keep_alive'):
300
+ self.transport.keep_alive()
301
+ last_keepalive = now
302
+
303
+ waiting = self.transport.in_waiting()
304
+ if waiting <= 0 and len(self._rx_pushback) > 0:
305
+ waiting = 1
306
+
307
+ if waiting > 0:
308
+ want = min(4096, max(256, waiting))
309
+ chunk = _read_some(want)
310
+ if chunk:
311
+ pos = _feed_chunk(chunk)
312
+ last_activity = time.monotonic()
313
+ if pos >= 0:
314
+ tail = chunk[pos:]
315
+ if tail:
316
+ self._rx_pushback[:] = tail + self._rx_pushback
317
+ return _get_result()
318
+ else:
319
+ time.sleep(0.001)
320
+ else:
321
+ time.sleep(0.001)
322
+
323
+ if timeout > 0:
324
+ idle_limit = max(timeout * 2, 10)
325
+ if (time.monotonic() - last_activity) > idle_limit:
326
+ break
327
+
328
+ return _get_result()
329
+
330
+ except TransportError:
331
+ if not self._stop_event.is_set():
332
+ raise
333
+ return _get_result()
334
+ except ProtocolError:
335
+ raise
336
+ except Exception as exc:
337
+ raise ProtocolError(f"REPL read failed while waiting for {ending!r}: {exc}") from exc
338
+
339
+
340
+ def _enter_repl(self, timeout: float = 5.0, attempts: int = 4):
341
+ self._in_raw_repl = False
342
+ self._rx_pushback.clear()
343
+ delay = 0.1
344
+
345
+ for attempt in range(1, attempts + 1):
346
+ try:
347
+ self.transport.reset_input_buffer()
348
+ time.sleep(0.05)
349
+ self.transport.reset_output_buffer()
350
+ time.sleep(0.05)
351
+ except Exception:
352
+ pass
353
+
354
+ self.transport.write(b'\r' + CTRL_C + CTRL_C)
355
+ time.sleep(delay)
356
+
357
+ try:
358
+ self.transport.reset_input_buffer()
359
+ time.sleep(0.05)
360
+ except Exception:
361
+ pass
362
+
363
+ self.transport.write(b'\r' + CTRL_A)
364
+
365
+ try:
366
+ data = self._read_ex(1, RAW_REPL_PROMPT[:-1], timeout=timeout)
367
+ if not data.endswith(RAW_REPL_PROMPT[:-1]):
368
+ raise ProtocolError('could not enter raw repl')
369
+ self._in_raw_repl = True
370
+ return
371
+ except ProtocolError:
372
+ try:
373
+ self.transport.write(b'\r' + CTRL_B)
374
+ time.sleep(0.15)
375
+ try:
376
+ self.transport.reset_input_buffer()
377
+ except Exception:
378
+ pass
379
+ except Exception:
380
+ pass
381
+
382
+ time.sleep(0.2 * attempt)
383
+ continue
384
+
385
+ raise ProtocolError('could not enter raw repl')
386
+
387
+ def probe(self, timeout: float = 1.0) -> bool:
388
+ if self._session_depth:
389
+ raise ProtocolError("Cannot probe the REPL during an active session")
390
+ if self._stop_event.is_set():
391
+ raise TransportError("REPL is stopping")
392
+ if self._rx_pushback or self.transport.in_waiting() > 0:
393
+ return True
394
+
395
+ try:
396
+ self._enter_repl(timeout=timeout, attempts=1)
397
+ self._leave_repl()
398
+ return True
399
+ except (ProtocolError, TransportError, OSError):
400
+ self._in_raw_repl = False
401
+ self._rx_pushback.clear()
402
+ raise
403
+
404
+ def _soft_reset(self):
405
+ timeout = 3
406
+ try:
407
+ self.transport.write(CTRL_C)
408
+ time.sleep(0.1)
409
+ self.transport.write(CTRL_C)
410
+ time.sleep(0.1)
411
+ self.transport.reset_input_buffer()
412
+
413
+ self.transport.write(CTRL_A)
414
+ time.sleep(0.1)
415
+ self.transport.reset_input_buffer()
416
+
417
+ self.transport.write(CTRL_D)
418
+ data = self._read_ex(1, SOFT_REBOOT_MSG, timeout=timeout)
419
+ if not data.endswith(SOFT_REBOOT_MSG):
420
+ raise ProtocolError('soft reset failed')
421
+
422
+ data = self._read_ex(1, RAW_REPL_PROMPT[:-1], timeout=timeout)
423
+ if not data.endswith(RAW_REPL_PROMPT[:-1]):
424
+ raise ProtocolError('soft reset failed')
425
+ except Exception:
426
+ raise
427
+
428
+ def _resync_repl(self):
429
+ try:
430
+ self.transport.write(b'\r' + CTRL_B)
431
+ time.sleep(0.04)
432
+ self.transport.reset_input_buffer()
433
+ except Exception:
434
+ pass
435
+
436
+ try:
437
+ self.transport.write(b'\r' + CTRL_A)
438
+ got = self._read_ex(1, RAW_REPL_PROMPT[:-1], timeout=1)
439
+ if got.endswith(RAW_REPL_PROMPT[:-1]):
440
+ return True
441
+ except Exception:
442
+ pass
443
+ return False
444
+
445
+ def _leave_repl(self):
446
+ self.transport.write(b'\r' + CTRL_B)
447
+ self._in_raw_repl = False
448
+
449
+ @contextmanager
450
+ def session(self):
451
+ need_enter = self._session_depth == 0
452
+ if need_enter:
453
+ self._enter_repl()
454
+ self._raw_paste_supported = None
455
+ self._session_depth += 1
456
+ try:
457
+ yield
458
+ finally:
459
+ self._session_depth = max(0, self._session_depth - 1)
460
+ if need_enter:
461
+ self._leave_repl()
462
+
463
+ def _enter_raw_paste_mode(self) -> bool:
464
+ if not get_core_profile(self.core)['raw_paste']:
465
+ return False
466
+ # Only a negative result may be cached: every raw-paste exec must
467
+ # start with its own RAW_PASTE_INIT handshake.
468
+ if self._raw_paste_supported is False:
469
+ return False
470
+
471
+ self.transport.write(RAW_PASTE_INIT)
472
+
473
+ start = time.monotonic()
474
+ timeout = 0.5
475
+ response = b''
476
+ while len(response) < 2 and (time.monotonic() - start) < timeout:
477
+ waiting = self.transport.in_waiting()
478
+ if waiting > 0:
479
+ response += self._read(min(2 - len(response), waiting))
480
+ else:
481
+ time.sleep(0.01)
482
+
483
+ if len(response) < 2:
484
+ self._raw_paste_supported = False
485
+ if not self._resync_repl():
486
+ raise ProtocolError("Timed out negotiating raw-paste mode and could not restore the REPL")
487
+ raise ProtocolError("Timed out negotiating raw-paste mode")
488
+
489
+ if response == RAW_PASTE_SUPPORTED:
490
+ window_bytes = self._read(2)
491
+ if len(window_bytes) != 2:
492
+ self.transport.write(CTRL_D)
493
+ self._read_ex(1, b'>', timeout=1)
494
+ self._raw_paste_supported = False
495
+ raise ProtocolError("Incomplete raw-paste window-size response")
496
+
497
+ window_size = struct.unpack('<H', window_bytes)[0]
498
+ if window_size == 0:
499
+ self.transport.write(CTRL_D)
500
+ self._read_ex(1, b'>', timeout=1)
501
+ self._raw_paste_supported = False
502
+ raise ProtocolError("Invalid raw-paste window size: 0")
503
+
504
+ self._raw_paste_window_size = window_size
505
+ self._raw_paste_supported = True
506
+ return True
507
+
508
+ if response == RAW_PASTE_NOT_SUPPORTED:
509
+ self._raw_paste_supported = False
510
+ return False
511
+
512
+ if response == b'ra':
513
+ self._read_ex(1, b'>', timeout=1)
514
+ self._raw_paste_supported = False
515
+ return False
516
+
517
+ self._raw_paste_supported = False
518
+ if not self._resync_repl():
519
+ raise ProtocolError(f"Unexpected raw-paste response {response!r}; could not restore the REPL")
520
+ raise ProtocolError(f"Unexpected raw-paste response {response!r}")
521
+
522
+ def _follow_task(self, echo: bool):
523
+ try:
524
+ while not self._stop_event.is_set():
525
+ try:
526
+ ch = getch_nonblock()
527
+ if ch is None:
528
+ time.sleep(0.005)
529
+ continue
530
+
531
+ if not ch:
532
+ continue
533
+
534
+ if ch == CTRL_C:
535
+ self.request_interrupt()
536
+ time.sleep(0.1)
537
+ return
538
+
539
+ if ch == CTRL_D:
540
+ try:
541
+ self.transport.write(CTRL_D)
542
+ except Exception:
543
+ pass
544
+ time.sleep(0.08)
545
+ return
546
+
547
+ if echo:
548
+ putch(ch)
549
+
550
+ self.transport.write(CR if ch == LF else ch)
551
+ except Exception:
552
+ pass
553
+ finally:
554
+ pass
555
+
556
+ def _exec_raw_paste(self, command: bytes, data_consumer: Optional[Callable[[bytes], None]] = None) -> tuple[bytes, bytes]:
557
+ remaining_window = self._raw_paste_window_size
558
+ bytes_sent = 0
559
+ command_len = len(command)
560
+ aborted = False
561
+
562
+ while bytes_sent < command_len:
563
+ while remaining_window <= 0 or self.transport.in_waiting() > 0:
564
+ fc_byte = self._read(1)
565
+ if fc_byte == RAW_PASTE_WINDOW_INC:
566
+ remaining_window += self._raw_paste_window_size
567
+ elif fc_byte == RAW_PASTE_END_DATA:
568
+ self.transport.write(CTRL_D)
569
+ aborted = True
570
+ break
571
+ elif fc_byte:
572
+ raise ProtocolError(
573
+ f"Unexpected raw-paste flow-control byte: {fc_byte!r}"
574
+ )
575
+
576
+ if aborted:
577
+ break
578
+
579
+ chunk_size = min(remaining_window, command_len - bytes_sent)
580
+ chunk = command[bytes_sent:bytes_sent + chunk_size]
581
+ self.transport.write(chunk)
582
+ bytes_sent += chunk_size
583
+ remaining_window -= chunk_size
584
+
585
+ if not aborted and bytes_sent == command_len:
586
+ self.transport.write(CTRL_D)
587
+
588
+ ack = self._read_ex(1, EOF_MARKER, timeout=5)
589
+ if not ack.endswith(EOF_MARKER):
590
+ raise ProtocolError("Raw-paste compilation acknowledgment timeout")
591
+
592
+ stdout_data = self._read_ex(1, EOF_MARKER, timeout=0, data_consumer=data_consumer)
593
+ if stdout_data.endswith(EOF_MARKER):
594
+ stdout_data = stdout_data[:-1]
595
+
596
+ stderr_data = self._read_ex(1, EOF_MARKER, timeout=5)
597
+ if stderr_data.endswith(EOF_MARKER):
598
+ stderr_data = stderr_data[:-1]
599
+
600
+ # Leave the trailing raw-REPL '>' for the next command's prompt check.
601
+ return (stdout_data, stderr_data)
602
+
603
+ def _exec(self, command:str|bytes, interactive:bool=False, echo:bool=False, detach:bool=False,
604
+ data_consumer:Optional[Callable[[bytes], None]]=None,
605
+ force_raw_paste:Optional[bool]=None) -> bytes:
606
+ self._stop_event.clear()
607
+ self._reset_error_filter()
608
+
609
+ if isinstance(command, str):
610
+ command = command.encode('utf-8')
611
+
612
+ if not command or len(command) == 0:
613
+ return b''
614
+
615
+ if interactive:
616
+ ReplProtocol._active_instance = self
617
+
618
+ try:
619
+ return self._exec_command(command, interactive, echo, detach, data_consumer, force_raw_paste)
620
+ except ProtocolError:
621
+ self._in_raw_repl = False
622
+ self._rx_pushback.clear()
623
+ raise
624
+ finally:
625
+ if interactive:
626
+ ReplProtocol._active_instance = None
627
+
628
+ def _exec_command(self, command: bytes, interactive: bool, echo: bool, detach: bool,
629
+ data_consumer: Optional[Callable[[bytes], None]],
630
+ force_raw_paste: Optional[bool]) -> bytes:
631
+ data_err = b''
632
+ if data_consumer is None and interactive:
633
+ data_consumer = self._create_data_consumer()
634
+ follow_thread = None
635
+
636
+ data = self._read_ex(1, b'>')
637
+ if not data.endswith(b'>'):
638
+ raise ProtocolError('could not enter raw repl')
639
+
640
+ command_len = len(command)
641
+
642
+ prefer_raw_paste = force_raw_paste
643
+ if prefer_raw_paste is None:
644
+ prefer_raw_paste = command_len >= self._raw_paste_min_command_size
645
+ use_raw_paste = (
646
+ prefer_raw_paste
647
+ and not interactive
648
+ and not detach
649
+ and self._enter_raw_paste_mode()
650
+ )
651
+
652
+ if use_raw_paste:
653
+ try:
654
+ data, data_err = self._exec_raw_paste(command, data_consumer)
655
+ except TransportError:
656
+ raise
657
+ except Exception as e:
658
+ self._raw_paste_supported = False
659
+ if not self._resync_repl():
660
+ try:
661
+ self._leave_repl()
662
+ time.sleep(0.05)
663
+ self._enter_repl()
664
+ prompt = self._read_ex(1, b'>')
665
+ if not prompt.endswith(b'>'):
666
+ raise ProtocolError('could not recover after raw-paste failure')
667
+ except Exception as recovery_error:
668
+ raise ProtocolError(
669
+ f"Raw-paste transfer failed: {e}; REPL recovery failed: {recovery_error}"
670
+ ) from e
671
+ if isinstance(e, ProtocolError):
672
+ raise
673
+ raise ProtocolError(f"Raw-paste transfer failed: {e}") from e
674
+
675
+ if data_err and not self._interrupt_requested:
676
+ raise ProtocolError(data_err.decode('utf-8', errors='replace'))
677
+
678
+ self._interrupt_requested = False
679
+ return data
680
+
681
+ current_buffer_size = self._DEVICE_CHUNK_SIZES
682
+ bytes_sent = 0
683
+
684
+ start_time = time.monotonic()
685
+
686
+ while bytes_sent < command_len:
687
+ chunk_end = min(bytes_sent + current_buffer_size, command_len)
688
+ chunk = command[bytes_sent:chunk_end]
689
+
690
+ self.transport.write(chunk)
691
+ bytes_sent += len(chunk)
692
+
693
+ if self._RAW_MODE_DELAY > 0 and bytes_sent < command_len:
694
+ time.sleep(self._RAW_MODE_DELAY)
695
+
696
+ self.transport.write(EOF_MARKER)
697
+
698
+ transfer_time = time.monotonic() - start_time
699
+ timeout = max(5, int(transfer_time * 2))
700
+
701
+ data = self._read_ex(1, OK_RESPONSE, timeout=timeout)
702
+ if not data.endswith(OK_RESPONSE):
703
+ raise ProtocolError('could not execute command (response: %r)' % data)
704
+
705
+ if detach:
706
+ return b''
707
+
708
+ if interactive:
709
+ self._stop_event.clear()
710
+ self._interrupt_requested = False
711
+ follow_thread = threading.Thread(target=self._follow_task, args=(echo,), daemon=True)
712
+ follow_thread.start()
713
+
714
+ try:
715
+ data = self._read_ex(1, EOF_MARKER, 0, data_consumer)
716
+ if data.endswith(EOF_MARKER):
717
+ data = data[:-1]
718
+
719
+ data_err = self._read_ex(1, EOF_MARKER, 0, None)
720
+ if data_err.endswith(EOF_MARKER):
721
+ data_err = data_err[:-1]
722
+ elif data_err and not self._interrupt_requested:
723
+ raise ProtocolError(data_err.decode('utf-8', errors='replace'))
724
+ finally:
725
+ def _drain_consumer(b: bytes, _carry=[False]):
726
+ if len(b) == 1 and b == b'>':
727
+ return
728
+ if data_consumer:
729
+ data_consumer(b)
730
+ else:
731
+ _stdout_write_bytes(b)
732
+
733
+ if follow_thread and follow_thread.is_alive():
734
+ self._stop_event.set()
735
+ try:
736
+ follow_thread.join(timeout=0.05)
737
+ except Exception:
738
+ pass
739
+
740
+ self._stop_event.clear()
741
+
742
+ try:
743
+ self._read_ex(1, EOF_MARKER, timeout=0.1, data_consumer=_drain_consumer)
744
+ except Exception:
745
+ pass
746
+
747
+ if not data_consumer:
748
+ try:
749
+ _flush_outbuf()
750
+ except Exception:
751
+ pass
752
+
753
+ try:
754
+ if self._interrupt_requested and self.core != "EFR32MG":
755
+ self.transport.write(b'\r' + CTRL_B)
756
+ time.sleep(0.08)
757
+ self.transport.write(b'\r' + CTRL_A)
758
+ self._read_ex(1, RAW_REPL_PROMPT[:-1], timeout=1)
759
+ else:
760
+ self.transport.write(CTRL_D)
761
+ got = self._read_ex(1, SOFT_REBOOT_MSG, timeout=2)
762
+ if not got.endswith(SOFT_REBOOT_MSG):
763
+ self.transport.write(b'\r' + CTRL_B)
764
+ time.sleep(0.08)
765
+ self.transport.write(b'\r' + CTRL_A)
766
+ self._read_ex(1, RAW_REPL_PROMPT[:-1], timeout=1)
767
+ except Exception:
768
+ pass
769
+
770
+ if data_err:
771
+ if self._interrupt_requested:
772
+ data_err = b""
773
+ else:
774
+ raise ProtocolError(data_err.decode('utf-8', errors='replace'))
775
+
776
+ self._interrupt_requested = False
777
+ return data
778
+
779
+ def _create_data_consumer(self):
780
+ def data_consumer(chunk):
781
+ if not chunk:
782
+ return
783
+
784
+ self._error_header_buf = (self._error_header_buf + chunk)[-len(ERROR_HEADER):]
785
+ if self._error_header_buf == ERROR_HEADER:
786
+ self._skip_error_output = True
787
+ return
788
+
789
+ if not self._skip_error_output:
790
+ _stdout_write_bytes(chunk)
791
+ _flush_outbuf()
792
+
793
+ return data_consumer
794
+
795
+ def _drain_eof(self, max_ms:int=200):
796
+ deadline = time.monotonic() + max_ms / 1000
797
+ while time.monotonic() < deadline:
798
+ waiting = self.transport.in_waiting()
799
+ if waiting:
800
+ _ = self._read(waiting)
801
+ else:
802
+ time.sleep(0.01)
803
+
804
+ def _repl_serial_to_stdout(self):
805
+ PROMPT_COLOR = b"\033[92m"
806
+ CONT_COLOR = b"\033[93m"
807
+ RESET_COLOR = b"\033[0m"
808
+ read_errors = 0
809
+
810
+ try:
811
+ while self.serial_reader_running:
812
+ try:
813
+ count = self.transport.in_waiting()
814
+ except Exception:
815
+ break
816
+
817
+ if not count:
818
+ read_errors = 0
819
+ time.sleep(0.01)
820
+ continue
821
+
822
+ try:
823
+ data = self.transport.read(count)
824
+ except Exception:
825
+ break
826
+
827
+ if not data:
828
+ continue
829
+
830
+ read_errors = 0
831
+
832
+ if b">>> " in data:
833
+ self._repl_prompt_detected = True
834
+
835
+ if self.serial_out_put_enable and self.serial_out_put_count > 0:
836
+ data = data.replace(b">>> ", PROMPT_COLOR + b">>> " + RESET_COLOR)
837
+ data = data.replace(b"... ", CONT_COLOR + b"... " + RESET_COLOR)
838
+
839
+ if IS_WINDOWS:
840
+ sys.stdout.buffer.write(data.replace(b"\r", b""))
841
+ else:
842
+ sys.stdout.buffer.write(data)
843
+ sys.stdout.buffer.flush()
844
+
845
+ self.serial_out_put_count += 1
846
+ except KeyboardInterrupt:
847
+ try:
848
+ self.transport.close()
849
+ except Exception:
850
+ pass
851
+
852
+ def _reset(self):
853
+ command = """
854
+ import machine
855
+ machine.soft_reset() # Ctrl+D
856
+ """
857
+ self.exec(command)
858
+
859
+ def request_interrupt(self):
860
+ self._interrupt_requested = True
861
+ try:
862
+ self.transport.write(CTRL_C)
863
+ except Exception:
864
+ pass
865
+
866
+ def exec(self, command:str=None):
867
+ if not self._in_raw_repl:
868
+ self._enter_repl()
869
+
870
+ try:
871
+ command = textwrap.dedent(command)
872
+ return self._exec(command)
873
+ except ProtocolError:
874
+ raise
875
+ finally:
876
+ pass
877
+
878
+ def run(self, local, interactive:bool=False, echo:bool=False):
879
+ if not interactive and echo:
880
+ import typer
881
+
882
+ raise typer.BadParameter("Option chaining error: -n and -e can only be used once, not multiple times.")
883
+
884
+ with open(local, "rb") as f:
885
+ data = f.read()
886
+
887
+ if not data or len(data) == 0:
888
+ return
889
+
890
+ if not self._in_raw_repl:
891
+ self._enter_repl()
892
+
893
+ try:
894
+ self._soft_reset()
895
+ self._exec(data, interactive, echo, detach=not interactive)
896
+ if interactive:
897
+ self._drain_eof(max_ms=200)
898
+ except Exception:
899
+ self._leave_repl()
900
+ raise
901
+
902
+ def close(self):
903
+ self.transport.close()
904
+
905
+ def reset(self):
906
+ self._in_raw_repl = False
907
+ self._enter_repl()
908
+
909
+ try:
910
+ self._soft_reset()
911
+ except Exception:
912
+ self._leave_repl()
913
+ raise
914
+
915
+ def repl(self):
916
+ import time
917
+
918
+ def _write_with_timeout(data: bytes, timeout_sec: float = 1.5) -> bool:
919
+ result = {"ok": False, "err": None}
920
+
921
+ def _writer():
922
+ try:
923
+ self.transport.write(data)
924
+ result["ok"] = True
925
+ except Exception as e:
926
+ result["err"] = e
927
+
928
+ t = threading.Thread(target=_writer, daemon=True)
929
+ t.start()
930
+ t.join(timeout=timeout_sec)
931
+ if t.is_alive():
932
+ return False
933
+ return bool(result["ok"]) and result["err"] is None
934
+
935
+ self.serial_reader_running = True
936
+ self.serial_out_put_enable = False
937
+ self.serial_out_put_count = 0
938
+ self._repl_prompt_detected = False
939
+
940
+ repl_thread = threading.Thread(target=self._repl_serial_to_stdout, daemon=True, name='REPL')
941
+ repl_thread.start()
942
+
943
+ time.sleep(0.05)
944
+
945
+ if not _write_with_timeout(CTRL_B):
946
+ self.serial_reader_running = False
947
+ print('')
948
+ return
949
+ time.sleep(0.05)
950
+ if not _write_with_timeout(CTRL_C):
951
+ self.serial_reader_running = False
952
+ print('')
953
+ return
954
+ time.sleep(0.1)
955
+
956
+ drain_timeout = time.monotonic() + 0.5
957
+ while time.monotonic() < drain_timeout:
958
+ if self.transport.in_waiting() > 0:
959
+ self.transport.read(self.transport.in_waiting())
960
+ time.sleep(0.02)
961
+ else:
962
+ time.sleep(0.05)
963
+ if self.transport.in_waiting() == 0:
964
+ break
965
+
966
+ self.serial_out_put_enable = True
967
+ self.serial_out_put_count = 1
968
+ if not _write_with_timeout(b'\r'):
969
+ self.serial_reader_running = False
970
+ print('')
971
+ return
972
+
973
+ prompt_timeout = time.monotonic() + 1.0
974
+ while time.monotonic() < prompt_timeout:
975
+ time.sleep(0.05)
976
+ if self._repl_prompt_detected:
977
+ break
978
+
979
+ recent_chars = bytearray(6)
980
+ recent_len = 0
981
+
982
+ while True:
983
+ if self.serial_reader_running and not repl_thread.is_alive():
984
+ break
985
+
986
+ char = getch()
987
+ if not char:
988
+ continue
989
+
990
+ if char == b'\x07':
991
+ self.serial_out_put_enable = False
992
+ continue
993
+ elif char == b'\x0F':
994
+ self.serial_out_put_enable = True
995
+ self.serial_out_put_count = 0
996
+ continue
997
+ elif char == b'\x00' or not char:
998
+ continue
999
+
1000
+ if char == b'\r' or char == b'\n':
1001
+ if recent_len >= 4:
1002
+ cmd = bytes(recent_chars[:recent_len]).strip().lower()
1003
+ if cmd in (b'exit', b'exit()'):
1004
+ break
1005
+ recent_len = 0
1006
+ elif char == b'\x7f' or char == b'\x08':
1007
+ if recent_len > 0:
1008
+ recent_len -= 1
1009
+ elif char >= b' ' and len(char) == 1:
1010
+ if recent_len < 6:
1011
+ recent_chars[recent_len] = char[0]
1012
+ recent_len += 1
1013
+ else:
1014
+ recent_chars[:-1] = recent_chars[1:]
1015
+ recent_chars[5] = char[0]
1016
+
1017
+ try:
1018
+ if not _write_with_timeout(b'\r' if char == b'\n' else char):
1019
+ break
1020
+ except Exception:
1021
+ break
1022
+
1023
+ self.serial_reader_running = False
1024
+ if repl_thread.is_alive():
1025
+ try:
1026
+ repl_thread.join(timeout=0.2)
1027
+ except Exception:
1028
+ pass
1029
+ print('')