mpytool 2.1.0__py3-none-any.whl → 2.2.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.
mpytool/conn_serial.py CHANGED
@@ -6,7 +6,7 @@ import mpytool.conn as _conn
6
6
 
7
7
 
8
8
  class ConnSerial(_conn.Conn):
9
- RECONNECT_TIMEOUT = 5 # seconds
9
+ RECONNECT_TIMEOUT = 10 # seconds
10
10
 
11
11
  def __init__(self, log=None, **serial_config):
12
12
  super().__init__(log)
@@ -28,14 +28,24 @@ class ConnSerial(_conn.Conn):
28
28
 
29
29
  def _read_available(self):
30
30
  """Read available data from serial port"""
31
- in_waiting = self._serial.in_waiting
32
- if in_waiting > 0:
33
- return self._serial.read(in_waiting)
34
- return None
31
+ if self._serial is None:
32
+ raise _conn.ConnError("Not connected")
33
+ try:
34
+ in_waiting = self._serial.in_waiting
35
+ if in_waiting > 0:
36
+ return self._serial.read(in_waiting)
37
+ return None
38
+ except OSError as err:
39
+ raise _conn.ConnError(f"Connection lost: {err}") from err
35
40
 
36
41
  def _write_raw(self, data):
37
42
  """Write data to serial port"""
38
- return self._serial.write(data)
43
+ if self._serial is None:
44
+ raise _conn.ConnError("Not connected")
45
+ try:
46
+ return self._serial.write(data)
47
+ except OSError as err:
48
+ raise _conn.ConnError(f"Connection lost: {err}") from err
39
49
 
40
50
  def _is_usb_cdc(self):
41
51
  """Check if this is a USB-CDC port (native USB on ESP32-S/C)"""
mpytool/mpy.py CHANGED
@@ -65,7 +65,8 @@ def _mt_tree(p):
65
65
  def _mt_mkdir(p):
66
66
  p=p.rstrip('/');c='';f=1
67
67
  for d in p.split('/'):
68
- c+='/'+d if c else d
68
+ if not d:c='/';continue
69
+ c='/'+d if c=='/' else (c+'/'+d if c else d)
69
70
  if f:
70
71
  try:
71
72
  if os.stat(c)[0]=={_ATTR_FILE}:return 1
@@ -295,6 +296,27 @@ def _mt_pfind(label):
295
296
  self.import_module('os')
296
297
  self._mpy_comm.exec(f"os.rename('{_escape_path(src)}', '{_escape_path(dst)}')")
297
298
 
299
+ def getcwd(self):
300
+ """Get current working directory
301
+
302
+ Returns:
303
+ current working directory path
304
+ """
305
+ self.import_module('os')
306
+ return self._mpy_comm.exec_eval("repr(os.getcwd())")
307
+
308
+ def chdir(self, path):
309
+ """Change current working directory
310
+
311
+ Arguments:
312
+ path: directory path to change to
313
+ """
314
+ self.import_module('os')
315
+ try:
316
+ self._mpy_comm.exec(f"os.chdir('{_escape_path(path)}')")
317
+ except _mpy_comm.CmdError as err:
318
+ raise DirNotFound(path) from err
319
+
298
320
  def hashfile(self, path):
299
321
  """Compute SHA256 hash of file
300
322
 
@@ -1124,11 +1146,12 @@ def _mt_pfind(label):
1124
1146
  self._mpy_comm.soft_reset_raw()
1125
1147
  self.reset_state()
1126
1148
 
1127
- def machine_reset(self, reconnect=True):
1149
+ def machine_reset(self, reconnect=True, timeout=None):
1128
1150
  """MCU reset using machine.reset()
1129
1151
 
1130
1152
  Arguments:
1131
1153
  reconnect: if True, attempt to reconnect after reset
1154
+ timeout: reconnect timeout in seconds (None = default)
1132
1155
 
1133
1156
  Returns:
1134
1157
  True if reconnected successfully, False otherwise
@@ -1139,7 +1162,7 @@ def _mt_pfind(label):
1139
1162
  self._conn.write(b"import machine; machine.reset()\x04")
1140
1163
  self.reset_state()
1141
1164
  if reconnect:
1142
- self._conn.reconnect()
1165
+ self._conn.reconnect(timeout=timeout)
1143
1166
  return True
1144
1167
  return False
1145
1168
 
mpytool/mpytool.py CHANGED
@@ -23,6 +23,38 @@ class ParamsError(_mpytool.MpyError):
23
23
  """Invalid command parameters"""
24
24
 
25
25
 
26
+ def _join_remote_path(base, name):
27
+ """Join remote path components (handles empty string and '/' correctly)"""
28
+ if not name:
29
+ return base
30
+ if base == '/':
31
+ return '/' + name
32
+ elif base:
33
+ return base + '/' + name
34
+ else:
35
+ return name
36
+
37
+
38
+ def _remote_basename(path):
39
+ """Get basename from remote path"""
40
+ return path.rstrip('/').split('/')[-1]
41
+
42
+
43
+ def _parse_device_path(path, cmd_name):
44
+ """Parse device path with : prefix, raise ParamsError if missing
45
+
46
+ Args:
47
+ path: path string (should start with :)
48
+ cmd_name: command name for error message
49
+
50
+ Returns:
51
+ path without : prefix
52
+ """
53
+ if not path.startswith(':'):
54
+ raise ParamsError(f'{cmd_name} requires device path (: prefix): {path}')
55
+ return path[1:]
56
+
57
+
26
58
  class MpyTool():
27
59
  SPACE = ' '
28
60
  BRANCH = '│ '
@@ -128,7 +160,11 @@ class MpyTool():
128
160
  """Format progress/skip line: [2/5] 100% 24.1K source -> dest (base64)"""
129
161
  size_str = self.format_size(total)
130
162
  multi = self._progress_total_files > 1
131
- prefix = f"[{self._progress_current_file}/{self._progress_total_files}]" if multi else ""
163
+ if multi:
164
+ width = len(str(self._progress_total_files))
165
+ prefix = f"[{self._progress_current_file:>{width}}/{self._progress_total_files}]"
166
+ else:
167
+ prefix = ""
132
168
  src_w = max(len(self._progress_src), self._progress_max_src_len)
133
169
  dst_w = max(len(self._progress_dst), self._progress_max_dst_len)
134
170
  enc = self._format_encoding_info(encodings, pad=multi) if encodings else (" " * self._ENC_WIDTH if multi else "")
@@ -167,7 +203,7 @@ class MpyTool():
167
203
  else:
168
204
  self._progress_src = self._format_local_path(src)
169
205
  if is_dst_remote:
170
- self._progress_dst = ':' + dst
206
+ self._progress_dst = ':' + ('/' + dst if not dst.startswith('/') else dst)
171
207
  else:
172
208
  self._progress_dst = self._format_local_path(dst)
173
209
  if len(self._progress_dst) > self._progress_max_dst_len:
@@ -294,9 +330,6 @@ class MpyTool():
294
330
  paths.extend(self._collect_remote_paths(src_path))
295
331
  else:
296
332
  paths.extend(self._collect_local_paths(src_path))
297
- elif cmd == 'put' and len(commands) >= 2:
298
- src_path = commands[1]
299
- paths.extend(self._collect_local_paths(src_path))
300
333
  return paths
301
334
 
302
335
  def _collect_remote_paths(self, path):
@@ -348,15 +381,9 @@ class MpyTool():
348
381
  # remote -> remote (file only)
349
382
  stat = self._mpy.stat(src_path)
350
383
  if stat is not None and stat >= 0:
351
- basename = src_path.split('/')[-1]
352
- dst_paths.append(':' + (dest_path + basename if dest_is_dir else dest_path))
384
+ dst = _join_remote_path(dest_path, _remote_basename(src_path)) if dest_is_dir else dest_path
385
+ dst_paths.append(':' + dst)
353
386
  return dst_paths
