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,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.