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,220 @@
|
|
|
1
|
+
import gc
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
from replx_agent.utils.exceptions import TransportError
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import serial
|
|
8
|
+
except ImportError:
|
|
9
|
+
serial = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _require_serial() -> None:
|
|
13
|
+
if serial is None:
|
|
14
|
+
raise ImportError("pyserial is required. Install with: pip install pyserial")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_PORT_PRESENCE_TTL = 0.5
|
|
18
|
+
_port_presence_cache: dict = {"ts": 0.0, "ports": None}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _enumerate_ports() -> set | None:
|
|
22
|
+
try:
|
|
23
|
+
from serial.tools.list_ports import comports
|
|
24
|
+
ports = {p.device for p in comports()}
|
|
25
|
+
except Exception:
|
|
26
|
+
return None
|
|
27
|
+
_port_presence_cache["ports"] = ports
|
|
28
|
+
_port_presence_cache["ts"] = time.time()
|
|
29
|
+
return ports
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _port_in(port: str, ports: set) -> bool:
|
|
33
|
+
if not port:
|
|
34
|
+
return False
|
|
35
|
+
if port in ports:
|
|
36
|
+
return True
|
|
37
|
+
if sys.platform == "win32":
|
|
38
|
+
from replx_agent.utils.constants import canon_port
|
|
39
|
+
needle = canon_port(str(port))
|
|
40
|
+
return any(canon_port(str(p)) == needle for p in ports)
|
|
41
|
+
try:
|
|
42
|
+
import os
|
|
43
|
+
real_needle = os.path.realpath(port)
|
|
44
|
+
for p in ports:
|
|
45
|
+
if p == real_needle or os.path.realpath(p) == real_needle:
|
|
46
|
+
return True
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _port_currently_present(port: str) -> bool | None:
|
|
53
|
+
"""Return True/False if the OS reports ``port`` present, or None if the
|
|
54
|
+
check itself failed (caller should skip/ignore in that case)."""
|
|
55
|
+
now = time.time()
|
|
56
|
+
ports = _port_presence_cache["ports"]
|
|
57
|
+
fresh = False
|
|
58
|
+
if ports is None or (now - _port_presence_cache["ts"]) > _PORT_PRESENCE_TTL:
|
|
59
|
+
ports = _enumerate_ports()
|
|
60
|
+
if ports is None:
|
|
61
|
+
return None
|
|
62
|
+
fresh = True
|
|
63
|
+
|
|
64
|
+
if _port_in(port, ports):
|
|
65
|
+
return True
|
|
66
|
+
# A cached snapshot can predate a re-enumeration; confirm absence live.
|
|
67
|
+
if not fresh:
|
|
68
|
+
ports = _enumerate_ports()
|
|
69
|
+
if ports is None:
|
|
70
|
+
return None
|
|
71
|
+
return _port_in(port, ports)
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SerialTransport:
|
|
76
|
+
|
|
77
|
+
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
|
|
78
|
+
_require_serial()
|
|
79
|
+
self.port = port
|
|
80
|
+
self.baudrate = baudrate
|
|
81
|
+
if sys.platform != "win32" and timeout > 0.6:
|
|
82
|
+
timeout = 0.6
|
|
83
|
+
self._default_timeout = timeout
|
|
84
|
+
self._serial = None
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
self._serial = serial.Serial(
|
|
88
|
+
port=port,
|
|
89
|
+
baudrate=baudrate,
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
write_timeout=timeout,
|
|
92
|
+
inter_byte_timeout=0.1
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
self._serial.set_buffer_size(rx_size=262144, tx_size=65536)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
except serial.SerialException as e:
|
|
99
|
+
raise TransportError(f"Failed to open serial port {port}: {e}") from e
|
|
100
|
+
|
|
101
|
+
def set_baudrate(self, baudrate: int) -> None:
|
|
102
|
+
try:
|
|
103
|
+
self._serial.baudrate = baudrate
|
|
104
|
+
except (serial.SerialException, ValueError) as e:
|
|
105
|
+
raise TransportError(f"Failed to set baud rate {baudrate}: {e}") from e
|
|
106
|
+
self.baudrate = baudrate
|
|
107
|
+
|
|
108
|
+
def write(self, data: bytes) -> int:
|
|
109
|
+
try:
|
|
110
|
+
return self._serial.write(data)
|
|
111
|
+
except (serial.SerialException, OSError) as e:
|
|
112
|
+
raise TransportError(f"Serial write error: {e}") from e
|
|
113
|
+
|
|
114
|
+
def read(self, size: int = 1) -> bytes:
|
|
115
|
+
try:
|
|
116
|
+
return self._serial.read(size)
|
|
117
|
+
except (serial.SerialException, OSError) as e:
|
|
118
|
+
raise TransportError(f"Serial read error: {e}") from e
|
|
119
|
+
|
|
120
|
+
def read_byte(self, timeout: float = None) -> bytes:
|
|
121
|
+
try:
|
|
122
|
+
if timeout is not None:
|
|
123
|
+
old_timeout = self._serial.timeout
|
|
124
|
+
self._serial.timeout = timeout
|
|
125
|
+
try:
|
|
126
|
+
return self._serial.read(1)
|
|
127
|
+
finally:
|
|
128
|
+
self._serial.timeout = old_timeout
|
|
129
|
+
return self._serial.read(1)
|
|
130
|
+
except (serial.SerialException, OSError) as e:
|
|
131
|
+
raise TransportError(f"Serial read_byte error: {e}") from e
|
|
132
|
+
|
|
133
|
+
def read_available(self) -> bytes:
|
|
134
|
+
try:
|
|
135
|
+
waiting = self._serial.in_waiting
|
|
136
|
+
if waiting > 0:
|
|
137
|
+
return self._serial.read(waiting)
|
|
138
|
+
return b""
|
|
139
|
+
except (serial.SerialException, OSError) as e:
|
|
140
|
+
error_msg = str(e).lower()
|
|
141
|
+
if "clearcommerror" in error_msg or "not exist" in error_msg or "cannot find" in error_msg:
|
|
142
|
+
raise TransportError("Serial port disconnected (device removed or cable unplugged)") from e
|
|
143
|
+
raise TransportError(f"Serial read_available error: {e}") from e
|
|
144
|
+
|
|
145
|
+
def in_waiting(self) -> int:
|
|
146
|
+
try:
|
|
147
|
+
return self._serial.in_waiting
|
|
148
|
+
except (serial.SerialException, OSError) as e:
|
|
149
|
+
error_msg = str(e).lower()
|
|
150
|
+
raise TransportError(f"Serial input status error: {error_msg}") from e
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
if self._serial:
|
|
154
|
+
serial_obj = self._serial
|
|
155
|
+
self._serial = None
|
|
156
|
+
try:
|
|
157
|
+
if serial_obj.is_open:
|
|
158
|
+
try:
|
|
159
|
+
serial_obj.cancel_read()
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
try:
|
|
163
|
+
serial_obj.cancel_write()
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
try:
|
|
167
|
+
serial_obj.reset_input_buffer()
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
try:
|
|
171
|
+
serial_obj.reset_output_buffer()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
serial_obj.close()
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
finally:
|
|
178
|
+
del serial_obj
|
|
179
|
+
if sys.platform == 'win32':
|
|
180
|
+
gc.collect()
|
|
181
|
+
|
|
182
|
+
def reset_input_buffer(self) -> None:
|
|
183
|
+
if self._serial:
|
|
184
|
+
self._serial.reset_input_buffer()
|
|
185
|
+
|
|
186
|
+
def reset_output_buffer(self) -> None:
|
|
187
|
+
if self._serial:
|
|
188
|
+
self._serial.reset_output_buffer()
|
|
189
|
+
|
|
190
|
+
def check_connection(self) -> bool:
|
|
191
|
+
try:
|
|
192
|
+
if not self._serial or not self._serial.is_open:
|
|
193
|
+
return False
|
|
194
|
+
if _port_currently_present(self.port) is False:
|
|
195
|
+
return False
|
|
196
|
+
_ = self._serial.in_waiting
|
|
197
|
+
return True
|
|
198
|
+
except serial.SerialException as e:
|
|
199
|
+
error_msg = str(e).lower()
|
|
200
|
+
if "clearcommerror" in error_msg or "not exist" in error_msg or "cannot find" in error_msg or "access is denied" in error_msg:
|
|
201
|
+
return False
|
|
202
|
+
return False
|
|
203
|
+
except (OSError, IOError):
|
|
204
|
+
return False
|
|
205
|
+
except Exception:
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
def keep_alive(self) -> None:
|
|
209
|
+
if not self._serial or not self._serial.is_open:
|
|
210
|
+
raise TransportError("Serial port disconnected (device removed or cable unplugged)")
|
|
211
|
+
if _port_currently_present(self.port) is False:
|
|
212
|
+
raise TransportError("Serial port disconnected (device removed or cable unplugged)")
|
|
213
|
+
try:
|
|
214
|
+
_ = self._serial.in_waiting
|
|
215
|
+
except (serial.SerialException, OSError, IOError) as e:
|
|
216
|
+
raise TransportError(f"Serial keep-alive failed: {e}") from e
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def is_open(self) -> bool:
|
|
220
|
+
return self._serial.is_open if self._serial else False
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import re as _re
|
|
2
|
+
import sys as _sys
|
|
3
|
+
from typing import Optional as _Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def device_name_to_path(device_name: str) -> str:
|
|
7
|
+
return device_name.replace('-', '_')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def canon_port(port: _Optional[str]) -> _Optional[str]:
|
|
11
|
+
"""Canonical form of a serial port name.
|
|
12
|
+
|
|
13
|
+
Windows: ``COM3``/``com3``/``Com3`` are equivalent — normalise to upper case.
|
|
14
|
+
POSIX: pass through stripped string. ``None`` and empty input map to themselves.
|
|
15
|
+
"""
|
|
16
|
+
if port is None:
|
|
17
|
+
return None
|
|
18
|
+
p = str(port).strip()
|
|
19
|
+
if not p:
|
|
20
|
+
return p
|
|
21
|
+
if _sys.platform == "win32" or _sys.platform.startswith("win"):
|
|
22
|
+
if _re.match(r"(?i)^com\d+$", p):
|
|
23
|
+
return p.upper()
|
|
24
|
+
return p
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
from .device_info import (
|
|
28
|
+
parse_device_banner,
|
|
29
|
+
get_root_fs_for_core,
|
|
30
|
+
get_core_profile,
|
|
31
|
+
CORE_PROFILES,
|
|
32
|
+
SUPPORT_CORE_DEVICE_TYPES,
|
|
33
|
+
CORE_ROOT_FS,
|
|
34
|
+
DEFAULT_ROOT_FS,
|
|
35
|
+
)
|
|
36
|
+
from .constants import (
|
|
37
|
+
CTRL_A, CTRL_B, CTRL_C, CTRL_D, CTRL_E,
|
|
38
|
+
REPL_PROMPT, EOF_MARKER,
|
|
39
|
+
RAW_REPL_PROMPT, SOFT_REBOOT_MSG, OK_RESPONSE, ERROR_HEADER,
|
|
40
|
+
RAW_PASTE_INIT, RAW_PASTE_SUPPORTED, RAW_PASTE_NOT_SUPPORTED,
|
|
41
|
+
RAW_PASTE_FALLBACK, RAW_PASTE_WINDOW_INC, RAW_PASTE_END_DATA,
|
|
42
|
+
PORT_SCAN_TIMEOUT, REPL_READ_TIMEOUT,
|
|
43
|
+
SERIAL_TIMEOUT, SERIAL_WRITE_TIMEOUT,
|
|
44
|
+
REPL_BUFSIZE, RAW_PASTE_DEFAULT_WINDOW_SIZE,
|
|
45
|
+
DEVICE_CHUNK_SIZE_DEFAULT, DEVICE_CHUNK_SIZE_EFR32MG,
|
|
46
|
+
PUT_BATCH_BYTES_DEFAULT, PUT_BATCH_BYTES_EFR32MG,
|
|
47
|
+
RAW_MODE_DELAY_DEFAULT, RAW_MODE_DELAY_EFR32MG,
|
|
48
|
+
DEFAULT_AGENT_PORT, MAX_AGENT_PORT, AGENT_HOST,
|
|
49
|
+
MAX_CONNECTIONS, AGENT_SOCKET_TIMEOUT,
|
|
50
|
+
MAX_PAYLOAD_SIZE,
|
|
51
|
+
HEARTBEAT_INTERVAL, ZOMBIE_CHECK_INTERVAL, IDLE_COMMAND_THRESHOLD,
|
|
52
|
+
GC_THRESHOLD,
|
|
53
|
+
)
|
|
54
|
+
from .exceptions import (
|
|
55
|
+
ReplxException,
|
|
56
|
+
TransportError,
|
|
57
|
+
ProtocolError,
|
|
58
|
+
FileSystemError,
|
|
59
|
+
ValidationError,
|
|
60
|
+
CompilationError,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"device_name_to_path",
|
|
65
|
+
"canon_port",
|
|
66
|
+
"parse_device_banner",
|
|
67
|
+
"get_root_fs_for_core",
|
|
68
|
+
"get_core_profile",
|
|
69
|
+
"CORE_PROFILES",
|
|
70
|
+
"SUPPORT_CORE_DEVICE_TYPES",
|
|
71
|
+
"CORE_ROOT_FS",
|
|
72
|
+
"DEFAULT_ROOT_FS",
|
|
73
|
+
"CTRL_A", "CTRL_B", "CTRL_C", "CTRL_D", "CTRL_E",
|
|
74
|
+
"REPL_PROMPT", "EOF_MARKER",
|
|
75
|
+
"RAW_REPL_PROMPT", "SOFT_REBOOT_MSG", "OK_RESPONSE", "ERROR_HEADER",
|
|
76
|
+
"RAW_PASTE_INIT", "RAW_PASTE_SUPPORTED", "RAW_PASTE_NOT_SUPPORTED",
|
|
77
|
+
"RAW_PASTE_FALLBACK", "RAW_PASTE_WINDOW_INC", "RAW_PASTE_END_DATA",
|
|
78
|
+
"PORT_SCAN_TIMEOUT", "REPL_READ_TIMEOUT",
|
|
79
|
+
"SERIAL_TIMEOUT", "SERIAL_WRITE_TIMEOUT",
|
|
80
|
+
"REPL_BUFSIZE", "RAW_PASTE_DEFAULT_WINDOW_SIZE",
|
|
81
|
+
"DEVICE_CHUNK_SIZE_DEFAULT", "DEVICE_CHUNK_SIZE_EFR32MG",
|
|
82
|
+
"PUT_BATCH_BYTES_DEFAULT", "PUT_BATCH_BYTES_EFR32MG",
|
|
83
|
+
"RAW_MODE_DELAY_DEFAULT", "RAW_MODE_DELAY_EFR32MG",
|
|
84
|
+
"DEFAULT_AGENT_PORT", "MAX_AGENT_PORT", "AGENT_HOST",
|
|
85
|
+
"MAX_CONNECTIONS", "AGENT_SOCKET_TIMEOUT",
|
|
86
|
+
"MAX_PAYLOAD_SIZE",
|
|
87
|
+
"HEARTBEAT_INTERVAL", "ZOMBIE_CHECK_INTERVAL", "IDLE_COMMAND_THRESHOLD",
|
|
88
|
+
"GC_THRESHOLD",
|
|
89
|
+
"ReplxException",
|
|
90
|
+
"TransportError",
|
|
91
|
+
"ProtocolError",
|
|
92
|
+
"FileSystemError",
|
|
93
|
+
"ValidationError",
|
|
94
|
+
"CompilationError",
|
|
95
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
CTRL_A = b'\x01' # Raw REPL mode / Paste mode
|
|
2
|
+
CTRL_B = b'\x02' # Normal REPL mode / Exit paste mode
|
|
3
|
+
CTRL_C = b'\x03' # Keyboard interrupt
|
|
4
|
+
CTRL_D = b'\x04' # EOF / Soft reboot
|
|
5
|
+
CTRL_E = b'\x05' # Enter paste mode
|
|
6
|
+
REPL_PROMPT = b'>>>'
|
|
7
|
+
|
|
8
|
+
EOF_MARKER = b'\x04'
|
|
9
|
+
|
|
10
|
+
RAW_REPL_PROMPT = b'raw REPL; CTRL-B to exit\r\n>'
|
|
11
|
+
SOFT_REBOOT_MSG = b'soft reboot\r\n'
|
|
12
|
+
OK_RESPONSE = b'OK'
|
|
13
|
+
|
|
14
|
+
ERROR_HEADER = b"Traceback (most recent call last):"
|
|
15
|
+
|
|
16
|
+
RAW_PASTE_INIT = b'\x05A\x01'
|
|
17
|
+
RAW_PASTE_SUPPORTED = b'R\x01'
|
|
18
|
+
RAW_PASTE_NOT_SUPPORTED = b'R\x00'
|
|
19
|
+
RAW_PASTE_FALLBACK = b'raw REPL; CTRL-B'
|
|
20
|
+
RAW_PASTE_WINDOW_INC = b'\x01'
|
|
21
|
+
RAW_PASTE_END_DATA = b'\x04'
|
|
22
|
+
|
|
23
|
+
PORT_SCAN_TIMEOUT = 0.05
|
|
24
|
+
REPL_READ_TIMEOUT = 0.5
|
|
25
|
+
SERIAL_TIMEOUT = 0.5
|
|
26
|
+
SERIAL_WRITE_TIMEOUT = 0.3
|
|
27
|
+
|
|
28
|
+
REPL_BUFSIZE = 2048
|
|
29
|
+
RAW_PASTE_DEFAULT_WINDOW_SIZE = 128
|
|
30
|
+
|
|
31
|
+
DEVICE_CHUNK_SIZE_DEFAULT = 4096
|
|
32
|
+
DEVICE_CHUNK_SIZE_EFR32MG = 256
|
|
33
|
+
|
|
34
|
+
PUT_BATCH_BYTES_DEFAULT = 16 * 1024
|
|
35
|
+
PUT_BATCH_BYTES_EFR32MG = 2 * 1024
|
|
36
|
+
|
|
37
|
+
RAW_MODE_DELAY_DEFAULT = 0.0
|
|
38
|
+
RAW_MODE_DELAY_EFR32MG = 0.01
|
|
39
|
+
|
|
40
|
+
# REPL baud rates tried in order when opening a serial port. Native USB CDC
|
|
41
|
+
# ignores the value; USB-UART bridges (e.g. the CH343 on ESP32-P4 boards) need
|
|
42
|
+
# the rate the firmware was built with. REPLX_BAUD prepends extra candidates.
|
|
43
|
+
REPL_BAUD_DEFAULT = 115200
|
|
44
|
+
REPL_BAUD_CANDIDATES = (115200, 921600)
|
|
45
|
+
|
|
46
|
+
# Above this baud rate a firmware with a small stdin buffer can be overrun by
|
|
47
|
+
# plain raw-REPL writes, so longer commands use flow-controlled raw-paste.
|
|
48
|
+
RAW_PASTE_SAFE_BAUD = 115200
|
|
49
|
+
RAW_PASTE_HIGH_BAUD_MIN_COMMAND_SIZE = 256
|
|
50
|
+
# Baud/REPL detection sweeps on connect (covers a board still booting after reset)
|
|
51
|
+
CONNECT_DETECT_ROUNDS = 3
|
|
52
|
+
CONNECT_DETECT_RETRY_DELAY = 0.5
|
|
53
|
+
|
|
54
|
+
DEFAULT_AGENT_PORT = 49153
|
|
55
|
+
MIN_AGENT_PORT = 49152
|
|
56
|
+
MAX_AGENT_PORT = 65535
|
|
57
|
+
AGENT_HOST = '127.0.0.1'
|
|
58
|
+
|
|
59
|
+
MAX_CONNECTIONS = 20
|
|
60
|
+
AGENT_SOCKET_TIMEOUT = 1.0
|
|
61
|
+
HTTP_REQUEST_TIMEOUT = 15
|
|
62
|
+
|
|
63
|
+
MAX_PAYLOAD_SIZE = 60000
|
|
64
|
+
|
|
65
|
+
PUTDIR_EXCLUDED_DIRS = frozenset({
|
|
66
|
+
'__pycache__', '.git', '.svn', '.hg', 'node_modules',
|
|
67
|
+
'.venv', 'venv', '__MACOSX',
|
|
68
|
+
})
|
|
69
|
+
PUTDIR_EXCLUDED_EXTENSIONS = frozenset({
|
|
70
|
+
'.pyc', '.pyo', '.pyd', '.so', '.dll', '.dylib',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
HEARTBEAT_INTERVAL = 1
|
|
74
|
+
ZOMBIE_CHECK_INTERVAL = 10
|
|
75
|
+
IDLE_COMMAND_THRESHOLD = 5
|
|
76
|
+
GC_COLLECT_INTERVAL = 60
|
|
77
|
+
|
|
78
|
+
GC_THRESHOLD = (1500, 15, 15)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from replx_agent.utils.constants import (
|
|
5
|
+
DEVICE_CHUNK_SIZE_DEFAULT, DEVICE_CHUNK_SIZE_EFR32MG,
|
|
6
|
+
PUT_BATCH_BYTES_DEFAULT, PUT_BATCH_BYTES_EFR32MG,
|
|
7
|
+
RAW_MODE_DELAY_DEFAULT, RAW_MODE_DELAY_EFR32MG,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_DEFAULT_PROFILE = {
|
|
12
|
+
'std': True,
|
|
13
|
+
'devices': set(),
|
|
14
|
+
'root_fs': '/',
|
|
15
|
+
'chunk_size': DEVICE_CHUNK_SIZE_DEFAULT,
|
|
16
|
+
'put_batch_bytes': PUT_BATCH_BYTES_DEFAULT,
|
|
17
|
+
'raw_mode_delay': RAW_MODE_DELAY_DEFAULT,
|
|
18
|
+
'raw_paste': True,
|
|
19
|
+
'get_chunk_size': 12288,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
CORE_PROFILES = {
|
|
23
|
+
'EFR32MG': {
|
|
24
|
+
'std': False,
|
|
25
|
+
'devices': {'xnode'},
|
|
26
|
+
'root_fs': '/flash',
|
|
27
|
+
'chunk_size': DEVICE_CHUNK_SIZE_EFR32MG,
|
|
28
|
+
'put_batch_bytes': PUT_BATCH_BYTES_EFR32MG,
|
|
29
|
+
'raw_mode_delay': RAW_MODE_DELAY_EFR32MG,
|
|
30
|
+
'raw_paste': False,
|
|
31
|
+
'get_chunk_size': DEVICE_CHUNK_SIZE_EFR32MG,
|
|
32
|
+
},
|
|
33
|
+
'RP2350': {
|
|
34
|
+
'devices': {'ticle', 'ticle-lite', 'ticle-sensor', 'ticle-auto'},
|
|
35
|
+
},
|
|
36
|
+
'MIMXRT1062DVJ6A': {
|
|
37
|
+
'devices': {'teensy'},
|
|
38
|
+
'root_fs': '/flash',
|
|
39
|
+
},
|
|
40
|
+
'ESP32C5': {
|
|
41
|
+
'devices': {'ESP32C5'},
|
|
42
|
+
},
|
|
43
|
+
'ESP32S3': {
|
|
44
|
+
'devices': {'ESP32S3'},
|
|
45
|
+
},
|
|
46
|
+
'ESP32P4': {
|
|
47
|
+
'devices': {'ESP32P4'},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
SUPPORT_CORE_DEVICE_TYPES = {
|
|
52
|
+
k: {'std': v.get('std', _DEFAULT_PROFILE['std']), 'devices': v.get('devices', set())}
|
|
53
|
+
for k, v in CORE_PROFILES.items()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
CORE_ROOT_FS = {
|
|
57
|
+
k: v.get('root_fs', _DEFAULT_PROFILE['root_fs'])
|
|
58
|
+
for k, v in CORE_PROFILES.items()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
DEFAULT_ROOT_FS = '/'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_version(version: str) -> str:
|
|
65
|
+
if version is None:
|
|
66
|
+
return "?"
|
|
67
|
+
|
|
68
|
+
text = str(version).strip()
|
|
69
|
+
if not text or text == '?':
|
|
70
|
+
return "?"
|
|
71
|
+
|
|
72
|
+
match = re.search(r'(\d+)\.(\d+)(?:\.(\d+))?', text)
|
|
73
|
+
if not match:
|
|
74
|
+
return text
|
|
75
|
+
|
|
76
|
+
major, minor, patch = match.groups()
|
|
77
|
+
if patch is None:
|
|
78
|
+
return f"{major}.{minor}.0"
|
|
79
|
+
return f"{major}.{minor}.{patch}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def normalize_core(core: str) -> str:
|
|
83
|
+
core = (core or '').upper().replace('ESP32-', 'ESP32')
|
|
84
|
+
if core and "/" in core:
|
|
85
|
+
core = core.split("/", 1)[0]
|
|
86
|
+
|
|
87
|
+
if core in CORE_PROFILES:
|
|
88
|
+
return core
|
|
89
|
+
|
|
90
|
+
if core.startswith("EFR32MG"):
|
|
91
|
+
return "EFR32MG"
|
|
92
|
+
if core in ("ESP32P4C5", "ESP32P4C6"):
|
|
93
|
+
return "ESP32P4"
|
|
94
|
+
|
|
95
|
+
if core and len(core) > 1 and core[-1].isalpha() and core[-2].isdigit():
|
|
96
|
+
return core[:-1]
|
|
97
|
+
return core
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_core_profile(core: str) -> dict:
|
|
101
|
+
normalized = normalize_core(core)
|
|
102
|
+
profile = CORE_PROFILES.get(normalized, {})
|
|
103
|
+
return {**_DEFAULT_PROFILE, **profile}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_root_fs_for_core(core: str) -> str:
|
|
107
|
+
return get_core_profile(core)['root_fs']
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_std_micropython(core: str) -> bool:
|
|
111
|
+
return get_core_profile(core)['std']
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_devices_for_core(core: str) -> set:
|
|
115
|
+
return get_core_profile(core)['devices']
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_device_banner(banner_text: str) -> Optional[Tuple[str, str, str, str]]:
|
|
119
|
+
version_match = re.search(r'v(\d+\.\d+(?:\.\d+)?)', banner_text)
|
|
120
|
+
version = normalize_version(version_match.group(1)) if version_match else '?'
|
|
121
|
+
|
|
122
|
+
if re.search(r'\bXBee\s*3\b', banner_text, re.IGNORECASE):
|
|
123
|
+
return version, 'EFR32MG', 'xbee3', 'Digi'
|
|
124
|
+
|
|
125
|
+
multi_with_match = re.search(r';\s*(.+?)\s+with\s+(.+?)\s+module\s+of\s+external\s+([\w-]+)\s+with\s+([\w-]+)', banner_text)
|
|
126
|
+
if multi_with_match:
|
|
127
|
+
prefix = multi_with_match.group(1).strip()
|
|
128
|
+
wifi_desc = multi_with_match.group(2).strip()
|
|
129
|
+
core2 = multi_with_match.group(3).strip().upper().replace('-', '')
|
|
130
|
+
core1 = multi_with_match.group(4).strip().upper().replace('-', '')
|
|
131
|
+
|
|
132
|
+
if core1 == "ESP32P4" and core2 in ("ESP32C5", "ESP32C6"):
|
|
133
|
+
core = f"ESP32P4{core2[-2:]}"
|
|
134
|
+
else:
|
|
135
|
+
core = core1
|
|
136
|
+
|
|
137
|
+
if prefix.endswith(" module"):
|
|
138
|
+
prefix = prefix[:-7].strip()
|
|
139
|
+
|
|
140
|
+
manufacturer = f"{prefix} with {wifi_desc} ({core2})".strip()
|
|
141
|
+
|
|
142
|
+
device = core
|
|
143
|
+
return version, core, device, manufacturer
|
|
144
|
+
|
|
145
|
+
match = re.search(r';\s*(.+?)\s+with\s+(\S+)', banner_text)
|
|
146
|
+
if not match:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
prefix = match.group(1).strip()
|
|
150
|
+
core_raw = match.group(2).strip().upper()
|
|
151
|
+
core = normalize_core(core_raw)
|
|
152
|
+
|
|
153
|
+
device_set = get_devices_for_core(core)
|
|
154
|
+
|
|
155
|
+
device = None
|
|
156
|
+
manufacturer = None
|
|
157
|
+
|
|
158
|
+
for known_device in sorted(device_set, key=len, reverse=True):
|
|
159
|
+
prefix_lower = prefix.lower()
|
|
160
|
+
if prefix_lower.endswith(known_device):
|
|
161
|
+
device = known_device
|
|
162
|
+
idx = prefix_lower.rfind(known_device)
|
|
163
|
+
manufacturer = prefix[:idx].strip()
|
|
164
|
+
break
|
|
165
|
+
|
|
166
|
+
if device is None:
|
|
167
|
+
if len(device_set) == 1:
|
|
168
|
+
device = next(iter(device_set))
|
|
169
|
+
manufacturer = prefix.split()[0] if prefix else "Unknown"
|
|
170
|
+
else:
|
|
171
|
+
device = core
|
|
172
|
+
manufacturer = prefix
|
|
173
|
+
|
|
174
|
+
if manufacturer:
|
|
175
|
+
if manufacturer.startswith("Raspberry Pi"):
|
|
176
|
+
manufacturer = "Raspberry Pi"
|
|
177
|
+
elif manufacturer.endswith(" module"):
|
|
178
|
+
manufacturer = manufacturer[:-7].strip()
|
|
179
|
+
|
|
180
|
+
if not manufacturer:
|
|
181
|
+
manufacturer = "Unknown"
|
|
182
|
+
|
|
183
|
+
return version, core, device, manufacturer
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class ReplxException(Exception):
|
|
2
|
+
def __init__(self, message: str):
|
|
3
|
+
super().__init__(message)
|
|
4
|
+
self.message = message
|
|
5
|
+
|
|
6
|
+
def __str__(self):
|
|
7
|
+
return f"{self.__class__.__name__}: {self.message}"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TransportError(ReplxException):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProtocolError(ReplxException):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FileSystemError(ReplxException):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ValidationError(ReplxException):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CompilationError(ReplxException):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PkgError(ReplxException):
|
|
31
|
+
pass
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: replx-agent
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Local MicroPython board-management agent for replx.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://planxlab.com
|
|
7
|
+
Project-URL: Repository, https://github.com/PlanXLab/replx
|
|
8
|
+
Project-URL: Issues, https://github.com/PlanXLab/replx/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: pyserial>=3.5
|
|
13
|
+
Requires-Dist: psutil>=7.2.2
|
|
14
|
+
Requires-Dist: esptool<6,>=5.6.0
|
|
15
|
+
Requires-Dist: mpy-cross>=1.29.0
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# replx-agent
|
|
19
|
+
|
|
20
|
+
`replx-agent` is the board-management service used by the `replx` MicroPython CLI. It owns serial discovery and connections, board filesystem operations, transfer staging, and firmware flashing through `esptool`.
|
|
21
|
+
|
|
22
|
+
## Package Boundary
|
|
23
|
+
|
|
24
|
+
The CLI depends on `replx-agent`; the agent never imports `replx`. The packages communicate through a versioned TCP RPC protocol, allowing separate builds while protocol versions remain compatible. The distribution version is defined in `src/replx_agent/__init__.py`.
|
|
25
|
+
|
|
26
|
+
## Runtime
|
|
27
|
+
|
|
28
|
+
- The agent is a single process per endpoint and supports multiple TCP clients using `ThreadingTCPServer` and bounded command worker pools.
|
|
29
|
+
- Serial access is owned by the agent. Commands for the same board are serialized; separate boards can be serviced concurrently.
|
|
30
|
+
- Client sessions are identified by source IP and terminal SID. `replx agent status` reports the current client's foreground/background board assignments.
|
|
31
|
+
- The default endpoint is `127.0.0.1:49153`. The CLI creates `.vscode/.replx` as needed and stores `AGENT_IP` and `AGENT_PORT` there.
|
|
32
|
+
- Legacy `~/.replx/config`, `~/.replx/.config`, and `~/.replx/.agent_port` settings are read for migration compatibility.
|
|
33
|
+
|
|
34
|
+
## Remote Access
|
|
35
|
+
|
|
36
|
+
The default loopback endpoint does not require a token. Binding to a non-loopback interface or connecting to a non-loopback agent requires the same `REPLX_AGENT_TOKEN` environment variable on the agent and every client.
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
$env:REPLX_AGENT_TOKEN = "<shared-secret>"
|
|
40
|
+
replx agent start --host 192.168.1.20 --port 49153
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
On Linux/macOS, export the variable before starting the agent or running the client:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
export REPLX_AGENT_TOKEN="<shared-secret>"
|
|
47
|
+
replx agent connect 192.168.1.20 --port 49153
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The RPC transport is plain TCP, not TLS. Use remote access only on a trusted LAN or VPN; do not expose the port to an untrusted network.
|
|
51
|
+
|
|
52
|
+
## CLI Lifecycle
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
replx agent status
|
|
56
|
+
replx agent start --port 49153
|
|
57
|
+
replx agent connect 192.168.1.20 --port 49153
|
|
58
|
+
replx agent disconnect 192.168.1.30
|
|
59
|
+
replx agent stop
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`agent start` restarts an existing local agent at the selected endpoint. `agent connect` verifies the endpoint before saving workspace settings. `agent disconnect IP` removes that client's sessions and releases only boards not still assigned to another client. `agent stop` stops the configured agent and releases its board connections.
|
|
63
|
+
|
|
64
|
+
## Build and Install
|
|
65
|
+
|
|
66
|
+
Build this package before building `replx`. Windows, Linux, and macOS builds use PowerShell 7 and `uv`:
|
|
67
|
+
|
|
68
|
+
```powershell
|
|
69
|
+
.\build.ps1
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Use `-Install` to install the wheel into the active virtual environment. The build script locates `Scripts/python.exe` on Windows and `bin/python` on Linux/macOS. On Windows it bootstraps pip if the environment lacks it and avoids uv's metadata replacement path. `-NoDeps` skips dependency installation.
|
|
73
|
+
|
|
74
|
+
Build the local agent wheel before the CLI wheel. The `replx` build script installs the matching local agent wheel when it is present.
|