mpytool 2.0.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/mpytool.py CHANGED
@@ -1,14 +1,17 @@
1
1
  """MicroPython tool"""
2
2
 
3
- import os as _os
4
- import sys as _sys
5
3
  import argparse as _argparse
4
+ import fnmatch as _fnmatch
6
5
  import hashlib as _hashlib
6
+ import importlib.metadata as _metadata
7
+ import os as _os
8
+ import sys as _sys
9
+ import time as _time
10
+
7
11
  import mpytool as _mpytool
8
12
  import mpytool.terminal as _terminal
9
13
  import mpytool.utils as _utils
10
14
  from mpytool.logger import SimpleColorLogger
11
- import importlib.metadata as _metadata
12
15
 
13
16
  try:
14
17
  _about = _metadata.metadata("mpytool")
@@ -20,30 +23,79 @@ class ParamsError(_mpytool.MpyError):
20
23
  """Invalid command parameters"""
21
24
 
22
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
+
23
58
  class MpyTool():
24
59
  SPACE = ' '
25
60
  BRANCH = '│ '
26
61
  TEE = '├─ '
27
62
  LAST = '└─ '
28
63
 
29
- def __init__(self, conn, log=None, verbose=None, exclude_dirs=None, force=False):
64
+ def __init__(
65
+ self, conn, log=None, verbose=None, exclude_dirs=None,
66
+ force=False, compress=None, chunk_size=None):
30
67
  self._conn = conn
31
68
  self._log = log if log is not None else SimpleColorLogger()
32
69
  self._verbose_out = verbose # None = no verbose output (API mode)
33
- self._exclude_dirs = {'__pycache__', '.git', '.svn'}
70
+ self._exclude_dirs = {'.*', '*.pyc'}
34
71
  if exclude_dirs:
35
72
  self._exclude_dirs.update(exclude_dirs)
36
- self._mpy = _mpytool.Mpy(conn, log=self._log)
37
- self._force = force # Skip unchanged file check
38
- # Progress tracking
73
+ self._mpy = _mpytool.Mpy(conn, log=self._log, chunk_size=chunk_size)
74
+ self._force = force
75
+ self._compress = compress
39
76
  self._progress_total_files = 0
40
77
  self._progress_current_file = 0
41
78
  self._progress_src = ''
42
79
  self._progress_dst = ''
43
80
  self._progress_max_src_len = 0
81
+ self._progress_max_dst_len = 0
44
82
  self._is_debug = getattr(self._log, '_loglevel', 1) >= 4
45
83
  self._batch_mode = False
46
84
  self._skipped_files = 0
85
+ self._stats_total_bytes = 0
86
+ self._stats_transferred_bytes = 0
87
+ self._stats_wire_bytes = 0 # Actual bytes sent over wire (with encoding)
88
+ self._stats_transferred_files = 0
89
+ self._stats_start_time = None
90
+ # Remote file info cache for batch operations
91
+ self._remote_file_cache = {} # {path: (size, hash) or None}
92
+
93
+ def _is_excluded(self, name):
94
+ """Check if name matches any exclude pattern (supports wildcards)"""
95
+ for pattern in self._exclude_dirs:
96
+ if _fnmatch.fnmatch(name, pattern):
97
+ return True
98
+ return False
47
99
 
48
100
  @property
49
101
  def _is_tty(self):
@@ -61,6 +113,14 @@ class MpyTool():
61
113
  if self._verbose_out is not None:
62
114
  self._verbose_out.verbose(msg, level, color, end, overwrite)
63
115
 
116
+ def print_transfer_info(self):
117
+ """Print transfer settings (chunk size and compression)"""
118
+ chunk = self._mpy._detect_chunk_size()
119
+ chunk_str = f"{chunk // 1024}K" if chunk >= 1024 else str(chunk)
120
+ compress = self._mpy._detect_deflate() if self._compress is None else self._compress
121
+ compress_str = "on" if compress else "off"
122
+ self.verbose(f"COPY (chunk: {chunk_str}, compress: {compress_str})", 1)
123
+
64
124
  @staticmethod
65
125
  def _format_local_path(path):
66
126
  """Format local path: relative from CWD, absolute if > 2 levels up"""
@@ -81,16 +141,40 @@ class MpyTool():
81
141
  # On Windows, relpath fails for different drives
82
142
  return _os.path.abspath(path)
83
143
 
84
- def _format_progress_line(self, percent, total):
85
- """Format progress line: [2/5] 23% 24.1KB source -> dest"""
86
- size_str = self.format_size(total).replace(' ', '')
87
- if self._progress_total_files > 1:
88
- prefix = f"[{self._progress_current_file}/{self._progress_total_files}]"
144
+ # Encoding info strings for alignment: (base64), (compressed), (base64, compressed), (unchanged)
145
+ _ENC_WIDTH = 22 # Length of longest: " (base64, compressed)"
146
+
147
+ def _format_encoding_info(self, encodings, pad=False):
148
+ """Format encoding info: (base64), (compressed), (base64, compressed)"""
149
+ if not encodings or encodings == {'raw'}:
150
+ return " " * self._ENC_WIDTH if pad else ""
151
+ types = sorted(e for e in encodings if e != 'raw')
152
+ if not types:
153
+ return " " * self._ENC_WIDTH if pad else ""
154
+ info = f" ({', '.join(types)})"
155
+ if pad:
156
+ return f"{info:<{self._ENC_WIDTH}}"
157
+ return info
158
+
159
+ def _format_line(self, status, total, encodings=None):
160
+ """Format progress/skip line: [2/5] 100% 24.1K source -> dest (base64)"""
161
+ size_str = self.format_size(total)
162
+ multi = self._progress_total_files > 1
163
+ if multi:
164
+ width = len(str(self._progress_total_files))
165
+ prefix = f"[{self._progress_current_file:>{width}}/{self._progress_total_files}]"
89
166
  else:
90
167
  prefix = ""
91
- # Pad source to align ->
92
- src_width = max(len(self._progress_src), self._progress_max_src_len)
93
- return f"{prefix:>7} {percent:3d}% {size_str:>7} {self._progress_src:<{src_width}} -> {self._progress_dst}"
168
+ src_w = max(len(self._progress_src), self._progress_max_src_len)
169
+ dst_w = max(len(self._progress_dst), self._progress_max_dst_len)
170
+ enc = self._format_encoding_info(encodings, pad=multi) if encodings else (" " * self._ENC_WIDTH if multi else "")
171
+ return f"{prefix:>7} {status} {size_str:>5} {self._progress_src:<{src_w}} -> {self._progress_dst:<{dst_w}}{enc}"
172
+
173
+ def _format_progress_line(self, percent, total, encodings=None):
174
+ return self._format_line(f"{percent:3d}%", total, encodings)
175
+
176
+ def _format_skip_line(self, total):
177
+ return self._format_line("skip", total, {'unchanged'})
94
178
 
95
179
  def _progress_callback(self, transferred, total):
96
180
  """Callback for file transfer progress"""
@@ -103,14 +187,13 @@ class MpyTool():
103
187
  # Normal mode: overwrite line
104
188
  self.verbose(line, color='cyan', end='', overwrite=True)
105
189
 
106
- def _progress_complete(self, total):
190
+ def _progress_complete(self, total, encodings=None):
107
191
  """Mark current file as complete"""
108
- line = self._format_progress_line(100, total)
192
+ line = self._format_progress_line(100, total, encodings)
109
193
  if self._is_debug:
110
194
  # Already printed with newline in callback
111
195
  pass
112
196
  else:
113
- # Print final line with newline
114
197
  self.verbose(line, color='cyan', overwrite=True)
115
198
 
116
199
  def _set_progress_info(self, src, dst, is_src_remote, is_dst_remote):
@@ -120,19 +203,80 @@ class MpyTool():
120
203
  else:
121
204
  self._progress_src = self._format_local_path(src)
122
205
  if is_dst_remote:
123
- self._progress_dst = ':' + dst
206
+ self._progress_dst = ':' + ('/' + dst if not dst.startswith('/') else dst)
124
207
  else:
125
208
  self._progress_dst = self._format_local_path(dst)
209
+ if len(self._progress_dst) > self._progress_max_dst_len:
210
+ self._progress_max_dst_len = len(self._progress_dst)
126
211
 
127
- def _count_local_files(self, path):
128
- """Count files in local directory (excluding excluded dirs)"""
212
+ def _collect_local_paths(self, path):
213
+ """Collect all local file paths (formatted for display)"""
129
214
  if _os.path.isfile(path):