354
- elif cmd == 'put' and len(commands) >= 2:
355
- src_path = commands[1]
356
- dst_path = commands[2] if len(commands) > 2 else '/'
357
- if _os.path.exists(src_path):
358
- files = self._collect_dst_files(src_path, dst_path.rstrip('/') or '/', add_src_basename=True)
359
- return [':' + p for p in files]
360
387
  return []
361
388
 
362
389
  def _collect_remote_to_local_dst(self, src_path, dest_path, dest_is_dir, copy_contents):
@@ -366,10 +393,10 @@ class MpyTool():
366
393
  return []
367
394
  base_dst = dest_path.rstrip('/') or '.'
368
395
  if dest_is_dir and not copy_contents and src_path != '/':
369
- base_dst = _os.path.join(base_dst, src_path.split('/')[-1])
396
+ base_dst = _os.path.join(base_dst, _remote_basename(src_path))
370
397
  if stat >= 0: # file
371
398
  if _os.path.isdir(base_dst) or dest_is_dir:
372
- return [self._format_local_path(_os.path.join(base_dst, src_path.split('/')[-1]))]
399
+ return [self._format_local_path(_os.path.join(base_dst, _remote_basename(src_path)))]
373
400
  return [self._format_local_path(base_dst)]
374
401
  return self._collect_remote_dir_dst(src_path, base_dst)
375
402
 
@@ -444,14 +471,6 @@ class MpyTool():
444
471
  file_info = f"{total_files} files"
445
472
  self.verbose(f" {summary} ({file_info})", color='green')
446
473
 
447
- def cmd_ls(self, dir_name):
448
- result = self._mpy.ls(dir_name)
449
- for name, size in result:
450
- if size is not None:
451
- print(f'{self.format_size(size):>9} {name}')
452
- else:
453
- print(f'{"":9} {name}/')
454
-
455
474
  @classmethod
456
475
  def print_tree(cls, tree, prefix='', print_size=True, first=True, last=True):
