mpytool 2.0.0__py3-none-any.whl → 2.1.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")
@@ -26,24 +29,41 @@ class MpyTool():
26
29
  TEE = '├─ '
27
30
  LAST = '└─ '
28
31
 
29
- def __init__(self, conn, log=None, verbose=None, exclude_dirs=None, force=False):
32
+ def __init__(
33
+ self, conn, log=None, verbose=None, exclude_dirs=None,
34
+ force=False, compress=None, chunk_size=None):
30
35
  self._conn = conn
31
36
  self._log = log if log is not None else SimpleColorLogger()
32
37
  self._verbose_out = verbose # None = no verbose output (API mode)
33
- self._exclude_dirs = {'__pycache__', '.git', '.svn'}
38
+ self._exclude_dirs = {'.*', '*.pyc'}
34
39
  if exclude_dirs:
35
40
  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
41
+ self._mpy = _mpytool.Mpy(conn, log=self._log, chunk_size=chunk_size)
42
+ self._force = force
43
+ self._compress = compress
39
44
  self._progress_total_files = 0
40
45
  self._progress_current_file = 0
41
46
  self._progress_src = ''
42
47
  self._progress_dst = ''
43
48
  self._progress_max_src_len = 0
49
+ self._progress_max_dst_len = 0
44
50
  self._is_debug = getattr(self._log, '_loglevel', 1) >= 4
45
51
  self._batch_mode = False
46
52
  self._skipped_files = 0
53
+ self._stats_total_bytes = 0
54
+ self._stats_transferred_bytes = 0
55
+ self._stats_wire_bytes = 0 # Actual bytes sent over wire (with encoding)
56
+ self._stats_transferred_files = 0
57
+ self._stats_start_time = None
58
+ # Remote file info cache for batch operations
59
+ self._remote_file_cache = {} # {path: (size, hash) or None}
60
+
61
+ def _is_excluded(self, name):
62
+ """Check if name matches any exclude pattern (supports wildcards)"""
63
+ for pattern in self._exclude_dirs:
64
+ if _fnmatch.fnmatch(name, pattern):
65
+ return True
66
+ return False
47
67
 
48
68
  @property
49
69
  def _is_tty(self):
@@ -61,6 +81,14 @@ class MpyTool():
61
81
  if self._verbose_out is not None:
62
82
  self._verbose_out.verbose(msg, level, color, end, overwrite)
63
83
 
84
+ def print_transfer_info(self):
85
+ """Print transfer settings (chunk size and compression)"""
86
+ chunk = self._mpy._detect_chunk_size()
87
+ chunk_str = f"{chunk // 1024}K" if chunk >= 1024 else str(chunk)
88
+ compress = self._mpy._detect_deflate() if self._compress is None else self._compress
89
+ compress_str = "on" if compress else "off"
90
+ self.verbose(f"COPY (chunk: {chunk_str}, compress: {compress_str})", 1)
91
+
64
92
  @staticmethod
65
93
  def _format_local_path(path):
66
94
  """Format local path: relative from CWD, absolute if > 2 levels up"""
@@ -81,16 +109,36 @@ class MpyTool():
81
109
  # On Windows, relpath fails for different drives
82
110
  return _os.path.abspath(path)
83
111
 
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}]"
89
- else:
90
- 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}"
112
+ # Encoding info strings for alignment: (base64), (compressed), (base64, compressed), (unchanged)
113
+ _ENC_WIDTH = 22 # Length of longest: " (base64, compressed)"
114
+
115
+ def _format_encoding_info(self, encodings, pad=False):
116
+ """Format encoding info: (base64), (compressed), (base64, compressed)"""
117
+ if not encodings or encodings == {'raw'}:
118
+ return " " * self._ENC_WIDTH if pad else ""
119
+ types = sorted(e for e in encodings if e != 'raw')
120
+ if not types:
121
+ return " " * self._ENC_WIDTH if pad else ""
122
+ info = f" ({', '.join(types)})"
123
+ if pad:
124
+ return f"{info:<{self._ENC_WIDTH}}"
125
+ return info
126
+
127
+ def _format_line(self, status, total, encodings=None):
128
+ """Format progress/skip line: [2/5] 100% 24.1K source -> dest (base64)"""
129
+ size_str = self.format_size(total)
130
+ multi = self._progress_total_files > 1
131
+ prefix = f"[{self._progress_current_file}/{self._progress_total_files}]" if multi else ""
132
+ src_w = max(len(self._progress_src), self._progress_max_src_len)
133
+ dst_w = max(len(self._progress_dst), self._progress_max_dst_len)
134
+ enc = self._format_encoding_info(encodings, pad=multi) if encodings else (" " * self._ENC_WIDTH if multi else "")
135
+ return f"{prefix:>7} {status} {size_str:>5} {self._progress_src:<{src_w}} -> {self._progress_dst:<{dst_w}}{enc}"
136
+
137
+ def _format_progress_line(self, percent, total, encodings=None):
138
+ return self._format_line(f"{percent:3d}%", total, encodings)
139
+
140
+ def _format_skip_line(self, total):
141
+ return self._format_line("skip", total, {'unchanged'})
94
142
 
95
143
  def _progress_callback(self, transferred, total):
96
144
  """Callback for file transfer progress"""
@@ -103,14 +151,13 @@ class MpyTool():
103
151
  # Normal mode: overwrite line
104
152
  self.verbose(line, color='cyan', end='', overwrite=True)
105
153
 
106
- def _progress_complete(self, total):
154
+ def _progress_complete(self, total, encodings=None):
107
155
  """Mark current file as complete"""
108
- line = self._format_progress_line(100, total)
156
+ line = self._format_progress_line(100, total, encodings)
109
157
  if self._is_debug:
110
158
  # Already printed with newline in callback
111
159
  pass
112
160
  else:
113
- # Print final line with newline
114
161
  self.verbose(line, color='cyan', overwrite=True)
115
162
 
116
163
  def _set_progress_info(self, src, dst, is_src_remote, is_dst_remote):
@@ -123,16 +170,77 @@ class MpyTool():
123
170
  self._progress_dst = ':' + dst
124
171
  else:
125
172
  self._progress_dst = self._format_local_path(dst)
173
+ if len(self._progress_dst) > self._progress_max_dst_len:
174
+ self._progress_max_dst_len = len(self._progress_dst)
126
175
 
127
- def _count_local_files(self, path):
128
- """Count files in local directory (excluding excluded dirs)"""
176
+ def _collect_local_paths(self, path):
177
+ """Collect all local file paths (formatted for display)"""
129
178
  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
