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.
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.post1.dist-info/METADATA +62 -0
  42. replx_agent-2.0.0.post1.dist-info/RECORD +46 -0
  43. replx_agent-2.0.0.post1.dist-info/WHEEL +5 -0
  44. replx_agent-2.0.0.post1.dist-info/entry_points.txt +2 -0
  45. replx_agent-2.0.0.post1.dist-info/licenses/LICENSE +21 -0
  46. replx_agent-2.0.0.post1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ __version__ = "2.0.0.post1"
2
+
3
+ __all__ = ["__version__"]
@@ -0,0 +1,5 @@
1
+ from .server.core import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -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,3 @@
1
+ from .scanner import DeviceScanner
2
+
3
+ __all__ = ["DeviceScanner"]
@@ -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,11 @@
1
+ from .store import StoreManager
2
+ from .registry import InstallHelper, SearchHelper, RegistryHelper
3
+ from .compiler import CompilerHelper
4
+
5
+ __all__ = [
6
+ "StoreManager",
7
+ "InstallHelper",
8
+ "SearchHelper",
9
+ "RegistryHelper",
10
+ "CompilerHelper",
11
+ ]
@@ -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