457
476
  """Print tree of files
@@ -464,12 +483,14 @@ class MpyTool():
464
483
  else:
465
484
  this_prefix = cls.TEE
466
485
  sufix = ''
467
- if sub_tree is not None and name != ('/'):
486
+ if sub_tree is not None and name != '/':
468
487
  sufix = '/'
488
+ # For root, display './' only for empty path (CWD)
489
+ display_name = '.' if first and name in ('', '.') else name
469
490
  line = ''
470
491
  if print_size:
471
492
  line += f'{cls.format_size(size):>9} '
472
- line += prefix + this_prefix + name + sufix
493
+ line += prefix + this_prefix + display_name + sufix
473
494
  print(line)
474
495
  if not sub_tree:
475
496
  return
@@ -493,16 +514,6 @@ class MpyTool():
493
514
  first=False,
494
515
  last=True)
495
516
 
496
- def cmd_tree(self, dir_name):
497
- tree = self._mpy.tree(dir_name)
498
- self.print_tree(tree)
499
-
500
- def cmd_get(self, *file_names):
501
- for file_name in file_names:
502
- self.verbose(f"GET: {file_name}", 2)
503
- data = self._mpy.get(file_name)
504
- print(data.decode('utf-8'))
505
-
506
517
  def _upload_file(self, data, src_path, dst_path, show_progress):
507
518
  """Upload file data to device with stats tracking and progress display"""
508
519
  file_size = len(data)
@@ -527,10 +538,21 @@ class MpyTool():
527
538
  self._stats_wire_bytes += wire
528
539
  return True # uploaded
529
540
 
530
- def _put_dir(self, src_path, dst_path, show_progress=True):
531
- basename = _os.path.basename(_os.path.abspath(src_path))
541
+ def _put_dir(self, src_path, dst_path, show_progress=True, target_name=None):
542
+ """Upload directory to device
543
+
544
+ Arguments:
545
+ src_path: local source directory
546
+ dst_path: remote destination parent directory
547
+ show_progress: show progress bar
548
+ target_name: if set, use this as directory name instead of src basename
549
+ """
550
+ if target_name is not None:
551
+ basename = target_name
552
+ else:
553
+ basename = _os.path.basename(_os.path.abspath(src_path))
532
554
  if basename:
533
- dst_path = _os.path.join(dst_path, basename)
555
+ dst_path = _join_remote_path(dst_path, basename)
534
556
  self.verbose(f"PUT DIR: {src_path} -> {dst_path}", 2)
535
557
  created_dirs = set()
536
558
  for path, dirs, files in _os.walk(src_path, topdown=True):
@@ -565,17 +587,6 @@ class MpyTool():
565
587
  raise _mpytool.MpyError(f'Error creating file under file: {parent}')
566
588
  self._upload_file(data, src_path, dst_path, show_progress)
567
589
 
568
- def cmd_put(self, src_path, dst_path):
569
- if self._verbose >= 1 and not self._batch_mode:
570
- self._progress_total_files = len(self._collect_local_paths(src_path))
571
- self._progress_current_file = 0
572
- if _os.path.isdir(src_path):
573
- self._put_dir(src_path, dst_path)
574
- elif _os.path.isfile(src_path):
575
- self._put_file(src_path, dst_path)
576
- else:
577
- raise ParamsError(f'No file or directory to upload: {src_path}')
578
-
579
590
  def _get_file(self, src_path, dst_path, show_progress=True):
580
591
  """Download single file from device"""
581
592
  self.verbose(f"GET FILE: {src_path} -> {dst_path}", 2)
@@ -599,7 +610,7 @@ class MpyTool():
599
610
  def _get_dir(self, src_path, dst_path, copy_contents=False, show_progress=True):
600
611
  """Download directory from device"""
601
612
  if not copy_contents:
602
- basename = src_path.rstrip('/').split('/')[-1]
613
+ basename = _remote_basename(src_path)
603
614
  if basename:
604
615
  dst_path = _os.path.join(dst_path, basename)
605
616
  self.verbose(f"GET DIR: {src_path} -> {dst_path}", 2)
@@ -615,19 +626,28 @@ class MpyTool():
615
626
  self._get_file(src_entry, dst_entry, show_progress=show_progress)
616
627
 
617
628
  def _cp_local_to_remote(self, src_path, dst_path, dst_is_dir):
618
- """Upload local file/dir to device"""
619
- src_is_dir = _os.path.isdir(src_path)
629
+ """Upload local file/dir to device
630
+
631
+ Path semantics:
632
+ - dst_is_dir=True: add source basename to dst_path (unless copy_contents)
633
+ - dst_is_dir=False: dst_path is the target name (rename)
634
+ - copy_contents (src ends with /): copy contents, not directory itself
635
+ """
636
+ src_is_dir = _os.path.isdir(src_path.rstrip('/'))
620
637
  copy_contents = src_path.endswith('/')
621
638
  src_path = src_path.rstrip('/')
622
639
  if not _os.path.exists(src_path):
623
640
  raise ParamsError(f'Source not found: {src_path}')
624
- if dst_is_dir:
625
- if not copy_contents:
626
- basename = _os.path.basename(src_path)
627
- dst_path = dst_path + basename
641
+ # Normalize dst_path (remove trailing slash, but keep '/' for root)
642
+ if dst_path == '/':
643
+ pass # Keep '/' as is
644
+ elif dst_path:
628
645
  dst_path = dst_path.rstrip('/')
646
+ else:
647
+ dst_path = ''
629
648
  if src_is_dir:
630
649
  if copy_contents:
650
+ # Copy directory contents to dst_path
631
651
  for item in _os.listdir(src_path):
632
652
  if self._is_excluded(item):
633
653
  continue
@@ -635,10 +655,19 @@ class MpyTool():
635
655
  if _os.path.isdir(item_src):
636
656
  self._put_dir(item_src, dst_path)
637
657
  else:
638
- self._put_file(item_src, dst_path + '/')
658
+ self._put_file(item_src, _join_remote_path(dst_path, item))
659
+ elif dst_is_dir:
660
+ # Copy directory to destination directory (_put_dir adds basename)
661
+ self._put_dir(src_path, dst_path)
639
662
  else:
640
- self._put_dir(src_path, _os.path.dirname(dst_path) or '/')
663
+ # Rename: copy directory with new name
664
+ parent = _os.path.dirname(dst_path)
665
+ target_name = _os.path.basename(dst_path)
666
+ self._put_dir(src_path, parent, target_name=target_name)
641
667
  else:
668
+ # File: add basename if dst_is_dir
669
+ if dst_is_dir:
670
+ dst_path = _join_remote_path(dst_path, _os.path.basename(src_path))
642
671
  self._put_file(src_path, dst_path)
643
672
 
644
673
  def _cp_remote_to_local(self, src_path, dst_path, dst_is_dir):
@@ -653,14 +682,12 @@ class MpyTool():
653
682
  if not _os.path.exists(dst_path):
654
683
  _os.makedirs(dst_path)
655
684
  if not copy_contents and src_path != '/':
656
- basename = src_path.split('/')[-1]
657
- dst_path = _os.path.join(dst_path, basename)
685
+ dst_path = _os.path.join(dst_path, _remote_basename(src_path))
658
686
  if src_is_dir:
659
687
  self._get_dir(src_path, dst_path, copy_contents=copy_contents)
660
688
  else:
661
689
  if _os.path.isdir(dst_path):
662
- basename = src_path.split('/')[-1]
663
- dst_path = _os.path.join(dst_path, basename)
690
+ dst_path = _os.path.join(dst_path, _remote_basename(src_path))
664
691
  self._get_file(src_path, dst_path)
665
692
 
666
693
  def _cp_remote_to_remote(self, src_path, dst_path, dst_is_dir):
@@ -672,8 +699,7 @@ class MpyTool():
672
699
  if stat == -1:
673
700
  raise ParamsError('Remote-to-remote directory copy not supported yet')
674
701
  if dst_is_dir:
675
- basename = src_path.split('/')[-1]
676
- dst_path = dst_path + basename
702
+ dst_path = _join_remote_path(dst_path, _remote_basename(src_path))
677
703
  self.verbose(f"COPY: {src_path} -> {dst_path}", 2)
678
704
  if self._verbose >= 1:
679
705
  self._progress_current_file += 1
@@ -740,11 +766,18 @@ class MpyTool():
740
766
  dest = args[-1]
741
767
  dest_is_remote = dest.startswith(':')
742
768
  dest_path = dest[1:] if dest_is_remote else dest
743
- if not dest_path:
744
- dest_path = '/'
745
- dest_is_dir = dest_path.endswith('/')
746
- if len(sources) > 1 and not dest_is_dir:
747
- raise ParamsError('multiple sources require destination directory (ending with /)')
769
+ # Determine if destination is a directory:
770
+ # - Remote: '' (CWD) or '/' (root) or ends with '/'
771
+ # - Local: ends with '/' or exists as directory
772
+ if dest_is_remote:
773
+ dest_is_dir = dest_path == '' or dest_path == '/' or dest_path.endswith('/')
774
+ else:
775
+ dest_is_dir = dest_path.endswith('/') or _os.path.isdir(dest_path)
776
+ # Check if any source copies contents (trailing slash) or multiple sources
777
+ has_multi_source = len(sources) > 1
778
+ has_contents_copy = any(s.rstrip('/') != s for s in sources) # any source has trailing /
779
+ if (has_multi_source or has_contents_copy) and not dest_is_dir:
780
+ raise ParamsError('multiple sources or directory contents require destination directory (ending with /)')
748
781
  if self._verbose >= 1 and not self._batch_mode:
749
782
  total_files = 0
750
783
  for src in sources:
@@ -766,8 +799,9 @@ class MpyTool():
766
799
  copy_contents = src.endswith('/')
767
800
  src_path = src.rstrip('/')
768
801
  if _os.path.exists(src_path):
769
- add_basename = not copy_contents
770
- all_dst_files.update(self._collect_dst_files(src_path, dest_path.rstrip('/') or '/', add_basename))
802
+ add_basename = dest_is_dir and not copy_contents
803
+ base_path = dest_path.rstrip('/') if dest_path else ''
804
+ all_dst_files.update(self._collect_dst_files(src_path, base_path, add_basename))
771
805
  if all_dst_files and not self._batch_mode:
772
806
  self._progress_max_dst_len = max(len(':' + p) for p in all_dst_files)
773
807
  if not self._force:
@@ -792,50 +826,51 @@ class MpyTool():
792
826
  raise ParamsError('mv requires source and destination')
793
827
  sources = list(args[:-1])
794
828
  dest = args[-1]
795
- if not dest.startswith(':'):
796
- raise ParamsError('mv destination must be device path (: prefix)')
829
+ dest_path = _parse_device_path(dest, 'mv destination')
797
830
  for src in sources:
798
- if not src.startswith(':'):
799
- raise ParamsError('mv source must be device path (: prefix)')
800
- dest_path = dest[1:] or '/'
801
- dest_is_dir = dest_path.endswith('/')
831
+ _parse_device_path(src, 'mv source') # validate only
832
+ # ':' = CWD (empty string), ':/' = root
833
+ dest_is_dir = dest_path == '' or dest_path == '/' or dest_path.endswith('/')
802
834
  if len(sources) > 1 and not dest_is_dir:
803
835
  raise ParamsError('multiple sources require destination directory (ending with /)')
804
836
  self._mpy.import_module('os')
805
837
  for src in sources:
806
- src_path = src[1:]
838
+ src_path = _parse_device_path(src, 'mv')
807
839
  stat = self._mpy.stat(src_path)
808
840
  if stat is None:
809
841
  raise ParamsError(f'Source not found on device: {src_path}')
810
842
  if dest_is_dir:
811
- dst_dir = dest_path.rstrip('/')
812
- if dst_dir and self._mpy.stat(dst_dir) is None:
843
+ # Preserve '/' as root, strip trailing slash from others
844
+ dst_dir = '/' if dest_path == '/' else dest_path.rstrip('/')
845
+ if dst_dir and dst_dir != '/' and self._mpy.stat(dst_dir) is None:
813
846
  self._mpy.mkdir(dst_dir)
814
- basename = src_path.rstrip('/').split('/')[-1]
815
- final_dest = dest_path + basename
847
+ final_dest = _join_remote_path(dst_dir, _remote_basename(src_path))
816
848
  else:
817
849
  final_dest = dest_path
818
850
  self.verbose(f"MV: {src_path} -> {final_dest}", 1)
819
851
  self._mpy.rename(src_path, final_dest)
820
852
 
821
- def cmd_mkdir(self, *dir_names):
822
- for dir_name in dir_names:
823
- self.verbose(f"MKDIR: {dir_name}", 1)
824
- self._mpy.mkdir(dir_name)
825
-
826
- def cmd_delete(self, *file_names):
853
+ def cmd_rm(self, *file_names):
854
+ """Delete files/directories on device"""
827
855
  for file_name in file_names:
828
- contents_only = file_name.endswith('/')
829
- path = file_name.rstrip('/') or '/'
856
+ raw_path = _parse_device_path(file_name, 'rm')
857
+ contents_only = raw_path.endswith('/') or raw_path == ''
858
+ # ':' = CWD contents, ':/' = root, ':/path' = path, ':path/' = path contents
859
+ if raw_path == '':
860
+ path = '' # CWD
861
+ elif raw_path == '/':
862
+ path = '/' # root
863
+ else:
864
+ path = raw_path.rstrip('/') if contents_only else raw_path
830
865
  if contents_only:
831
- self.verbose(f"DELETE contents: {path}", 1)
866
+ self.verbose(f"RM contents: {path or 'CWD'}", 1)
832
867
  entries = self._mpy.ls(path)
833
868
  for name, size in entries:
834
- entry_path = path + '/' + name if path != '/' else '/' + name
869
+ entry_path = _join_remote_path(path, name)
835
870
  self.verbose(f" {entry_path}", 1)
836
871
  self._mpy.delete(entry_path)
837
872
  else:
838
- self.verbose(f"DELETE: {path}", 1)
873
+ self.verbose(f"RM: {path}", 1)
839
874
  self._mpy.delete(path)
840
875
 
841
876
  def cmd_monitor(self):
@@ -861,12 +896,6 @@ class MpyTool():
861
896
  terminal.run()
862
897
  self._log.info('Exiting..')
863
898
 
864
- def cmd_exec(self, code):
865
- self.verbose(f"EXEC: {code}", 1)
866
- result = self._mpy.comm.exec(code)
867
- if result:
868
- print(result.decode('utf-8', 'backslashreplace'), end='')
869
-
870
899
  @staticmethod
871
900
  def format_size(size):
872
901
  """Format size in bytes to human readable format (like ls -h)"""
@@ -882,28 +911,6 @@ class MpyTool():
882
911
  return f"{size:.0f}{unit}"
883
912
  return f"{size:.0f}T"
884
913
 
885
- def cmd_paths(self, dir_name='.'):
886
- """Print all paths (for shell completion) - undocumented"""
887
- tree = self._mpy.tree(dir_name)
888
- self._print_paths(tree, '')
889
-
890
- def _print_paths(self, entry, prefix):
891
- """Recursively print paths from tree structure"""
892
- name, size, children = entry
893
- if name in ('.', './'):
894
- path = ''
895
- else:
896
- path = prefix + name
897
- if children is None:
898
- # File
899
- print(path)
900
- else:
901
- # Directory
902
- if path:
903
- print(path + '/')
904
- for child in children:
905
- self._print_paths(child, path + '/' if path else '')
906
-
907
914
  def cmd_ota(self, firmware_path):
908
915
  """OTA firmware update from local .app-bin file"""
909
916
  self.verbose("OTA UPDATE", 1)
@@ -1095,213 +1102,272 @@ class MpyTool():
1095
1102
  fs_pct = (fs['used'] / fs['total'] * 100) if fs['total'] > 0 else 0
1096
1103
  print(f"{label:12} {self.format_size(fs['used'])} / {self.format_size(fs['total'])} ({fs_pct:.2f}%)")
1097
1104
 
1098
- def cmd_mreset(self, reconnect=True):
1099
- """MCU reset using machine.reset() with optional auto-reconnect"""
1100
- self.verbose("MRESET", 1)
1101
- if reconnect:
1105
+ def cmd_reset(self, mode='soft', reconnect=True, timeout=None):
1106
+ """Reset device in specified mode
1107
+
1108
+ Modes:
1109
+ soft - Ctrl-D soft reset, runs boot.py/main.py (default)
1110
+ raw - soft reset in raw REPL, clears RAM only
1111
+ machine - machine.reset() with optional reconnect
1112
+ rts - hardware reset via DTR/RTS with optional reconnect
1113
+ boot - enter bootloader via machine.bootloader()
1114
+ dtr-boot - enter bootloader via DTR/RTS signals (ESP32)
1115
+ """
1116
+ self.verbose(f"RESET {mode}", 1)
1117
+ if mode == 'soft':
1118
+ self._mpy.soft_reset()
1119
+ elif mode == 'raw':
1120
+ self._mpy.soft_reset_raw()
1121
+ elif mode == 'machine':
1122
+ if reconnect:
1123
+ try:
1124
+ self.verbose(" reconnecting...", 1, color='yellow')
1125
+ self._mpy.machine_reset(
1126
+ reconnect=True, timeout=timeout)
1127
+ self.verbose(" connected", 1, color='green')
1128
+ except (_mpytool.ConnError, OSError) as err:
1129
+ self.verbose(
1130
+ f" reconnect failed: {err}", 1, color='red')
1131
+ raise _mpytool.ConnError(
1132
+ f"Reconnect failed: {err}")
1133
+ else:
1134
+ self._mpy.machine_reset(reconnect=False)
1135
+ elif mode == 'rts':
1102
1136
  try:
1103
- self.verbose(" reconnecting...", 1, color='yellow')
1104
- self._mpy.machine_reset(reconnect=True)
1105
- self.verbose(" connected", 1, color='green')
1137
+ self._mpy.hard_reset()
1138
+ if reconnect:
1139
+ self.verbose(
1140
+ " reconnecting...", 1, color='yellow')
1141
+ _time.sleep(1.0) # Wait for device to boot
1142
+ self._mpy._conn.reconnect()
1143
+ self.verbose(" connected", 1, color='green')
1144
+ except NotImplementedError:
1145
+ raise _mpytool.MpyError(
1146
+ "Hardware reset not available (serial only)")
1106
1147
  except (_mpytool.ConnError, OSError) as err:
1107
- self.verbose(f" reconnect failed: {err}", 1, color='red')
1108
- raise _mpytool.ConnError(f"Reconnect failed: {err}")
1148
+ self.verbose(
1149
+ f" reconnect failed: {err}", 1, color='red')
1150
+ raise _mpytool.ConnError(
1151
+ f"Reconnect failed: {err}")
1152
+ elif mode == 'boot':
1153
+ self._mpy.machine_bootloader()
1154
+ elif mode == 'dtr-boot':
1155
+ try:
1156
+ self._mpy.reset_to_bootloader()
1157
+ except NotImplementedError:
1158
+ raise _mpytool.MpyError(
1159
+ "DTR boot not available (serial only)")
1160
+
1161
+ # -- dispatch methods for process_commands() --
1162
+
1163
+ def _dispatch_ls(self, commands, is_last_group):
1164
+ dir_name = ':'
1165
+ if commands:
1166
+ dir_name = commands.pop(0)
1167
+ # Strip trailing / except for root
1168
+ if dir_name not in (':', ':/'):
1169
+ dir_name = dir_name.rstrip('/')
1170
+ path = _parse_device_path(dir_name, 'ls')
1171
+ result = self._mpy.ls(path)
1172
+ for name, size in result:
1173
+ if size is not None:
1174
+ print(f'{self.format_size(size):>9} {name}')
1175
+ else:
1176
+ print(f'{"":9} {name}/')
1177
+
1178
+ def _dispatch_tree(self, commands, is_last_group):
1179
+ dir_name = ':'
1180
+ if commands:
1181
+ dir_name = commands.pop(0)
1182
+ if dir_name not in (':', ':/'):
1183
+ dir_name = dir_name.rstrip('/')
1184
+ path = _parse_device_path(dir_name, 'tree')
1185
+ tree = self._mpy.tree(path)
1186
+ self.print_tree(tree)
1187
+
1188
+ def _dispatch_cat(self, commands, is_last_group):
1189
+ if not commands:
1190
+ raise ParamsError('missing file name for cat command')
1191
+ for file_name in commands:
1192
+ path = _parse_device_path(file_name, 'cat')
1193
+ self.verbose(f"CAT: {path}", 2)
1194
+ data = self._mpy.get(path)
1195
+ print(data.decode('utf-8'))
1196
+ commands.clear()
1197
+
1198
+ def _dispatch_mkdir(self, commands, is_last_group):
1199
+ if not commands:
1200
+ raise ParamsError('missing directory name for mkdir command')
1201
+ for dir_name in commands:
1202
+ path = _parse_device_path(dir_name, 'mkdir')
1203
+ self.verbose(f"MKDIR: {path}", 1)
1204
+ self._mpy.mkdir(path)
1205
+ commands.clear()
1206
+
1207
+ def _dispatch_rm(self, commands, is_last_group):
1208
+ if not commands:
1209
+ raise ParamsError('missing file name for rm command')
1210
+ self.cmd_rm(*commands)
1211
+ commands.clear()
1212
+
1213
+ def _dispatch_pwd(self, commands, is_last_group):
1214
+ cwd = self._mpy.getcwd()
1215
+ print(cwd)
1216
+
1217
+ def _dispatch_cd(self, commands, is_last_group):
1218
+ if not commands:
1219
+ raise ParamsError('missing directory for cd command')
1220
+ dir_name = commands.pop(0)
1221
+ path = _parse_device_path(dir_name, 'cd')
1222
+ self.verbose(f"CD: {path}", 2)
1223
+ self._mpy.chdir(path)
1224
+
1225
+ def _dispatch_reset(self, commands, is_last_group):
1226
+ mode = 'soft'
1227
+ timeout = None
1228
+ _reset_modes = (
1229
+ '--machine', '--rts', '--raw',
1230
+ '--boot', '--dtr-boot')
1231
+ while commands and commands[0].startswith('-'):
1232
+ flag = commands[0]
1233
+ if flag in _reset_modes:
1234
+ mode = commands.pop(0)[2:] # strip --
1235
+ elif flag in ('-t', '--timeout'):
1236
+ commands.pop(0)
1237
+ if commands:
1238
+ timeout = int(commands.pop(0))
1239
+ else:
1240
+ raise ParamsError(
1241
+ 'missing value for --timeout')
1242
+ else:
1243
+ raise ParamsError(
1244
+ f"unknown reset flag: '{flag}'")
1245
+ if timeout and mode not in ('machine', 'rts'):
1246
+ raise ParamsError(
1247
+ '--timeout only with --machine or --rts')
1248
+ has_more = bool(commands) or not is_last_group
1249
+ reconnect = has_more if mode in (
1250
+ 'machine', 'rts') else True
1251
+ self.cmd_reset(
1252
+ mode=mode, reconnect=reconnect,
1253
+ timeout=timeout)
1254
+
1255
+ def _dispatch_monitor(self, commands, is_last_group):
1256
+ self.cmd_monitor()
1257
+ commands.clear()
1258
+
1259
+ def _dispatch_repl(self, commands, is_last_group):
1260
+ self.cmd_repl()
1261
+ commands.clear()
1262
+
1263
+ def _dispatch_exec(self, commands, is_last_group):
1264
+ if not commands:
1265
+ raise ParamsError('missing code for exec command')
1266
+ code = commands.pop(0)
1267
+ self.verbose(f"EXEC: {code}", 1)
1268
+ result = self._mpy.comm.exec(code)
1269
+ if result:
1270
+ print(result.decode('utf-8', 'backslashreplace'), end='')
1271
+
1272
+ def _dispatch_info(self, commands, is_last_group):
1273
+ self.cmd_info()
1274
+
1275
+ def _dispatch_flash(self, commands, is_last_group):
1276
+ if commands and commands[0] == 'read':
1277
+ commands.pop(0)
1278
+ if len(commands) >= 2:
1279
+ # ESP32: flash read <label> <file>
1280
+ label = commands.pop(0)
1281
+ dest_path = commands.pop(0)
1282
+ self.cmd_flash_read(dest_path, label=label)
1283
+ elif len(commands) == 1:
1284
+ # RP2: flash read <file>
1285
+ dest_path = commands.pop(0)
1286
+ self.cmd_flash_read(dest_path)
1287
+ else:
1288
+ raise ParamsError('flash read requires destination file')
1289
+ elif commands and commands[0] == 'write':
1290
+ commands.pop(0)
1291
+ if len(commands) >= 2:
1292
+ # ESP32: flash write <label> <file>
1293
+ label = commands.pop(0)
1294
+ src_path = commands.pop(0)
1295
+ self.cmd_flash_write(src_path, label=label)
1296
+ elif len(commands) == 1:
1297
+ # RP2: flash write <file>
1298
+ src_path = commands.pop(0)
1299
+ self.cmd_flash_write(src_path)
1300
+ else:
1301
+ raise ParamsError('flash write requires source file')
1302
+ elif commands and commands[0] == 'erase':
1303
+ commands.pop(0)
1304
+ full = False
1305
+ label = None
1306
+ while commands and (commands[0] == '--full' or not commands[0].startswith('-')):
1307
+ if commands[0] == '--full':
1308
+ full = True
1309
+ commands.pop(0)
1310
+ elif label is None:
1311
+ label = commands.pop(0)
1312
+ else:
1313
+ break
1314
+ self.cmd_flash_erase(label=label, full=full)
1109
1315
  else:
1110
- self._mpy.machine_reset(reconnect=False)
1316
+ self.cmd_flash()
1111
1317
 
1112
- def cmd_sreset(self):
1113
- """Soft reset in raw REPL - clears RAM but doesn't run boot.py/main.py"""
1114
- self.verbose("SRESET", 1)
1115
- self._mpy.soft_reset_raw()
1318
+ def _dispatch_ota(self, commands, is_last_group):
1319
+ if not commands:
1320
+ raise ParamsError('ota requires firmware file path')
1321
+ self.cmd_ota(commands.pop(0))
1116
1322
 