130
- return 1
131
- count = 0
132
- for _, dirs, files in _os.walk(path, topdown=True):
133
- dirs[:] = [d for d in dirs if d not in self._exclude_dirs]
134
- count += len(files)
135
- return count
215
+ return [self._format_local_path(path)]
216
+ paths = []
217
+ for root, dirs, files in _os.walk(path, topdown=True):
218
+ dirs[:] = [d for d in dirs if not self._is_excluded(d)]
219
+ files = [f for f in files if not self._is_excluded(f)]
220
+ paths.extend(self._format_local_path(_os.path.join(root, f)) for f in files)
221
+ return paths
222
+
223
+ def _prefetch_remote_info(self, dst_files):
224
+ """Prefetch remote file info (size and hash) for multiple files
225
+
226
+ Arguments:
227
+ dst_files: dict {remote_path: local_size} - sizes used to skip hash if mismatch
228
+
229
+ Uses batch call to reduce round-trips. Results are cached in _remote_file_cache.
230
+ """
231
+ if self._force or not dst_files:
232
+ return
233
+ files_to_fetch = {p: s for p, s in dst_files.items() if p not in self._remote_file_cache}
234
+ if not files_to_fetch:
235
+ return
236
+ self.verbose(f"Checking {len(files_to_fetch)} files...", 2)
237
+ result = self._mpy.fileinfo(files_to_fetch)
238
+ if result is None:
239
+ # hashlib not available - mark all as needing update
240
+ for path in files_to_fetch:
241
+ self._remote_file_cache[path] = None
242
+ else:
243
+ self._remote_file_cache.update(result)
244
+
245
+ def _collect_dst_files(self, src_path, dst_path, add_src_basename=True):
246
+ """Collect destination paths and local sizes for a local->remote copy operation
247
+
248
+ Arguments:
249
+ src_path: local source path (file or directory)
250
+ dst_path: remote destination base path
251
+ add_src_basename: if True, add source basename to dst_path (matching _put_dir behavior)
252
+
253
+ Returns:
254
+ dict {remote_path: local_file_size}
255
+ """
256
+ files = {}
257
+ if _os.path.isfile(src_path):
258
+ # For files, add basename if dst_path ends with /
259
+ basename = _os.path.basename(src_path)
260
+ if basename and not _os.path.basename(dst_path):
261
+ dst_path = _os.path.join(dst_path, basename)
262
+ files[dst_path] = _os.path.getsize(src_path)
263
+ elif _os.path.isdir(src_path):
264
+ # For directories, mimic _put_dir behavior
265
+ if add_src_basename:
266
+ basename = _os.path.basename(_os.path.abspath(src_path))
267
+ if basename:
268
+ dst_path = _os.path.join(dst_path, basename)
269
+ for root, dirs, filenames in _os.walk(src_path, topdown=True):
270
+ dirs[:] = [d for d in dirs if not self._is_excluded(d)]
271
+ filenames = [f for f in filenames if not self._is_excluded(f)]
272
+ rel_path = _os.path.relpath(root, src_path)
273
+ if rel_path == '.':
274
+ rel_path = ''
275
+ for file_name in filenames:
276
+ spath = _os.path.join(root, file_name)
277
+ dpath = _os.path.join(dst_path, rel_path, file_name) if rel_path else _os.path.join(dst_path, file_name)
278
+ files[dpath] = _os.path.getsize(spath)
279
+ return files
136
280
 
137
281
  def _file_needs_update(self, local_data, remote_path):
138
282
  """Check if local file differs from remote file
@@ -141,7 +285,20 @@ class MpyTool():
141
285
  """
142
286
  if self._force:
143
287
  return True
144
- # Check remote file size first (fast)
288
+ # Check cache first (populated by _prefetch_remote_info)
289
+ if remote_path in self._remote_file_cache:
290
+ cached = self._remote_file_cache[remote_path]
291
+ if cached is None:
292
+ return True # File doesn't exist or hashlib not available
293
+ remote_size, remote_hash = cached
294
+ local_size = len(local_data)
295
+ if local_size != remote_size:
296
+ return True # Different size
297
+ if remote_hash is None:
298
+ return True # Size matched but hash wasn't computed (shouldn't happen with prefetch)
299
+ local_hash = _hashlib.sha256(local_data).digest()
300
+ return local_hash != remote_hash
301
+ # Fallback to individual calls (for single file operations)
145
302
  remote_size = self._mpy.stat(remote_path)
146
303
  if remote_size is None or remote_size < 0:
147
304
  return True # File doesn't exist or is a directory
@@ -155,24 +312,6 @@ class MpyTool():
155
312
  return True # hashlib not available on device
156
313
  return local_hash != remote_hash
157
314
 
158
- def _count_remote_files(self, path):
159
- """Count files on device"""
160
- stat = self._mpy.stat(path)
161
- if stat is None:
162
- return 0
163
- if stat >= 0: # file
164
- return 1
165
- # directory - count recursively
166
- count = 0
167
- entries = self._mpy.ls(path)
168
- for name, size in entries:
169
- if size is None: # directory
170
- entry_path = path.rstrip('/') + '/' + name
171
- count += self._count_remote_files(entry_path)
172
- else: # file
173
- count += 1
174
- return count
175
-
176
315
  def _collect_source_paths(self, commands):
177
316
  """Collect source paths for a cp/put command (for alignment calculation)"""
178
317
  paths = []
@@ -188,27 +327,9 @@ class MpyTool():
188
327
  src_path = '/'
189
328
  src_path = src_path.rstrip('/') or '/'
190
329
  if src_is_remote:
191
- # Collect all file paths from remote
192
330
  paths.extend(self._collect_remote_paths(src_path))
193
331
  else:
194
- # Collect all file paths from local
195
332
  paths.extend(self._collect_local_paths(src_path))
196
- elif cmd == 'put' and len(commands) >= 2:
197
- src_path = commands[1]
198
- paths.extend(self._collect_local_paths(src_path))
199
- return paths
200
-
201
- def _collect_local_paths(self, path):
202
- """Collect all local file paths (formatted for display)"""
203
- paths = []
204
- if _os.path.isfile(path):
205
- paths.append(self._format_local_path(path))
206
- elif _os.path.isdir(path):
207
- for root, dirs, files in _os.walk(path, topdown=True):
208
- dirs[:] = [d for d in dirs if d not in self._exclude_dirs]
209
- for f in files:
210
- full_path = _os.path.join(root, f)
211
- paths.append(self._format_local_path(full_path))
212
333
  return paths
213
334
 
214
335
  def _collect_remote_paths(self, path):
@@ -229,20 +350,90 @@ class MpyTool():
229
350
  paths.append(':' + entry_path)
230
351
  return paths
231
352
 
353
+ def _collect_destination_paths(self, commands):
354
+ """Collect formatted destination paths for a cp/put command"""
355
+ if not commands:
356
+ return []
357
+ cmd = commands[0]
358
+ if cmd == 'cp' and len(commands) >= 3:
359
+ # Filter out flags
360
+ args = [a for a in commands[1:] if not a.startswith('-')]
361
+ if len(args) < 2:
362
+ return []
363
+ sources, dest = args[:-1], args[-1]
364
+ dest_is_remote = dest.startswith(':')
365
+ dest_path = (dest[1:] or '/') if dest_is_remote else dest
366
+ dest_is_dir = dest_path.endswith('/')
367
+ dst_paths = []
368
+ for src in sources:
369
+ src_is_remote = src.startswith(':')
370
+ src_path = ((src[1:] or '/') if src_is_remote else src).rstrip('/') or '/'
371
+ copy_contents = src.endswith('/')
372
+ if dest_is_remote and not src_is_remote:
373
+ # local -> remote: reuse _collect_dst_files
374
+ if _os.path.exists(src_path):
375
+ files = self._collect_dst_files(src_path, dest_path.rstrip('/') or '/', not copy_contents)
376
+ dst_paths.extend(':' + p for p in files)
377
+ elif not dest_is_remote and src_is_remote:
378
+ # remote -> local
379
+ dst_paths.extend(self._collect_remote_to_local_dst(src_path, dest_path, dest_is_dir, copy_contents))
380
+ elif dest_is_remote and src_is_remote:
381
+ # remote -> remote (file only)
382
+ stat = self._mpy.stat(src_path)
383
+ if stat is not None and stat >= 0:
384
+ dst = _join_remote_path(dest_path, _remote_basename(src_path)) if dest_is_dir else dest_path
385
+ dst_paths.append(':' + dst)
386
+ return dst_paths
387
+ return []
388
+
389
+ def _collect_remote_to_local_dst(self, src_path, dest_path, dest_is_dir, copy_contents):
390
+ """Collect destination paths for remote->local copy"""
391
+ stat = self._mpy.stat(src_path)
392
+ if stat is None:
393
+ return []
394
+ base_dst = dest_path.rstrip('/') or '.'
395
+ if dest_is_dir and not copy_contents and src_path != '/':
396
+ base_dst = _os.path.join(base_dst, _remote_basename(src_path))
397
+ if stat >= 0: # file
398
+ if _os.path.isdir(base_dst) or dest_is_dir:
399
+ return [self._format_local_path(_os.path.join(base_dst, _remote_basename(src_path)))]
400
+ return [self._format_local_path(base_dst)]
401
+ return self._collect_remote_dir_dst(src_path, base_dst)
402
+
403
+ def _collect_remote_dir_dst(self, src_path, base_dst):
404
+ """Collect local destination paths for remote directory download"""
405
+ paths = []
406
+ for name, size in self._mpy.ls(src_path):
407
+ entry_src = src_path.rstrip('/') + '/' + name
408
+ entry_dst = _os.path.join(base_dst, name)
409
+ if size is None: # directory
410
+ paths.extend(self._collect_remote_dir_dst(entry_src, entry_dst))
411
+ else: # file
412
+ paths.append(self._format_local_path(entry_dst))
413
+ return paths
414
+
232
415
  def count_files_for_command(self, commands):