179
+ return [self._format_local_path(path)]
180
+ paths = []
181
+ for root, dirs, files in _os.walk(path, topdown=True):
182
+ dirs[:] = [d for d in dirs if not self._is_excluded(d)]
183
+ files = [f for f in files if not self._is_excluded(f)]
184
+ paths.extend(self._format_local_path(_os.path.join(root, f)) for f in files)
185
+ return paths
186
+
187
+ def _prefetch_remote_info(self, dst_files):
188
+ """Prefetch remote file info (size and hash) for multiple files
189
+
190
+ Arguments:
191
+ dst_files: dict {remote_path: local_size} - sizes used to skip hash if mismatch
192
+
193
+ Uses batch call to reduce round-trips. Results are cached in _remote_file_cache.
194
+ """
195
+ if self._force or not dst_files:
196
+ return
197
+ files_to_fetch = {p: s for p, s in dst_files.items() if p not in self._remote_file_cache}
198
+ if not files_to_fetch:
199
+ return
200
+ self.verbose(f"Checking {len(files_to_fetch)} files...", 2)
201
+ result = self._mpy.fileinfo(files_to_fetch)
202
+ if result is None:
203
+ # hashlib not available - mark all as needing update
204
+ for path in files_to_fetch:
205
+ self._remote_file_cache[path] = None
206
+ else:
207
+ self._remote_file_cache.update(result)
208
+
209
+ def _collect_dst_files(self, src_path, dst_path, add_src_basename=True):
210
+ """Collect destination paths and local sizes for a local->remote copy operation
211
+
212
+ Arguments:
213
+ src_path: local source path (file or directory)
214
+ dst_path: remote destination base path
215
+ add_src_basename: if True, add source basename to dst_path (matching _put_dir behavior)
216
+
217
+ Returns:
218
+ dict {remote_path: local_file_size}
219
+ """
220
+ files = {}
221
+ if _os.path.isfile(src_path):
222
+ # For files, add basename if dst_path ends with /
223
+ basename = _os.path.basename(src_path)
224
+ if basename and not _os.path.basename(dst_path):
225
+ dst_path = _os.path.join(dst_path, basename)
226
+ files[dst_path] = _os.path.getsize(src_path)
227
+ elif _os.path.isdir(src_path):
228
+ # For directories, mimic _put_dir behavior
229
+ if add_src_basename:
230
+ basename = _os.path.basename(_os.path.abspath(src_path))
231
+ if basename:
232
+ dst_path = _os.path.join(dst_path, basename)
233
+ for root, dirs, filenames in _os.walk(src_path, topdown=True):
234
+ dirs[:] = [d for d in dirs if not self._is_excluded(d)]
235
+ filenames = [f for f in filenames if not self._is_excluded(f)]
236
+ rel_path = _os.path.relpath(root, src_path)
237
+ if rel_path == '.':
238
+ rel_path = ''
239
+ for file_name in filenames:
240
+ spath = _os.path.join(root, file_name)
241
+ dpath = _os.path.join(dst_path, rel_path, file_name) if rel_path else _os.path.join(dst_path, file_name)
242
+ files[dpath] = _os.path.getsize(spath)
243
+ return files
136
244
 
137
245
  def _file_needs_update(self, local_data, remote_path):
138
246
  """Check if local file differs from remote file
@@ -141,7 +249,20 @@ class MpyTool():
141
249
  """
142
250
  if self._force:
143
251
  return True
144
- # Check remote file size first (fast)
252
+ # Check cache first (populated by _prefetch_remote_info)
253
+ if remote_path in self._remote_file_cache:
254
+ cached = self._remote_file_cache[remote_path]
255
+ if cached is None:
256
+ return True # File doesn't exist or hashlib not available
257
+ remote_size, remote_hash = cached
258
+ local_size = len(local_data)
259
+ if local_size != remote_size:
260
+ return True # Different size
261
+ if remote_hash is None:
262
+ return True # Size matched but hash wasn't computed (shouldn't happen with prefetch)
263
+ local_hash = _hashlib.sha256(local_data).digest()
264
+ return local_hash != remote_hash
265
+ # Fallback to individual calls (for single file operations)
145
266
  remote_size = self._mpy.stat(remote_path)
146
267
  if remote_size is None or remote_size < 0:
147
268
  return True # File doesn't exist or is a directory
@@ -155,24 +276,6 @@ class MpyTool():
155
276
  return True # hashlib not available on device
156
277
  return local_hash != remote_hash
157
278
 
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
279
  def _collect_source_paths(self, commands):
177
280
  """Collect source paths for a cp/put command (for alignment calculation)"""
178
281
  paths = []
@@ -188,29 +291,14 @@ class MpyTool():
188
291
  src_path = '/'
189
292
  src_path = src_path.rstrip('/') or '/'
190
293
  if src_is_remote:
191
- # Collect all file paths from remote
192
294
  paths.extend(self._collect_remote_paths(src_path))
193
295
  else:
194
- # Collect all file paths from local
195
296
  paths.extend(self._collect_local_paths(src_path))
196
297
  elif cmd == 'put' and len(commands) >= 2:
197
298
  src_path = commands[1]
198
299
  paths.extend(self._collect_local_paths(src_path))
199
300
  return paths
200
301
 
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
- return paths
213
-
214
302
  def _collect_remote_paths(self, path):
215
303
  """Collect all remote file paths (formatted for display)"""
216
304
  paths = []
@@ -229,20 +317,96 @@ class MpyTool():
229
317
  paths.append(':' + entry_path)
230
318
  return paths
231
319
 