1117
- def cmd_rtsreset(self, reconnect=True):
1118
- """Hardware reset device using RTS signal with optional auto-reconnect"""
1119
- self.verbose("RTSRESET", 1)
1323
+ def _dispatch_sleep(self, commands, is_last_group):
1324
+ if not commands:
1325
+ raise ParamsError('sleep requires a number (seconds)')
1120
1326
  try:
1121
- self._mpy.hard_reset()
1122
- if reconnect:
1123
- self.verbose(" reconnecting...", 1, color='yellow')
1124
- _time.sleep(1.0) # Wait for device to boot
1125
- self._mpy._conn.reconnect()
1126
- self.verbose(" connected", 1, color='green')
1127
- except NotImplementedError:
1128
- raise _mpytool.MpyError("Hardware reset not available (serial only)")
1129
- except (_mpytool.ConnError, OSError) as err:
1130
- self.verbose(f" reconnect failed: {err}", 1, color='red')
1131
- raise _mpytool.ConnError(f"Reconnect failed: {err}")
1327
+ seconds = float(commands.pop(0))
1328
+ except ValueError:
1329
+ raise ParamsError('sleep requires a number (seconds)')
1330
+ self.verbose(f"SLEEP {seconds}s", 1)
1331
+ _time.sleep(seconds)
1132
1332
 