233
416
  """Count files that would be transferred by cp/put command.
234
- Returns (is_copy_command, file_count, source_paths)"""
235
- paths = self._collect_source_paths(commands)
236
- if paths:
237
- return True, len(paths), paths
238
- return False, 0, []
417
+ Returns (is_copy_command, file_count, source_paths, dest_paths)"""
418
+ src_paths = self._collect_source_paths(commands)
419
+ if src_paths:
420
+ dst_paths = self._collect_destination_paths(commands)
421
+ return True, len(src_paths), src_paths, dst_paths
422
+ return False, 0, [], []
239
423
 
240
- def set_batch_progress(self, total_files, max_src_len=0):
424
+ def set_batch_progress(self, total_files, max_src_len=0, max_dst_len=0):
241
425
  """Set batch progress for consecutive copy commands"""
242
426
  self._progress_total_files = total_files
243
427
  self._progress_current_file = 0
244
428
  self._progress_max_src_len = max_src_len
429
+ self._progress_max_dst_len = max_dst_len
245
430
  self._batch_mode = True
431
+ self._stats_total_bytes = 0
432
+ self._stats_transferred_bytes = 0
433
+ self._stats_wire_bytes = 0
434
+ self._stats_transferred_files = 0
435
+ self._skipped_files = 0
436
+ self._stats_start_time = _time.time()
246
437
 
247
438
  def reset_batch_progress(self):
248
439
  """Reset batch progress mode"""
@@ -250,14 +441,35 @@ class MpyTool():
250
441
  self._progress_total_files = 0
251
442
  self._progress_current_file = 0
252
443
  self._progress_max_src_len = 0
444
+ self._progress_max_dst_len = 0
445
+ self._remote_file_cache.clear()
253
446
 
254
- def cmd_ls(self, dir_name):
255
- result = self._mpy.ls(dir_name)
256
- for name, size in result:
257
- if size is not None:
258
- print(f'{size:8d} {name}')
259
- else:
260
- print(f'{"":8} {name}/')
447
+ def print_copy_summary(self):
448
+ """Print summary after copy operation"""
449
+ if self._stats_start_time is None:
450
+ return
451
+ elapsed = _time.time() - self._stats_start_time
452
+ total = self._stats_total_bytes
453
+ transferred = self._stats_transferred_bytes
454
+ wire = self._stats_wire_bytes
455
+ total_files = self._stats_transferred_files + self._skipped_files
456
+ parts = []
457
+ parts.append(f"{self.format_size(transferred).strip()}")
458
+ if elapsed > 0:
459
+ speed = transferred / elapsed
460
+ parts.append(f"{self.format_size(speed).strip()}/s")
461
+ parts.append(f"{elapsed:.1f}s")
462
+ # Combined speedup: total file size vs actual wire bytes
463
+ # Includes savings from: skipped files, base64 encoding, compression
464
+ if wire > 0 and total > wire:
465
+ speedup = total / wire
466
+ parts.append(f"speedup {speedup:.1f}x")
467
+ summary = " ".join(parts)
468
+ if self._skipped_files > 0:
469
+ file_info = f"{self._stats_transferred_files} transferred, {self._skipped_files} skipped"
470
+ else:
471
+ file_info = f"{total_files} files"
472
+ self.verbose(f" {summary} ({file_info})", color='green')
261
473
 
262
474
  @classmethod
263
475
  def print_tree(cls, tree, prefix='', print_size=True, first=True, last=True):
@@ -271,12 +483,14 @@ class MpyTool():
271
483
  else:
272
484
  this_prefix = cls.TEE
273
485
  sufix = ''
274
- if sub_tree is not None and name != ('/'):
486
+ if sub_tree is not None and name != '/':
275
487
  sufix = '/'
488
+ # For root, display './' only for empty path (CWD)
489
+ display_name = '.' if first and name in ('', '.') else name
276
490
  line = ''
277
491
  if print_size:
278
- line += f'{size:8d} '
279
- line += prefix + this_prefix + name + sufix
492
+ line += f'{cls.format_size(size):>9} '
493
+ line += prefix + this_prefix + display_name + sufix
280
494
  print(line)
281
495
  if not sub_tree:
282
496
  return
@@ -300,103 +514,82 @@ class MpyTool():
300
514
  first=False,
301
515
  last=True)
302
516
 
303
- def cmd_tree(self, dir_name):
304
- tree = self._mpy.tree(dir_name)
305
- self.print_tree(tree)
517
+ def _upload_file(self, data, src_path, dst_path, show_progress):
518
+ """Upload file data to device with stats tracking and progress display"""
519
+ file_size = len(data)
520
+ self._stats_total_bytes += file_size
521
+ if not self._file_needs_update(data, dst_path):
522
+ self._skipped_files += 1
523
+ if show_progress and self._verbose >= 1:
524
+ self._progress_current_file += 1
525
+ self._set_progress_info(src_path, dst_path, False, True)
526
+ self.verbose(self._format_skip_line(file_size), color='yellow')
527
+ return False # skipped
528
+ self._stats_transferred_bytes += file_size
529
+ self._stats_transferred_files += 1
530
+ if show_progress and self._verbose >= 1:
531
+ self._progress_current_file += 1
532
+ self._set_progress_info(src_path, dst_path, False, True)
533
+ encodings, wire = self._mpy.put(data, dst_path, self._progress_callback, self._compress)
534
+ self._stats_wire_bytes += wire
535
+ self._progress_complete(file_size, encodings)
536
+ else:
537
+ _, wire = self._mpy.put(data, dst_path, compress=self._compress)
538
+ self._stats_wire_bytes += wire
539
+ return True # uploaded
306
540
 
307
- def cmd_get(self, *file_names):
308
- for file_name in file_names:
309
- self.verbose(f"GET: {file_name}", 2)
310
- data = self._mpy.get(file_name)
311
- print(data.decode('utf-8'))
541
+ def _put_dir(self, src_path, dst_path, show_progress=True, target_name=None):
542
+ """Upload directory to device
312
543
 
313
- def _put_dir(self, src_path, dst_path, show_progress=True):
314
- basename = _os.path.basename(src_path)
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))
315
554
  if basename:
316
- dst_path = _os.path.join(dst_path, basename)
555
+ dst_path = _join_remote_path(dst_path, basename)
317
556
  self.verbose(f"PUT DIR: {src_path} -> {dst_path}", 2)
557
+ created_dirs = set()
318
558
  for path, dirs, files in _os.walk(src_path, topdown=True):
319
- dirs[:] = [d for d in dirs if d not in self._exclude_dirs]
320
- basename = _os.path.basename(path)
321
- if basename in self._exclude_dirs:
559
+ dirs[:] = [d for d in dirs if not self._is_excluded(d)]
560
+ files = [f for f in files if not self._is_excluded(f)]
561
+ if not files:
322
562
  continue