320
+ def _collect_destination_paths(self, commands):
321
+ """Collect formatted destination paths for a cp/put command"""
322
+ if not commands:
323
+ return []
324
+ cmd = commands[0]
325
+ if cmd == 'cp' and len(commands) >= 3:
326
+ # Filter out flags
327
+ args = [a for a in commands[1:] if not a.startswith('-')]
328
+ if len(args) < 2:
329
+ return []
330
+ sources, dest = args[:-1], args[-1]
331
+ dest_is_remote = dest.startswith(':')
332
+ dest_path = (dest[1:] or '/') if dest_is_remote else dest
333
+ dest_is_dir = dest_path.endswith('/')
334
+ dst_paths = []
335
+ for src in sources:
336
+ src_is_remote = src.startswith(':')
337
+ src_path = ((src[1:] or '/') if src_is_remote else src).rstrip('/') or '/'
338
+ copy_contents = src.endswith('/')
339
+ if dest_is_remote and not src_is_remote:
340
+ # local -> remote: reuse _collect_dst_files
341
+ if _os.path.exists(src_path):
342
+ files = self._collect_dst_files(src_path, dest_path.rstrip('/') or '/', not copy_contents)
343
+ dst_paths.extend(':' + p for p in files)
344
+ elif not dest_is_remote and src_is_remote:
345
+ # remote -> local
346
+ dst_paths.extend(self._collect_remote_to_local_dst(src_path, dest_path, dest_is_dir, copy_contents))
347
+ elif dest_is_remote and src_is_remote:
348
+ # remote -> remote (file only)
349
+ stat = self._mpy.stat(src_path)
350
+ 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))
353
+ 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
+ return []
361
+
362
+ def _collect_remote_to_local_dst(self, src_path, dest_path, dest_is_dir, copy_contents):
363
+ """Collect destination paths for remote->local copy"""
364
+ stat = self._mpy.stat(src_path)
365
+ if stat is None:
366
+ return []
367
+ base_dst = dest_path.rstrip('/') or '.'
368
+ if dest_is_dir and not copy_contents and src_path != '/':
369
+ base_dst = _os.path.join(base_dst, src_path.split('/')[-1])
370
+ if stat >= 0: # file
371
+ 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]))]
373
+ return [self._format_local_path(base_dst)]
374
+ return self._collect_remote_dir_dst(src_path, base_dst)
375
+
376
+ def _collect_remote_dir_dst(self, src_path, base_dst):
377
+ """Collect local destination paths for remote directory download"""
378
+ paths = []
379
+ for name, size in self._mpy.ls(src_path):
380
+ entry_src = src_path.rstrip('/') + '/' + name
381
+ entry_dst = _os.path.join(base_dst, name)
382
+ if size is None: # directory
383
+ paths.extend(self._collect_remote_dir_dst(entry_src, entry_dst))
384
+ else: # file
385
+ paths.append(self._format_local_path(entry_dst))
386
+ return paths
387
+
232
388
  def count_files_for_command(self, commands):
233
389
  """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, []
390
+ Returns (is_copy_command, file_count, source_paths, dest_paths)"""
391
+ src_paths = self._collect_source_paths(commands)
392
+ if src_paths:
393
+ dst_paths = self._collect_destination_paths(commands)
394
+ return True, len(src_paths), src_paths, dst_paths
395
+ return False, 0, [], []
239
396
 
240
- def set_batch_progress(self, total_files, max_src_len=0):
397
+ def set_batch_progress(self, total_files, max_src_len=0, max_dst_len=0):
241
398
  """Set batch progress for consecutive copy commands"""
242
399
  self._progress_total_files = total_files
243
400
  self._progress_current_file = 0
244
401
  self._progress_max_src_len = max_src_len
402
+ self._progress_max_dst_len = max_dst_len
245
403
  self._batch_mode = True
404
+ self._stats_total_bytes = 0
405
+ self._stats_transferred_bytes = 0
406
+ self._stats_wire_bytes = 0
407
+ self._stats_transferred_files = 0
408
+ self._skipped_files = 0
409
+ self._stats_start_time = _time.time()
246
410
 
247
411
  def reset_batch_progress(self):
248
412
  """Reset batch progress mode"""
@@ -250,14 +414,43 @@ class MpyTool():
250
414
  self._progress_total_files = 0
251
415
  self._progress_current_file = 0
252
416
  self._progress_max_src_len = 0
417
+ self._progress_max_dst_len = 0
418
+ self._remote_file_cache.clear()
419
+
420
+ def print_copy_summary(self):
421
+ """Print summary after copy operation"""
422
+ if self._stats_start_time is None:
423
+ return
424
+ elapsed = _time.time() - self._stats_start_time
425
+ total = self._stats_total_bytes
426
+ transferred = self._stats_transferred_bytes
427
+ wire = self._stats_wire_bytes
428
+ total_files = self._stats_transferred_files + self._skipped_files
429
+ parts = []
430
+ parts.append(f"{self.format_size(transferred).strip()}")
431
+ if elapsed > 0:
432
+ speed = transferred / elapsed
433
+ parts.append(f"{self.format_size(speed).strip()}/s")
434
+ parts.append(f"{elapsed:.1f}s")
435
+ # Combined speedup: total file size vs actual wire bytes
436
+ # Includes savings from: skipped files, base64 encoding, compression
437
+ if wire > 0 and total > wire:
438
+ speedup = total / wire
439
+ parts.append(f"speedup {speedup:.1f}x")
440
+ summary = " ".join(parts)
441
+ if self._skipped_files > 0:
442
+ file_info = f"{self._stats_transferred_files} transferred, {self._skipped_files} skipped"
443
+ else:
444
+ file_info = f"{total_files} files"
445
+ self.verbose(f" {summary} ({file_info})", color='green')
253
446
 
254
447
  def cmd_ls(self, dir_name):
255
448
  result = self._mpy.ls(dir_name)
256
449
  for name, size in result:
257
450
  if size is not None:
258
- print(f'{size:8d} {name}')
451
+ print(f'{self.format_size(size):>9} {name}')
259
452
  else:
260
- print(f'{"":8} {name}/')
453
+ print(f'{"":9} {name}/')
261
454
 
262
455
  @classmethod
263
456
  def print_tree(cls, tree, prefix='', print_size=True, first=True, last=True):
@@ -275,7 +468,7 @@ class MpyTool():
275
468
  sufix = '/'
276
469
  line = ''
277
470
  if print_size:
278
- line += f'{size:8d} '
471
+ line += f'{cls.format_size(size):>9} '
279
472
  line += prefix + this_prefix + name + sufix
280
473
  print(line)
281
474
  if not sub_tree:
@@ -310,81 +503,71 @@ class MpyTool():
310
503
  data = self._mpy.get(file_name)
311
504
  print(data.decode('utf-8'))
312
505
 
506
+ def _upload_file(self, data, src_path, dst_path, show_progress):
507
+ """Upload file data to device with stats tracking and progress display"""
508
+ file_size = len(data)
509
+ self._stats_total_bytes += file_size
510
+ if not self._file_needs_update(data, dst_path):
511
+ self._skipped_files += 1
512
+ if show_progress and self._verbose >= 1:
513
+ self._progress_current_file += 1
514
+ self._set_progress_info(src_path, dst_path, False, True)
515
+ self.verbose(self._format_skip_line(file_size), color='yellow')
516
+ return False # skipped
517
+ self._stats_transferred_bytes += file_size
518
+ self._stats_transferred_files += 1
519
+ if show_progress and self._verbose >= 1:
520
+ self._progress_current_file += 1
521
+ self._set_progress_info(src_path, dst_path, False, True)
522
+ encodings, wire = self._mpy.put(data, dst_path, self._progress_callback, self._compress)
523
+ self._stats_wire_bytes += wire
524
+ self._progress_complete(file_size, encodings)
525
+ else:
526
+ _, wire = self._mpy.put(data, dst_path, compress=self._compress)
527
+ self._stats_wire_bytes += wire
528
+ return True # uploaded
529
+
313
530
  def _put_dir(self, src_path, dst_path, show_progress=True):