1133
- def cmd_bootloader(self):
1134
- """Enter bootloader using machine.bootloader()"""
1135
- self.verbose("BOOTLOADER", 1)
1136
- self._mpy.machine_bootloader()
1333
+ def _dispatch_cp(self, commands, is_last_group):
1334
+ if len(commands) < 2:
1335
+ raise ParamsError('cp requires source and destination')
1336
+ self.cmd_cp(*commands)
1337
+ commands.clear()
1137
1338
 
1138
- def cmd_dtrboot(self):
1139
- """Enter bootloader using DTR/RTS signals (ESP32 only)
1339
+ def _dispatch_mv(self, commands, is_last_group):
1340
+ if len(commands) < 2:
1341
+ raise ParamsError('mv requires source and destination')
1342
+ self.cmd_mv(*commands)
1343
+ commands.clear()
1140
1344
 
1141
- Auto-detects port type:
1142
- - USB-UART: classic DTR/RTS sequence (GPIO0 directly connected)
1143
- - USB-CDC: USB-JTAG-Serial sequence (ESP32-S/C native USB)
1144
- """
1145
- self.verbose("DTRBOOT", 1)
1345
+ def _dispatch_paths(self, commands, is_last_group):
1346
+ # For shell completion
1347
+ dir_name = commands.pop(0) if commands else ':'
1348
+ path = _parse_device_path(dir_name, '_paths')
1146
1349
  try:
1147
- self._mpy.reset_to_bootloader()
1148
- except NotImplementedError:
1149
- raise _mpytool.MpyError("DTR boot not available (serial only)")
1350
+ entries = self._mpy.ls(path)
1351
+ except (_mpytool.DirNotFound, _mpytool.MpyError):
1352
+ return
1353
+ for name, size in entries:
1354
+ print(name + '/' if size is None else name)
1150
1355
 