323
563
  rel_path = _os.path.relpath(path, src_path)
324
- if rel_path == '.':
325
- rel_path = ''
326
- rel_path = _os.path.join(dst_path, rel_path)
327
- if rel_path:
564
+ rel_path = _os.path.join(dst_path, '' if rel_path == '.' else rel_path)
565
+ if rel_path and rel_path not in created_dirs:
328
566
  self.verbose(f'MKDIR: {rel_path}', 2)
329
567
  self._mpy.mkdir(rel_path)
568
+ created_dirs.add(rel_path)
330
569
  for file_name in files:
331
570
  spath = _os.path.join(path, file_name)
332
- dpath = _os.path.join(rel_path, file_name)
333
- with open(spath, 'rb') as src_file:
334
- data = src_file.read()
335
- # Check if file needs update
336
- if not self._file_needs_update(data, dpath):
337
- self._skipped_files += 1
338
- if show_progress and self._verbose >= 1:
339
- self._progress_current_file += 1
340
- self._set_progress_info(spath, dpath, False, True)
341
- self.verbose(f" skip {self._progress_src} (unchanged)", color='yellow')
342
- continue
343
- if show_progress and self._verbose >= 1:
344
- self._progress_current_file += 1
345
- self._set_progress_info(spath, dpath, False, True)
346
- self._mpy.put(data, dpath, self._progress_callback)
347
- self._progress_complete(len(data))
348
- else:
349
- self._mpy.put(data, dpath)
571
+ with open(spath, 'rb') as f:
572
+ self._upload_file(f.read(), spath, _os.path.join(rel_path, file_name), show_progress)
350
573
 
351
574
  def _put_file(self, src_path, dst_path, show_progress=True):
352
575
  basename = _os.path.basename(src_path)
353
576
  if basename and not _os.path.basename(dst_path):
354
577
  dst_path = _os.path.join(dst_path, basename)
355
578
  self.verbose(f"PUT FILE: {src_path} -> {dst_path}", 2)
356
- # Read local file
357
- with open(src_path, 'rb') as src_file:
358
- data = src_file.read()
359
- # Check if file needs update
360
- if not self._file_needs_update(data, dst_path):
361
- self._skipped_files += 1
362
- if show_progress and self._verbose >= 1:
363
- self._progress_current_file += 1
364
- self._set_progress_info(src_path, dst_path, False, True)
365
- self.verbose(f" skip {self._progress_src} (unchanged)", color='yellow')
366
- return
367
- # Create parent directory if needed
368
- path = _os.path.dirname(dst_path)
369
- if path:
370
- result = self._mpy.stat(path)
371
- if result is None:
372
- self._mpy.mkdir(path)
373
- elif result >= 0:
374
- raise _mpytool.MpyError(
375
- f'Error creating file under file: {path}')
376
- # Upload file
377
- if show_progress and self._verbose >= 1:
378
- self._progress_current_file += 1
379
- self._set_progress_info(src_path, dst_path, False, True)
380
- self._mpy.put(data, dst_path, self._progress_callback)
381
- self._progress_complete(len(data))
382
- else:
383
- self._mpy.put(data, dst_path)
384
-
385
- def cmd_put(self, src_path, dst_path):
386
- if self._verbose >= 1 and not self._batch_mode:
387
- self._progress_total_files = self._count_local_files(src_path)
388
- self._progress_current_file = 0
389
- if _os.path.isdir(src_path):
390
- self._put_dir(src_path, dst_path)
391
- elif _os.path.isfile(src_path):
392
- self._put_file(src_path, dst_path)
393
- else:
394
- raise ParamsError(f'No file or directory to upload: {src_path}')
579
+ with open(src_path, 'rb') as f:
580
+ data = f.read()
581
+ parent = _os.path.dirname(dst_path)
582
+ if parent:
583
+ stat = self._mpy.stat(parent)
584
+ if stat is None:
585
+ self._mpy.mkdir(parent)
586
+ elif stat >= 0:
587
+ raise _mpytool.MpyError(f'Error creating file under file: {parent}')
588
+ self._upload_file(data, src_path, dst_path, show_progress)
395
589
 
396
590
  def _get_file(self, src_path, dst_path, show_progress=True):
397
591
  """Download single file from device"""
398
592
  self.verbose(f"GET FILE: {src_path} -> {dst_path}", 2)
399
- # Create destination directory if needed
400
593
  dst_dir = _os.path.dirname(dst_path)
401
594
  if dst_dir and not _os.path.exists(dst_dir):
402
595
  _os.makedirs(dst_dir)
@@ -407,13 +600,17 @@ class MpyTool():
407
600
  self._progress_complete(len(data))
408
601
  else:
409
602
  data = self._mpy.get(src_path)
603
+ file_size = len(data)
604
+ self._stats_total_bytes += file_size
605
+ self._stats_transferred_bytes += file_size
606
+ self._stats_transferred_files += 1
410
607
  with open(dst_path, 'wb') as dst_file:
411
608
  dst_file.write(data)
412
609
 
413
610
  def _get_dir(self, src_path, dst_path, copy_contents=False, show_progress=True):
414
611
  """Download directory from device"""
415
612
  if not copy_contents:
416
- basename = src_path.rstrip('/').split('/')[-1]
613
+ basename = _remote_basename(src_path)
417
614
  if basename:
418
615
  dst_path = _os.path.join(dst_path, basename)
419
616
  self.verbose(f"GET DIR: {src_path} -> {dst_path}", 2)
@@ -429,29 +626,48 @@ class MpyTool():
429
626
  self._get_file(src_entry, dst_entry, show_progress=show_progress)
430
627
 
431
628
  def _cp_local_to_remote(self, src_path, dst_path, dst_is_dir):
432
- """Upload local file/dir to device"""
433
- 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('/'))
434
637
  copy_contents = src_path.endswith('/')
435
638
  src_path = src_path.rstrip('/')
436
639
  if not _os.path.exists(src_path):
437
640
  raise ParamsError(f'Source not found: {src_path}')
438
- if dst_is_dir:
439
- if not copy_contents:
440
- basename = _os.path.basename(src_path)
441
- 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:
442
645
  dst_path = dst_path.rstrip('/')
646
+ else:
647
+ dst_path = ''
443
648
  if src_is_dir:
444
649
  if copy_contents:
445
- # Copy contents of directory
650
+ # Copy directory contents to dst_path
446
651
  for item in _os.listdir(src_path):
652
+ if self._is_excluded(item):
653
+ continue
447
654
  item_src = _os.path.join(src_path, item)
448
655
  if _os.path.isdir(item_src):
449
656
  self._put_dir(item_src, dst_path)
450
657
  else:
451
- 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)
452
662
  else:
453
- 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)
454
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))
455
671
  self._put_file(src_path, dst_path)
456
672
 
457
673
  def _cp_remote_to_local(self, src_path, dst_path, dst_is_dir):
@@ -466,14 +682,12 @@ class MpyTool():
466
682
  if not _os.path.exists(dst_path):
467
683
  _os.makedirs(dst_path)
468
684
  if not copy_contents and src_path != '/':
469
- basename = src_path.split('/')[-1]
470
- dst_path = _os.path.join(dst_path, basename)
685
+ dst_path = _os.path.join(dst_path, _remote_basename(src_path))
471
686
  if src_is_dir:
472
687
  self._get_dir(src_path, dst_path, copy_contents=copy_contents)
473
688
  else:
474
689
  if _os.path.isdir(dst_path):
475
- basename = src_path.split('/')[-1]
476
- dst_path = _os.path.join(dst_path, basename)
690
+ dst_path = _os.path.join(dst_path, _remote_basename(src_path))
477
691
  self._get_file(src_path, dst_path)
478
692
 
479
693
  def _cp_remote_to_remote(self, src_path, dst_path, dst_is_dir):
@@ -484,35 +698,86 @@ class MpyTool():
484
698
  raise ParamsError(f'Source not found on device: {src_path}')
485
699
  if stat == -1:
486
700
  raise ParamsError('Remote-to-remote directory copy not supported yet')
487
- # File copy on device
488
701
  if dst_is_dir:
489
- basename = src_path.split('/')[-1]
490
- dst_path = dst_path + basename
702
+ dst_path = _join_remote_path(dst_path, _remote_basename(src_path))
491
703
  self.verbose(f"COPY: {src_path} -> {dst_path}", 2)
492
704
  if self._verbose >= 1:
493
705
  self._progress_current_file += 1
494
706
  self._set_progress_info(src_path, dst_path, True, True)
495
707
  data = self._mpy.get(src_path, self._progress_callback)
496
- self._mpy.put(data, dst_path)
497
- self._progress_complete(len(data))
708
+ encodings, wire = self._mpy.put(data, dst_path, compress=self._compress)
709
+ self._stats_wire_bytes += wire
710
+ self._progress_complete(len(data), encodings)
498
711
  else:
499
712
  data = self._mpy.get(src_path)
500
- self._mpy.put(data, dst_path)
713
+ _, wire = self._mpy.put(data, dst_path, compress=self._compress)
714
+ self._stats_wire_bytes += wire
715
+ file_size = len(data)
716
+ self._stats_total_bytes += file_size
717
+ self._stats_transferred_bytes += file_size
718
+ self._stats_transferred_files += 1
501
719
 
502
720
  def cmd_cp(self, *args):
503
721
  """Copy files between local and device"""
722
+ # Parse flags: -f/--force, -z/--compress, -Z/--no-compress
723
+ args = list(args)
724
+ force = None
725
+ compress = None # None = use global setting
726
+ filtered_args = []
727
+ for a in args:
728
+ if a == '--force':
729
+ force = True
730
+ elif a == '--compress':
731
+ compress = True
732
+ elif a == '--no-compress':
733
+ compress = False
734
+ elif a.startswith('-') and not a.startswith('--') and len(a) > 1:
735
+ # Handle combined short flags like -fz, -fZ
736
+ flags = a[1:]
737
+ if 'f' in flags:
738
+ force = True
739
+ if 'z' in flags:
740
+ compress = True
741
+ if 'Z' in flags:
742
+ compress = False
743
+ remaining = flags.replace('f', '').replace('z', '').replace('Z', '')
744
+ if remaining:
745
+ filtered_args.append('-' + remaining)
746
+ else:
747
+ filtered_args.append(a)
748
+ args = filtered_args
504
749
  if len(args) < 2:
505
750
  raise ParamsError('cp requires source and destination')
751
+ # Save and set flags for this command
752
+ saved_force = self._force
753
+ saved_compress = self._compress
754
+ if force is not None:
755
+ self._force = force
756
+ if compress is not None:
757
+ self._compress = compress
758
+ try:
759
+ self._cmd_cp_impl(args)
760
+ finally:
761
+ self._force = saved_force
762
+ self._compress = saved_compress
763
+
764
+ def _cmd_cp_impl(self, args):
506
765
  sources = list(args[:-1])
507
766
  dest = args[-1]
508
767
  dest_is_remote = dest.startswith(':')
509
768
  dest_path = dest[1:] if dest_is_remote else dest
510
- if not dest_path:
511
- dest_path = '/'
512
- dest_is_dir = dest_path.endswith('/')
513
- if len(sources) > 1 and not dest_is_dir:
514
- raise ParamsError('multiple sources require destination directory (ending with /)')
515
- # Count total files for progress (only if not in batch mode)
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 /)')
516
781
  if self._verbose >= 1 and not self._batch_mode:
517
782
  total_files = 0
518
783
  for src in sources:
@@ -522,11 +787,25 @@ class MpyTool():
522
787
  src_path = '/'
523
788
  src_path_clean = src_path.rstrip('/') or '/'
524
789
  if src_is_remote:
525
- total_files += self._count_remote_files(src_path_clean)
790
+ total_files += len(self._collect_remote_paths(src_path_clean))
526
791
  else:
527
- total_files += self._count_local_files(src_path_clean)
792
+ total_files += len(self._collect_local_paths(src_path_clean))
528
793
  self._progress_total_files = total_files
529
794
  self._progress_current_file = 0
795
+ all_dst_files = {}
796
+ if dest_is_remote:
797
+ for src in sources:
798
+ if not src.startswith(':'): # local source
799
+ copy_contents = src.endswith('/')
800
+ src_path = src.rstrip('/')
801
+ if _os.path.exists(src_path):
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))
805
+ if all_dst_files and not self._batch_mode:
806
+ self._progress_max_dst_len = max(len(':' + p) for p in all_dst_files)
807
+ if not self._force:
808
+ self._prefetch_remote_info(all_dst_files)
530
809
  for src in sources:
531
810
  src_is_remote = src.startswith(':')
532
811
  src_path = src[1:] if src_is_remote else src
@@ -547,56 +826,55 @@ class MpyTool():
547
826
  raise ParamsError('mv requires source and destination')
548
827
  sources = list(args[:-1])
549
828
  dest = args[-1]
550
- # Validate all paths are remote
551
- if not dest.startswith(':'):
552
- raise ParamsError('mv destination must be device path (: prefix)')
829
+ dest_path = _parse_device_path(dest, 'mv destination')
553
830
  for src in sources:
554
- if not src.startswith(':'):
555
- raise ParamsError('mv source must be device path (: prefix)')
556
- dest_path = dest[1:] or '/'
557
- 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('/')
558
834
  if len(sources) > 1 and not dest_is_dir:
559
835
  raise ParamsError('multiple sources require destination directory (ending with /)')
560
836
  self._mpy.import_module('os')
561
837
  for src in sources:
562
- src_path = src[1:]
838
+ src_path = _parse_device_path(src, 'mv')
563
839
  stat = self._mpy.stat(src_path)
564
840
  if stat is None:
565
841
  raise ParamsError(f'Source not found on device: {src_path}')
566
842
  if dest_is_dir:
567
- # Ensure destination directory exists
568
- dst_dir = dest_path.rstrip('/')
569
- 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:
570
846
  self._mpy.mkdir(dst_dir)
571
- basename = src_path.rstrip('/').split('/')[-1]
572
- final_dest = dest_path + basename
847
+ final_dest = _join_remote_path(dst_dir, _remote_basename(src_path))
573
848
  else:
574
849
  final_dest = dest_path
575
850
  self.verbose(f"MV: {src_path} -> {final_dest}", 1)
576
851
  self._mpy.rename(src_path, final_dest)
577
852
 
578
- def cmd_mkdir(self, *dir_names):
579
- for dir_name in dir_names:
580
- self.verbose(f"MKDIR: {dir_name}", 1)
581
- self._mpy.mkdir(dir_name)
582
-
583
- def cmd_delete(self, *file_names):
853
+ def cmd_rm(self, *file_names):
854
+ """Delete files/directories on device"""
584
855
  for file_name in file_names:
585
- contents_only = file_name.endswith('/')
586
- 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
587
865
  if contents_only:
588
- self.verbose(f"DELETE contents: {path}", 1)
866
+ self.verbose(f"RM contents: {path or 'CWD'}", 1)
589
867
  entries = self._mpy.ls(path)
590
868
  for name, size in entries:
591
- entry_path = path + '/' + name if path != '/' else '/' + name
869
+ entry_path = _join_remote_path(path, name)
592
870
  self.verbose(f" {entry_path}", 1)
593
871
  self._mpy.delete(entry_path)
594
872
  else:
595
- self.verbose(f"DELETE: {path}", 1)
873
+ self.verbose(f"RM: {path}", 1)
596
874
  self._mpy.delete(path)
597
875
 
598
- def cmd_follow(self):
599
- self.verbose("FOLLOW (Ctrl+C to stop)", 1)
876
+ def cmd_monitor(self):
877
+ self.verbose("MONITOR (Ctrl+C to stop)", 1)
600
878
  try:
601
879
  while True:
602
880
  line = self._conn.read_line()
@@ -604,7 +882,7 @@ class MpyTool():
604
882
  print(line)
605
883
  except KeyboardInterrupt:
606
884
  self.verbose('', level=0, overwrite=True) # newline after ^C
607
- except _mpytool.ConnError as err:
885
+ except (_mpytool.ConnError, OSError) as err:
608
886
  if self._log:
609
887
  self._log.error(err)
610
888
 
@@ -618,159 +896,478 @@ class MpyTool():
618
896
  terminal.run()
619
897
  self._log.info('Exiting..')
620
898
 
621
- def cmd_exec(self, code):
622
- self.verbose(f"EXEC: {code}", 1)
623
- result = self._mpy.comm.exec(code)
624
- if result:
625
- print(result.decode('utf-8', 'backslashreplace'), end='')
626
-
627
899
  @staticmethod
628
900
  def format_size(size):
