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/conn.py +29 -0
- mpytool/conn_serial.py +77 -0
- mpytool/mpy.py +1040 -78
- mpytool/mpy_comm.py +140 -11
- mpytool/mpytool.py +820 -234
- mpytool/terminal.py +1 -1
- mpytool/utils.py +4 -3
- mpytool-2.1.0.dist-info/METADATA +451 -0
- mpytool-2.1.0.dist-info/RECORD +16 -0
- {mpytool-2.0.0.dist-info → mpytool-2.1.0.dist-info}/WHEEL +1 -1
- mpytool-2.0.0.dist-info/METADATA +0 -233
- mpytool-2.0.0.dist-info/RECORD +0 -16
- {mpytool-2.0.0.dist-info → mpytool-2.1.0.dist-info}/entry_points.txt +0 -0
- {mpytool-2.0.0.dist-info → mpytool-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {mpytool-2.0.0.dist-info → mpytool-2.1.0.dist-info}/top_level.txt +0 -0
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__(
|
|
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 = {'
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
128
|
-
"""
|
|
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
|
|
131
|
-
|
|
132
|
-
for
|
|
133
|
-
dirs[:] = [d for d in dirs if
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
236
|
-
if
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
451
|
+
print(f'{self.format_size(size):>9} {name}')
|
|
259
452
|
else:
|
|
260
|
-
print(f'{"":
|
|
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
|
|
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
|
|
320
|
-
|
|
321
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
757
|
+
total_files += len(self._collect_remote_paths(src_path_clean))
|
|
526
758
|
else:
|
|
527
|
-
total_files += self.
|
|
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
|
|
599
|
-
self.verbose("
|
|
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
|
|
630
|
-
if size <
|
|
631
|
-
return f"{size}
|
|
632
|
-
for unit in ('
|
|
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}
|
|
878
|
+
return f"{size:.2f}{unit}"
|
|
636
879
|
if size < 100:
|
|
637
|
-
return f"{size:.1f}
|
|
638
|
-
if size <
|
|
639
|
-
return f"{size:.0f}
|
|
640
|
-
return f"{size:.0f}
|
|
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.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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.
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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}
|
|
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
|
-
|
|
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 --
|
|
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
|
-
|
|
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,
|
|
1360
|
+
is_copy, count, src_paths, dst_paths = mpy_tool.count_files_for_command(command_groups[i])
|
|
818
1361
|
if not is_copy:
|
|
819
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1376
|
+
all_src_paths.extend(src_paths_j)
|
|
1377
|
+
all_dst_paths.extend(dst_paths_j)
|
|
833
1378
|
j += 1
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
mpy_tool.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
866
|
-
|
|
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=
|
|
1463
|
+
log.verbose(f"Using {port}", level=2)
|
|
890
1464
|
else:
|
|
891
|
-
log.error("Multiple serial ports found
|
|
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
|
-
|
|
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__':
|