1151
- def cmd_sleep(self, seconds):
1152
- """Sleep for specified number of seconds"""
1153
- self.verbose(f"SLEEP {seconds}s", 1)
1154
- _time.sleep(seconds)
1356
+ # -- command dispatching --
1357
+
1358
+ _COMMANDS = frozenset({
1359
+ 'ls', 'tree', 'cat', 'mkdir', 'rm', 'pwd', 'cd',
1360
+ 'reset', 'monitor', 'repl', 'exec', 'info', 'flash',
1361
+ 'ota', 'sleep', 'cp', 'mv', '_paths',
1362
+ })
1155
1363
 
1156
1364
  def process_commands(self, commands, is_last_group=False):
1157
- try:
1158
- while commands:
1159
- command = commands.pop(0)
1160
- if command in ('ls', 'dir'):
1161
- if commands:
1162
- dir_name = commands.pop(0)
1163
- if dir_name != '/':
1164
- dir_name = dir_name.rstrip('/')
1165
- self.cmd_ls(dir_name)
1166
- continue
1167
- self.cmd_ls('.')
1168
- elif command == 'tree':
1169
- if commands:
1170
- dir_name = commands.pop(0)
1171
- if dir_name != '/':
1172
- dir_name = dir_name.rstrip('/')
1173
- self.cmd_tree(dir_name)
1174
- continue
1175
- self.cmd_tree('.')
1176
- elif command in ('get', 'cat'):
1177
- if commands:
1178
- self.cmd_get(*commands)
1179
- break
1180
- raise ParamsError('missing file name for get command')
1181
- elif command == 'put':
1182
- if commands:
1183
- src_path = commands.pop(0)
1184
- dst_path = ''
1185
- if commands:
1186
- dst_path = commands.pop(0)
1187
- self.cmd_put(src_path, dst_path)
1188
- else:
1189
- raise ParamsError('missing file name for put command')
1190
- elif command == 'mkdir':
1191
- self.cmd_mkdir(*commands)
1192
- break
1193
- elif command in ('del', 'delete', 'rm'):
1194
- self.cmd_delete(*commands)
1195
- break
1196
- elif command == 'reset':
1197
- self.verbose("RESET", 1)
1198
- self._mpy.soft_reset()
1199
- elif command == 'mreset':
1200
- # Reconnect only if there are more commands (in this or next group)
1201
- has_more = bool(commands) or not is_last_group
1202
- self.cmd_mreset(reconnect=has_more)
1203
- elif command == 'sreset':
1204
- self.cmd_sreset()
1205
- elif command in ('monitor', 'follow'):
1206
- self.cmd_monitor()
1207
- break
1208
- elif command == 'repl':
1209
- self.cmd_repl()
1210
- break
1211
- elif command == 'exec':
1212
- if commands:
1213
- code = commands.pop(0)
1214
- self.cmd_exec(code)
1215
- else:
1216
- raise ParamsError('missing code for exec command')
1217
- elif command == 'info':
1218
- self.cmd_info()
1219
- elif command == 'flash':
1220
- if commands and commands[0] == 'read':
1221
- commands.pop(0)
1222
- if len(commands) >= 2:
1223
- # ESP32: flash read <label> <file>
1224
- label = commands.pop(0)
1225
- dest_path = commands.pop(0)
1226
- self.cmd_flash_read(dest_path, label=label)
1227
- elif len(commands) == 1:
1228
- # RP2: flash read <file>
1229
- dest_path = commands.pop(0)
1230
- self.cmd_flash_read(dest_path)
1231
- else:
1232
- raise ParamsError('flash read requires destination file')
1233
- elif commands and commands[0] == 'write':
1234
- commands.pop(0)
1235
- if len(commands) >= 2:
1236
- # ESP32: flash write <label> <file>
1237
- label = commands.pop(0)
1238
- src_path = commands.pop(0)
1239
- self.cmd_flash_write(src_path, label=label)
1240
- elif len(commands) == 1:
1241
- # RP2: flash write <file>
1242
- src_path = commands.pop(0)
1243
- self.cmd_flash_write(src_path)
1244
- else:
1245
- raise ParamsError('flash write requires source file')
1246
- elif commands and commands[0] == 'erase':
1247
- commands.pop(0)
1248
- # Check for --full flag and optional label
1249
- full = False
1250
- label = None
1251
- while commands and (commands[0] == '--full' or not commands[0].startswith('-')):
1252
- if commands[0] == '--full':
1253
- full = True
1254
- commands.pop(0)
1255
- elif label is None:
1256
- label = commands.pop(0)
1257
- else:
1258
- break
1259
- self.cmd_flash_erase(label=label, full=full)
1260
- else:
1261
- self.cmd_flash()
1262
- elif command == 'ota':
1263
- if commands:
1264
- firmware_path = commands.pop(0)
1265
- self.cmd_ota(firmware_path)
1266
- else:
1267
- raise ParamsError('ota requires firmware file path')
1268
- elif command == 'rtsreset':
1269
- # Reconnect only if there are more commands (in this or next group)
1270
- has_more = bool(commands) or not is_last_group
1271
- self.cmd_rtsreset(reconnect=has_more)
1272
- elif command == 'bootloader':
1273
- self.cmd_bootloader()
1274
- elif command == 'dtrboot':
1275
- self.cmd_dtrboot()
1276
- elif command == 'sleep':
1277
- if commands:
1278
- try:
1279
- seconds = float(commands.pop(0))
1280
- except ValueError:
1281
- raise ParamsError('sleep requires a number (seconds)')
1282
- self.cmd_sleep(seconds)
1283
- else:
1284
- raise ParamsError('sleep requires a number (seconds)')
1285
- elif command == 'cp':
1286
- if len(commands) >= 2:
1287
- self.cmd_cp(*commands)
1288
- break
1289
- raise ParamsError('cp requires source and destination')
1290
- elif command == 'mv':
1291
- if len(commands) >= 2:
1292
- self.cmd_mv(*commands)
1293
- break
1294
- raise ParamsError('mv requires source and destination')
1295
- elif command == '_paths':
1296
- # Undocumented: for shell completion
1297
- if commands:
1298
- self.cmd_paths(commands.pop(0))
1299
- else:
1300
- self.cmd_paths()
1301
- else:
1302
- raise ParamsError(f"unknown command: '{command}'")
1303
- except (_mpytool.MpyError, _mpytool.ConnError) as err:
1304
- self._log.error(err)
1365
+ while commands:
1366
+ command = commands.pop(0)
1367
+ if command not in self._COMMANDS:
1368
+ raise ParamsError(f"unknown command: '{command}'")
1369
+ dispatch = getattr(self, f'_dispatch_{command.lstrip("_")}')
1370
+ dispatch(commands, is_last_group)
1305
1371
  try:
1306
1372
  self._mpy.comm.exit_raw_repl()
1307
1373
  except _mpytool.ConnError:
@@ -1313,38 +1379,34 @@ if _about:
1313
1379
  else:
1314
1380
  _VERSION_STR = "mpytool (not installed version)"