629
- """Format size in bytes to human readable format with 3+ digits"""
630
- if size < 1000:
631
- return f"{size} B"
632
- for unit in ('KB', 'MB', 'GB', 'TB'):
901
+ """Format size in bytes to human readable format (like ls -h)"""
902
+ if size < 1024:
903
+ return f"{int(size)}B"
904
+ for unit in ('K', 'M', 'G', 'T'):
633
905
  size /= 1024
634
906
  if size < 10:
635
- return f"{size:.2f} {unit}"
907
+ return f"{size:.2f}{unit}"
636
908
  if size < 100:
637
- return f"{size:.1f} {unit}"
638
- if size < 1000 or unit == 'TB':
639
- return f"{size:.0f} {unit}"
640
- return f"{size:.0f} TB"
909
+ return f"{size:.1f}{unit}"
910
+ if size < 1024 or unit == 'T':
911
+ return f"{size:.0f}{unit}"
912
+ return f"{size:.0f}T"
913
+
914
+ def cmd_ota(self, firmware_path):
915
+ """OTA firmware update from local .app-bin file"""
916
+ self.verbose("OTA UPDATE", 1)
917
+ if not _os.path.isfile(firmware_path):
918
+ raise ParamsError(f"Firmware file not found: {firmware_path}")
919
+
920
+ with open(firmware_path, 'rb') as f:
921
+ firmware = f.read()
922
+
923
+ fw_size = len(firmware)
924
+ self.verbose(f" Firmware: {self.format_size(fw_size)}", 1)
925
+ info = self._mpy.partitions()
926
+ if not info['next_ota']:
927
+ raise _mpytool.MpyError("OTA not available (no OTA partitions)")
928
+
929
+ self.verbose(f" Target: {info['next_ota']} ({self.format_size(info['next_ota_size'])})", 1)
930
+ use_compress = self._mpy._detect_deflate()
931
+ chunk_size = self._mpy._detect_chunk_size()
932
+ chunk_str = f"{chunk_size // 1024}K" if chunk_size >= 1024 else str(chunk_size)
933
+ self.verbose(f" Writing (chunk: {chunk_str}, compress: {'on' if use_compress else 'off'})...", 1)
934
+ start_time = _time.time()
935
+
936
+ def progress_callback(transferred, total, wire_bytes):
937
+ if self._verbose >= 1:
938
+ pct = transferred * 100 // total
939
+ elapsed = _time.time() - start_time
940
+ speed = transferred / elapsed / 1024 if elapsed > 0 else 0
941
+ line = f" Writing: {pct:3d}% {self.format_size(transferred):>6} / {self.format_size(total)} {speed:.1f} KB/s"
942
+ self.verbose(line, color='cyan', end='', overwrite=True)
943
+
944
+ result = self._mpy.ota_write(firmware, progress_callback, self._compress)
945
+ elapsed = _time.time() - start_time
946
+ speed = fw_size / elapsed / 1024 if elapsed > 0 else 0
947
+ ratio = fw_size / result['wire_bytes'] if result['wire_bytes'] > 0 else 1
948
+ self.verbose(
949
+ f" Writing: 100% {self.format_size(fw_size):>6} {elapsed:.1f}s {speed:.1f} KB/s ratio {ratio:.2f}x",
950
+ color='cyan', overwrite=True
951
+ )
952
+
953
+ self.verbose(f" OTA complete! Use 'mreset' to boot into new firmware.", 1, color='green')
954
+
955
+ def cmd_flash(self):
956
+ """Show flash information (auto-detect platform)"""
957
+ self.verbose("FLASH", 2)
958
+ platform = self._mpy.platform()['platform']
959
+
960
+ if platform == 'rp2':
961
+ self._cmd_flash_rp2()
962
+ elif platform == 'esp32':
963
+ self._cmd_flash_esp32()
964
+ else:
965
+ raise _mpytool.MpyError(f"Flash info not supported for platform: {platform}")
966
+
967
+ def _cmd_flash_rp2(self):
968
+ """Show RP2 flash information"""
969
+ info = self._mpy.flash_info()
970
+ print(f"Platform: RP2")
971
+ print(f"Flash size: {self.format_size(info['size'])}")
972
+ print(f"Block size: {info['block_size']} bytes")
973
+ print(f"Block count: {info['block_count']}")
974
+ fs_line = f"Filesystem: {info['filesystem']}"
975
+ # For FAT, show cluster size if detected from magic
976
+ if info.get('fs_block_size'):
977
+ fs_line += f" (cluster: {self.format_size(info['fs_block_size'])})"
978
+ if info['filesystem'] == 'unknown' and info.get('magic'):
979
+ magic_hex = ' '.join(f'{b:02x}' for b in info['magic'])
980
+ fs_line += f" (magic: {magic_hex})"
981
+ print(fs_line)
982
+
983
+ def _make_progress(self, action, label=None):
984
+ """Create progress callback for flash operations"""
985
+ def progress(transferred, total, *_):
986
+ if self._verbose >= 1:
987
+ pct = (transferred / total * 100) if total > 0 else 0
988
+ prefix = f"{action} {label}" if label else action
989
+ self.verbose(
990
+ f" {prefix}: {pct:.0f}% {self.format_size(transferred)} / {self.format_size(total)}",
991
+ color='cyan', end='', overwrite=True)
992
+ return progress
993
+
994
+ def cmd_flash_read(self, dest_path, label=None):
995
+ """Read flash/partition content to file"""
996
+ if label:
997
+ self.verbose(f"FLASH READ {label} -> {dest_path}", 1)
998
+ else:
999
+ self.verbose(f"FLASH READ -> {dest_path}", 1)
1000
+
1001
+ data = self._mpy.flash_read(label=label, progress_callback=self._make_progress("reading", label))
1002
+
1003
+ if self._verbose >= 1:
1004
+ print() # newline after progress
1005
+
1006
+ with open(dest_path, 'wb') as f:
1007
+ f.write(data)
1008
+
1009
+ self.verbose(f" saved {self.format_size(len(data))} to {dest_path}", 1, color='green')
1010
+
1011
+ def cmd_flash_write(self, src_path, label=None):
1012
+ """Write file content to flash/partition"""
1013
+ if label:
1014
+ self.verbose(f"FLASH WRITE {src_path} -> {label}", 1)
1015
+ else:
1016
+ self.verbose(f"FLASH WRITE {src_path}", 1)
1017
+
1018
+ with open(src_path, 'rb') as f:
1019
+ data = f.read()
1020
+
1021
+ result = self._mpy.flash_write(
1022
+ data, label=label,
1023
+ progress_callback=self._make_progress("writing", label),
1024
+ compress=self._compress)
1025
+
1026
+ if self._verbose >= 1:
1027
+ print() # newline after progress
1028
+
1029
+ target = label or "flash"
1030
+ comp_info = " (compressed)" if result.get('compressed') else ""
1031
+ self.verbose(f" wrote {self.format_size(result['written'])} to {target}{comp_info}", 1, color='green')
1032
+
1033
+ def cmd_flash_erase(self, label=None, full=False):
1034
+ """Erase flash/partition (filesystem reset)"""
1035
+ mode = "full" if full else "quick"
1036
+ if label:
1037
+ self.verbose(f"FLASH ERASE {label} ({mode})", 1)
1038
+ else:
1039
+ self.verbose(f"FLASH ERASE ({mode})", 1)
1040
+
1041
+ result = self._mpy.flash_erase(label=label, full=full, progress_callback=self._make_progress("erasing", label))
1042
+
1043
+ if self._verbose >= 1:
1044
+ print() # newline after progress
1045
+
1046
+ target = label or "flash"
1047
+ self.verbose(f" erased {self.format_size(result['erased'])} from {target}", 1, color='green')
1048
+ if not label:
1049
+ self.verbose(" filesystem will be recreated on next boot", 1, color='yellow')
1050
+
1051
+ def _cmd_flash_esp32(self):
1052
+ """List ESP32 partitions"""
1053
+ info = self._mpy.partitions()
1054
+ print(f"{'Label':<12} {'Type':<8} {'Subtype':<10} {'Address':>10} {'Size':>10} "
1055
+ f"{'Block':>8} {'Actual FS':<12} {'Flags'}")
1056
+ print("-" * 90)
1057
+
1058
+ for p in info['partitions']:
1059
+ flags = []
1060
+ if p['encrypted']:
1061
+ flags.append('enc')
1062
+ if p['running']:
1063
+ flags.append('running')
1064
+ # Block size column
1065
+ block_str = ''
1066
+ if p.get('fs_block_size'):
1067
+ block_str = self.format_size(p['fs_block_size'])
1068
+ # Filesystem column
1069
+ fs_info = ''
1070
+ if p.get('filesystem'):
1071
+ fs_info = p['filesystem']
1072
+ # For FAT, append cluster size
1073
+ if p.get('fs_cluster_size'):
1074
+ fs_info += f" ({self.format_size(p['fs_cluster_size'])})"
1075
+ print(f"{p['label']:<12} {p['type_name']:<8} {p['subtype_name']:<10} "
1076
+ f"{p['offset']:>#10x} {self.format_size(p['size']):>10} "
1077
+ f"{block_str:>8} {fs_info:<12} {', '.join(flags)}")
1078
+
1079
+ if info['boot']:
1080
+ print(f"\nBoot partition: {info['boot']}")
1081
+ if info['next_ota']:
1082
+ print(f"Next OTA: {info['next_ota']}")
641
1083
 