314
- basename = _os.path.basename(src_path)
531
+ basename = _os.path.basename(_os.path.abspath(src_path))
315
532
  if basename:
316
533
  dst_path = _os.path.join(dst_path, basename)
317
534
  self.verbose(f"PUT DIR: {src_path} -> {dst_path}", 2)
535
+ created_dirs = set()
318
536
  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:
537
+ dirs[:] = [d for d in dirs if not self._is_excluded(d)]
538
+ files = [f for f in files if not self._is_excluded(f)]
539
+ if not files:
322
540
  continue
323
541
  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:
542
+ rel_path = _os.path.join(dst_path, '' if rel_path == '.' else rel_path)
543
+ if rel_path and rel_path not in created_dirs:
328
544
  self.verbose(f'MKDIR: {rel_path}', 2)
329
545
  self._mpy.mkdir(rel_path)
546
+ created_dirs.add(rel_path)
330
547
  for file_name in files:
331
548
  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)
549
+ with open(spath, 'rb') as f:
550
+ self._upload_file(f.read(), spath, _os.path.join(rel_path, file_name), show_progress)
350
551
 
351
552
  def _put_file(self, src_path, dst_path, show_progress=True):
352
553
  basename = _os.path.basename(src_path)
353
554
  if basename and not _os.path.basename(dst_path):
354
555
  dst_path = _os.path.join(dst_path, basename)
355
556
  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)
557
+ with open(src_path, 'rb') as f:
558
+ data = f.read()
559
+ parent = _os.path.dirname(dst_path)
560
+ if parent:
561
+ stat = self._mpy.stat(parent)
562
+ if stat is None:
563
+ self._mpy.mkdir(parent)
564
+ elif stat >= 0:
565
+ raise _mpytool.MpyError(f'Error creating file under file: {parent}')
566
+ self._upload_file(data, src_path, dst_path, show_progress)
384
567
 
385
568
  def cmd_put(self, src_path, dst_path):
386
569
  if self._verbose >= 1 and not self._batch_mode:
387
- self._progress_total_files = self._count_local_files(src_path)
570
+ self._progress_total_files = len(self._collect_local_paths(src_path))
388
571
  self._progress_current_file = 0
389
572
  if _os.path.isdir(src_path):
390
573
  self._put_dir(src_path, dst_path)
@@ -396,7 +579,6 @@ class MpyTool():
396
579
  def _get_file(self, src_path, dst_path, show_progress=True):
397
580
  """Download single file from device"""
398
581
  self.verbose(f"GET FILE: {src_path} -> {dst_path}", 2)
399
- # Create destination directory if needed
400
582
  dst_dir = _os.path.dirname(dst_path)
401
583
  if dst_dir and not _os.path.exists(dst_dir):
402
584
  _os.makedirs(dst_dir)
@@ -407,6 +589,10 @@ class MpyTool():
407
589
  self._progress_complete(len(data))
408
590
  else:
409
591
  data = self._mpy.get(src_path)
592
+ file_size = len(data)
593
+ self._stats_total_bytes += file_size
594
+ self._stats_transferred_bytes += file_size
595
+ self._stats_transferred_files += 1
410
596
  with open(dst_path, 'wb') as dst_file:
411
597
  dst_file.write(data)
412
598
 
@@ -442,8 +628,9 @@ class MpyTool():
442
628
  dst_path = dst_path.rstrip('/')
443
629
  if src_is_dir:
444
630
  if copy_contents:
445
- # Copy contents of directory
446
631
  for item in _os.listdir(src_path):
632
+ if self._is_excluded(item):
633
+ continue
447
634
  item_src = _os.path.join(src_path, item)
448
635
  if _os.path.isdir(item_src):
449
636
  self._put_dir(item_src, dst_path)
@@ -484,7 +671,6 @@ class MpyTool():
484
671
  raise ParamsError(f'Source not found on device: {src_path}')
485
672
  if stat == -1:
486
673
  raise ParamsError('Remote-to-remote directory copy not supported yet')
487
- # File copy on device
488
674
  if dst_is_dir:
489
675
  basename = src_path.split('/')[-1]
490
676
  dst_path = dst_path + basename
@@ -493,16 +679,63 @@ class MpyTool():
493
679
  self._progress_current_file += 1
494
680
  self._set_progress_info(src_path, dst_path, True, True)
495
681
  data = self._mpy.get(src_path, self._progress_callback)
496
- self._mpy.put(data, dst_path)
497
- self._progress_complete(len(data))
682
+ encodings, wire = self._mpy.put(data, dst_path, compress=self._compress)
683
+ self._stats_wire_bytes += wire
684
+ self._progress_complete(len(data), encodings)
498
685
  else:
499
686
  data = self._mpy.get(src_path)
500
- self._mpy.put(data, dst_path)
687
+ _, wire = self._mpy.put(data, dst_path, compress=self._compress)
688
+ self._stats_wire_bytes += wire
689
+ file_size = len(data)
690
+ self._stats_total_bytes += file_size
691
+ self._stats_transferred_bytes += file_size
692
+ self._stats_transferred_files += 1
501
693
 
502
694
  def cmd_cp(self, *args):
503
695
  """Copy files between local and device"""
696
+ # Parse flags: -f/--force, -z/--compress, -Z/--no-compress
697
+ args = list(args)
698
+ force = None
699
+ compress = None # None = use global setting
700
+ filtered_args = []
701
+ for a in args:
702
+ if a == '--force':
703
+ force = True
704
+ elif a == '--compress':
705
+ compress = True
706
+ elif a == '--no-compress':
707
+ compress = False
708
+ elif a.startswith('-') and not a.startswith('--') and len(a) > 1:
709
+ # Handle combined short flags like -fz, -fZ
710
+ flags = a[1:]
711
+ if 'f' in flags:
712
+ force = True
713
+ if 'z' in flags:
714
+ compress = True
715
+ if 'Z' in flags:
716
+ compress = False
717
+ remaining = flags.replace('f', '').replace('z', '').replace('Z', '')
718
+ if remaining:
719
+ filtered_args.append('-' + remaining)
720
+ else:
721
+ filtered_args.append(a)
722
+ args = filtered_args
504
723
  if len(args) < 2:
505
724
  raise ParamsError('cp requires source and destination')
725
+ # Save and set flags for this command
726
+ saved_force = self._force
727
+ saved_compress = self._compress
728
+ if force is not None:
729
+ self._force = force
730
+ if compress is not None:
731
+ self._compress = compress
732
+ try:
733
+ self._cmd_cp_impl(args)
734
+ finally:
735
+ self._force = saved_force
736
+ self._compress = saved_compress
737
+
738
+ def _cmd_cp_impl(self, args):
506
739
  sources = list(args[:-1])
507
740
  dest = args[-1]
508
741
  dest_is_remote = dest.startswith(':')
@@ -512,7 +745,6 @@ class MpyTool():
512
745
  dest_is_dir = dest_path.endswith('/')
513
746
  if len(sources) > 1 and not dest_is_dir:
514
747
  raise ParamsError('multiple sources require destination directory (ending with /)')
515
- # Count total files for progress (only if not in batch mode)
516
748
  if self._verbose >= 1 and not self._batch_mode:
517
749
  total_files = 0
518
750
  for src in sources:
@@ -522,11 +754,24 @@ class MpyTool():
522
754
  src_path = '/'
523
755
  src_path_clean = src_path.rstrip('/') or '/'
524
756
  if src_is_remote:
525
- total_files += self._count_remote_files(src_path_clean)
757
+ total_files += len(self._collect_remote_paths(src_path_clean))
526
758
  else:
527
- total_files += self._count_local_files(src_path_clean)
759
+ total_files += len(self._collect_local_paths(src_path_clean))
528
760
  self._progress_total_files = total_files
529
761
  self._progress_current_file = 0
762
+ all_dst_files = {}
763
+ if dest_is_remote:
764
+ for src in sources:
765
+ if not src.startswith(':'): # local source
766
+ copy_contents = src.endswith('/')
767
+ src_path = src.rstrip('/')
768
+ 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))
771
+ if all_dst_files and not self._batch_mode:
772
+ self._progress_max_dst_len = max(len(':' + p) for p in all_dst_files)
773
+ if not self._force:
774
+ self._prefetch_remote_info(all_dst_files)
530
775
  for src in sources:
531
776
  src_is_remote = src.startswith(':')
532
777
  src_path = src[1:] if src_is_remote else src
@@ -547,7 +792,6 @@ class MpyTool():
547
792
  raise ParamsError('mv requires source and destination')
548
793
  sources = list(args[:-1])
549
794
  dest = args[-1]
550
- # Validate all paths are remote
551
795
  if not dest.startswith(':'):
552
796
  raise ParamsError('mv destination must be device path (: prefix)')
553
797
  for src in sources:
@@ -564,7 +808,6 @@ class MpyTool():
564
808
  if stat is None:
565
809
  raise ParamsError(f'Source not found on device: {src_path}')
566
810
  if dest_is_dir:
567
- # Ensure destination directory exists
568
811
  dst_dir = dest_path.rstrip('/')
569
812
  if dst_dir and self._mpy.stat(dst_dir) is None:
570
813
  self._mpy.mkdir(dst_dir)
@@ -595,8 +838,8 @@ class MpyTool():
595
838
  self.verbose(f"DELETE: {path}", 1)
596
839
  self._mpy.delete(path)
597
840
 
598
- def cmd_follow(self):
599
- self.verbose("FOLLOW (Ctrl+C to stop)", 1)
841
+ def cmd_monitor(self):
842
+ self.verbose("MONITOR (Ctrl+C to stop)", 1)
600
843
  try:
601
844
  while True:
602
845
  line = self._conn.read_line()
@@ -604,7 +847,7 @@ class MpyTool():
604
847
  print(line)
605
848
  except KeyboardInterrupt:
606
849
  self.verbose('', level=0, overwrite=True) # newline after ^C
607
- except _mpytool.ConnError as err:
850
+ except (_mpytool.ConnError, OSError) as err:
608
851
  if self._log:
609
852
  self._log.error(err)
610
853
 
@@ -626,80 +869,291 @@ class MpyTool():
626
869
 
627
870
  @staticmethod
628
871
  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'):
872
+ """Format size in bytes to human readable format (like ls -h)"""
873
+ if size < 1024:
874
+ return f"{int(size)}B"
875
+ for unit in ('K', 'M', 'G', 'T'):
633
876
  size /= 1024
634
877
  if size < 10:
635
- return f"{size:.2f} {unit}"
878
+ return f"{size:.2f}{unit}"
636
879
  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"