1315
1381
  _COMMANDS_HELP_STR = """
1316
- List of available commands:
1317
- ls [{path}] list files and its sizes
1318
- tree [{path}] list tree of structure and sizes
1319
- cp [-f] {src} [...] {dst} copy files (: prefix = device path, -f = force)
1320
- mv {src} [...] {dst} move/rename on device (: prefix required)
1321
- get {path} [...] get file and print it
1322
- put {src_path} [{dst_path}] put file or directory to destination
1323
- mkdir {path} [...] create directory (also create all parents)
1324
- delete {path} [...] remove file/dir (path/ = contents only)
1325
- reset soft reset (Ctrl-D, runs boot.py/main.py)
1326
- sreset soft reset in raw REPL (clears RAM only)
1327
- mreset MCU reset (machine.reset, auto-reconnect)
1328
- rtsreset RTS reset (hardware reset via RTS signal)
1329
- bootloader enter bootloader (machine.bootloader)
1330
- dtrboot enter bootloader via DTR/RTS (ESP32 only)
1331
- monitor print output of running program
1332
- repl enter REPL mode [UNIX OS ONLY]
1333
- exec {code} execute Python code on device
1382
+ Commands (: prefix = device path, :/ = root, : = CWD):
1383
+ ls [:path] list files and sizes (default: CWD)
1384
+ tree [:path] list directory tree (default: CWD)
1385
+ cat {:path} [...] print file content to stdout
1386
+ cp [-f] {src} [...] {dst} copy files (-f = force overwrite)
1387
+ mv {:src} [...] {:dst} move/rename on device
1388
+ mkdir {:path} [...] create directory (with parents)
1389
+ rm {:path} [...] delete file/dir (:path/ = contents only)
1390
+ pwd print current working directory
1391
+ cd {:path} change current working directory
1392
+ reset [flags] soft reset (Ctrl-D) by default
1393
+ --machine [-t {s}] machine.reset() with reconnect
1394
+ --rts [-t {s}] hardware reset via DTR/RTS signal
1395
+ --raw soft reset in raw REPL
1396
+ --boot enter bootloader (machine.bootloader)
1397
+ --dtr-boot bootloader via DTR/RTS (ESP32)
1398
+ monitor print device output (Ctrl+C to stop)
1399
+ repl interactive REPL [Unix only]
1400
+ exec {code} execute Python code
1334
1401
  info show device information
1335
- flash show flash info (RP2) or partitions (ESP32)
1402
+ flash show flash/partitions info
1336
1403
  flash read [{label}] {file} read flash/partition to file
1337
1404
  flash write [{label}] {file} write file to flash/partition
1338
1405
  flash erase [{label}] [--full] erase flash/partition
1339
- ota {firmware.app-bin} OTA firmware update (ESP32 only)
1340
- sleep {seconds} sleep for specified seconds
1341
- Aliases:
1342
- dir alias to ls
1343
- cat alias to get
1344
- del, rm alias to delete
1345
- follow alias to monitor
1346
- Use -- to separate multiple commands:
1347
- mpytool put main.py / -- reset -- monitor
1406
+ ota {firmware.app-bin} OTA update (ESP32)
1407
+ sleep {seconds} pause between commands
1408
+ Use -- to chain commands:
1409
+ mpytool cp main.py : -- reset -- monitor
1348
1410
  """
1349
1411
 
1350
1412
 
@@ -1488,11 +1550,11 @@ def main():
1488
1550
  command_groups = _utils.split_commands(args.commands)
1489
1551
  try:
1490
1552
  _run_commands(mpy_tool, command_groups, with_progress=(args.verbose >= 1))
1553
+ except (_mpytool.MpyError, _mpytool.ConnError, _mpytool.Timeout) as err:
1554
+ log.error(err)
1491
1555
  except KeyboardInterrupt:
1492
1556
  # Clear partial progress line and show clean message
1493
1557
  log.verbose('Interrupted', level=0, overwrite=True)
1494
- except (_mpytool.MpyError, _mpytool.ConnError, _mpytool.Timeout) as err:
1495
- log.error(err)
1496
1558
 
1497
1559
 
1498
1560
  if __name__ == '__main__':
mpytool/utils.py CHANGED
@@ -15,12 +15,11 @@ def parse_remote_path(path: str) -> str:
15
15
  path: path with : prefix
16
16
 
17
17
  Returns:
