replx-agent 2.0.0.post1__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.
- 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.post1.dist-info/METADATA +62 -0
- replx_agent-2.0.0.post1.dist-info/RECORD +46 -0
- replx_agent-2.0.0.post1.dist-info/WHEEL +5 -0
- replx_agent-2.0.0.post1.dist-info/entry_points.txt +2 -0
- replx_agent-2.0.0.post1.dist-info/licenses/LICENSE +21 -0
- replx_agent-2.0.0.post1.dist-info/top_level.txt +1 -0
replx_agent/__init__.py
ADDED
replx_agent/__main__.py
ADDED
replx_agent/commands.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
|
|
2
|
+
class Cmd:
|
|
3
|
+
CONNECT = 'connect'
|
|
4
|
+
DISCONNECT_PORT = 'disconnect_port'
|
|
5
|
+
SESSION_SETUP = 'session_setup'
|
|
6
|
+
SESSION_DISCONNECT = 'session_disconnect'
|
|
7
|
+
SESSION_SWITCH_FG = 'session_switch_fg'
|
|
8
|
+
SESSION_INFO = 'session_info'
|
|
9
|
+
CLIENT_DISCONNECT = 'client_disconnect'
|
|
10
|
+
SET_DEFAULT = 'set_default'
|
|
11
|
+
RELEASE = 'release'
|
|
12
|
+
|
|
13
|
+
STATUS = 'status'
|
|
14
|
+
SCAN = 'scan'
|
|
15
|
+
SHUTDOWN = 'shutdown'
|
|
16
|
+
PING = 'ping'
|
|
17
|
+
RESET = 'reset'
|
|
18
|
+
|
|
19
|
+
EXEC = 'exec'
|
|
20
|
+
RUN = 'run'
|
|
21
|
+
RUN_STOP = 'run_stop'
|
|
22
|
+
RUN_INTERACTIVE = 'run_interactive'
|
|
23
|
+
|
|
24
|
+
REPL_ENTER = 'repl_enter'
|
|
25
|
+
REPL_EXIT = 'repl_exit'
|
|
26
|
+
REPL_WRITE = 'repl_write'
|
|
27
|
+
REPL_READ = 'repl_read'
|
|
28
|
+
|
|
29
|
+
LS = 'ls'
|
|
30
|
+
LS_RECURSIVE = 'ls_recursive'
|
|
31
|
+
CAT = 'cat'
|
|
32
|
+
STAT = 'stat'
|
|
33
|
+
IS_DIR = 'is_dir'
|
|
34
|
+
|
|
35
|
+
RM = 'rm'
|
|
36
|
+
RMDIR = 'rmdir'
|
|
37
|
+
MKDIR = 'mkdir'
|
|
38
|
+
CP = 'cp'
|
|
39
|
+
MV = 'mv'
|
|
40
|
+
TOUCH = 'touch'
|
|
41
|
+
FORMAT = 'format'
|
|
42
|
+
|
|
43
|
+
GET_FILE = 'get_file'
|
|
44
|
+
GET_FILE_STREAMING = 'get_file_streaming'
|
|
45
|
+
GET_FILE_BATCH = 'get_file_batch'
|
|
46
|
+
PUT_FILE = 'put_file'
|
|
47
|
+
PUT_FILE_BATCH = 'put_file_batch'
|
|
48
|
+
TRANSFER_START = 'transfer_start'
|
|
49
|
+
FIRMWARE_STAGE = 'firmware_stage'
|
|
50
|
+
FIRMWARE_INFO = 'firmware_info'
|
|
51
|
+
FIRMWARE_FLASH = 'firmware_flash'
|
|
52
|
+
FIRMWARE_DISCARD = 'firmware_discard'
|
|
53
|
+
|
|
54
|
+
MEM = 'mem'
|
|
55
|
+
DF = 'df'
|
|
56
|
+
|
|
57
|
+
I2C_BUS_SET = 'i2c_bus_set'
|
|
58
|
+
I2C_BUS_GET = 'i2c_bus_get'
|
|
59
|
+
I2C_BUS_CLEAR = 'i2c_bus_clear'
|
|
60
|
+
|
|
61
|
+
UART_BUS_SET = 'uart_bus_set'
|
|
62
|
+
UART_BUS_GET = 'uart_bus_get'
|
|
63
|
+
UART_BUS_CLEAR = 'uart_bus_clear'
|
|
64
|
+
|
|
65
|
+
SPI_BUS_SET = 'spi_bus_set'
|
|
66
|
+
SPI_BUS_GET = 'spi_bus_get'
|
|
67
|
+
SPI_BUS_CLEAR = 'spi_bus_clear'
|
|
68
|
+
|
|
69
|
+
PKG_SEARCH = 'pkg_search'
|
|
70
|
+
PKG_DOWNLOAD = 'pkg_download'
|
|
71
|
+
PKG_INSTALL = 'pkg_install'
|
|
72
|
+
PKG_CLEAN = 'pkg_clean'
|
|
73
|
+
PKG_STORE_STATUS = 'pkg_store_status'
|
|
74
|
+
PKG_TYPEHINTS_LIST = 'pkg_typehints_list'
|
|
75
|
+
PKG_TYPEHINTS_GET = 'pkg_typehints_get'
|
|
76
|
+
|
|
77
|
+
class CmdGroups:
|
|
78
|
+
NON_REPL = frozenset({
|
|
79
|
+
Cmd.CONNECT, Cmd.RELEASE, Cmd.DISCONNECT_PORT,
|
|
80
|
+
Cmd.STATUS, Cmd.SCAN, Cmd.SHUTDOWN, Cmd.PING, Cmd.RUN_STOP,
|
|
81
|
+
Cmd.SESSION_INFO, Cmd.CLIENT_DISCONNECT, Cmd.SESSION_SETUP, Cmd.SESSION_DISCONNECT,
|
|
82
|
+
Cmd.SESSION_SWITCH_FG, Cmd.SET_DEFAULT,
|
|
83
|
+
Cmd.I2C_BUS_SET, Cmd.I2C_BUS_GET, Cmd.I2C_BUS_CLEAR,
|
|
84
|
+
Cmd.UART_BUS_SET, Cmd.UART_BUS_GET, Cmd.UART_BUS_CLEAR,
|
|
85
|
+
Cmd.SPI_BUS_SET, Cmd.SPI_BUS_GET, Cmd.SPI_BUS_CLEAR,
|
|
86
|
+
Cmd.FIRMWARE_STAGE, Cmd.FIRMWARE_INFO, Cmd.FIRMWARE_FLASH,
|
|
87
|
+
Cmd.FIRMWARE_DISCARD,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
READ_ONLY = frozenset({
|
|
91
|
+
Cmd.SHUTDOWN, Cmd.PING, Cmd.SESSION_INFO, Cmd.STATUS, Cmd.SCAN,
|
|
92
|
+
Cmd.EXEC, Cmd.LS, Cmd.LS_RECURSIVE, Cmd.CAT, Cmd.STAT,
|
|
93
|
+
Cmd.IS_DIR, Cmd.MEM, Cmd.DF,
|
|
94
|
+
Cmd.GET_FILE,
|
|
95
|
+
Cmd.GET_FILE_BATCH,
|
|
96
|
+
Cmd.GET_FILE_STREAMING, Cmd.FIRMWARE_INFO,
|
|
97
|
+
Cmd.I2C_BUS_GET,
|
|
98
|
+
Cmd.UART_BUS_GET,
|
|
99
|
+
Cmd.SPI_BUS_GET,
|
|
100
|
+
Cmd.PKG_SEARCH, Cmd.PKG_STORE_STATUS,
|
|
101
|
+
Cmd.PKG_TYPEHINTS_LIST, Cmd.PKG_TYPEHINTS_GET,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
SESSION = frozenset({
|
|
105
|
+
Cmd.SESSION_SETUP, Cmd.SESSION_DISCONNECT, Cmd.SESSION_SWITCH_FG,
|
|
106
|
+
Cmd.CONNECT, Cmd.DISCONNECT_PORT, Cmd.CLIENT_DISCONNECT
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
PERSISTENT_BUSY = frozenset({
|
|
110
|
+
Cmd.RUN_INTERACTIVE, Cmd.REPL_ENTER
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
STREAMING = frozenset({
|
|
114
|
+
Cmd.GET_FILE_STREAMING, Cmd.TRANSFER_START,
|
|
115
|
+
Cmd.FIRMWARE_STAGE,
|
|
116
|
+
Cmd.RUN_INTERACTIVE, Cmd.RUN_STOP,
|
|
117
|
+
Cmd.PKG_DOWNLOAD, Cmd.PKG_INSTALL
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
REPL = frozenset({
|
|
121
|
+
Cmd.REPL_ENTER, Cmd.REPL_EXIT, Cmd.REPL_WRITE, Cmd.REPL_READ
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
DETACHED_ALLOW = frozenset({
|
|
125
|
+
Cmd.RUN_STOP, Cmd.RESET, Cmd.STATUS, Cmd.PING,
|
|
126
|
+
Cmd.SHUTDOWN, Cmd.SESSION_INFO,
|
|
127
|
+
Cmd.SESSION_DISCONNECT, Cmd.DISCONNECT_PORT, Cmd.RELEASE
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
NON_REPL_COMMANDS = CmdGroups.NON_REPL
|
|
131
|
+
READ_ONLY_COMMANDS = CmdGroups.READ_ONLY
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
import gc
|
|
6
|
+
from typing import Any, Callable, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from replx_agent.utils import (
|
|
9
|
+
parse_device_banner,
|
|
10
|
+
canon_port,
|
|
11
|
+
)
|
|
12
|
+
from replx_agent.utils.constants import (
|
|
13
|
+
CTRL_B,
|
|
14
|
+
CTRL_C,
|
|
15
|
+
REPL_PROMPT,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DeviceScanner:
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def port_compare_key(port: str | None) -> str:
|
|
23
|
+
normalized = canon_port(port) or ""
|
|
24
|
+
if sys.platform.startswith("win"):
|
|
25
|
+
return normalized.lower()
|
|
26
|
+
return os.path.realpath(normalized) if normalized else ""
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def _close_scanner_serial(ser) -> None:
|
|
30
|
+
if not ser:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
if getattr(ser, 'is_open', False):
|
|
35
|
+
try:
|
|
36
|
+
ser.cancel_read()
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
try:
|
|
40
|
+
ser.cancel_write()
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
try:
|
|
44
|
+
ser.reset_input_buffer()
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
try:
|
|
48
|
+
ser.reset_output_buffer()
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
ser.close()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
finally:
|
|
55
|
+
if sys.platform.startswith('win'):
|
|
56
|
+
gc.collect()
|
|
57
|
+
time.sleep(0.05)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _probe_port(port_info, timeout: float = 3.0) -> Optional[Tuple[str, str, str, str]]:
|
|
61
|
+
from replx_agent.transport import repl_baud_candidates
|
|
62
|
+
|
|
63
|
+
rates = repl_baud_candidates()
|
|
64
|
+
deadline = time.monotonic() + timeout
|
|
65
|
+
for rate in rates:
|
|
66
|
+
remaining = deadline - time.monotonic()
|
|
67
|
+
if remaining <= 0:
|
|
68
|
+
break
|
|
69
|
+
info = DeviceScanner.get_board_info_from_banner(
|
|
70
|
+
port_info.device, timeout=remaining, baudrate=rate
|
|
71
|
+
)
|
|
72
|
+
if info:
|
|
73
|
+
return info
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _probe_reserved_port(
|
|
78
|
+
port_info, timeout: float, reserve_port: Callable, release_port: Callable
|
|
79
|
+
) -> Optional[Tuple[str, str, str, str]]:
|
|
80
|
+
reservation = reserve_port(port_info.device) if reserve_port else None
|
|
81
|
+
if reserve_port and reservation is None:
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
return DeviceScanner._probe_port(port_info, timeout=timeout)
|
|
85
|
+
finally:
|
|
86
|
+
if reservation is not None and release_port:
|
|
87
|
+
release_port(reservation)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def get_board_info_from_banner(port: str, timeout: float = 2.0,
|
|
91
|
+
baudrate: int = 115200) -> Optional[Tuple[str, str, str, str]]:
|
|
92
|
+
try:
|
|
93
|
+
import serial
|
|
94
|
+
except ImportError as e:
|
|
95
|
+
raise RuntimeError("pyserial is required to scan serial ports") from e
|
|
96
|
+
|
|
97
|
+
ser = None
|
|
98
|
+
overall_start = time.monotonic()
|
|
99
|
+
is_windows = sys.platform.startswith("win")
|
|
100
|
+
if not is_windows and timeout > 1.5:
|
|
101
|
+
timeout = 1.5
|
|
102
|
+
try:
|
|
103
|
+
serial_timeout = 0.5 if is_windows else 0.3
|
|
104
|
+
ser = serial.Serial(port, baudrate, timeout=serial_timeout, write_timeout=serial_timeout)
|
|
105
|
+
|
|
106
|
+
for _ in range(3):
|
|
107
|
+
if time.monotonic() - overall_start > timeout:
|
|
108
|
+
return None
|
|
109
|
+
ser.write(CTRL_C)
|
|
110
|
+
time.sleep(0.05)
|
|
111
|
+
|
|
112
|
+
ser.reset_input_buffer()
|
|
113
|
+
|
|
114
|
+
if time.monotonic() - overall_start > timeout:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
ser.write(CTRL_B)
|
|
118
|
+
time.sleep(0.1)
|
|
119
|
+
|
|
120
|
+
response = b""
|
|
121
|
+
read_timeout = 0.5 if is_windows else 0.3
|
|
122
|
+
read_start = time.monotonic()
|
|
123
|
+
while time.monotonic() - read_start < read_timeout:
|
|
124
|
+
if time.monotonic() - overall_start > timeout:
|
|
125
|
+
break
|
|
126
|
+
if ser.in_waiting:
|
|
127
|
+
response += ser.read(ser.in_waiting)
|
|
128
|
+
if REPL_PROMPT in response:
|
|
129
|
+
break
|
|
130
|
+
time.sleep(0.01)
|
|
131
|
+
|
|
132
|
+
response_str = response.decode(errors='ignore')
|
|
133
|
+
|
|
134
|
+
return parse_device_banner(response_str)
|
|
135
|
+
|
|
136
|
+
except PermissionError:
|
|
137
|
+
raise
|
|
138
|
+
except (OSError, serial.SerialException):
|
|
139
|
+
pass
|
|
140
|
+
finally:
|
|
141
|
+
DeviceScanner._close_scanner_serial(ser)
|
|
142
|
+
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def scan_serial_ports(
|
|
147
|
+
max_workers: int = 5,
|
|
148
|
+
exclude_ports: list = None,
|
|
149
|
+
port_infos: list = None,
|
|
150
|
+
reserve_port: Callable[[str], Any] = None,
|
|
151
|
+
release_port: Callable[[Any], None] = None,
|
|
152
|
+
) -> tuple:
|
|
153
|
+
from serial.tools.list_ports import comports as list_ports_comports
|
|
154
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
155
|
+
|
|
156
|
+
if max_workers < 1:
|
|
157
|
+
raise ValueError("max_workers must be at least 1")
|
|
158
|
+
if reserve_port and not release_port:
|
|
159
|
+
raise ValueError("release_port is required when reserve_port is provided")
|
|
160
|
+
|
|
161
|
+
results = []
|
|
162
|
+
permission_denied = []
|
|
163
|
+
excluded = {
|
|
164
|
+
DeviceScanner.port_compare_key(port)
|
|
165
|
+
for port in (exclude_ports or [])
|
|
166
|
+
if port
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
all_ports = list(port_infos) if port_infos is not None else list(list_ports_comports())
|
|
170
|
+
valid_ports = []
|
|
171
|
+
|
|
172
|
+
_plat = sys.platform
|
|
173
|
+
|
|
174
|
+
for port in all_ports:
|
|
175
|
+
if DeviceScanner.port_compare_key(port.device) in excluded:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
if DeviceScanner.is_bluetooth_port(port):
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
if _plat == "darwin" or _plat.startswith("linux"):
|
|
182
|
+
if not DeviceScanner.is_likely_micropython_port(port):
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
valid_ports.append(port)
|
|
186
|
+
|
|
187
|
+
if not valid_ports:
|
|
188
|
+
return results, permission_denied
|
|
189
|
+
|
|
190
|
+
if _plat == "darwin":
|
|
191
|
+
per_port_timeout = 2.5
|
|
192
|
+
elif _plat.startswith("linux"):
|
|
193
|
+
per_port_timeout = 2.5
|
|
194
|
+
else:
|
|
195
|
+
per_port_timeout = 3.0
|
|
196
|
+
max_workers = min(max_workers, len(valid_ports))
|
|
197
|
+
|
|
198
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
199
|
+
future_to_port = {
|
|
200
|
+
executor.submit(
|
|
201
|
+
DeviceScanner._probe_reserved_port,
|
|
202
|
+
port,
|
|
203
|
+
per_port_timeout,
|
|
204
|
+
reserve_port,
|
|
205
|
+
release_port,
|
|
206
|
+
): port.device
|
|
207
|
+
for port in valid_ports
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for future in as_completed(future_to_port):
|
|
211
|
+
port_device = future_to_port[future]
|
|
212
|
+
try:
|
|
213
|
+
board_info = future.result()
|
|
214
|
+
if board_info:
|
|
215
|
+
results.append((port_device, board_info))
|
|
216
|
+
except PermissionError:
|
|
217
|
+
# OS denied access to the port (e.g. user not in dialout/uucp group on Linux)
|
|
218
|
+
permission_denied.append(port_device)
|
|
219
|
+
except (OSError, IOError):
|
|
220
|
+
# Port access error
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
return results, permission_denied
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def is_bluetooth_port(port_info) -> bool:
|
|
227
|
+
bt_keywords = ('bluetooth', 'bth', 'rfcomm')
|
|
228
|
+
description = str(getattr(port_info, 'description', '') or '').lower()
|
|
229
|
+
device = str(getattr(port_info, 'device', '') or '').lower()
|
|
230
|
+
|
|
231
|
+
if any(keyword in description or keyword in device for keyword in bt_keywords):
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
if sys.platform == "darwin":
|
|
235
|
+
if "bluetooth" in device:
|
|
236
|
+
return True
|
|
237
|
+
if re.search(r'/dev/(cu|tty)\.(MALS|SOC)', device, re.IGNORECASE):
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def is_likely_micropython_port(port_info) -> bool:
|
|
244
|
+
device = str(getattr(port_info, 'device', '') or '').lower()
|
|
245
|
+
description = str(getattr(port_info, 'description', '') or '').lower()
|
|
246
|
+
vid = getattr(port_info, 'vid', None)
|
|
247
|
+
|
|
248
|
+
if DeviceScanner.is_bluetooth_port(port_info):
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
if vid is not None:
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
_plat = sys.platform
|
|
255
|
+
|
|
256
|
+
if _plat == "darwin":
|
|
257
|
+
device_candidate = device.startswith((
|
|
258
|
+
'/dev/cu.usbmodem',
|
|
259
|
+
'/dev/cu.usbserial',
|
|
260
|
+
'/dev/cu.wchusbserial',
|
|
261
|
+
'/dev/cu.slab_usbto',
|
|
262
|
+
'/dev/tty.usbmodem',
|
|
263
|
+
'/dev/tty.usbserial',
|
|
264
|
+
'/dev/tty.wchusbserial',
|
|
265
|
+
'/dev/tty.slab_usbto',
|
|
266
|
+
))
|
|
267
|
+
|
|
268
|
+
elif _plat.startswith("linux"):
|
|
269
|
+
device_candidate = (
|
|
270
|
+
device.startswith((
|
|
271
|
+
'/dev/ttyusb',
|
|
272
|
+
'/dev/ttyacm',
|
|
273
|
+
'/dev/ttyama',
|
|
274
|
+
'/dev/ttys',
|
|
275
|
+
'/dev/serial/by-id/',
|
|
276
|
+
'/dev/serial/by-path/',
|
|
277
|
+
))
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
description_candidate = any(
|
|
283
|
+
marker in description
|
|
284
|
+
for marker in ('cp210', 'ch340', 'ch341', 'ftdi', 'usb serial', 'usb to uart')
|
|
285
|
+
)
|
|
286
|
+
return device_candidate or description_candidate
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def is_valid_serial_port(port_name: str) -> bool:
|
|
290
|
+
_plat = sys.platform
|
|
291
|
+
|
|
292
|
+
if _plat.startswith("win"):
|
|
293
|
+
return re.fullmatch(r"COM[1-9][0-9]*", port_name, re.IGNORECASE) is not None
|
|
294
|
+
elif _plat.startswith("linux"):
|
|
295
|
+
return (
|
|
296
|
+
re.fullmatch(r"/dev/tty(USB|ACM|AMA|S)[0-9]+", port_name, re.IGNORECASE) is not None or
|
|
297
|
+
port_name.startswith("/dev/serial/by-id/") or
|
|
298
|
+
port_name.startswith("/dev/serial/by-path/")
|
|
299
|
+
)
|
|
300
|
+
elif _plat == "darwin":
|
|
301
|
+
return (
|
|
302
|
+
re.fullmatch(
|
|
303
|
+
r"/dev/(tty|cu)\.(usbmodem|usbserial|wchusbserial|slab_usbto[a-z0-9_-]+)",
|
|
304
|
+
port_name,
|
|
305
|
+
re.IGNORECASE,
|
|
306
|
+
) is not None
|
|
307
|
+
)
|
|
308
|
+
return False
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import hashlib
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from replx_agent.utils.device_info import normalize_core
|
|
6
|
+
from replx_agent.utils.exceptions import CompilationError, ValidationError, PkgError
|
|
7
|
+
from .store import StoreManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CompilerHelper:
|
|
11
|
+
_compile_cache = {}
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def _run_mpy_cross(args: list[str], source_path: str) -> None:
|
|
15
|
+
try:
|
|
16
|
+
import mpy_cross
|
|
17
|
+
except ImportError as e:
|
|
18
|
+
raise CompilationError("mpy-cross is not installed") from e
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
proc = mpy_cross.run(
|
|
22
|
+
*args,
|
|
23
|
+
stdout=subprocess.PIPE,
|
|
24
|
+
stderr=subprocess.PIPE,
|
|
25
|
+
text=True,
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
errors="replace",
|
|
28
|
+
)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
raise CompilationError(f"MPY compilation failed for {source_path}: {e}") from e
|
|
31
|
+
|
|
32
|
+
stdout = ""
|
|
33
|
+
stderr = ""
|
|
34
|
+
returncode = 0
|
|
35
|
+
|
|
36
|
+
if hasattr(proc, "communicate"):
|
|
37
|
+
stdout, stderr = proc.communicate()
|
|
38
|
+
returncode = proc.returncode or 0
|
|
39
|
+
|
|
40
|
+
if returncode != 0:
|
|
41
|
+
details = (stderr or stdout or f"mpy-cross exited with code {returncode}").strip()
|
|
42
|
+
raise CompilationError(f"MPY compilation failed for {source_path}: {details}")
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def compile_file(abs_py: str, out_mpy: str, core: str, version: str) -> str:
|
|
46
|
+
if not os.path.exists(abs_py):
|
|
47
|
+
raise ValidationError(f"Source file not found: {abs_py}")
|
|
48
|
+
|
|
49
|
+
if normalize_core(core) == "EFR32MG":
|
|
50
|
+
raise CompilationError(
|
|
51
|
+
"Digi XBee3 uses a legacy/vendor .mpy ABI. The bundled mpy-cross "
|
|
52
|
+
"cannot certify compatibility; upload .py source with put, "
|
|
53
|
+
"or use mip install --no-compile."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
out_dir = os.path.dirname(out_mpy)
|
|
57
|
+
if out_dir:
|
|
58
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
args = [abs_py, '-o', out_mpy]
|
|
61
|
+
args.extend(CompilerHelper._march_for_core(core, version or "1.29.0"))
|
|
62
|
+
CompilerHelper._run_mpy_cross(args, abs_py)
|
|
63
|
+
|
|
64
|
+
if os.path.exists(out_mpy) and os.path.getsize(out_mpy) > 0:
|
|
65
|
+
return out_mpy
|
|
66
|
+
|
|
67
|
+
raise CompilationError(f"Compilation failed: {out_mpy} not found or empty")
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def mpy_arch_tag(core: str) -> str:
|
|
71
|
+
if not core:
|
|
72
|
+
return "unknown"
|
|
73
|
+
if "/" in core:
|
|
74
|
+
return core.split("/", 1)[0]
|
|
75
|
+
return core
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def staging_out_for(abs_py: str, base: str, arch_tag: str) -> str:
|
|
79
|
+
rel = os.path.relpath(abs_py, base).replace("\\", "/")
|
|
80
|
+
rel_mpy = os.path.splitext(rel)[0] + ".mpy"
|
|
81
|
+
out_path = StoreManager.HOME_STAGING / arch_tag / rel_mpy
|
|
82
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
return str(out_path)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _compute_file_hash(filepath: str) -> str:
|
|
87
|
+
hash_md5 = hashlib.md5()
|
|
88
|
+
with open(filepath, "rb") as f:
|
|
89
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
90
|
+
hash_md5.update(chunk)
|
|
91
|
+
return hash_md5.hexdigest()
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _march_for_core(core: str, version: str) -> list[str]:
|
|
95
|
+
if not core:
|
|
96
|
+
raise PkgError("The core is unknown")
|
|
97
|
+
|
|
98
|
+
if "/" in core:
|
|
99
|
+
core = core.split("/", 1)[0]
|
|
100
|
+
|
|
101
|
+
args: list[str] = ['-msmall-int-bits=31']
|
|
102
|
+
|
|
103
|
+
if core == "EFR32MG":
|
|
104
|
+
try:
|
|
105
|
+
ver_parts = version.split('.')
|
|
106
|
+
ver_float = float(f"{ver_parts[0]}.{ver_parts[1]}" if len(ver_parts) >= 2 else version)
|
|
107
|
+
except (ValueError, IndexError):
|
|
108
|
+
ver_float = 0.0
|
|
109
|
+
if ver_float < 1.19:
|
|
110
|
+
args.append('-mno-unicode')
|
|
111
|
+
return args
|
|
112
|
+
|
|
113
|
+
if core in ("ESP32", "ESP32S2"):
|
|
114
|
+
args.append('-march=xtensa')
|
|
115
|
+
return args
|
|
116
|
+
|
|
117
|
+
if core == "ESP32S3":
|
|
118
|
+
args.append('-march=xtensawin')
|
|
119
|
+
return args
|
|
120
|
+
|
|
121
|
+
if core.startswith("ESP32C"):
|
|
122
|
+
args.append('-march=rv32imc')
|
|
123
|
+
return args
|
|
124
|
+
|
|
125
|
+
if core.startswith("ESP32P"):
|
|
126
|
+
args.append('-march=rv32imc')
|
|
127
|
+
return args
|
|
128
|
+
|
|
129
|
+
if core == "RP2350":
|
|
130
|
+
args.append('-march=armv7emsp')
|
|
131
|
+
return args
|
|
132
|
+
|
|
133
|
+
raise PkgError(f"The {core} is not supported")
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def compile_to_staging(abs_py: str, base: str, core: str, version: str) -> str:
|
|
137
|
+
if not os.path.exists(abs_py):
|
|
138
|
+
raise ValidationError(f"Source file not found: {abs_py}")
|
|
139
|
+
|
|
140
|
+
arch_tag = CompilerHelper.mpy_arch_tag(core)
|
|
141
|
+
out_mpy = CompilerHelper.staging_out_for(abs_py, base, arch_tag)
|
|
142
|
+
|
|
143
|
+
cache_key = (abs_py, arch_tag)
|
|
144
|
+
current_hash = CompilerHelper._compute_file_hash(abs_py)
|
|
145
|
+
|
|
146
|
+
if cache_key in CompilerHelper._compile_cache:
|
|
147
|
+
cached_hash, cached_out = CompilerHelper._compile_cache[cache_key]
|
|
148
|
+
if cached_hash == current_hash and os.path.exists(cached_out) and os.path.getsize(cached_out) > 0:
|
|
149
|
+
return cached_out
|
|
150
|
+
|
|
151
|
+
CompilerHelper.compile_file(abs_py, out_mpy, core, version or "1.29.0")
|
|
152
|
+
CompilerHelper._compile_cache[cache_key] = (current_hash, out_mpy)
|
|
153
|
+
return out_mpy
|