642
1084
  def cmd_info(self):
643
1085
  self.verbose("INFO", 2)
644
- self._mpy.comm.exec("import sys, gc, os")
645
- platform = self._mpy.comm.exec_eval("repr(sys.platform)")
646
- version = self._mpy.comm.exec_eval("repr(sys.version)")
647
- impl = self._mpy.comm.exec_eval("repr(sys.implementation.name)")
648
- gc_free = self._mpy.comm.exec_eval("gc.mem_free()")
649
- gc_alloc = self._mpy.comm.exec_eval("gc.mem_alloc()")
650
- gc_total = gc_free + gc_alloc
651
- gc_pct = (gc_alloc / gc_total * 100) if gc_total > 0 else 0
652
- try:
653
- uname = self._mpy.comm.exec_eval("tuple(os.uname())")
654
- machine = uname[4] if len(uname) > 4 else None
655
- except _mpytool.MpyError:
656
- machine = None
657
- # Collect filesystem info - root and any different mount points
658
- fs_info = []
659
- try:
660
- fs_stat = self._mpy.comm.exec_eval("os.statvfs('/')")
661
- fs_total = fs_stat[0] * fs_stat[2]
662
- fs_free = fs_stat[0] * fs_stat[3]
663
- if fs_total > 0:
664
- fs_info.append({
665
- 'mount': '/', 'total': fs_total,
666
- 'used': fs_total - fs_free,
667
- 'pct': ((fs_total - fs_free) / fs_total * 100)
668
- })
669
- except _mpytool.MpyError:
670
- pass
671
- # Check subdirectories for additional mount points
672
- try:
673
- root_dirs = self._mpy.comm.exec_eval("[d[0] for d in os.ilistdir('/') if d[1] == 0x4000]")
674
- for dirname in root_dirs:
675
- try:
676
- path = '/' + dirname
677
- sub_stat = self._mpy.comm.exec_eval(f"os.statvfs('{path}')")
678
- sub_total = sub_stat[0] * sub_stat[2]
679
- sub_free = sub_stat[0] * sub_stat[3]
680
- # Skip if same as root or zero size
681
- if sub_total == 0 or any(f['total'] == sub_total for f in fs_info):
682
- continue
683
- fs_info.append({
684
- 'mount': path, 'total': sub_total,
685
- 'used': sub_total - sub_free,
686
- 'pct': ((sub_total - sub_free) / sub_total * 100)
687
- })
688
- except _mpytool.MpyError:
689
- pass
690
- except _mpytool.MpyError:
691
- pass
692
- print(f"Platform: {platform}")
693
- print(f"Version: {version}")
694
- print(f"Impl: {impl}")
695
- if machine:
696
- print(f"Machine: {machine}")
697
- print(f"Memory: {self.format_size(gc_alloc)} / {self.format_size(gc_total)} ({gc_pct:.2f}%)")
698
- for fs in fs_info:
1086
+ plat = self._mpy.platform()
1087
+ print(f"Platform: {plat['platform']}")
1088
+ print(f"Version: {plat['version']}")
1089
+ print(f"Impl: {plat['impl']}")
1090
+ if plat['machine']:
1091
+ print(f"Machine: {plat['machine']}")
1092
+ uid = self._mpy.unique_id()
1093
+ if uid:
1094
+ print(f"Serial: {uid}")
1095
+ for iface, mac in self._mpy.mac_addresses():
1096
+ print(f"MAC {iface + ':':<8} {mac}")
1097
+ mem = self._mpy.memory()
1098
+ mem_pct = (mem['alloc'] / mem['total'] * 100) if mem['total'] > 0 else 0
1099
+ print(f"Memory: {self.format_size(mem['alloc'])} / {self.format_size(mem['total'])} ({mem_pct:.2f}%)")
1100
+ for fs in self._mpy.filesystems():
699
1101
  label = "Flash:" if fs['mount'] == '/' else fs['mount'] + ':'
700
- print(f"{label:12} {self.format_size(fs['used'])} / {self.format_size(fs['total'])} ({fs['pct']:.2f}%)")
1102
+ fs_pct = (fs['used'] / fs['total'] * 100) if fs['total'] > 0 else 0
1103
+ print(f"{label:12} {self.format_size(fs['used'])} / {self.format_size(fs['total'])} ({fs_pct:.2f}%)")
701
1104
 
702
- def process_commands(self, commands):
703
- try:
704
- while commands:
705
- command = commands.pop(0)
706
- if command in ('ls', 'dir'):
707
- if commands:
708
- dir_name = commands.pop(0)
709
- if dir_name != '/':
710
- dir_name = dir_name.rstrip('/')
711
- self.cmd_ls(dir_name)
712
- continue
713
- self.cmd_ls('.')
714
- elif command == 'tree':
715
- if commands:
716
- dir_name = commands.pop(0)
717
- if dir_name != '/':
718
- dir_name = dir_name.rstrip('/')
719
- self.cmd_tree(dir_name)
720
- continue
721
- self.cmd_tree('.')
722
- elif command in ('get', 'cat'):
723
- if commands:
724
- self.cmd_get(*commands)
725
- break
726
- raise ParamsError('missing file name for get command')
727
- elif command == 'put':
728
- if commands:
729
- src_path = commands.pop(0)
730
- dst_path = ''
731
- if commands:
732
- dst_path = commands.pop(0)
733
- self.cmd_put(src_path, dst_path)
734
- else:
735
- raise ParamsError('missing file name for put command')
736
- elif command == 'mkdir':
737
- self.cmd_mkdir(*commands)
738
- break
739
- elif command in ('del', 'delete', 'rm'):
740
- self.cmd_delete(*commands)
741
- break
742
- elif command == 'reset':
743
- self.verbose("RESET", 1)
744
- self._mpy.comm.soft_reset()
745
- self._mpy.reset_state()
746
- elif command == 'follow':
747
- self.cmd_follow()
748
- break
749
- elif command == 'repl':
750
- self.cmd_repl()
751
- break
752
- elif command == 'exec':
753
- if commands:
754
- code = commands.pop(0)
755
- self.cmd_exec(code)
756
- else:
757
- raise ParamsError('missing code for exec command')
758
- elif command == 'info':
759
- self.cmd_info()
760
- elif command == 'cp':
761
- if len(commands) >= 2:
762
- self.cmd_cp(*commands)
763
- break
764
- raise ParamsError('cp requires source and destination')
765
- elif command == 'mv':
766
- if len(commands) >= 2:
767
- self.cmd_mv(*commands)
768
- break
769
- raise ParamsError('mv requires source and destination')
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':
1136
+ try:
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)")
1147
+ except (_mpytool.ConnError, OSError) as 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)
770
1312
  else:
771
- raise ParamsError(f"unknown command: '{command}'")
772
- except (_mpytool.MpyError, _mpytool.ConnError) as err:
773
- self._log.error(err)
1313
+ break
1314
+ self.cmd_flash_erase(label=label, full=full)
1315
+ else:
1316
+ self.cmd_flash()
1317
+
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))
1322
+
1323
+ def _dispatch_sleep(self, commands, is_last_group):
1324
+ if not commands:
1325
+ raise ParamsError('sleep requires a number (seconds)')
1326
+ try:
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)
1332
+
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()
1338
+
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()
1344
+
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')
1349
+ try:
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)
1355
+
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
+ })
1363
+
1364
+ def process_commands(self, commands, is_last_group=False):
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)
774
1371
  try:
775
1372
  self._mpy.comm.exit_raw_repl()