880
+ return f"{size:.1f}{unit}"
881
+ if size < 1024 or unit == 'T':
882
+ return f"{size:.0f}{unit}"
883
+ return f"{size:.0f}T"
884
+
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
+ def cmd_ota(self, firmware_path):
908
+ """OTA firmware update from local .app-bin file"""
909
+ self.verbose("OTA UPDATE", 1)
910
+ if not _os.path.isfile(firmware_path):
911
+ raise ParamsError(f"Firmware file not found: {firmware_path}")
912
+
913
+ with open(firmware_path, 'rb') as f:
914
+ firmware = f.read()
915
+
916
+ fw_size = len(firmware)
917
+ self.verbose(f" Firmware: {self.format_size(fw_size)}", 1)
918
+ info = self._mpy.partitions()
919
+ if not info['next_ota']:
920
+ raise _mpytool.MpyError("OTA not available (no OTA partitions)")
921
+
922
+ self.verbose(f" Target: {info['next_ota']} ({self.format_size(info['next_ota_size'])})", 1)
923
+ use_compress = self._mpy._detect_deflate()
924
+ chunk_size = self._mpy._detect_chunk_size()
925
+ chunk_str = f"{chunk_size // 1024}K" if chunk_size >= 1024 else str(chunk_size)
926
+ self.verbose(f" Writing (chunk: {chunk_str}, compress: {'on' if use_compress else 'off'})...", 1)
927
+ start_time = _time.time()
928
+
929
+ def progress_callback(transferred, total, wire_bytes):
930
+ if self._verbose >= 1:
931
+ pct = transferred * 100 // total
932
+ elapsed = _time.time() - start_time
933
+ speed = transferred / elapsed / 1024 if elapsed > 0 else 0
934
+ line = f" Writing: {pct:3d}% {self.format_size(transferred):>6} / {self.format_size(total)} {speed:.1f} KB/s"
935
+ self.verbose(line, color='cyan', end='', overwrite=True)
936
+
937
+ result = self._mpy.ota_write(firmware, progress_callback, self._compress)
938
+ elapsed = _time.time() - start_time
939
+ speed = fw_size / elapsed / 1024 if elapsed > 0 else 0
940
+ ratio = fw_size / result['wire_bytes'] if result['wire_bytes'] > 0 else 1
941
+ self.verbose(
942
+ f" Writing: 100% {self.format_size(fw_size):>6} {elapsed:.1f}s {speed:.1f} KB/s ratio {ratio:.2f}x",
943
+ color='cyan', overwrite=True
944
+ )
945
+
946
+ self.verbose(f" OTA complete! Use 'mreset' to boot into new firmware.", 1, color='green')
947
+
948
+ def cmd_flash(self):
949
+ """Show flash information (auto-detect platform)"""
950
+ self.verbose("FLASH", 2)
951
+ platform = self._mpy.platform()['platform']
952
+
953
+ if platform == 'rp2':
954
+ self._cmd_flash_rp2()
955
+ elif platform == 'esp32':
956
+ self._cmd_flash_esp32()
957
+ else:
958
+ raise _mpytool.MpyError(f"Flash info not supported for platform: {platform}")
959
+
960
+ def _cmd_flash_rp2(self):
961
+ """Show RP2 flash information"""
962
+ info = self._mpy.flash_info()
963
+ print(f"Platform: RP2")
964
+ print(f"Flash size: {self.format_size(info['size'])}")
965
+ print(f"Block size: {info['block_size']} bytes")
966
+ print(f"Block count: {info['block_count']}")
967
+ fs_line = f"Filesystem: {info['filesystem']}"
968
+ # For FAT, show cluster size if detected from magic
969
+ if info.get('fs_block_size'):
970
+ fs_line += f" (cluster: {self.format_size(info['fs_block_size'])})"
971
+ if info['filesystem'] == 'unknown' and info.get('magic'):
972
+ magic_hex = ' '.join(f'{b:02x}' for b in info['magic'])
973
+ fs_line += f" (magic: {magic_hex})"
974
+ print(fs_line)
975
+
976
+ def _make_progress(self, action, label=None):
977
+ """Create progress callback for flash operations"""
978
+ def progress(transferred, total, *_):
979
+ if self._verbose >= 1:
980
+ pct = (transferred / total * 100) if total > 0 else 0
981
+ prefix = f"{action} {label}" if label else action
982
+ self.verbose(
983
+ f" {prefix}: {pct:.0f}% {self.format_size(transferred)} / {self.format_size(total)}",
984
+ color='cyan', end='', overwrite=True)
985
+ return progress
986
+
987
+ def cmd_flash_read(self, dest_path, label=None):
988
+ """Read flash/partition content to file"""
989
+ if label:
990
+ self.verbose(f"FLASH READ {label} -> {dest_path}", 1)
991
+ else:
992
+ self.verbose(f"FLASH READ -> {dest_path}", 1)
993
+
994
+ data = self._mpy.flash_read(label=label, progress_callback=self._make_progress("reading", label))
995
+
996
+ if self._verbose >= 1:
997
+ print() # newline after progress
998
+
999
+ with open(dest_path, 'wb') as f:
1000
+ f.write(data)
1001
+
1002
+ self.verbose(f" saved {self.format_size(len(data))} to {dest_path}", 1, color='green')
1003
+
1004
+ def cmd_flash_write(self, src_path, label=None):
1005
+ """Write file content to flash/partition"""
1006
+ if label:
1007
+ self.verbose(f"FLASH WRITE {src_path} -> {label}", 1)
1008
+ else:
1009
+ self.verbose(f"FLASH WRITE {src_path}", 1)
1010
+
1011
+ with open(src_path, 'rb') as f:
1012
+ data = f.read()
1013
+
1014
+ result = self._mpy.flash_write(
1015
+ data, label=label,
1016
+ progress_callback=self._make_progress("writing", label),
1017
+ compress=self._compress)
1018
+
1019
+ if self._verbose >= 1:
1020
+ print() # newline after progress
1021
+
1022
+ target = label or "flash"
1023
+ comp_info = " (compressed)" if result.get('compressed') else ""
1024
+ self.verbose(f" wrote {self.format_size(result['written'])} to {target}{comp_info}", 1, color='green')
1025
+
1026
+ def cmd_flash_erase(self, label=None, full=False):
1027
+ """Erase flash/partition (filesystem reset)"""
1028
+ mode = "full" if full else "quick"
1029
+ if label:
1030
+ self.verbose(f"FLASH ERASE {label} ({mode})", 1)
1031
+ else:
1032
+ self.verbose(f"FLASH ERASE ({mode})", 1)
1033
+
1034
+ result = self._mpy.flash_erase(label=label, full=full, progress_callback=self._make_progress("erasing", label))
1035
+
1036
+ if self._verbose >= 1:
1037
+ print() # newline after progress
1038
+
1039
+ target = label or "flash"
1040
+ self.verbose(f" erased {self.format_size(result['erased'])} from {target}", 1, color='green')
1041
+ if not label:
1042
+ self.verbose(" filesystem will be recreated on next boot", 1, color='yellow')
1043
+
1044
+ def _cmd_flash_esp32(self):
1045
+ """List ESP32 partitions"""
1046
+ info = self._mpy.partitions()
1047
+ print(f"{'Label':<12} {'Type':<8} {'Subtype':<10} {'Address':>10} {'Size':>10} "
1048
+ f"{'Block':>8} {'Actual FS':<12} {'Flags'}")
1049
+ print("-" * 90)
1050
+
1051
+ for p in info['partitions']:
1052
+ flags = []
1053
+ if p['encrypted']:
1054
+ flags.append('enc')
1055
+ if p['running']:
1056
+ flags.append('running')
1057
+ # Block size column
1058
+ block_str = ''
1059
+ if p.get('fs_block_size'):
1060
+ block_str = self.format_size(p['fs_block_size'])
1061
+ # Filesystem column
1062
+ fs_info = ''
1063
+ if p.get('filesystem'):
1064
+ fs_info = p['filesystem']
1065
+ # For FAT, append cluster size
1066
+ if p.get('fs_cluster_size'):
1067
+ fs_info += f" ({self.format_size(p['fs_cluster_size'])})"
1068
+ print(f"{p['label']:<12} {p['type_name']:<8} {p['subtype_name']:<10} "
1069
+ f"{p['offset']:>#10x} {self.format_size(p['size']):>10} "
1070
+ f"{block_str:>8} {fs_info:<12} {', '.join(flags)}")
1071
+
1072
+ if info['boot']:
1073
+ print(f"\nBoot partition: {info['boot']}")
1074
+ if info['next_ota']:
1075
+ print(f"Next OTA: {info['next_ota']}")
641
1076
 
