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,1018 @@
1
+ import os
2
+ import ast
3
+ import base64
4
+ import json
5
+ import textwrap
6
+ import posixpath
7
+ import tempfile
8
+ import zlib
9
+ from typing import Tuple, List
10
+
11
+ from replx_agent.utils.exceptions import ProtocolError, TransportError, FileSystemError
12
+ from replx_agent.utils.constants import PUTDIR_EXCLUDED_DIRS, PUTDIR_EXCLUDED_EXTENSIONS
13
+ from replx_agent.utils.device_info import normalize_core, get_core_profile
14
+
15
+
16
+ _DEFLATE_WBITS = 12
17
+
18
+
19
+ def _python_string_literal(value: str) -> str:
20
+ return repr(value)
21
+
22
+
23
+ class DeviceStorage:
24
+
25
+ def __init__(self, repl_protocol, core: str = "RP2350", device: str = "", device_root_fs: str = "/"):
26
+ self.repl = repl_protocol
27
+ self.core = normalize_core(core)
28
+ self.device = device
29
+ self.device_root_fs = device_root_fs
30
+
31
+ _profile = get_core_profile(self.core)
32
+ self._DEVICE_CHUNK_SIZES = _profile['chunk_size']
33
+ self._PUT_BATCH_BYTES = _profile['put_batch_bytes']
34
+ self._GET_CHUNK_SIZE = _profile['get_chunk_size']
35
+
36
+ if self.core == "EFR32MG":
37
+ self._ls_detailed_func = self._ls_detailed_xbee
38
+ self._ls_recursive_func = self._ls_recursive_xbee
39
+ else:
40
+ self._ls_detailed_func = self._ls_detailed_standard
41
+ self._ls_recursive_func = self._ls_recursive_standard
42
+
43
+ def ls(self, path: str = "/") -> List[str]:
44
+ path_literal = _python_string_literal(path)
45
+ command = f"""
46
+ import os
47
+ try:
48
+ print(os.listdir({path_literal}))
49
+ except Exception as e:
50
+ print('__ERROR__:' + str(e))
51
+ """
52
+ result = self.repl.exec(command).decode('utf-8').strip()
53
+ if result.startswith("__ERROR__:"):
54
+ raise FileSystemError(f"ls failed: {result[len('__ERROR__:'):].strip()}")
55
+ try:
56
+ items = ast.literal_eval(result)
57
+ except (ValueError, SyntaxError) as e:
58
+ raise FileSystemError(f"Invalid ls response: {e}") from e
59
+ if not isinstance(items, list):
60
+ raise FileSystemError("Invalid ls response: expected a list")
61
+ return items
62
+
63
+ def ls_detailed(self, path: str = "/") -> List[list]:
64
+ return self._ls_detailed_func(path)
65
+
66
+ def ls_recursive(self, path: str = "/") -> List[list]:
67
+ return self._ls_recursive_func(path)
68
+
69
+ def _ls_detailed_standard(self, dir: str = "/") -> List[list]:
70
+ dir = self._normalize_remote_path(dir)
71
+ if not dir.startswith("/"):
72
+ dir = "/" + dir
73
+ if dir != '/' and dir.endswith('/'):
74
+ dir = dir.rstrip('/')
75
+ dir_literal = _python_string_literal(dir)
76
+
77
+ command = f"""
78
+ import os
79
+ import json
80
+ def get_detailed_listing(path):
81
+ try:
82
+ items = []
83
+ for item in os.listdir(path):
84
+ full_path = path + ('/' + item if path != '/' else item)
85
+ try:
86
+ stat_info = os.stat(full_path)
87
+ is_dir = stat_info[0] & 0x4000 != 0
88
+ size = 0 if is_dir else stat_info[6]
89
+ items.append([item, size, is_dir])
90
+ except Exception:
91
+ try:
92
+ os.listdir(full_path)
93
+ items.append([item, 0, True])
94
+ except Exception:
95
+ items.append([item, 0, False])
96
+ return sorted(items, key=lambda x: (not x[2], x[0].lower()))
97
+ except Exception as e:
98
+ return {{'__error__': str(e)}}
99
+ print(json.dumps(get_detailed_listing({dir_literal})))
100
+ """
101
+ try:
102
+ out = self.repl.exec(command)
103
+ result_str = out.decode("utf-8", errors='replace').strip()
104
+ result = json.loads(result_str)
105
+ if isinstance(result, dict) and "__error__" in result:
106
+ raise FileSystemError(f"ls failed: {result['__error__']}")
107
+ return result
108
+ except TransportError:
109
+ raise
110
+ except json.JSONDecodeError as e:
111
+ raise FileSystemError(f"Invalid directory listing response: {e}") from e
112
+
113
+ def _ls_detailed_xbee(self, dir: str = "/") -> List[list]:
114
+ dir = self._normalize_remote_path(dir)
115
+ if not dir.startswith("/"):
116
+ dir = "/" + dir
117
+ dir_literal = _python_string_literal(dir)
118
+
119
+ command = f"""
120
+ import os
121
+ import json
122
+ def xbee_is_dir(path):
123
+ try:
124
+ os.listdir(path)
125
+ return True
126
+ except OSError:
127
+ return False
128
+
129
+ def xbee_get_file_size(path):
130
+ size = 0
131
+ with open(path, 'rb') as f:
132
+ while True:
133
+ chunk = f.read(256)
134
+ if not chunk:
135
+ break
136
+ size += len(chunk)
137
+ return size
138
+
139
+ def get_detailed_listing(path):
140
+ try:
141
+ items = []
142
+ for item in os.listdir(path):
143
+ full_path = path + ('/' + item if path != '/' else item)
144
+ is_dir = xbee_is_dir(full_path)
145
+ if is_dir:
146
+ size = 0
147
+ else:
148
+ size = xbee_get_file_size(full_path)
149
+ items.append([item, size, is_dir])
150
+ return sorted(items, key=lambda x: (not x[2], x[0].lower()))
151
+ except Exception as e:
152
+ return {{'__error__': str(e)}}
153
+ print(json.dumps(get_detailed_listing({dir_literal})))
154
+ """
155
+ try:
156
+ out = self.repl.exec(command)
157
+ result = json.loads(out.decode("utf-8").strip())
158
+ if isinstance(result, dict) and "__error__" in result:
159
+ raise FileSystemError(f"ls failed: {result['__error__']}")
160
+ return result
161
+ except TransportError:
162
+ raise
163
+ except json.JSONDecodeError as e:
164
+ raise FileSystemError(f"Invalid directory listing response: {e}") from e
165
+
166
+ def _ls_recursive_standard(self, dir: str = "/") -> List[list]:
167
+ dir = self._normalize_remote_path(dir)
168
+ if not dir.startswith("/"):
169
+ dir = "/" + dir
170
+ safe_dir = _python_string_literal(dir)
171
+
172
+ command = f"""
173
+ import os
174
+ import json
175
+
176
+ def get_recursive_listing(path, base_path=None):
177
+ if base_path is None:
178
+ base_path = path
179
+ items = []
180
+ try:
181
+ entries = os.listdir(path)
182
+ for item in entries:
183
+ full_path = path + ('/' + item if path != '/' else item)
184
+
185
+ # Calculate relative path from base
186
+ if base_path == '/':
187
+ rel_path = full_path
188
+ else:
189
+ rel_path = full_path[len(base_path):].lstrip('/')
190
+
191
+ try:
192
+ stat_info = os.stat(full_path)
193
+ is_dir = stat_info[0] & 0x4000 != 0
194
+ size = 0 if is_dir else stat_info[6]
195
+ except Exception:
196
+ try:
197
+ os.listdir(full_path)
198
+ is_dir = True
199
+ size = 0
200
+ except Exception:
201
+ is_dir = False
202
+ size = 0
203
+
204
+ if is_dir:
205
+ items.append([rel_path, 0, True])
206
+ child = get_recursive_listing(full_path, base_path)
207
+ if isinstance(child, dict) and '__error__' in child:
208
+ return child
209
+ items.extend(child)
210
+ else:
211
+ items.append([rel_path, size, False])
212
+
213
+ except Exception as e:
214
+ return {{'__error__': str(e)}}
215
+
216
+ return sorted(items, key=lambda x: x[0].lower())
217
+
218
+ print(json.dumps(get_recursive_listing({safe_dir})))
219
+ """
220
+ try:
221
+ out = self.repl.exec(command)
222
+ result = json.loads(out.decode("utf-8").strip())
223
+ if isinstance(result, dict) and "__error__" in result:
224
+ raise FileSystemError(f"ls_recursive failed: {result['__error__']}")
225
+ return result
226
+ except TransportError:
227
+ raise
228
+ except json.JSONDecodeError:
229
+ return self._ls_recursive_fallback(dir)
230
+
231
+ def _ls_recursive_xbee(self, dir: str = "/") -> List[list]:
232
+ dir = self._normalize_remote_path(dir)
233
+ if not dir.startswith("/"):
234
+ dir = "/" + dir
235
+ safe_dir = _python_string_literal(dir)
236
+
237
+ command = f"""
238
+ import os
239
+ import json
240
+
241
+ def xbee_is_dir(path):
242
+ try:
243
+ os.listdir(path)
244
+ return True
245
+ except OSError:
246
+ return False
247
+
248
+ def xbee_get_file_size(path):
249
+ size = 0
250
+ with open(path, 'rb') as f:
251
+ while True:
252
+ chunk = f.read(256)
253
+ if not chunk:
254
+ break
255
+ size += len(chunk)
256
+ return size
257
+
258
+ def get_recursive_listing(path, base_path=None):
259
+ if base_path is None:
260
+ base_path = path
261
+ items = []
262
+ try:
263
+ entries = os.listdir(path)
264
+ for item in entries:
265
+ full_path = path + ('/' + item if path != '/' else item)
266
+
267
+ # Calculate relative path from base
268
+ if base_path == '/':
269
+ rel_path = full_path
270
+ else:
271
+ rel_path = full_path[len(base_path):].lstrip('/')
272
+
273
+ is_dir = xbee_is_dir(full_path)
274
+
275
+ if is_dir:
276
+ items.append([rel_path, 0, True])
277
+ child = get_recursive_listing(full_path, base_path)
278
+ if isinstance(child, dict) and '__error__' in child:
279
+ return child
280
+ items.extend(child)
281
+ else:
282
+ size = xbee_get_file_size(full_path)
283
+ items.append([rel_path, size, is_dir])
284
+
285
+ except Exception as e:
286
+ return {{'__error__': str(e)}}
287
+
288
+ return sorted(items, key=lambda x: x[0].lower())
289
+
290
+ print(json.dumps(get_recursive_listing({safe_dir})))
291
+ """
292
+ try:
293
+ out = self.repl.exec(command)
294
+ result = json.loads(out.decode("utf-8").strip())
295
+ if isinstance(result, dict) and "__error__" in result:
296
+ raise FileSystemError(f"ls_recursive failed: {result['__error__']}")
297
+ return result
298
+ except TransportError:
299
+ raise
300
+ except json.JSONDecodeError:
301
+ return self._ls_recursive_fallback(dir)
302
+
303
+ def _ls_recursive_fallback(self, dir: str = "/") -> List[list]:
304
+ result = []
305
+
306
+ def walk_dir(current_dir, base_dir):
307
+ items = self.ls_detailed(current_dir)
308
+ for name, size, is_dir in items:
309
+ full_path = posixpath.join(current_dir, name)
310
+ if base_dir == '/':
311
+ rel_path = full_path
312
+ else:
313
+ rel_path = full_path[len(base_dir):].lstrip('/')
314
+
315
+ if is_dir:
316
+ result.append([rel_path, 0, True])
317
+ walk_dir(full_path, base_dir)
318
+ else:
319
+ result.append([rel_path, size, False])
320
+
321
+ walk_dir(dir, dir)
322
+
323
+ return sorted(result, key=lambda x: x[0].lower())
324
+
325
+ def is_dir(self, path: str) -> bool:
326
+ path = self._normalize_remote_path(path)
327
+ path_literal = _python_string_literal(path)
328
+
329
+ if self.core == "EFR32MG":
330
+ command = f"""
331
+ import os
332
+ try:
333
+ os.listdir({path_literal})
334
+ result = True
335
+ except OSError:
336
+ result = False
337
+ print(result)
338
+ """
339
+ else:
340
+ command = f"""
341
+ import os
342
+ try:
343
+ result = os.stat({path_literal})[0] & 0x4000 != 0
344
+ except Exception:
345
+ try:
346
+ os.listdir({path_literal})
347
+ result = True
348
+ except OSError:
349
+ result = False
350
+ print(result)
351
+ """
352
+ out = self.repl.exec(command)
353
+ try:
354
+ result = ast.literal_eval(out.decode("utf-8").strip())
355
+ except (ValueError, SyntaxError) as e:
356
+ raise FileSystemError(f"Invalid is_dir response: {e}") from e
357
+ if not isinstance(result, bool):
358
+ raise FileSystemError("Invalid is_dir response: expected a boolean")
359
+ return result
360
+
361
+ def state(self, path: str) -> int:
362
+ path = self._normalize_remote_path(path)
363
+ path_literal = _python_string_literal(path)
364
+
365
+ if self.core == "EFR32MG":
366
+ command = f"""
367
+ try:
368
+ with open({path_literal}, 'rb') as f:
369
+ size = 0
370
+ while True:
371
+ chunk = f.read(256)
372
+ if not chunk:
373
+ break
374
+ size += len(chunk)
375
+ print(size)
376
+ except OSError as e:
377
+ print('__ERROR__:' + str(e))
378
+ """
379
+ else:
380
+ command = f"""
381
+ import os
382
+ try:
383
+ st = os.stat({path_literal})
384
+ print(st[6])
385
+ except Exception:
386
+ print(0)
387
+ """
388
+ out = self.repl.exec(command)
389
+ result = out.decode('utf-8').strip()
390
+ if result.startswith('__ERROR__:'):
391
+ raise FileSystemError(f"File-size query failed: {result[10:].strip()}")
392
+ try:
393
+ return int(result)
394
+ except ValueError as e:
395
+ raise FileSystemError(f"Invalid file-size response: {result!r}") from e
396
+
397
+ def _file_exists(self, path: str) -> bool:
398
+ path_literal = _python_string_literal(path)
399
+
400
+ if self.core == "EFR32MG":
401
+ command = f"""
402
+ try:
403
+ f = open({path_literal}, 'rb')
404
+ f.close()
405
+ print('1')
406
+ except Exception:
407
+ print('0')
408
+ """
409
+ else:
410
+ command = f"""
411
+ import os
412
+ try:
413
+ os.stat({path_literal})
414
+ print('1')
415
+ except Exception:
416
+ print('0')
417
+ """
418
+ result = self.repl.exec(command).decode('utf-8').strip()
419
+ if result == '1':
420
+ return True
421
+ if result == '0':
422
+ return False
423
+ raise FileSystemError(f"Invalid file-exists response: {result!r}")
424
+
425
+ def mkdir(self, dir: str) -> bool:
426
+ dir = dir.replace('\\', '/')
427
+ if not dir:
428
+ return False
429
+ dir_literal = _python_string_literal(dir)
430
+ command = f"""
431
+ import os
432
+ def mkdir(path):
433
+ current = '/' if path.startswith('/') else ''
434
+ for part in path.split('/'):
435
+ if not part:
436
+ continue
437
+ if current == '/':
438
+ current += part
439
+ elif current:
440
+ current += '/' + part
441
+ else:
442
+ current = part
443
+ try:
444
+ os.mkdir(current)
445
+ except OSError:
446
+ try:
447
+ os.listdir(current)
448
+ except OSError:
449
+ return False
450
+ return True
451
+ print(mkdir({dir_literal}))
452
+ """
453
+ out = self.repl.exec(command)
454
+ try:
455
+ result = ast.literal_eval(out.decode("utf-8").strip())
456
+ except (ValueError, SyntaxError) as e:
457
+ raise FileSystemError(f"Invalid mkdir response: {e}") from e
458
+ if not isinstance(result, bool):
459
+ raise FileSystemError("Invalid mkdir response: expected a boolean")
460
+ return result
461
+
462
+ def rm(self, filename: str):
463
+ filename_literal = _python_string_literal(filename)
464
+ command = f"""
465
+ import os
466
+ os.remove({filename_literal})
467
+ """
468
+ self.repl.exec(command)
469
+
470
+ def touch(self, filename: str, core: str = None):
471
+ core = core or self.core
472
+ filename_literal = _python_string_literal(filename)
473
+ if core == "EFR32MG":
474
+ command = f"""
475
+ import os
476
+ try:
477
+ # Try to open file to check if it exists
478
+ f = open({filename_literal}, 'rb')
479
+ f.close()
480
+ except Exception:
481
+ # File doesn't exist, create it
482
+ f = open({filename_literal}, 'wb')
483
+ f.close()
484
+ """
485
+ else:
486
+ command = f"f = open({filename_literal}, 'a'); f.close()"
487
+ self.repl.exec(command)
488
+
489
+ def rmdir(self, dir: str):
490
+ dir_literal = _python_string_literal(dir)
491
+ if self.core == "EFR32MG":
492
+ command = f"""
493
+ import os
494
+ def rmdir(p):
495
+ for f in os.listdir(p):
496
+ fp = p + '/' + f
497
+ try:
498
+ os.remove(fp)
499
+ except OSError:
500
+ rmdir(fp)
501
+ os.rmdir(p)
502
+ rmdir({dir_literal})
503
+ """
504
+ else:
505
+ command = f"""
506
+ import os
507
+ def rmdir(p):
508
+ for name in os.listdir(p):
509
+ fp = p + '/' + name if p != '/' else '/' + name
510
+ try:
511
+ if os.stat(fp)[0] & 0x4000:
512
+ rmdir(fp)
513
+ else:
514
+ os.remove(fp)
515
+ except OSError:
516
+ try:
517
+ rmdir(fp)
518
+ except Exception:
519
+ pass
520
+ os.rmdir(p)
521
+ rmdir({dir_literal})
522
+ """
523
+ self.repl.exec(command)
524
+
525
+ def cp(self, source: str, dest: str):
526
+ source_literal = _python_string_literal(source)
527
+ dest_literal = _python_string_literal(dest)
528
+ command = f"""
529
+ with open({source_literal}, 'rb') as src:
530
+ with open({dest_literal}, 'wb') as dst:
531
+ while True:
532
+ chunk = src.read(512)
533
+ if not chunk:
534
+ break
535
+ dst.write(chunk)
536
+ """
537
+ self.repl.exec(command)
538
+
539
+ def mv(self, source: str, dest: str):
540
+ source_literal = _python_string_literal(source)
541
+ dest_literal = _python_string_literal(dest)
542
+ if self.core == "EFR32MG":
543
+ command = f"""
544
+ import os
545
+ try:
546
+ os.listdir({source_literal})
547
+ is_dir = True
548
+ except Exception:
549
+ is_dir = False
550
+
551
+ if is_dir:
552
+ # For directories: create dest, copy contents recursively, remove source
553
+ def copy_dir(src, dst):
554
+ try:
555
+ os.mkdir(dst)
556
+ except Exception:
557
+ pass
558
+ for f in os.listdir(src):
559
+ sf = src + '/' + f
560
+ df = dst + '/' + f
561
+ try:
562
+ os.listdir(sf)
563
+ copy_dir(sf, df)
564
+ except Exception:
565
+ with open(sf, 'rb') as s:
566
+ with open(df, 'wb') as d:
567
+ while True:
568
+ c = s.read(512)
569
+ if not c:
570
+ break
571
+ d.write(c)
572
+ def rm_dir(p):
573
+ for f in os.listdir(p):
574
+ fp = p + '/' + f
575
+ try:
576
+ os.remove(fp)
577
+ except OSError:
578
+ rm_dir(fp)
579
+ os.rmdir(p)
580
+ copy_dir({source_literal}, {dest_literal})
581
+ rm_dir({source_literal})
582
+ else:
583
+ # For files: simple copy and remove
584
+ with open({source_literal}, 'rb') as src:
585
+ with open({dest_literal}, 'wb') as dst:
586
+ while True:
587
+ chunk = src.read(512)
588
+ if not chunk:
589
+ break
590
+ dst.write(chunk)
591
+ os.remove({source_literal})
592
+ """
593
+ else:
594
+ command = f"""
595
+ import os
596
+ os.rename({source_literal}, {dest_literal})
597
+ """
598
+ self.repl.exec(command)
599
+
600
+ def mem(self) -> Tuple[int, int, int, float]:
601
+ command = """
602
+ import gc
603
+ gc.collect()
604
+ free = gc.mem_free()
605
+ alloc = gc.mem_alloc()
606
+ total = free + alloc
607
+ usage_pct = round(alloc / total * 100, 2)
608
+ print(free, alloc, total, usage_pct)
609
+ """
610
+ out = self.repl.exec(command)
611
+ free_str, alloc_str, total_str, usage_pct_str = out.decode("utf-8").strip().split()
612
+ return int(free_str), int(alloc_str), int(total_str), float(usage_pct_str)
613
+
614
+ def df(self) -> Tuple[int, int, int, float]:
615
+ command = """
616
+ import os
617
+ def get_fs_info(path='/'):
618
+ stats = os.statvfs(path)
619
+ block_size = stats[0]
620
+ total_blocks = stats[2]
621
+ free_blocks = stats[3]
622
+ total = block_size * total_blocks
623
+ free = block_size * free_blocks
624
+ used = total - free
625
+ usage_pct = round(used / total * 100, 2) if total > 0 else 0.0
626
+ return total, used, free, usage_pct
627
+ print(get_fs_info())
628
+ """
629
+ out = self.repl.exec(command)
630
+ return ast.literal_eval(out.decode("utf-8").strip())
631
+
632
+ def format(self) -> bool:
633
+ normalized_core = normalize_core(self.core)
634
+
635
+ if normalized_core in ("ESP32S3", "ESP32C6", "ESP32C5", "ESP32P4"):
636
+ command = """
637
+ import os
638
+ from flashbdev import bdev
639
+ os.umount('/')
640
+ os.VfsLfs2.mkfs(bdev)
641
+ os.mount(bdev, '/')
642
+ """
643
+ elif normalized_core == "EFR32MG":
644
+ command = """
645
+ import os
646
+ os.format()
647
+ """
648
+ elif normalized_core == "RP2350":
649
+ command = """
650
+ import os, rp2
651
+ try:
652
+ os.umount('/')
653
+ except Exception:
654
+ pass
655
+ bdev = rp2.Flash()
656
+ os.VfsLfs2.mkfs(bdev, progsize=256)
657
+ fs = os.VfsLfs2(bdev, progsize=256)
658
+ os.mount(fs, '/')
659
+ """
660
+ elif normalized_core == "MIMXRT1062DVJ6A":
661
+ command = """
662
+ import os, mimxrt
663
+ try:
664
+ os.umount('/flash')
665
+ except Exception:
666
+ pass
667
+ bdev = mimxrt.Flash()
668
+ os.VfsLfs2.mkfs(bdev)
669
+ fs = os.VfsLfs2(bdev)
670
+ os.mount(fs, '/flash')
671
+ os.chdir('/flash')
672
+ """
673
+ else:
674
+ return False
675
+
676
+ try:
677
+ self.repl.exec(command)
678
+ except ProtocolError:
679
+ return False
680
+ return True
681
+
682
+ def get(self, remote: str, local: str = None) -> bytes:
683
+ import binascii as binascii_module
684
+
685
+ local_file = None
686
+ local_target = None
687
+ temp_path = None
688
+ content_parts = []
689
+
690
+ if local:
691
+ if os.path.isdir(local):
692
+ local = os.path.join(local, posixpath.basename(remote.rstrip('/')))
693
+ local_target = os.path.abspath(local)
694
+
695
+ bytes_read = 0
696
+
697
+ try:
698
+ file_size = self.state(remote)
699
+
700
+ if file_size == 0:
701
+ if not self._file_exists(remote):
702
+ raise ProtocolError(f"File not found: {remote}")
703
+ if local_target:
704
+ local_dir = os.path.dirname(local_target) or os.curdir
705
+ os.makedirs(local_dir, exist_ok=True)
706
+ fd, temp_path = tempfile.mkstemp(
707
+ prefix=f".{os.path.basename(local_target)}.",
708
+ suffix=".tmp",
709
+ dir=local_dir,
710
+ )
711
+ os.close(fd)
712
+ os.replace(temp_path, local_target)
713
+ temp_path = None
714
+ return None
715
+ return b""
716
+
717
+ if local_target:
718
+ local_dir = os.path.dirname(local_target) or os.curdir
719
+ os.makedirs(local_dir, exist_ok=True)
720
+ fd, temp_path = tempfile.mkstemp(
721
+ prefix=f".{os.path.basename(local_target)}.",
722
+ suffix=".tmp",
723
+ dir=local_dir,
724
+ )
725
+ local_file = os.fdopen(fd, "wb")
726
+
727
+ with self.repl.session():
728
+ init_command = f"""
729
+ import sys
730
+ import binascii
731
+ f = open({_python_string_literal(remote)}, 'rb')
732
+ """
733
+ self.repl._exec(textwrap.dedent(init_command))
734
+
735
+ CHUNK_SIZE = self._GET_CHUNK_SIZE
736
+
737
+ try:
738
+ while bytes_read < file_size:
739
+ remaining = min(CHUNK_SIZE, file_size - bytes_read)
740
+
741
+ read_cmd = f"""
742
+ chunk = f.read({remaining})
743
+ if chunk:
744
+ encoded = binascii.b2a_base64(chunk)
745
+ sys.stdout.write(encoded.decode('ascii'))
746
+ """
747
+ encoded_data = self.repl._exec(textwrap.dedent(read_cmd))
748
+
749
+ if not encoded_data:
750
+ break
751
+ try:
752
+ cleaned_data = encoded_data.strip().replace(b'\r', b'').replace(b'\n', b'')
753
+ chunk_data = binascii_module.a2b_base64(cleaned_data)
754
+ except Exception as e:
755
+ raise ProtocolError(f"Failed to decode base64 data: {e}")
756
+
757
+ if not chunk_data:
758
+ break
759
+ if local_file is not None:
760
+ local_file.write(chunk_data)
761
+ else:
762
+ content_parts.append(chunk_data)
763
+
764
+ bytes_read += len(chunk_data)
765
+ finally:
766
+ self.repl._exec("f.close()")
767
+
768
+ if bytes_read != file_size:
769
+ raise ProtocolError(f"Download incomplete: got {bytes_read}/{file_size} bytes")
770
+
771
+ if local_file is not None:
772
+ local_file.flush()
773
+ os.fsync(local_file.fileno())
774
+ local_file.close()
775
+ local_file = None
776
+ os.replace(temp_path, local_target)
777
+ temp_path = None
778
+
779
+ except ProtocolError:
780
+ raise
781
+ except Exception as e:
782
+ raise ProtocolError(f"Download failed: {e}") from e
783
+ finally:
784
+ if local_file is not None:
785
+ local_file.close()
786
+ if temp_path:
787
+ try:
788
+ os.remove(temp_path)
789
+ except FileNotFoundError:
790
+ pass
791
+
792
+ if not local_target:
793
+ return b''.join(content_parts)
794
+
795
+ def put(self, local: str, remote: str, progress_callback=None):
796
+ sent = 0
797
+ needs_retry = False
798
+ file_opened = False
799
+
800
+ use_deflate = False
801
+ with self.repl.session():
802
+ try:
803
+ if self.core != "EFR32MG":
804
+ # Probe for the deflate module and define a helper that inflates
805
+ # raw-deflate (4 KiB window) chunks sent as base64.
806
+ out = self.repl._exec(
807
+ "import binascii\n"
808
+ "try:\n"
809
+ " import deflate,io\n"
810
+ f" def _w(d):f.write(deflate.DeflateIO(io.BytesIO(binascii.a2b_base64(d)),deflate.RAW,{_DEFLATE_WBITS}).read())\n"
811
+ " print('Z1')\n"
812
+ "except ImportError:\n"
813
+ " print('Z0')\n"
814
+ f"f = open({_python_string_literal(remote)}, 'wb')"
815
+ )
816
+ use_deflate = b'Z1' in out
817
+ else:
818
+ self.repl._exec(f"f = open({_python_string_literal(remote)}, 'wb')")
819
+ file_opened = True
820
+ except ProtocolError as e:
821
+ if "EEXIST" in str(e):
822
+ needs_retry = True
823
+ else:
824
+ raise
825
+ else:
826
+ try:
827
+ with open(local, "rb") as f:
828
+ total = os.fstat(f.fileno()).st_size
829
+
830
+ if progress_callback:
831
+ progress_callback(0, total)
832
+
833
+ batch_src_lines = []
834
+ batch_bytes = 0
835
+ DEVICE_CHUNK = self._DEVICE_CHUNK_SIZES
836
+ BATCH_LIMIT = max(1, int(self._PUT_BATCH_BYTES))
837
+
838
+ def _flush_batch():
839
+ nonlocal batch_src_lines, batch_bytes
840
+ if not batch_src_lines:
841
+ return
842
+
843
+ code = ";\n".join(batch_src_lines)
844
+ self.repl._exec(code)
845
+ batch_src_lines = []
846
+ batch_bytes = 0
847
+
848
+ if progress_callback:
849
+ progress_callback(sent, total)
850
+
851
+ while True:
852
+ chunk = f.read(DEVICE_CHUNK)
853
+ if not chunk:
854
+ _flush_batch()
855
+ break
856
+
857
+ line = f"f.write({repr(chunk)})"
858
+ if self.core != "EFR32MG":
859
+ encoded_line = f"f.write(binascii.a2b_base64({base64.b64encode(chunk)!r}))"
860
+ if len(encoded_line) < len(line):
861
+ line = encoded_line
862
+ if use_deflate:
863
+ z = zlib.compressobj(9, zlib.DEFLATED, -_DEFLATE_WBITS)
864
+ packed = z.compress(chunk) + z.flush()
865
+ deflate_line = f"_w({base64.b64encode(packed)!r})"
866
+ if len(deflate_line) < len(line):
867
+ line = deflate_line
868
+ batch_src_lines.append(line)
869
+ batch_bytes += len(line)
870
+ sent += len(chunk)
871
+
872
+ if batch_bytes >= BATCH_LIMIT:
873
+ _flush_batch()
874
+
875
+ self.repl._exec("f.close()")
876
+ file_opened = False
877
+
878
+ if progress_callback:
879
+ progress_callback(total, total)
880
+
881
+ except Exception:
882
+ if file_opened:
883
+ try:
884
+ self.repl._exec("try:\n f.close()\nexcept:\n pass")
885
+ except Exception:
886
+ pass
887
+ raise
888
+
889
+ if needs_retry:
890
+ self.rm(remote)
891
+ self.put(local, remote, progress_callback)
892
+
893
+ def getdir_batch(self, remote: str, local: str, progress_callback=None):
894
+ base_remote = remote.replace("\\", "/")
895
+ base_local = os.path.abspath(local)
896
+ os.makedirs(base_local, exist_ok=True)
897
+
898
+ try:
899
+ with self.repl.session():
900
+ try:
901
+ items = self.ls_recursive(base_remote)
902
+ except Exception as e:
903
+ raise ProtocolError(f"Failed to list remote directory: {e}") from e
904
+ return self._getdir_from_items(items, base_remote, base_local, progress_callback)
905
+ except ProtocolError:
906
+ raise
907
+ except Exception as e:
908
+ raise ProtocolError(f"Failed to download remote directory: {e}") from e
909
+
910
+ def _getdir_from_items(self, items, base_remote: str, base_local: str, progress_callback):
911
+ if not items:
912
+ return
913
+
914
+ file_specs = []
915
+ for item in items:
916
+ rel_path = item[0]
917
+ is_dir = item[2]
918
+ relative_path = rel_path.lstrip('/')
919
+ local_path = os.path.join(base_local, *relative_path.split('/'))
920
+ if is_dir:
921
+ os.makedirs(local_path, exist_ok=True)
922
+ continue
923
+
924
+ local_dir = os.path.dirname(local_path)
925
+ if local_dir and not os.path.exists(local_dir):
926
+ os.makedirs(local_dir, exist_ok=True)
927
+
928
+ if rel_path.startswith('/'):
929
+ full_remote = rel_path
930
+ elif base_remote == '/':
931
+ full_remote = '/' + rel_path
932
+ else:
933
+ full_remote = base_remote + ('/' if not base_remote.endswith('/') else '') + rel_path
934
+
935
+ file_specs.append((full_remote, local_path))
936
+
937
+ if not file_specs:
938
+ return
939
+
940
+ total = len(file_specs)
941
+ for idx, (remote_file, local_file) in enumerate(file_specs):
942
+ if progress_callback:
943
+ progress_callback(idx, total, os.path.basename(remote_file))
944
+
945
+ try:
946
+ self.get(remote_file, local_file)
947
+ except Exception as e:
948
+ raise ProtocolError(f"Failed to download {remote_file}: {e}")
949
+
950
+ if progress_callback:
951
+ progress_callback(total, total, "Complete")
952
+
953
+ def putdir(self, local: str, remote: str):
954
+ import posixpath
955
+
956
+ base_local = os.path.abspath(local)
957
+ base_remote = remote.replace("\\", "/")
958
+
959
+ with self.repl.session():
960
+ for parent, child_dirs, child_files in os.walk(base_local, followlinks=False):
961
+ child_dirs[:] = [
962
+ name for name in child_dirs if name.lower() not in PUTDIR_EXCLUDED_DIRS
963
+ ]
964
+ rel = os.path.relpath(parent, base_local).replace("\\", "/")
965
+ remote_parent = posixpath.normpath(
966
+ posixpath.join(base_remote, "" if rel == "." else rel)
967
+ )
968
+ if not self.mkdir(remote_parent):
969
+ raise FileSystemError(f"Could not create remote directory: {remote_parent}")
970
+
971
+ for filename in child_files:
972
+ if os.path.splitext(filename)[1].lower() in PUTDIR_EXCLUDED_EXTENSIONS:
973
+ continue
974
+ local_path = os.path.join(parent, filename)
975
+ remote_path = posixpath.join(remote_parent, filename)
976
+ self.put(local_path, remote_path)
977
+
978
+ def _normalize_remote_path(self, path: str) -> str:
979
+ root = posixpath.normpath(self.device_root_fs or '/')
980
+ path = (path or '/').replace('\\', '/')
981
+
982
+ if root == '/':
983
+ return posixpath.normpath('/' + path.lstrip('/'))
984
+
985
+ if path == root or path.startswith(root + '/'):
986
+ real_path = posixpath.normpath(path)
987
+ else:
988
+ real_path = posixpath.normpath(posixpath.join(root, path.lstrip('/')))
989
+
990
+ if real_path != root and not real_path.startswith(root + '/'):
991
+ raise FileSystemError("Remote path is outside the device filesystem root")
992
+ return real_path
993
+
994
+ def _print_progress_bar(self, current: int, total: int, bar_length: int = 40):
995
+ pct = 0 if total == 0 else min(1.0, current / total)
996
+ block = min(bar_length, int(round(bar_length * pct)))
997
+ bar = "#" * block + "-" * (bar_length - block)
998
+ percent = int(pct * 100)
999
+ print(f"\r[{bar}] {percent}% ({current}/{total})", end="", flush=True)
1000
+
1001
+
1002
+ def create_storage(repl_protocol, core: str = "RP2350", device: str = "", device_root_fs: str = "/") -> DeviceStorage:
1003
+ return DeviceStorage(
1004
+ repl_protocol,
1005
+ core=core,
1006
+ device=device,
1007
+ device_root_fs=device_root_fs
1008
+ )
1009
+
1010
+
1011
+ SerialStorage = DeviceStorage
1012
+
1013
+
1014
+ __all__ = [
1015
+ 'DeviceStorage',
1016
+ 'SerialStorage',
1017
+ 'create_storage',
1018
+ ]