776
1373
  except _mpytool.ConnError:
@@ -782,65 +1379,97 @@ if _about:
782
1379
  else:
783
1380
  _VERSION_STR = "mpytool (not installed version)"
784
1381
  _COMMANDS_HELP_STR = """
785
- List of available commands:
786
- ls [{path}] list files and its sizes
787
- tree [{path}] list tree of structure and sizes
788
- cp {src} [...] {dst} copy files (: prefix = device path)
789
- mv {src} [...] {dst} move/rename on device (: prefix required)
790
- get {path} [...] get file and print it
791
- put {src_path} [{dst_path}] put file or directory to destination
792
- mkdir {path} [...] create directory (also create all parents)
793
- delete {path} [...] remove file/dir (path/ = contents only)
794
- reset soft reset
795
- follow print log of running program
796
- repl enter REPL mode [UNIX OS ONLY]
797
- 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
798
1401
  info show device information
799
- Aliases:
800
- dir alias to ls
801
- cat alias to get
802
- del, rm alias to delete
803
- Use -- to separate multiple commands:
804
- mpytool put main.py / -- reset -- follow
1402
+ flash show flash/partitions info
1403
+ flash read [{label}] {file} read flash/partition to file
1404
+ flash write [{label}] {file} write file to flash/partition
1405
+ flash erase [{label}] [--full] erase flash/partition
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
805
1410
  """
806
1411
 
807
1412
 
808
1413
  def _run_commands(mpy_tool, command_groups, with_progress=True):
809
1414
  """Execute command groups with optional batch progress tracking"""
810
1415
  if not with_progress:
811
- for commands in command_groups:
812
- mpy_tool.process_commands(commands)
1416
+ for i, commands in enumerate(command_groups):
1417
+ is_last = (i == len(command_groups) - 1)
1418
+ mpy_tool.process_commands(commands, is_last_group=is_last)
813
1419
  return
814
- # Pre-scan to identify consecutive copy command batches (for progress)
815
1420
  i = 0
816
1421
  while i < len(command_groups):
817
- is_copy, count, paths = mpy_tool.count_files_for_command(command_groups[i])
1422
+ is_copy, count, src_paths, dst_paths = mpy_tool.count_files_for_command(command_groups[i])
818
1423
  if not is_copy:
819
- mpy_tool.process_commands(command_groups[i])
1424
+ is_last = (i == len(command_groups) - 1)
1425
+ mpy_tool.process_commands(command_groups[i], is_last_group=is_last)
820
1426
  i += 1
821
1427
  continue
822
- # Collect consecutive copy commands into a batch
823
1428
  batch_total = count
824
- all_paths = paths
1429
+ all_src_paths = src_paths
1430
+ all_dst_paths = dst_paths
825
1431
  batch_start = i
826
1432
  j = i + 1
827
1433
  while j < len(command_groups):
828
- is_copy_j, count_j, paths_j = mpy_tool.count_files_for_command(command_groups[j])
1434
+ is_copy_j, count_j, src_paths_j, dst_paths_j = mpy_tool.count_files_for_command(command_groups[j])
829
1435
  if not is_copy_j:
830
1436
  break
831
1437
  batch_total += count_j
832
- all_paths.extend(paths_j)
1438
+ all_src_paths.extend(src_paths_j)
1439
+ all_dst_paths.extend(dst_paths_j)
833
1440
  j += 1
834
- # Execute batch with combined count
835
- max_src_len = max(len(p) for p in all_paths) if all_paths else 0
836
- mpy_tool.verbose("COPY", 1)
837
- mpy_tool.set_batch_progress(batch_total, max_src_len)
1441
+ max_src_len = max(len(p) for p in all_src_paths) if all_src_paths else 0
1442
+ max_dst_len = max(len(p) for p in all_dst_paths) if all_dst_paths else 0
1443
+ mpy_tool.print_transfer_info()
1444
+ mpy_tool.set_batch_progress(batch_total, max_src_len, max_dst_len)
838
1445
  for k in range(batch_start, j):
839
- mpy_tool.process_commands(command_groups[k])
1446
+ is_last = (k == j - 1) and (j == len(command_groups))
1447
+ mpy_tool.process_commands(command_groups[k], is_last_group=is_last)
1448
+ mpy_tool.print_copy_summary()
840
1449
  mpy_tool.reset_batch_progress()
841
1450
  i = j
842
1451
 
843
1452
 
1453
+ def _parse_chunk_size(value):
1454
+ """Parse chunk size value (e.g., '1K', '2K', '4096')"""
1455
+ valid = {512, 1024, 2048, 4096, 8192, 16384, 32768}
1456
+ val = value.upper()
1457
+ if val.endswith('K'):
1458
+ try:
1459
+ num = int(val[:-1]) * 1024
1460
+ except ValueError:
1461
+ raise _argparse.ArgumentTypeError(f"invalid chunk size: {value}")
1462
+ else:
1463
+ try:
1464
+ num = int(value)
1465
+ except ValueError:
1466
+ raise _argparse.ArgumentTypeError(f"invalid chunk size: {value}")
1467
+ if num not in valid:
1468
+ valid_str = ', '.join(f'{v//1024}K' if v >= 1024 else str(v) for v in sorted(valid))
1469
+ raise _argparse.ArgumentTypeError(f"chunk size must be one of: {valid_str}")
1470
+ return num
1471
+
1472
+
844
1473
  def main():
845
1474
  """Main"""
846
1475
  _description = _about["Summary"] if _about else None
@@ -860,10 +1489,17 @@ def main():
860
1489
  parser.add_argument(
861
1490
  '-q', '--quiet', action='store_true', help='quiet mode (no progress)')
862
1491
  parser.add_argument(
863
- '-f', '--force', action='store_true', help='force copy even if unchanged')
1492
+ "-e", "--exclude", type=str, action='append', dest='exclude',
1493
+ help='exclude files/dirs (wildcards: *, ?), default: *.pyc, .*')
1494
+ parser.add_argument(
1495
+ '-f', '--force', action='store_true', help='force overwrite (skip unchanged check)')
864
1496
  parser.add_argument(
865
- "-e", "--exclude-dir", type=str, action='append', help='exclude dir, '
866
- 'by default are excluded directories: __pycache__, .git, .svn')
1497
+ '-z', '--compress', action='store_true', default=None, help='force compression')
1498
+ parser.add_argument(
1499
+ '-Z', '--no-compress', action='store_true', help='disable compression')
1500
+ parser.add_argument(
1501
+ '-c', '--chunk-size', type=_parse_chunk_size, metavar='SIZE',
1502
+ help='transfer chunk size: 512, 1K, 2K, 4K, 8K, 16K, 32K (default: auto)')
867
1503
  parser.add_argument('commands', nargs=_argparse.REMAINDER, help='commands')
868
1504
  args = parser.parse_args()
869
1505
  # Convert to numeric level: 0=quiet, 1=progress, 2=verbose
@@ -886,9 +1522,11 @@ def main():
886
1522
  return
887
1523
  if len(ports) == 1:
888
1524
  port = ports[0]
889
- log.verbose(f"Using {port}", level=1)
1525
+ log.verbose(f"Using {port}", level=2)
890
1526
  else:
891
- log.error("Multiple serial ports found: %s. Use -p to specify one.", ", ".join(ports))
1527
+ log.error("Multiple serial ports found. Use -p to specify one:")
1528
+ for p in ports:
1529
+ print(f" {p}")
892
1530
  return
893
1531
  try:
894
1532
  if port:
@@ -900,10 +1538,20 @@ def main():
900
1538
  except _mpytool.ConnError as err:
901
1539
  log.error(err)
902
1540
  return
903
- mpy_tool = MpyTool(conn, log=log, verbose=log, exclude_dirs=args.exclude_dir, force=args.force)
1541
+ # Determine compression setting: None=auto, True=force, False=disable
1542
+ compress = None
1543
+ if args.no_compress:
1544
+ compress = False
1545
+ elif args.compress:
1546
+ compress = True
1547
+ mpy_tool = MpyTool(
1548
+ conn, log=log, verbose=log, exclude_dirs=args.exclude,
1549
+ force=args.force, compress=compress, chunk_size=args.chunk_size)
904
1550
  command_groups = _utils.split_commands(args.commands)
905
1551
  try:
906
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)
907
1555
  except KeyboardInterrupt:
908
1556
  # Clear partial progress line and show clean message
909
1557
  log.verbose('Interrupted', level=0, overwrite=True)