18
- path without : prefix, or '/' if path is just ':'
18
+ path without : prefix (empty string for ':' means CWD)
19
19
  """
20
20
  if not is_remote_path(path):
21
21
  raise ValueError(f"Not a remote path: {path}")
22
- result = path[1:]
23
- return result if result else "/"
22
+ return path[1:]
24
23
 
25
24
 
26
25
  def split_commands(args: list[str], separator: str = "--") -> list[list[str]]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpytool
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: MPY tool - manage files on devices running MicroPython
5
5
  Author-email: Pavel Revak <pavel.revak@gmail.com>
6
6
  License-Expression: MIT
@@ -83,27 +83,29 @@ $ mpytool --help
83
83
 
84
84
  list files:
85
85
  ```
86
- $ mpytool -p /dev/ttyACM0 ls
87
- $ mpytool -p /dev/ttyACM0 ls lib
86
+ $ mpytool -p /dev/ttyACM0 ls # list CWD (default)
87
+ $ mpytool -p /dev/ttyACM0 ls :/lib # list /lib
88
88
  ```
89
89
 
90
- tree files:
90
+ tree:
91
91
  ```
92
- $ mpytool -p /dev/ttyACM0 tree
92
+ $ mpytool -p /dev/ttyACM0 tree # tree of CWD (default)
93
93
  ```
94
94
 
95
95
  copy files (: prefix = device path):
96
96
  ```
97
97
  $ mpytool cp main.py :/ # upload file to device root
98
98
  $ mpytool cp main.py lib.py :/lib/ # upload multiple files to directory
99
- $ mpytool cp myapp/ :/ # upload directory (creates /myapp/)
100
- $ mpytool cp myapp/ :/lib/ # upload directory into /lib/
99
+ $ mpytool cp myapp :/ # upload directory (creates :/myapp/)
100
+ $ mpytool cp myapp :/lib/ # upload directory into :/lib/ (creates :/lib/myapp/)
101
+ $ mpytool cp myapp/ :/lib/ # upload directory contents into :/lib/
101
102
  $ mpytool cp :/main.py ./ # download file to current directory
102
103
  $ mpytool cp :/ ./backup/ # download entire device to backup/
103
104
  $ mpytool cp :/old.py :/new.py # copy file on device
104
105
  $ mpytool cp -f main.py :/ # force upload even if unchanged
105
106
  ```
106
107
 
108
+ Path semantics: `:` = device CWD, `:/` = device root. Trailing `/` on source = copy contents only.
107
109
  Unchanged files are automatically skipped (compares size and SHA256 hash).
108
110
  Use `-f` or `--force` to upload all files regardless.
109
111
 
@@ -124,29 +126,43 @@ $ mpytool mv :/file.py :/lib/ # move file to directory
124
126
  $ mpytool mv :/a.py :/b.py :/lib/ # move multiple files to directory
125
127
  ```
126
128
 
127
- legacy upload/download (still available):
129
+ view file contents:
128
130
  ```
129
- $ mpytool put boot.py /
130
- $ mpytool get boot.py >> boot.py
131
+ $ mpytool cat :boot.py # print file from CWD
132
+ $ mpytool cat :/lib/module.py # print file with absolute path
131
133
  ```
132
134
 
133
- make directory, delete files:
135
+ make directory, delete files (: prefix = device path):
134
136
  ```
135
- $ mpytool mkdir a/b/c/d xyz/abc # create directories
136
- $ mpytool rm mydir # delete directory and contents
137
- $ mpytool rm mydir/ # delete contents only, keep directory
138
- $ mpytool rm / # delete everything on device
137
+ $ mpytool mkdir :lib :data # create directories in CWD
138
+ $ mpytool mkdir :/lib/subdir # create with absolute path
139
+ $ mpytool rm :old.py # delete file in CWD
140
+ $ mpytool rm :mydir # delete directory and contents
141
+ $ mpytool rm :mydir/ # delete contents only, keep directory
142
+ $ mpytool rm : # delete everything in CWD
143
+ $ mpytool rm :/ # delete everything on device (root)
144
+ ```
145
+
146
+ current working directory:
147
+ ```
148
+ $ mpytool pwd # print current directory
149
+ /
150
+ $ mpytool cd :/lib # change to /lib
151
+ $ mpytool cd :subdir # change to relative path (from CWD)
152
+ $ mpytool cd :.. # change to parent directory
153
+ $ mpytool cd :/lib -- ls # change directory and list files
139
154
  ```
140
155
 
141
156
  reset and REPL:
142
157
  ```
143
158
  $ mpytool reset # soft reset (Ctrl-D, runs boot.py/main.py)
144
- $ mpytool sreset # soft reset in raw REPL (clears RAM only)
145
- $ mpytool mreset # MCU reset (machine.reset, auto-reconnect)
146
- $ mpytool rtsreset # hardware reset via RTS signal (serial only)
147
- $ mpytool bootloader # enter bootloader (machine.bootloader)
148
- $ mpytool dtrboot # enter bootloader via DTR/RTS (ESP32 only)
149
- $ mpytool reset monitor # reset and monitor output
159
+ $ mpytool reset --raw # soft reset in raw REPL (clears RAM only)
160
+ $ mpytool reset --machine # MCU reset (machine.reset, auto-reconnect)
161
+ $ mpytool reset --machine -t 30 # MCU reset with 30s reconnect timeout
162
+ $ mpytool reset --rts # hardware reset via RTS signal (serial only)
163
+ $ mpytool reset --boot # enter bootloader (machine.bootloader)
164
+ $ mpytool reset --dtr-boot # enter bootloader via DTR/RTS (ESP32 only)
165
+ $ mpytool reset -- monitor # reset and monitor output
150
166
  $ mpytool repl # enter REPL mode
151
167
  $ mpytool sleep 2 # sleep for 2 seconds (useful between commands)
152
168
  ```
@@ -214,13 +230,14 @@ $ mpytool flash erase vfs --full # full erase partition
214
230
  OTA firmware update (ESP32):
215
231
  ```
216
232
  $ mpytool ota firmware.app-bin # flash to next OTA partition
217
- $ mpytool ota firmware.app-bin -- mreset # flash and reboot
233
+ $ mpytool ota firmware.app-bin -- reset --machine # flash and reboot
234
+ $ mpytool ota firmware.app-bin -- reset --machine -t 30 # flash and reboot with 30s timeout
218
235
  ```
219
236
 
220
237
  multiple commands separated by `--`:
221
238
  ```
222
- $ mpytool cp main.py boot.py :/ -- reset -- monitor
223
- $ mpytool delete old.py -- cp new.py :/ -- reset
239
+ $ mpytool cp main.py boot.py : -- reset -- monitor
240
+ $ mpytool rm :old.py -- cp new.py : -- reset
224
241
  ```
225
242
 
226
243
  auto-detect serial port (if only one device is connected):
@@ -265,12 +282,6 @@ show version:
265
282
  $ mpytool -V
266
283
  ```
267
284
 
268
- Command aliases:
269
- - `dir` = `ls`
270
- - `cat` = `get`
271
- - `del`, `rm` = `delete`
272
- - `follow` = `monitor`
273
-
274
285
  ## Python API
275
286
 
276
287
  ```python
@@ -0,0 +1,16 @@
1
+ mpytool/__init__.py,sha256=4BRbfLRLpReWcDDbUA1A2UO1B8sNa6O5lgXRObbnP20,266
2
+ mpytool/conn.py,sha256=3WvSyeHY2qv5YT9qEEPqhSGj68wIJOXqz6G2bPCL_OM,4331
3
+ mpytool/conn_serial.py,sha256=AME-ry8dpBtkrkL5gp-aVxaO2Ddq5S6CmoLC5aIC7zI,4430
4
+ mpytool/conn_socket.py,sha256=EZh6KGMwPgNoNgl0wK4l47N9KVtaSLd3DeCTIiZ4bRw,1531
5
+ mpytool/logger.py,sha256=e9xaDf52nsMXU4T4Kv_pogzGq5hzP-643RxOMEDZjC0,2727
6
+ mpytool/mpy.py,sha256=aaHG0bg04pYKq-VU84VYR9zCSAf_TKSDXCsdhHFRKew,46360
7
+ mpytool/mpy_comm.py,sha256=QFHCIjdFGfDSLTVMdhAmIgz8J3QQ9VbNqzQGiaOfHKs,9149
8
+ mpytool/mpytool.py,sha256=ZN8YG0lKMYQZARhQHXMdfi6Z9iZJqw8Yfikv89PMLDM,65824
9
+ mpytool/terminal.py,sha256=YEWWwaGHISDpPWRileMPeh2drudEjF0lmjorXbrIWDY,2305
10
+ mpytool/utils.py,sha256=Y6DNKgXYDwnVDLOVs671XjyL7HqnsW6FMHIgCJ5MgZM,1972
11
+ mpytool-2.2.0.dist-info/licenses/LICENSE,sha256=wfMg5yKH2O86Digcowcm2g8Wh4zAo_gX-VqQXjmpf74,1078
12
+ mpytool-2.2.0.dist-info/METADATA,sha256=GXhslqFH26oFKW3TT1ksZWbWImIg-BOm-wzAfsRTrW8,14902
13
+ mpytool-2.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
+ mpytool-2.2.0.dist-info/entry_points.txt,sha256=wmnyPWEKLEbPJFR98Cc2Ky0krbMxA2BcVsS5KCTGP8Q,49
15
+ mpytool-2.2.0.dist-info/top_level.txt,sha256=4ZornVQbsgLIpB6d_5V1tOzX5bJvHxxDOuk8qFRjP2c,8
16
+ mpytool-2.2.0.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- mpytool/__init__.py,sha256=4BRbfLRLpReWcDDbUA1A2UO1B8sNa6O5lgXRObbnP20,266
2
- mpytool/conn.py,sha256=3WvSyeHY2qv5YT9qEEPqhSGj68wIJOXqz6G2bPCL_OM,4331
3
- mpytool/conn_serial.py,sha256=dcNQ9-utloZC6AyGaj0_BkEphpJOiOzudz2OQErFG8c,4013
4
- mpytool/conn_socket.py,sha256=EZh6KGMwPgNoNgl0wK4l47N9KVtaSLd3DeCTIiZ4bRw,1531
5
- mpytool/logger.py,sha256=e9xaDf52nsMXU4T4Kv_pogzGq5hzP-643RxOMEDZjC0,2727
6
- mpytool/mpy.py,sha256=NjQrjRh15tgAvJP--ZFrirjkS3PF4aQ-NvHi-RrHkQc,45625
7
- mpytool/mpy_comm.py,sha256=QFHCIjdFGfDSLTVMdhAmIgz8J3QQ9VbNqzQGiaOfHKs,9149
8
- mpytool/mpytool.py,sha256=9sRAqFwWbzbm-znaDmlsJzj8Dqg7IrQcBf_FPGVIaLA,64574
9
- mpytool/terminal.py,sha256=YEWWwaGHISDpPWRileMPeh2drudEjF0lmjorXbrIWDY,2305
10
- mpytool/utils.py,sha256=KbL3nDicLUT-qwP2jDl8ZCKOM1PPptZF_qxFUHYF5V8,2006
11
- mpytool-2.1.0.dist-info/licenses/LICENSE,sha256=wfMg5yKH2O86Digcowcm2g8Wh4zAo_gX-VqQXjmpf74,1078
12
- mpytool-2.1.0.dist-info/METADATA,sha256=4ZXTX1hUnXTnKZHKRLkGWwJwekg0P8rlfh4vIhcUjzw,13948
13
- mpytool-2.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
14
- mpytool-2.1.0.dist-info/entry_points.txt,sha256=wmnyPWEKLEbPJFR98Cc2Ky0krbMxA2BcVsS5KCTGP8Q,49
15
- mpytool-2.1.0.dist-info/top_level.txt,sha256=4ZornVQbsgLIpB6d_5V1tOzX5bJvHxxDOuk8qFRjP2c,8
16
- mpytool-2.1.0.dist-info/RECORD,,