642
1077
  def cmd_info(self):
643
1078
  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 = []
1079
+ plat = self._mpy.platform()
1080
+ print(f"Platform: {plat['platform']}")
1081
+ print(f"Version: {plat['version']}")
1082
+ print(f"Impl: {plat['impl']}")
1083
+ if plat['machine']:
1084
+ print(f"Machine: {plat['machine']}")
1085
+ uid = self._mpy.unique_id()
1086
+ if uid:
1087
+ print(f"Serial: {uid}")
1088
+ for iface, mac in self._mpy.mac_addresses():
1089
+ print(f"MAC {iface + ':':<8} {mac}")
1090
+ mem = self._mpy.memory()
1091
+ mem_pct = (mem['alloc'] / mem['total'] * 100) if mem['total'] > 0 else 0
1092
+ print(f"Memory: {self.format_size(mem['alloc'])} / {self.format_size(mem['total'])} ({mem_pct:.2f}%)")
1093
+ for fs in self._mpy.filesystems():
1094
+ label = "Flash:" if fs['mount'] == '/' else fs['mount'] + ':'
1095
+ fs_pct = (fs['used'] / fs['total'] * 100) if fs['total'] > 0 else 0
1096
+ print(f"{label:12} {self.format_size(fs['used'])} / {self.format_size(fs['total'])} ({fs_pct:.2f}%)")
1097
+
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:
1102
+ try:
1103
+ self.verbose(" reconnecting...", 1, color='yellow')
1104
+ self._mpy.machine_reset(reconnect=True)
1105
+ self.verbose(" connected", 1, color='green')
1106
+ except (_mpytool.ConnError, OSError) as err:
1107
+ self.verbose(f" reconnect failed: {err}", 1, color='red')
1108
+ raise _mpytool.ConnError(f"Reconnect failed: {err}")
1109
+ else:
1110
+ self._mpy.machine_reset(reconnect=False)
1111
+
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()
1116
+
1117
+ def cmd_rtsreset(self, reconnect=True):
1118
+ """Hardware reset device using RTS signal with optional auto-reconnect"""
1119
+ self.verbose("RTSRESET", 1)
659
1120
  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
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}")
1132
+
1133
+ def cmd_bootloader(self):
1134
+ """Enter bootloader using machine.bootloader()"""
1135
+ self.verbose("BOOTLOADER", 1)
1136
+ self._mpy.machine_bootloader()
1137
+
1138
+ def cmd_dtrboot(self):
1139
+ """Enter bootloader using DTR/RTS signals (ESP32 only)
1140
+
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)
672
1146
  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:
699
- 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}%)")
1147
+ self._mpy.reset_to_bootloader()
1148
+ except NotImplementedError:
1149
+ raise _mpytool.MpyError("DTR boot not available (serial only)")
1150
+
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)
701
1155
 
702
- def process_commands(self, commands):
1156
+ def process_commands(self, commands, is_last_group=False):
703
1157
  try:
704
1158
  while commands:
705
1159
  command = commands.pop(0)
@@ -741,10 +1195,15 @@ class MpyTool():
741
1195
  break
742
1196
  elif command == 'reset':
743
1197
  self.verbose("RESET", 1)
744
- self._mpy.comm.soft_reset()
745
- self._mpy.reset_state()
746
- elif command == 'follow':
747
- self.cmd_follow()
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()
748
1207
  break
749
1208
  elif command == 'repl':
750
1209
  self.cmd_repl()
@@ -757,6 +1216,72 @@ class MpyTool():
757
1216
  raise ParamsError('missing code for exec command')
758
1217
  elif command == 'info':
759
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)')
760
1285
  elif command == 'cp':
761
1286
  if len(commands) >= 2:
762
1287
  self.cmd_cp(*commands)
@@ -767,6 +1292,12 @@ class MpyTool():
767
1292
  self.cmd_mv(*commands)
768
1293
  break
769
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()
770
1301
  else:
771
1302
  raise ParamsError(f"unknown command: '{command}'")
772
1303
  except (_mpytool.MpyError, _mpytool.ConnError) as err:
@@ -785,62 +1316,98 @@ _COMMANDS_HELP_STR = """
785
1316
  List of available commands:
786
1317
  ls [{path}] list files and its sizes
787
1318
  tree [{path}] list tree of structure and sizes
788
- cp {src} [...] {dst} copy files (: prefix = device path)
1319
+ cp [-f] {src} [...] {dst} copy files (: prefix = device path, -f = force)
789
1320
  mv {src} [...] {dst} move/rename on device (: prefix required)
790
1321
  get {path} [...] get file and print it
791
1322
  put {src_path} [{dst_path}] put file or directory to destination
792
1323
  mkdir {path} [...] create directory (also create all parents)
793
1324
  delete {path} [...] remove file/dir (path/ = contents only)
794
- reset soft reset
795
- follow print log of running program
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
796
1332
  repl enter REPL mode [UNIX OS ONLY]
797
1333
  exec {code} execute Python code on device
798
1334
  info show device information
1335
+ flash show flash info (RP2) or partitions (ESP32)
1336
+ flash read [{label}] {file} read flash/partition to file
1337
+ flash write [{label}] {file} write file to flash/partition
1338
+ flash erase [{label}] [--full] erase flash/partition
1339
+ ota {firmware.app-bin} OTA firmware update (ESP32 only)
1340
+ sleep {seconds} sleep for specified seconds
799
1341
  Aliases:
800
1342
  dir alias to ls
801
1343
  cat alias to get
802
1344
  del, rm alias to delete
1345
+ follow alias to monitor
803
1346
  Use -- to separate multiple commands:
804
- mpytool put main.py / -- reset -- follow
1347
+ mpytool put main.py / -- reset -- monitor
805
1348
  """
806
1349
 
807
1350
 
808
1351
  def _run_commands(mpy_tool, command_groups, with_progress=True):
809
1352
  """Execute command groups with optional batch progress tracking"""
810
1353
  if not with_progress:
811
- for commands in command_groups:
812
- mpy_tool.process_commands(commands)
1354
+ for i, commands in enumerate(command_groups):
1355
+ is_last = (i == len(command_groups) - 1)
1356
+ mpy_tool.process_commands(commands, is_last_group=is_last)
813
1357
  return
814
- # Pre-scan to identify consecutive copy command batches (for progress)
815
1358
  i = 0
816
1359
  while i < len(command_groups):
817
- is_copy, count, paths = mpy_tool.count_files_for_command(command_groups[i])
1360
+ is_copy, count, src_paths, dst_paths = mpy_tool.count_files_for_command(command_groups[i])
818
1361
  if not is_copy:
819
- mpy_tool.process_commands(command_groups[i])
1362
+ is_last = (i == len(command_groups) - 1)
1363
+ mpy_tool.process_commands(command_groups[i], is_last_group=is_last)
820
1364
  i += 1
821
1365
  continue
822
- # Collect consecutive copy commands into a batch
823
1366
  batch_total = count
824
- all_paths = paths
1367
+ all_src_paths = src_paths
1368
+ all_dst_paths = dst_paths
825
1369
  batch_start = i
826
1370
  j = i + 1
827
1371
  while j < len(command_groups):
828
- is_copy_j, count_j, paths_j = mpy_tool.count_files_for_command(command_groups[j])
1372
+ is_copy_j, count_j, src_paths_j, dst_paths_j = mpy_tool.count_files_for_command(command_groups[j])
829
1373
  if not is_copy_j:
830
1374
  break
831
1375
  batch_total += count_j
832
- all_paths.extend(paths_j)
1376
+ all_src_paths.extend(src_paths_j)
1377
+ all_dst_paths.extend(dst_paths_j)
833
1378
  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)
1379
+ max_src_len = max(len(p) for p in all_src_paths) if all_src_paths else 0
1380
+ max_dst_len = max(len(p) for p in all_dst_paths) if all_dst_paths else 0
1381
+ mpy_tool.print_transfer_info()
1382
+ mpy_tool.set_batch_progress(batch_total, max_src_len, max_dst_len)
838
1383
  for k in range(batch_start, j):
839
- mpy_tool.process_commands(command_groups[k])
1384
+ is_last = (k == j - 1) and (j == len(command_groups))
1385
+ mpy_tool.process_commands(command_groups[k], is_last_group=is_last)
1386
+ mpy_tool.print_copy_summary()
840
1387
  mpy_tool.reset_batch_progress()
841
1388
  i = j
842
1389
 
843
1390
 
1391
+ def _parse_chunk_size(value):
1392
+ """Parse chunk size value (e.g., '1K', '2K', '4096')"""
1393
+ valid = {512, 1024, 2048, 4096, 8192, 16384, 32768}
1394
+ val = value.upper()
1395
+ if val.endswith('K'):
1396
+ try:
1397
+ num = int(val[:-1]) * 1024
1398
+ except ValueError:
1399
+ raise _argparse.ArgumentTypeError(f"invalid chunk size: {value}")
1400
+ else:
1401
+ try:
1402
+ num = int(value)
1403
+ except ValueError:
1404
+ raise _argparse.ArgumentTypeError(f"invalid chunk size: {value}")
1405
+ if num not in valid:
1406
+ valid_str = ', '.join(f'{v//1024}K' if v >= 1024 else str(v) for v in sorted(valid))
1407
+ raise _argparse.ArgumentTypeError(f"chunk size must be one of: {valid_str}")
1408
+ return num
1409
+
1410
+
844
1411
  def main():
845
1412
  """Main"""
846
1413
  _description = _about["Summary"] if _about else None
@@ -860,10 +1427,17 @@ def main():
860
1427
  parser.add_argument(
861
1428
  '-q', '--quiet', action='store_true', help='quiet mode (no progress)')
862
1429
  parser.add_argument(
863
- '-f', '--force', action='store_true', help='force copy even if unchanged')
1430
+ "-e", "--exclude", type=str, action='append', dest='exclude',
1431
+ help='exclude files/dirs (wildcards: *, ?), default: *.pyc, .*')
1432
+ parser.add_argument(
1433
+ '-f', '--force', action='store_true', help='force overwrite (skip unchanged check)')
1434
+ parser.add_argument(
1435
+ '-z', '--compress', action='store_true', default=None, help='force compression')
864
1436
  parser.add_argument(
865
- "-e", "--exclude-dir", type=str, action='append', help='exclude dir, '
866
- 'by default are excluded directories: __pycache__, .git, .svn')
1437
+ '-Z', '--no-compress', action='store_true', help='disable compression')
1438
+ parser.add_argument(
1439
+ '-c', '--chunk-size', type=_parse_chunk_size, metavar='SIZE',
1440
+ help='transfer chunk size: 512, 1K, 2K, 4K, 8K, 16K, 32K (default: auto)')
867
1441
  parser.add_argument('commands', nargs=_argparse.REMAINDER, help='commands')
868
1442
  args = parser.parse_args()
869
1443
  # Convert to numeric level: 0=quiet, 1=progress, 2=verbose
@@ -886,9 +1460,11 @@ def main():
886
1460
  return
887
1461
  if len(ports) == 1:
888
1462
  port = ports[0]
889
- log.verbose(f"Using {port}", level=1)
1463
+ log.verbose(f"Using {port}", level=2)
890
1464
  else:
891
- log.error("Multiple serial ports found: %s. Use -p to specify one.", ", ".join(ports))
1465
+ log.error("Multiple serial ports found. Use -p to specify one:")
1466
+ for p in ports:
1467
+ print(f" {p}")
892
1468
  return
893
1469
  try:
894
1470
  if port:
@@ -900,13 +1476,23 @@ def main():
900
1476
  except _mpytool.ConnError as err:
901
1477
  log.error(err)
902
1478
  return
903
- mpy_tool = MpyTool(conn, log=log, verbose=log, exclude_dirs=args.exclude_dir, force=args.force)
1479
+ # Determine compression setting: None=auto, True=force, False=disable
1480
+ compress = None
1481
+ if args.no_compress:
1482
+ compress = False
1483
+ elif args.compress:
1484
+ compress = True
1485
+ mpy_tool = MpyTool(
1486
+ conn, log=log, verbose=log, exclude_dirs=args.exclude,
1487
+ force=args.force, compress=compress, chunk_size=args.chunk_size)
904
1488
  command_groups = _utils.split_commands(args.commands)
905
1489
  try:
906
1490
  _run_commands(mpy_tool, command_groups, with_progress=(args.verbose >= 1))
907
1491
  except KeyboardInterrupt:
908
1492
  # Clear partial progress line and show clean message
909
1493
  log.verbose('Interrupted', level=0, overwrite=True)
1494
+ except (_mpytool.MpyError, _mpytool.ConnError, _mpytool.Timeout) as err:
1495
+ log.error(err)
910
1496
 
911
1497
 
912
1498
  if __name__ == '__main__':