mpytool 2.0.0__py3-none-any.whl → 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mpytool/conn.py +29 -0
- mpytool/conn_serial.py +92 -5
- mpytool/mpy.py +1063 -78
- mpytool/mpy_comm.py +140 -11
- mpytool/mpytool.py +1041 -393
- mpytool/terminal.py +1 -1
- mpytool/utils.py +6 -6
- mpytool-2.2.0.dist-info/METADATA +462 -0
- mpytool-2.2.0.dist-info/RECORD +16 -0
- {mpytool-2.0.0.dist-info → mpytool-2.2.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.2.0.dist-info}/entry_points.txt +0 -0
- {mpytool-2.0.0.dist-info → mpytool-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {mpytool-2.0.0.dist-info → mpytool-2.2.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")
|
|
@@ -20,30 +23,79 @@ class ParamsError(_mpytool.MpyError):
|
|
|
20
23
|
"""Invalid command parameters"""
|
|
21
24
|
|
|
22
25
|
|
|
26
|
+
def _join_remote_path(base, name):
|
|
27
|
+
"""Join remote path components (handles empty string and '/' correctly)"""
|
|
28
|
+
if not name:
|
|
29
|
+
return base
|
|
30
|
+
if base == '/':
|
|
31
|
+
return '/' + name
|
|
32
|
+
elif base:
|
|
33
|
+
return base + '/' + name
|
|
34
|
+
else:
|
|
35
|
+
return name
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _remote_basename(path):
|
|
39
|
+
"""Get basename from remote path"""
|
|
40
|
+
return path.rstrip('/').split('/')[-1]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_device_path(path, cmd_name):
|
|
44
|
+
"""Parse device path with : prefix, raise ParamsError if missing
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: path string (should start with :)
|
|
48
|
+
cmd_name: command name for error message
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
path without : prefix
|
|
52
|
+
"""
|
|
53
|
+
if not path.startswith(':'):
|
|
54
|
+
raise ParamsError(f'{cmd_name} requires device path (: prefix): {path}')
|
|
55
|
+
return path[1:]
|
|
56
|
+
|
|
57
|
+
|
|
23
58
|
class MpyTool():
|
|
24
59
|
SPACE = ' '
|
|
25
60
|
BRANCH = '│ '
|
|
26
61
|
TEE = '├─ '
|
|
27
62
|
LAST = '└─ '
|
|
28
63
|
|
|
29
|
-
def __init__(
|
|
64
|
+
def __init__(
|
|
65
|
+
self, conn, log=None, verbose=None, exclude_dirs=None,
|
|
66
|
+
force=False, compress=None, chunk_size=None):
|
|
30
67
|
self._conn = conn
|
|
31
68
|
self._log = log if log is not None else SimpleColorLogger()
|
|
32
69
|
self._verbose_out = verbose # None = no verbose output (API mode)
|
|
33
|
-
self._exclude_dirs = {'
|
|
70
|
+
self._exclude_dirs = {'.*', '*.pyc'}
|
|
34
71
|
if exclude_dirs:
|
|
35
72
|
self._exclude_dirs.update(exclude_dirs)
|
|
36
|
-
self._mpy = _mpytool.Mpy(conn, log=self._log)
|
|
37
|
-
self._force = force
|
|
38
|
-
|
|
73
|
+
self._mpy = _mpytool.Mpy(conn, log=self._log, chunk_size=chunk_size)
|
|
74
|
+
self._force = force
|
|
75
|
+
self._compress = compress
|
|
39
76
|
self._progress_total_files = 0
|
|
40
77
|
self._progress_current_file = 0
|
|
41
78
|
self._progress_src = ''
|
|
42
79
|
self._progress_dst = ''
|
|
43
80
|
self._progress_max_src_len = 0
|
|
81
|
+
self._progress_max_dst_len = 0
|
|
44
82
|
self._is_debug = getattr(self._log, '_loglevel', 1) >= 4
|
|
45
83
|
self._batch_mode = False
|
|
46
84
|
self._skipped_files = 0
|
|
85
|
+
self._stats_total_bytes = 0
|
|
86
|
+
self._stats_transferred_bytes = 0
|
|
87
|
+
self._stats_wire_bytes = 0 # Actual bytes sent over wire (with encoding)
|
|
88
|
+
self._stats_transferred_files = 0
|
|
89
|
+
self._stats_start_time = None
|
|
90
|
+
# Remote file info cache for batch operations
|
|
91
|
+
self._remote_file_cache = {} # {path: (size, hash) or None}
|
|
92
|
+
|
|
93
|
+
def _is_excluded(self, name):
|
|
94
|
+
"""Check if name matches any exclude pattern (supports wildcards)"""
|
|
95
|
+
for pattern in self._exclude_dirs:
|
|
96
|
+
if _fnmatch.fnmatch(name, pattern):
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
47
99
|
|
|
48
100
|
@property
|
|
49
101
|
def _is_tty(self):
|
|
@@ -61,6 +113,14 @@ class MpyTool():
|
|
|
61
113
|
if self._verbose_out is not None:
|
|
62
114
|
self._verbose_out.verbose(msg, level, color, end, overwrite)
|
|
63
115
|
|
|
116
|
+
def print_transfer_info(self):
|
|
117
|
+
"""Print transfer settings (chunk size and compression)"""
|
|
118
|
+
chunk = self._mpy._detect_chunk_size()
|
|
119
|
+
chunk_str = f"{chunk // 1024}K" if chunk >= 1024 else str(chunk)
|
|
120
|
+
compress = self._mpy._detect_deflate() if self._compress is None else self._compress
|
|
121
|
+
compress_str = "on" if compress else "off"
|
|
122
|
+
self.verbose(f"COPY (chunk: {chunk_str}, compress: {compress_str})", 1)
|
|
123
|
+
|
|
64
124
|
@staticmethod
|
|
65
125
|
def _format_local_path(path):
|
|
66
126
|
"""Format local path: relative from CWD, absolute if > 2 levels up"""
|
|
@@ -81,16 +141,40 @@ class MpyTool():
|
|
|
81
141
|
# On Windows, relpath fails for different drives
|
|
82
142
|
return _os.path.abspath(path)
|
|
83
143
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
144
|
+
# Encoding info strings for alignment: (base64), (compressed), (base64, compressed), (unchanged)
|
|
145
|
+
_ENC_WIDTH = 22 # Length of longest: " (base64, compressed)"
|
|
146
|
+
|
|
147
|
+
def _format_encoding_info(self, encodings, pad=False):
|
|
148
|
+
"""Format encoding info: (base64), (compressed), (base64, compressed)"""
|
|
149
|
+
if not encodings or encodings == {'raw'}:
|
|
150
|
+
return " " * self._ENC_WIDTH if pad else ""
|
|
151
|
+
types = sorted(e for e in encodings if e != 'raw')
|
|
152
|
+
if not types:
|
|
153
|
+
return " " * self._ENC_WIDTH if pad else ""
|
|
154
|
+
info = f" ({', '.join(types)})"
|
|
155
|
+
if pad:
|
|
156
|
+
return f"{info:<{self._ENC_WIDTH}}"
|
|
157
|
+
return info
|
|
158
|
+
|
|
159
|
+
def _format_line(self, status, total, encodings=None):
|
|
160
|
+
"""Format progress/skip line: [2/5] 100% 24.1K source -> dest (base64)"""
|
|
161
|
+
size_str = self.format_size(total)
|
|
162
|
+
multi = self._progress_total_files > 1
|
|
163
|
+
if multi:
|
|
164
|
+
width = len(str(self._progress_total_files))
|
|
165
|
+
prefix = f"[{self._progress_current_file:>{width}}/{self._progress_total_files}]"
|
|
89
166
|
else:
|
|
90
167
|
prefix = ""
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
168
|
+
src_w = max(len(self._progress_src), self._progress_max_src_len)
|
|
169
|
+
dst_w = max(len(self._progress_dst), self._progress_max_dst_len)
|
|
170
|
+
enc = self._format_encoding_info(encodings, pad=multi) if encodings else (" " * self._ENC_WIDTH if multi else "")
|
|
171
|
+
return f"{prefix:>7} {status} {size_str:>5} {self._progress_src:<{src_w}} -> {self._progress_dst:<{dst_w}}{enc}"
|
|
172
|
+
|
|
173
|
+
def _format_progress_line(self, percent, total, encodings=None):
|
|
174
|
+
return self._format_line(f"{percent:3d}%", total, encodings)
|
|
175
|
+
|
|
176
|
+
def _format_skip_line(self, total):
|
|
177
|
+
return self._format_line("skip", total, {'unchanged'})
|
|
94
178
|
|
|
95
179
|
def _progress_callback(self, transferred, total):
|
|
96
180
|
"""Callback for file transfer progress"""
|
|
@@ -103,14 +187,13 @@ class MpyTool():
|
|
|
103
187
|
# Normal mode: overwrite line
|
|
104
188
|
self.verbose(line, color='cyan', end='', overwrite=True)
|
|
105
189
|
|
|
106
|
-
def _progress_complete(self, total):
|
|
190
|
+
def _progress_complete(self, total, encodings=None):
|
|
107
191
|
"""Mark current file as complete"""
|
|
108
|
-
line = self._format_progress_line(100, total)
|
|
192
|
+
line = self._format_progress_line(100, total, encodings)
|
|
109
193
|
if self._is_debug:
|
|
110
194
|
# Already printed with newline in callback
|
|
111
195
|
pass
|
|
112
196
|
else:
|
|
113
|
-
# Print final line with newline
|
|
114
197
|
self.verbose(line, color='cyan', overwrite=True)
|
|
115
198
|
|
|
116
199
|
def _set_progress_info(self, src, dst, is_src_remote, is_dst_remote):
|
|
@@ -120,19 +203,80 @@ class MpyTool():
|
|
|
120
203
|
else:
|
|
121
204
|
self._progress_src = self._format_local_path(src)
|
|
122
205
|
if is_dst_remote:
|
|
123
|
-
self._progress_dst = ':' + dst
|
|
206
|
+
self._progress_dst = ':' + ('/' + dst if not dst.startswith('/') else dst)
|
|
124
207
|
else:
|
|
125
208
|
self._progress_dst = self._format_local_path(dst)
|
|
209
|
+
if len(self._progress_dst) > self._progress_max_dst_len:
|
|
210
|
+
self._progress_max_dst_len = len(self._progress_dst)
|
|
126
211
|
|
|
127
|
-
def
|
|
128
|
-
"""
|
|
212
|
+
def _collect_local_paths(self, path):
|
|
213
|
+
"""Collect all local file paths (formatted for display)"""
|
|
129
214
|
if _os.path.isfile(path):
|
|
130
|
-
return
|
|
131
|
-
|
|
132
|
-
for
|
|
133
|
-
dirs[:] = [d for d in dirs if
|
|
134
|
-
|
|
135
|
-
|
|
215
|
+
return [self._format_local_path(path)]
|
|
216
|
+
paths = []
|
|
217
|
+
for root, dirs, files in _os.walk(path, topdown=True):
|
|
218
|
+
dirs[:] = [d for d in dirs if not self._is_excluded(d)]
|
|
219
|
+
files = [f for f in files if not self._is_excluded(f)]
|
|
220
|
+
paths.extend(self._format_local_path(_os.path.join(root, f)) for f in files)
|
|
221
|
+
return paths
|
|
222
|
+
|
|
223
|
+
def _prefetch_remote_info(self, dst_files):
|
|
224
|
+
"""Prefetch remote file info (size and hash) for multiple files
|
|
225
|
+
|
|
226
|
+
Arguments:
|
|
227
|
+
dst_files: dict {remote_path: local_size} - sizes used to skip hash if mismatch
|
|
228
|
+
|
|
229
|
+
Uses batch call to reduce round-trips. Results are cached in _remote_file_cache.
|
|
230
|
+
"""
|
|
231
|
+
if self._force or not dst_files:
|
|
232
|
+
return
|
|
233
|
+
files_to_fetch = {p: s for p, s in dst_files.items() if p not in self._remote_file_cache}
|
|
234
|
+
if not files_to_fetch:
|
|
235
|
+
return
|
|
236
|
+
self.verbose(f"Checking {len(files_to_fetch)} files...", 2)
|
|
237
|
+
result = self._mpy.fileinfo(files_to_fetch)
|
|
238
|
+
if result is None:
|
|
239
|
+
# hashlib not available - mark all as needing update
|
|
240
|
+
for path in files_to_fetch:
|
|
241
|
+
self._remote_file_cache[path] = None
|
|
242
|
+
else:
|
|
243
|
+
self._remote_file_cache.update(result)
|
|
244
|
+
|
|
245
|
+
def _collect_dst_files(self, src_path, dst_path, add_src_basename=True):
|
|
246
|
+
"""Collect destination paths and local sizes for a local->remote copy operation
|
|
247
|
+
|
|
248
|
+
Arguments:
|
|
249
|
+
src_path: local source path (file or directory)
|
|
250
|
+
dst_path: remote destination base path
|
|
251
|
+
add_src_basename: if True, add source basename to dst_path (matching _put_dir behavior)
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
dict {remote_path: local_file_size}
|
|
255
|
+
"""
|
|
256
|
+
files = {}
|
|
257
|
+
if _os.path.isfile(src_path):
|
|
258
|
+
# For files, add basename if dst_path ends with /
|
|
259
|
+
basename = _os.path.basename(src_path)
|
|
260
|
+
if basename and not _os.path.basename(dst_path):
|
|
261
|
+
dst_path = _os.path.join(dst_path, basename)
|
|
262
|
+
files[dst_path] = _os.path.getsize(src_path)
|
|
263
|
+
elif _os.path.isdir(src_path):
|
|
264
|
+
# For directories, mimic _put_dir behavior
|
|
265
|
+
if add_src_basename:
|
|
266
|
+
basename = _os.path.basename(_os.path.abspath(src_path))
|
|
267
|
+
if basename:
|
|
268
|
+
dst_path = _os.path.join(dst_path, basename)
|
|
269
|
+
for root, dirs, filenames in _os.walk(src_path, topdown=True):
|
|
270
|
+
dirs[:] = [d for d in dirs if not self._is_excluded(d)]
|
|
271
|
+
filenames = [f for f in filenames if not self._is_excluded(f)]
|
|
272
|
+
rel_path = _os.path.relpath(root, src_path)
|
|
273
|
+
if rel_path == '.':
|
|
274
|
+
rel_path = ''
|
|
275
|
+
for file_name in filenames:
|
|
276
|
+
spath = _os.path.join(root, file_name)
|
|
277
|
+
dpath = _os.path.join(dst_path, rel_path, file_name) if rel_path else _os.path.join(dst_path, file_name)
|
|
278
|
+
files[dpath] = _os.path.getsize(spath)
|
|
279
|
+
return files
|
|
136
280
|
|
|
137
281
|
def _file_needs_update(self, local_data, remote_path):
|
|
138
282
|
"""Check if local file differs from remote file
|
|
@@ -141,7 +285,20 @@ class MpyTool():
|
|
|
141
285
|
"""
|
|
142
286
|
if self._force:
|
|
143
287
|
return True
|
|
144
|
-
# Check
|
|
288
|
+
# Check cache first (populated by _prefetch_remote_info)
|
|
289
|
+
if remote_path in self._remote_file_cache:
|
|
290
|
+
cached = self._remote_file_cache[remote_path]
|
|
291
|
+
if cached is None:
|
|
292
|
+
return True # File doesn't exist or hashlib not available
|
|
293
|
+
remote_size, remote_hash = cached
|
|
294
|
+
local_size = len(local_data)
|
|
295
|
+
if local_size != remote_size:
|
|
296
|
+
return True # Different size
|
|
297
|
+
if remote_hash is None:
|
|
298
|
+
return True # Size matched but hash wasn't computed (shouldn't happen with prefetch)
|
|
299
|
+
local_hash = _hashlib.sha256(local_data).digest()
|
|
300
|
+
return local_hash != remote_hash
|
|
301
|
+
# Fallback to individual calls (for single file operations)
|
|
145
302
|
remote_size = self._mpy.stat(remote_path)
|
|
146
303
|
if remote_size is None or remote_size < 0:
|
|
147
304
|
return True # File doesn't exist or is a directory
|
|
@@ -155,24 +312,6 @@ class MpyTool():
|
|
|
155
312
|
return True # hashlib not available on device
|
|
156
313
|
return local_hash != remote_hash
|
|
157
314
|
|
|
158
|
-
def _count_remote_files(self, path):
|
|
159
|
-
"""Count files on device"""
|
|
160
|
-
stat = self._mpy.stat(path)
|
|
161
|
-
if stat is None:
|
|
162
|
-
return 0
|
|
163
|
-
if stat >= 0: # file
|
|
164
|
-
return 1
|
|
165
|
-
# directory - count recursively
|
|
166
|
-
count = 0
|
|
167
|
-
entries = self._mpy.ls(path)
|
|
168
|
-
for name, size in entries:
|
|
169
|
-
if size is None: # directory
|
|
170
|
-
entry_path = path.rstrip('/') + '/' + name
|
|
171
|
-
count += self._count_remote_files(entry_path)
|
|
172
|
-
else: # file
|
|
173
|
-
count += 1
|
|
174
|
-
return count
|
|
175
|
-
|
|
176
315
|
def _collect_source_paths(self, commands):
|
|
177
316
|
"""Collect source paths for a cp/put command (for alignment calculation)"""
|
|
178
317
|
paths = []
|
|
@@ -188,27 +327,9 @@ class MpyTool():
|
|
|
188
327
|
src_path = '/'
|
|
189
328
|
src_path = src_path.rstrip('/') or '/'
|
|
190
329
|
if src_is_remote:
|
|
191
|
-
# Collect all file paths from remote
|
|
192
330
|
paths.extend(self._collect_remote_paths(src_path))
|
|
193
331
|
else:
|
|
194
|
-
# Collect all file paths from local
|
|
195
332
|
paths.extend(self._collect_local_paths(src_path))
|
|
196
|
-
elif cmd == 'put' and len(commands) >= 2:
|
|
197
|
-
src_path = commands[1]
|
|
198
|
-
paths.extend(self._collect_local_paths(src_path))
|
|
199
|
-
return paths
|
|
200
|
-
|
|
201
|
-
def _collect_local_paths(self, path):
|
|
202
|
-
"""Collect all local file paths (formatted for display)"""
|
|
203
|
-
paths = []
|
|
204
|
-
if _os.path.isfile(path):
|
|
205
|
-
paths.append(self._format_local_path(path))
|
|
206
|
-
elif _os.path.isdir(path):
|
|
207
|
-
for root, dirs, files in _os.walk(path, topdown=True):
|
|
208
|
-
dirs[:] = [d for d in dirs if d not in self._exclude_dirs]
|
|
209
|
-
for f in files:
|
|
210
|
-
full_path = _os.path.join(root, f)
|
|
211
|
-
paths.append(self._format_local_path(full_path))
|
|
212
333
|
return paths
|
|
213
334
|
|
|
214
335
|
def _collect_remote_paths(self, path):
|
|
@@ -229,20 +350,90 @@ class MpyTool():
|
|
|
229
350
|
paths.append(':' + entry_path)
|
|
230
351
|
return paths
|
|
231
352
|
|
|
353
|
+
def _collect_destination_paths(self, commands):
|
|
354
|
+
"""Collect formatted destination paths for a cp/put command"""
|
|
355
|
+
if not commands:
|
|
356
|
+
return []
|
|
357
|
+
cmd = commands[0]
|
|
358
|
+
if cmd == 'cp' and len(commands) >= 3:
|
|
359
|
+
# Filter out flags
|
|
360
|
+
args = [a for a in commands[1:] if not a.startswith('-')]
|
|
361
|
+
if len(args) < 2:
|
|
362
|
+
return []
|
|
363
|
+
sources, dest = args[:-1], args[-1]
|
|
364
|
+
dest_is_remote = dest.startswith(':')
|
|
365
|
+
dest_path = (dest[1:] or '/') if dest_is_remote else dest
|
|
366
|
+
dest_is_dir = dest_path.endswith('/')
|
|
367
|
+
dst_paths = []
|
|
368
|
+
for src in sources:
|
|
369
|
+
src_is_remote = src.startswith(':')
|
|
370
|
+
src_path = ((src[1:] or '/') if src_is_remote else src).rstrip('/') or '/'
|
|
371
|
+
copy_contents = src.endswith('/')
|
|
372
|
+
if dest_is_remote and not src_is_remote:
|
|
373
|
+
# local -> remote: reuse _collect_dst_files
|
|
374
|
+
if _os.path.exists(src_path):
|
|
375
|
+
files = self._collect_dst_files(src_path, dest_path.rstrip('/') or '/', not copy_contents)
|
|
376
|
+
dst_paths.extend(':' + p for p in files)
|
|
377
|
+
elif not dest_is_remote and src_is_remote:
|
|
378
|
+
# remote -> local
|
|
379
|
+
dst_paths.extend(self._collect_remote_to_local_dst(src_path, dest_path, dest_is_dir, copy_contents))
|
|
380
|
+
elif dest_is_remote and src_is_remote:
|
|
381
|
+
# remote -> remote (file only)
|
|
382
|
+
stat = self._mpy.stat(src_path)
|
|
383
|
+
if stat is not None and stat >= 0:
|
|
384
|
+
dst = _join_remote_path(dest_path, _remote_basename(src_path)) if dest_is_dir else dest_path
|
|
385
|
+
dst_paths.append(':' + dst)
|
|
386
|
+
return dst_paths
|
|
387
|
+
return []
|
|
388
|
+
|
|
389
|
+
def _collect_remote_to_local_dst(self, src_path, dest_path, dest_is_dir, copy_contents):
|
|
390
|
+
"""Collect destination paths for remote->local copy"""
|
|
391
|
+
stat = self._mpy.stat(src_path)
|
|
392
|
+
if stat is None:
|
|
393
|
+
return []
|
|
394
|
+
base_dst = dest_path.rstrip('/') or '.'
|
|
395
|
+
if dest_is_dir and not copy_contents and src_path != '/':
|
|
396
|
+
base_dst = _os.path.join(base_dst, _remote_basename(src_path))
|
|
397
|
+
if stat >= 0: # file
|
|
398
|
+
if _os.path.isdir(base_dst) or dest_is_dir:
|
|
399
|
+
return [self._format_local_path(_os.path.join(base_dst, _remote_basename(src_path)))]
|
|
400
|
+
return [self._format_local_path(base_dst)]
|
|
401
|
+
return self._collect_remote_dir_dst(src_path, base_dst)
|
|
402
|
+
|
|
403
|
+
def _collect_remote_dir_dst(self, src_path, base_dst):
|
|
404
|
+
"""Collect local destination paths for remote directory download"""
|
|
405
|
+
paths = []
|
|
406
|
+
for name, size in self._mpy.ls(src_path):
|
|
407
|
+
entry_src = src_path.rstrip('/') + '/' + name
|
|
408
|
+
entry_dst = _os.path.join(base_dst, name)
|
|
409
|
+
if size is None: # directory
|
|
410
|
+
paths.extend(self._collect_remote_dir_dst(entry_src, entry_dst))
|
|
411
|
+
else: # file
|
|
412
|
+
paths.append(self._format_local_path(entry_dst))
|
|
413
|
+
return paths
|
|
414
|
+
|
|
232
415
|
def count_files_for_command(self, commands):
|
|
233
416
|
"""Count files that would be transferred by cp/put command.
|
|
234
|
-
Returns (is_copy_command, file_count, source_paths)"""
|
|
235
|
-
|
|
236
|
-
if
|
|
237
|
-
|
|
238
|
-
|
|
417
|
+
Returns (is_copy_command, file_count, source_paths, dest_paths)"""
|
|
418
|
+
src_paths = self._collect_source_paths(commands)
|
|
419
|
+
if src_paths:
|
|
420
|
+
dst_paths = self._collect_destination_paths(commands)
|
|
421
|
+
return True, len(src_paths), src_paths, dst_paths
|
|
422
|
+
return False, 0, [], []
|
|
239
423
|
|
|
240
|
-
def set_batch_progress(self, total_files, max_src_len=0):
|
|
424
|
+
def set_batch_progress(self, total_files, max_src_len=0, max_dst_len=0):
|
|
241
425
|
"""Set batch progress for consecutive copy commands"""
|
|
242
426
|
self._progress_total_files = total_files
|
|
243
427
|
self._progress_current_file = 0
|
|
244
428
|
self._progress_max_src_len = max_src_len
|
|
429
|
+
self._progress_max_dst_len = max_dst_len
|
|
245
430
|
self._batch_mode = True
|
|
431
|
+
self._stats_total_bytes = 0
|
|
432
|
+
self._stats_transferred_bytes = 0
|
|
433
|
+
self._stats_wire_bytes = 0
|
|
434
|
+
self._stats_transferred_files = 0
|
|
435
|
+
self._skipped_files = 0
|
|
436
|
+
self._stats_start_time = _time.time()
|
|
246
437
|
|
|
247
438
|
def reset_batch_progress(self):
|
|
248
439
|
"""Reset batch progress mode"""
|
|
@@ -250,14 +441,35 @@ class MpyTool():
|
|
|
250
441
|
self._progress_total_files = 0
|
|
251
442
|
self._progress_current_file = 0
|
|
252
443
|
self._progress_max_src_len = 0
|
|
444
|
+
self._progress_max_dst_len = 0
|
|
445
|
+
self._remote_file_cache.clear()
|
|
253
446
|
|
|
254
|
-
def
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
447
|
+
def print_copy_summary(self):
|
|
448
|
+
"""Print summary after copy operation"""
|
|
449
|
+
if self._stats_start_time is None:
|
|
450
|
+
return
|
|
451
|
+
elapsed = _time.time() - self._stats_start_time
|
|
452
|
+
total = self._stats_total_bytes
|
|
453
|
+
transferred = self._stats_transferred_bytes
|
|
454
|
+
wire = self._stats_wire_bytes
|
|
455
|
+
total_files = self._stats_transferred_files + self._skipped_files
|
|
456
|
+
parts = []
|
|
457
|
+
parts.append(f"{self.format_size(transferred).strip()}")
|
|
458
|
+
if elapsed > 0:
|
|
459
|
+
speed = transferred / elapsed
|
|
460
|
+
parts.append(f"{self.format_size(speed).strip()}/s")
|
|
461
|
+
parts.append(f"{elapsed:.1f}s")
|
|
462
|
+
# Combined speedup: total file size vs actual wire bytes
|
|
463
|
+
# Includes savings from: skipped files, base64 encoding, compression
|
|
464
|
+
if wire > 0 and total > wire:
|
|
465
|
+
speedup = total / wire
|
|
466
|
+
parts.append(f"speedup {speedup:.1f}x")
|
|
467
|
+
summary = " ".join(parts)
|
|
468
|
+
if self._skipped_files > 0:
|
|
469
|
+
file_info = f"{self._stats_transferred_files} transferred, {self._skipped_files} skipped"
|
|
470
|
+
else:
|
|
471
|
+
file_info = f"{total_files} files"
|
|
472
|
+
self.verbose(f" {summary} ({file_info})", color='green')
|
|
261
473
|
|
|
262
474
|
@classmethod
|
|
263
475
|
def print_tree(cls, tree, prefix='', print_size=True, first=True, last=True):
|
|
@@ -271,12 +483,14 @@ class MpyTool():
|
|
|
271
483
|
else:
|
|
272
484
|
this_prefix = cls.TEE
|
|
273
485
|
sufix = ''
|
|
274
|
-
if sub_tree is not None and name !=
|
|
486
|
+
if sub_tree is not None and name != '/':
|
|
275
487
|
sufix = '/'
|
|
488
|
+
# For root, display './' only for empty path (CWD)
|
|
489
|
+
display_name = '.' if first and name in ('', '.') else name
|
|
276
490
|
line = ''
|
|
277
491
|
if print_size:
|
|
278
|
-
line += f'{size
|
|
279
|
-
line += prefix + this_prefix +
|
|
492
|
+
line += f'{cls.format_size(size):>9} '
|
|
493
|
+
line += prefix + this_prefix + display_name + sufix
|
|
280
494
|
print(line)
|
|
281
495
|
if not sub_tree:
|
|
282
496
|
return
|
|
@@ -300,103 +514,82 @@ class MpyTool():
|
|
|
300
514
|
first=False,
|
|
301
515
|
last=True)
|
|
302
516
|
|
|
303
|
-
def
|
|
304
|
-
|
|
305
|
-
|
|
517
|
+
def _upload_file(self, data, src_path, dst_path, show_progress):
|
|
518
|
+
"""Upload file data to device with stats tracking and progress display"""
|
|
519
|
+
file_size = len(data)
|
|
520
|
+
self._stats_total_bytes += file_size
|
|
521
|
+
if not self._file_needs_update(data, dst_path):
|
|
522
|
+
self._skipped_files += 1
|
|
523
|
+
if show_progress and self._verbose >= 1:
|
|
524
|
+
self._progress_current_file += 1
|
|
525
|
+
self._set_progress_info(src_path, dst_path, False, True)
|
|
526
|
+
self.verbose(self._format_skip_line(file_size), color='yellow')
|
|
527
|
+
return False # skipped
|
|
528
|
+
self._stats_transferred_bytes += file_size
|
|
529
|
+
self._stats_transferred_files += 1
|
|
530
|
+
if show_progress and self._verbose >= 1:
|
|
531
|
+
self._progress_current_file += 1
|
|
532
|
+
self._set_progress_info(src_path, dst_path, False, True)
|
|
533
|
+
encodings, wire = self._mpy.put(data, dst_path, self._progress_callback, self._compress)
|
|
534
|
+
self._stats_wire_bytes += wire
|
|
535
|
+
self._progress_complete(file_size, encodings)
|
|
536
|
+
else:
|
|
537
|
+
_, wire = self._mpy.put(data, dst_path, compress=self._compress)
|
|
538
|
+
self._stats_wire_bytes += wire
|
|
539
|
+
return True # uploaded
|
|
306
540
|
|
|
307
|
-
def
|
|
308
|
-
|
|
309
|
-
self.verbose(f"GET: {file_name}", 2)
|
|
310
|
-
data = self._mpy.get(file_name)
|
|
311
|
-
print(data.decode('utf-8'))
|
|
541
|
+
def _put_dir(self, src_path, dst_path, show_progress=True, target_name=None):
|
|
542
|
+
"""Upload directory to device
|
|
312
543
|
|
|
313
|
-
|
|
314
|
-
|
|
544
|
+
Arguments:
|
|
545
|
+
src_path: local source directory
|
|
546
|
+
dst_path: remote destination parent directory
|
|
547
|
+
show_progress: show progress bar
|
|
548
|
+
target_name: if set, use this as directory name instead of src basename
|
|
549
|
+
"""
|
|
550
|
+
if target_name is not None:
|
|
551
|
+
basename = target_name
|
|
552
|
+
else:
|
|
553
|
+
basename = _os.path.basename(_os.path.abspath(src_path))
|
|
315
554
|
if basename:
|
|
316
|
-
dst_path =
|
|
555
|
+
dst_path = _join_remote_path(dst_path, basename)
|
|
317
556
|
self.verbose(f"PUT DIR: {src_path} -> {dst_path}", 2)
|
|
557
|
+
created_dirs = set()
|
|
318
558
|
for path, dirs, files in _os.walk(src_path, topdown=True):
|
|
319
|
-
dirs[:] = [d for d in dirs if
|
|
320
|
-
|
|
321
|
-
if
|
|
559
|
+
dirs[:] = [d for d in dirs if not self._is_excluded(d)]
|
|
560
|
+
files = [f for f in files if not self._is_excluded(f)]
|
|
561
|
+
if not files:
|
|
322
562
|
continue
|
|
323
563
|
rel_path = _os.path.relpath(path, src_path)
|
|
324
|
-
if rel_path == '.'
|
|
325
|
-
|
|
326
|
-
rel_path = _os.path.join(dst_path, rel_path)
|
|
327
|
-
if rel_path:
|
|
564
|
+
rel_path = _os.path.join(dst_path, '' if rel_path == '.' else rel_path)
|
|
565
|
+
if rel_path and rel_path not in created_dirs:
|
|
328
566
|
self.verbose(f'MKDIR: {rel_path}', 2)
|
|
329
567
|
self._mpy.mkdir(rel_path)
|
|
568
|
+
created_dirs.add(rel_path)
|
|
330
569
|
for file_name in files:
|
|
331
570
|
spath = _os.path.join(path, file_name)
|
|
332
|
-
|
|
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)
|
|
571
|
+
with open(spath, 'rb') as f:
|
|
572
|
+
self._upload_file(f.read(), spath, _os.path.join(rel_path, file_name), show_progress)
|
|
350
573
|
|
|
351
574
|
def _put_file(self, src_path, dst_path, show_progress=True):
|
|
352
575
|
basename = _os.path.basename(src_path)
|
|
353
576
|
if basename and not _os.path.basename(dst_path):
|
|
354
577
|
dst_path = _os.path.join(dst_path, basename)
|
|
355
578
|
self.verbose(f"PUT FILE: {src_path} -> {dst_path}", 2)
|
|
356
|
-
|
|
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)
|
|
384
|
-
|
|
385
|
-
def cmd_put(self, src_path, dst_path):
|
|
386
|
-
if self._verbose >= 1 and not self._batch_mode:
|
|
387
|
-
self._progress_total_files = self._count_local_files(src_path)
|
|
388
|
-
self._progress_current_file = 0
|
|
389
|
-
if _os.path.isdir(src_path):
|
|
390
|
-
self._put_dir(src_path, dst_path)
|
|
391
|
-
elif _os.path.isfile(src_path):
|
|
392
|
-
self._put_file(src_path, dst_path)
|
|
393
|
-
else:
|
|
394
|
-
raise ParamsError(f'No file or directory to upload: {src_path}')
|
|
579
|
+
with open(src_path, 'rb') as f:
|
|
580
|
+
data = f.read()
|
|
581
|
+
parent = _os.path.dirname(dst_path)
|
|
582
|
+
if parent:
|
|
583
|
+
stat = self._mpy.stat(parent)
|
|
584
|
+
if stat is None:
|
|
585
|
+
self._mpy.mkdir(parent)
|
|
586
|
+
elif stat >= 0:
|
|
587
|
+
raise _mpytool.MpyError(f'Error creating file under file: {parent}')
|
|
588
|
+
self._upload_file(data, src_path, dst_path, show_progress)
|
|
395
589
|
|
|
396
590
|
def _get_file(self, src_path, dst_path, show_progress=True):
|
|
397
591
|
"""Download single file from device"""
|
|
398
592
|
self.verbose(f"GET FILE: {src_path} -> {dst_path}", 2)
|
|
399
|
-
# Create destination directory if needed
|
|
400
593
|
dst_dir = _os.path.dirname(dst_path)
|
|
401
594
|
if dst_dir and not _os.path.exists(dst_dir):
|
|
402
595
|
_os.makedirs(dst_dir)
|
|
@@ -407,13 +600,17 @@ class MpyTool():
|
|
|
407
600
|
self._progress_complete(len(data))
|
|
408
601
|
else:
|
|
409
602
|
data = self._mpy.get(src_path)
|
|
603
|
+
file_size = len(data)
|
|
604
|
+
self._stats_total_bytes += file_size
|
|
605
|
+
self._stats_transferred_bytes += file_size
|
|
606
|
+
self._stats_transferred_files += 1
|
|
410
607
|
with open(dst_path, 'wb') as dst_file:
|
|
411
608
|
dst_file.write(data)
|
|
412
609
|
|
|
413
610
|
def _get_dir(self, src_path, dst_path, copy_contents=False, show_progress=True):
|
|
414
611
|
"""Download directory from device"""
|
|
415
612
|
if not copy_contents:
|
|
416
|
-
basename = src_path
|
|
613
|
+
basename = _remote_basename(src_path)
|
|
417
614
|
if basename:
|
|
418
615
|
dst_path = _os.path.join(dst_path, basename)
|
|
419
616
|
self.verbose(f"GET DIR: {src_path} -> {dst_path}", 2)
|
|
@@ -429,29 +626,48 @@ class MpyTool():
|
|
|
429
626
|
self._get_file(src_entry, dst_entry, show_progress=show_progress)
|
|
430
627
|
|
|
431
628
|
def _cp_local_to_remote(self, src_path, dst_path, dst_is_dir):
|
|
432
|
-
"""Upload local file/dir to device
|
|
433
|
-
|
|
629
|
+
"""Upload local file/dir to device
|
|
630
|
+
|
|
631
|
+
Path semantics:
|
|
632
|
+
- dst_is_dir=True: add source basename to dst_path (unless copy_contents)
|
|
633
|
+
- dst_is_dir=False: dst_path is the target name (rename)
|
|
634
|
+
- copy_contents (src ends with /): copy contents, not directory itself
|
|
635
|
+
"""
|
|
636
|
+
src_is_dir = _os.path.isdir(src_path.rstrip('/'))
|
|
434
637
|
copy_contents = src_path.endswith('/')
|
|
435
638
|
src_path = src_path.rstrip('/')
|
|
436
639
|
if not _os.path.exists(src_path):
|
|
437
640
|
raise ParamsError(f'Source not found: {src_path}')
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
641
|
+
# Normalize dst_path (remove trailing slash, but keep '/' for root)
|
|
642
|
+
if dst_path == '/':
|
|
643
|
+
pass # Keep '/' as is
|
|
644
|
+
elif dst_path:
|
|
442
645
|
dst_path = dst_path.rstrip('/')
|
|
646
|
+
else:
|
|
647
|
+
dst_path = ''
|
|
443
648
|
if src_is_dir:
|
|
444
649
|
if copy_contents:
|
|
445
|
-
# Copy contents
|
|
650
|
+
# Copy directory contents to dst_path
|
|
446
651
|
for item in _os.listdir(src_path):
|
|
652
|
+
if self._is_excluded(item):
|
|
653
|
+
continue
|
|
447
654
|
item_src = _os.path.join(src_path, item)
|
|
448
655
|
if _os.path.isdir(item_src):
|
|
449
656
|
self._put_dir(item_src, dst_path)
|
|
450
657
|
else:
|
|
451
|
-
self._put_file(item_src, dst_path
|
|
658
|
+
self._put_file(item_src, _join_remote_path(dst_path, item))
|
|
659
|
+
elif dst_is_dir:
|
|
660
|
+
# Copy directory to destination directory (_put_dir adds basename)
|
|
661
|
+
self._put_dir(src_path, dst_path)
|
|
452
662
|
else:
|
|
453
|
-
|
|
663
|
+
# Rename: copy directory with new name
|
|
664
|
+
parent = _os.path.dirname(dst_path)
|
|
665
|
+
target_name = _os.path.basename(dst_path)
|
|
666
|
+
self._put_dir(src_path, parent, target_name=target_name)
|
|
454
667
|
else:
|
|
668
|
+
# File: add basename if dst_is_dir
|
|
669
|
+
if dst_is_dir:
|
|
670
|
+
dst_path = _join_remote_path(dst_path, _os.path.basename(src_path))
|
|
455
671
|
self._put_file(src_path, dst_path)
|
|
456
672
|
|
|
457
673
|
def _cp_remote_to_local(self, src_path, dst_path, dst_is_dir):
|
|
@@ -466,14 +682,12 @@ class MpyTool():
|
|
|
466
682
|
if not _os.path.exists(dst_path):
|
|
467
683
|
_os.makedirs(dst_path)
|
|
468
684
|
if not copy_contents and src_path != '/':
|
|
469
|
-
|
|
470
|
-
dst_path = _os.path.join(dst_path, basename)
|
|
685
|
+
dst_path = _os.path.join(dst_path, _remote_basename(src_path))
|
|
471
686
|
if src_is_dir:
|
|
472
687
|
self._get_dir(src_path, dst_path, copy_contents=copy_contents)
|
|
473
688
|
else:
|
|
474
689
|
if _os.path.isdir(dst_path):
|
|
475
|
-
|
|
476
|
-
dst_path = _os.path.join(dst_path, basename)
|
|
690
|
+
dst_path = _os.path.join(dst_path, _remote_basename(src_path))
|
|
477
691
|
self._get_file(src_path, dst_path)
|
|
478
692
|
|
|
479
693
|
def _cp_remote_to_remote(self, src_path, dst_path, dst_is_dir):
|
|
@@ -484,35 +698,86 @@ class MpyTool():
|
|
|
484
698
|
raise ParamsError(f'Source not found on device: {src_path}')
|
|
485
699
|
if stat == -1:
|
|
486
700
|
raise ParamsError('Remote-to-remote directory copy not supported yet')
|
|
487
|
-
# File copy on device
|
|
488
701
|
if dst_is_dir:
|
|
489
|
-
|
|
490
|
-
dst_path = dst_path + basename
|
|
702
|
+
dst_path = _join_remote_path(dst_path, _remote_basename(src_path))
|
|
491
703
|
self.verbose(f"COPY: {src_path} -> {dst_path}", 2)
|
|
492
704
|
if self._verbose >= 1:
|
|
493
705
|
self._progress_current_file += 1
|
|
494
706
|
self._set_progress_info(src_path, dst_path, True, True)
|
|
495
707
|
data = self._mpy.get(src_path, self._progress_callback)
|
|
496
|
-
self._mpy.put(data, dst_path)
|
|
497
|
-
self.
|
|
708
|
+
encodings, wire = self._mpy.put(data, dst_path, compress=self._compress)
|
|
709
|
+
self._stats_wire_bytes += wire
|
|
710
|
+
self._progress_complete(len(data), encodings)
|
|
498
711
|
else:
|
|
499
712
|
data = self._mpy.get(src_path)
|
|
500
|
-
self._mpy.put(data, dst_path)
|
|
713
|
+
_, wire = self._mpy.put(data, dst_path, compress=self._compress)
|
|
714
|
+
self._stats_wire_bytes += wire
|
|
715
|
+
file_size = len(data)
|
|
716
|
+
self._stats_total_bytes += file_size
|
|
717
|
+
self._stats_transferred_bytes += file_size
|
|
718
|
+
self._stats_transferred_files += 1
|
|
501
719
|
|
|
502
720
|
def cmd_cp(self, *args):
|
|
503
721
|
"""Copy files between local and device"""
|
|
722
|
+
# Parse flags: -f/--force, -z/--compress, -Z/--no-compress
|
|
723
|
+
args = list(args)
|
|
724
|
+
force = None
|
|
725
|
+
compress = None # None = use global setting
|
|
726
|
+
filtered_args = []
|
|
727
|
+
for a in args:
|
|
728
|
+
if a == '--force':
|
|
729
|
+
force = True
|
|
730
|
+
elif a == '--compress':
|
|
731
|
+
compress = True
|
|
732
|
+
elif a == '--no-compress':
|
|
733
|
+
compress = False
|
|
734
|
+
elif a.startswith('-') and not a.startswith('--') and len(a) > 1:
|
|
735
|
+
# Handle combined short flags like -fz, -fZ
|
|
736
|
+
flags = a[1:]
|
|
737
|
+
if 'f' in flags:
|
|
738
|
+
force = True
|
|
739
|
+
if 'z' in flags:
|
|
740
|
+
compress = True
|
|
741
|
+
if 'Z' in flags:
|
|
742
|
+
compress = False
|
|
743
|
+
remaining = flags.replace('f', '').replace('z', '').replace('Z', '')
|
|
744
|
+
if remaining:
|
|
745
|
+
filtered_args.append('-' + remaining)
|
|
746
|
+
else:
|
|
747
|
+
filtered_args.append(a)
|
|
748
|
+
args = filtered_args
|
|
504
749
|
if len(args) < 2:
|
|
505
750
|
raise ParamsError('cp requires source and destination')
|
|
751
|
+
# Save and set flags for this command
|
|
752
|
+
saved_force = self._force
|
|
753
|
+
saved_compress = self._compress
|
|
754
|
+
if force is not None:
|
|
755
|
+
self._force = force
|
|
756
|
+
if compress is not None:
|
|
757
|
+
self._compress = compress
|
|
758
|
+
try:
|
|
759
|
+
self._cmd_cp_impl(args)
|
|
760
|
+
finally:
|
|
761
|
+
self._force = saved_force
|
|
762
|
+
self._compress = saved_compress
|
|
763
|
+
|
|
764
|
+
def _cmd_cp_impl(self, args):
|
|
506
765
|
sources = list(args[:-1])
|
|
507
766
|
dest = args[-1]
|
|
508
767
|
dest_is_remote = dest.startswith(':')
|
|
509
768
|
dest_path = dest[1:] if dest_is_remote else dest
|
|
510
|
-
if
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if
|
|
514
|
-
|
|
515
|
-
|
|
769
|
+
# Determine if destination is a directory:
|
|
770
|
+
# - Remote: '' (CWD) or '/' (root) or ends with '/'
|
|
771
|
+
# - Local: ends with '/' or exists as directory
|
|
772
|
+
if dest_is_remote:
|
|
773
|
+
dest_is_dir = dest_path == '' or dest_path == '/' or dest_path.endswith('/')
|
|
774
|
+
else:
|
|
775
|
+
dest_is_dir = dest_path.endswith('/') or _os.path.isdir(dest_path)
|
|
776
|
+
# Check if any source copies contents (trailing slash) or multiple sources
|
|
777
|
+
has_multi_source = len(sources) > 1
|
|
778
|
+
has_contents_copy = any(s.rstrip('/') != s for s in sources) # any source has trailing /
|
|
779
|
+
if (has_multi_source or has_contents_copy) and not dest_is_dir:
|
|
780
|
+
raise ParamsError('multiple sources or directory contents require destination directory (ending with /)')
|
|
516
781
|
if self._verbose >= 1 and not self._batch_mode:
|
|
517
782
|
total_files = 0
|
|
518
783
|
for src in sources:
|
|
@@ -522,11 +787,25 @@ class MpyTool():
|
|
|
522
787
|
src_path = '/'
|
|
523
788
|
src_path_clean = src_path.rstrip('/') or '/'
|
|
524
789
|
if src_is_remote:
|
|
525
|
-
total_files += self.
|
|
790
|
+
total_files += len(self._collect_remote_paths(src_path_clean))
|
|
526
791
|
else:
|
|
527
|
-
total_files += self.
|
|
792
|
+
total_files += len(self._collect_local_paths(src_path_clean))
|
|
528
793
|
self._progress_total_files = total_files
|
|
529
794
|
self._progress_current_file = 0
|
|
795
|
+
all_dst_files = {}
|
|
796
|
+
if dest_is_remote:
|
|
797
|
+
for src in sources:
|
|
798
|
+
if not src.startswith(':'): # local source
|
|
799
|
+
copy_contents = src.endswith('/')
|
|
800
|
+
src_path = src.rstrip('/')
|
|
801
|
+
if _os.path.exists(src_path):
|
|
802
|
+
add_basename = dest_is_dir and not copy_contents
|
|
803
|
+
base_path = dest_path.rstrip('/') if dest_path else ''
|
|
804
|
+
all_dst_files.update(self._collect_dst_files(src_path, base_path, add_basename))
|
|
805
|
+
if all_dst_files and not self._batch_mode:
|
|
806
|
+
self._progress_max_dst_len = max(len(':' + p) for p in all_dst_files)
|
|
807
|
+
if not self._force:
|
|
808
|
+
self._prefetch_remote_info(all_dst_files)
|
|
530
809
|
for src in sources:
|
|
531
810
|
src_is_remote = src.startswith(':')
|
|
532
811
|
src_path = src[1:] if src_is_remote else src
|
|
@@ -547,56 +826,55 @@ class MpyTool():
|
|
|
547
826
|
raise ParamsError('mv requires source and destination')
|
|
548
827
|
sources = list(args[:-1])
|
|
549
828
|
dest = args[-1]
|
|
550
|
-
|
|
551
|
-
if not dest.startswith(':'):
|
|
552
|
-
raise ParamsError('mv destination must be device path (: prefix)')
|
|
829
|
+
dest_path = _parse_device_path(dest, 'mv destination')
|
|
553
830
|
for src in sources:
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
dest_is_dir = dest_path.endswith('/')
|
|
831
|
+
_parse_device_path(src, 'mv source') # validate only
|
|
832
|
+
# ':' = CWD (empty string), ':/' = root
|
|
833
|
+
dest_is_dir = dest_path == '' or dest_path == '/' or dest_path.endswith('/')
|
|
558
834
|
if len(sources) > 1 and not dest_is_dir:
|
|
559
835
|
raise ParamsError('multiple sources require destination directory (ending with /)')
|
|
560
836
|
self._mpy.import_module('os')
|
|
561
837
|
for src in sources:
|
|
562
|
-
src_path = src
|
|
838
|
+
src_path = _parse_device_path(src, 'mv')
|
|
563
839
|
stat = self._mpy.stat(src_path)
|
|
564
840
|
if stat is None:
|
|
565
841
|
raise ParamsError(f'Source not found on device: {src_path}')
|
|
566
842
|
if dest_is_dir:
|
|
567
|
-
#
|
|
568
|
-
dst_dir = dest_path.rstrip('/')
|
|
569
|
-
if dst_dir and self._mpy.stat(dst_dir) is None:
|
|
843
|
+
# Preserve '/' as root, strip trailing slash from others
|
|
844
|
+
dst_dir = '/' if dest_path == '/' else dest_path.rstrip('/')
|
|
845
|
+
if dst_dir and dst_dir != '/' and self._mpy.stat(dst_dir) is None:
|
|
570
846
|
self._mpy.mkdir(dst_dir)
|
|
571
|
-
|
|
572
|
-
final_dest = dest_path + basename
|
|
847
|
+
final_dest = _join_remote_path(dst_dir, _remote_basename(src_path))
|
|
573
848
|
else:
|
|
574
849
|
final_dest = dest_path
|
|
575
850
|
self.verbose(f"MV: {src_path} -> {final_dest}", 1)
|
|
576
851
|
self._mpy.rename(src_path, final_dest)
|
|
577
852
|
|
|
578
|
-
def
|
|
579
|
-
|
|
580
|
-
self.verbose(f"MKDIR: {dir_name}", 1)
|
|
581
|
-
self._mpy.mkdir(dir_name)
|
|
582
|
-
|
|
583
|
-
def cmd_delete(self, *file_names):
|
|
853
|
+
def cmd_rm(self, *file_names):
|
|
854
|
+
"""Delete files/directories on device"""
|
|
584
855
|
for file_name in file_names:
|
|
585
|
-
|
|
586
|
-
|
|
856
|
+
raw_path = _parse_device_path(file_name, 'rm')
|
|
857
|
+
contents_only = raw_path.endswith('/') or raw_path == ''
|
|
858
|
+
# ':' = CWD contents, ':/' = root, ':/path' = path, ':path/' = path contents
|
|
859
|
+
if raw_path == '':
|
|
860
|
+
path = '' # CWD
|
|
861
|
+
elif raw_path == '/':
|
|
862
|
+
path = '/' # root
|
|
863
|
+
else:
|
|
864
|
+
path = raw_path.rstrip('/') if contents_only else raw_path
|
|
587
865
|
if contents_only:
|
|
588
|
-
self.verbose(f"
|
|
866
|
+
self.verbose(f"RM contents: {path or 'CWD'}", 1)
|
|
589
867
|
entries = self._mpy.ls(path)
|
|
590
868
|
for name, size in entries:
|
|
591
|
-
entry_path = path
|
|
869
|
+
entry_path = _join_remote_path(path, name)
|
|
592
870
|
self.verbose(f" {entry_path}", 1)
|
|
593
871
|
self._mpy.delete(entry_path)
|
|
594
872
|
else:
|
|
595
|
-
self.verbose(f"
|
|
873
|
+
self.verbose(f"RM: {path}", 1)
|
|
596
874
|
self._mpy.delete(path)
|
|
597
875
|
|
|
598
|
-
def
|
|
599
|
-
self.verbose("
|
|
876
|
+
def cmd_monitor(self):
|
|
877
|
+
self.verbose("MONITOR (Ctrl+C to stop)", 1)
|
|
600
878
|
try:
|
|
601
879
|
while True:
|
|
602
880
|
line = self._conn.read_line()
|
|
@@ -604,7 +882,7 @@ class MpyTool():
|
|
|
604
882
|
print(line)
|
|
605
883
|
except KeyboardInterrupt:
|
|
606
884
|
self.verbose('', level=0, overwrite=True) # newline after ^C
|
|
607
|
-
except _mpytool.ConnError as err:
|
|
885
|
+
except (_mpytool.ConnError, OSError) as err:
|
|
608
886
|
if self._log:
|
|
609
887
|
self._log.error(err)
|
|
610
888
|
|
|
@@ -618,159 +896,478 @@ class MpyTool():
|
|
|
618
896
|
terminal.run()
|
|
619
897
|
self._log.info('Exiting..')
|
|
620
898
|
|
|
621
|
-
def cmd_exec(self, code):
|
|
622
|
-
self.verbose(f"EXEC: {code}", 1)
|
|
623
|
-
result = self._mpy.comm.exec(code)
|
|
624
|
-
if result:
|
|
625
|
-
print(result.decode('utf-8', 'backslashreplace'), end='')
|
|
626
|
-
|
|
627
899
|
@staticmethod
|
|
628
900
|
def format_size(size):
|
|
629
|
-
"""Format size in bytes to human readable format
|
|
630
|
-
if size <
|
|
631
|
-
return f"{size}
|
|
632
|
-
for unit in ('
|
|
901
|
+
"""Format size in bytes to human readable format (like ls -h)"""
|
|
902
|
+
if size < 1024:
|
|
903
|
+
return f"{int(size)}B"
|
|
904
|
+
for unit in ('K', 'M', 'G', 'T'):
|
|
633
905
|
size /= 1024
|
|
634
906
|
if size < 10:
|
|
635
|
-
return f"{size:.2f}
|
|
907
|
+
return f"{size:.2f}{unit}"
|
|
636
908
|
if size < 100:
|
|
637
|
-
return f"{size:.1f}
|
|
638
|
-
if size <
|
|
639
|
-
return f"{size:.0f}
|
|
640
|
-
return f"{size:.0f}
|
|
909
|
+
return f"{size:.1f}{unit}"
|
|
910
|
+
if size < 1024 or unit == 'T':
|
|
911
|
+
return f"{size:.0f}{unit}"
|
|
912
|
+
return f"{size:.0f}T"
|
|
913
|
+
|
|
914
|
+
def cmd_ota(self, firmware_path):
|
|
915
|
+
"""OTA firmware update from local .app-bin file"""
|
|
916
|
+
self.verbose("OTA UPDATE", 1)
|
|
917
|
+
if not _os.path.isfile(firmware_path):
|
|
918
|
+
raise ParamsError(f"Firmware file not found: {firmware_path}")
|
|
919
|
+
|
|
920
|
+
with open(firmware_path, 'rb') as f:
|
|
921
|
+
firmware = f.read()
|
|
922
|
+
|
|
923
|
+
fw_size = len(firmware)
|
|
924
|
+
self.verbose(f" Firmware: {self.format_size(fw_size)}", 1)
|
|
925
|
+
info = self._mpy.partitions()
|
|
926
|
+
if not info['next_ota']:
|
|
927
|
+
raise _mpytool.MpyError("OTA not available (no OTA partitions)")
|
|
928
|
+
|
|
929
|
+
self.verbose(f" Target: {info['next_ota']} ({self.format_size(info['next_ota_size'])})", 1)
|
|
930
|
+
use_compress = self._mpy._detect_deflate()
|
|
931
|
+
chunk_size = self._mpy._detect_chunk_size()
|
|
932
|
+
chunk_str = f"{chunk_size // 1024}K" if chunk_size >= 1024 else str(chunk_size)
|
|
933
|
+
self.verbose(f" Writing (chunk: {chunk_str}, compress: {'on' if use_compress else 'off'})...", 1)
|
|
934
|
+
start_time = _time.time()
|
|
935
|
+
|
|
936
|
+
def progress_callback(transferred, total, wire_bytes):
|
|
937
|
+
if self._verbose >= 1:
|
|
938
|
+
pct = transferred * 100 // total
|
|
939
|
+
elapsed = _time.time() - start_time
|
|
940
|
+
speed = transferred / elapsed / 1024 if elapsed > 0 else 0
|
|
941
|
+
line = f" Writing: {pct:3d}% {self.format_size(transferred):>6} / {self.format_size(total)} {speed:.1f} KB/s"
|
|
942
|
+
self.verbose(line, color='cyan', end='', overwrite=True)
|
|
943
|
+
|
|
944
|
+
result = self._mpy.ota_write(firmware, progress_callback, self._compress)
|
|
945
|
+
elapsed = _time.time() - start_time
|
|
946
|
+
speed = fw_size / elapsed / 1024 if elapsed > 0 else 0
|
|
947
|
+
ratio = fw_size / result['wire_bytes'] if result['wire_bytes'] > 0 else 1
|
|
948
|
+
self.verbose(
|
|
949
|
+
f" Writing: 100% {self.format_size(fw_size):>6} {elapsed:.1f}s {speed:.1f} KB/s ratio {ratio:.2f}x",
|
|
950
|
+
color='cyan', overwrite=True
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
self.verbose(f" OTA complete! Use 'mreset' to boot into new firmware.", 1, color='green')
|
|
954
|
+
|
|
955
|
+
def cmd_flash(self):
|
|
956
|
+
"""Show flash information (auto-detect platform)"""
|
|
957
|
+
self.verbose("FLASH", 2)
|
|
958
|
+
platform = self._mpy.platform()['platform']
|
|
959
|
+
|
|
960
|
+
if platform == 'rp2':
|
|
961
|
+
self._cmd_flash_rp2()
|
|
962
|
+
elif platform == 'esp32':
|
|
963
|
+
self._cmd_flash_esp32()
|
|
964
|
+
else:
|
|
965
|
+
raise _mpytool.MpyError(f"Flash info not supported for platform: {platform}")
|
|
966
|
+
|
|
967
|
+
def _cmd_flash_rp2(self):
|
|
968
|
+
"""Show RP2 flash information"""
|
|
969
|
+
info = self._mpy.flash_info()
|
|
970
|
+
print(f"Platform: RP2")
|
|
971
|
+
print(f"Flash size: {self.format_size(info['size'])}")
|
|
972
|
+
print(f"Block size: {info['block_size']} bytes")
|
|
973
|
+
print(f"Block count: {info['block_count']}")
|
|
974
|
+
fs_line = f"Filesystem: {info['filesystem']}"
|
|
975
|
+
# For FAT, show cluster size if detected from magic
|
|
976
|
+
if info.get('fs_block_size'):
|
|
977
|
+
fs_line += f" (cluster: {self.format_size(info['fs_block_size'])})"
|
|
978
|
+
if info['filesystem'] == 'unknown' and info.get('magic'):
|
|
979
|
+
magic_hex = ' '.join(f'{b:02x}' for b in info['magic'])
|
|
980
|
+
fs_line += f" (magic: {magic_hex})"
|
|
981
|
+
print(fs_line)
|
|
982
|
+
|
|
983
|
+
def _make_progress(self, action, label=None):
|
|
984
|
+
"""Create progress callback for flash operations"""
|
|
985
|
+
def progress(transferred, total, *_):
|
|
986
|
+
if self._verbose >= 1:
|
|
987
|
+
pct = (transferred / total * 100) if total > 0 else 0
|
|
988
|
+
prefix = f"{action} {label}" if label else action
|
|
989
|
+
self.verbose(
|
|
990
|
+
f" {prefix}: {pct:.0f}% {self.format_size(transferred)} / {self.format_size(total)}",
|
|
991
|
+
color='cyan', end='', overwrite=True)
|
|
992
|
+
return progress
|
|
993
|
+
|
|
994
|
+
def cmd_flash_read(self, dest_path, label=None):
|
|
995
|
+
"""Read flash/partition content to file"""
|
|
996
|
+
if label:
|
|
997
|
+
self.verbose(f"FLASH READ {label} -> {dest_path}", 1)
|
|
998
|
+
else:
|
|
999
|
+
self.verbose(f"FLASH READ -> {dest_path}", 1)
|
|
1000
|
+
|
|
1001
|
+
data = self._mpy.flash_read(label=label, progress_callback=self._make_progress("reading", label))
|
|
1002
|
+
|
|
1003
|
+
if self._verbose >= 1:
|
|
1004
|
+
print() # newline after progress
|
|
1005
|
+
|
|
1006
|
+
with open(dest_path, 'wb') as f:
|
|
1007
|
+
f.write(data)
|
|
1008
|
+
|
|
1009
|
+
self.verbose(f" saved {self.format_size(len(data))} to {dest_path}", 1, color='green')
|
|
1010
|
+
|
|
1011
|
+
def cmd_flash_write(self, src_path, label=None):
|
|
1012
|
+
"""Write file content to flash/partition"""
|
|
1013
|
+
if label:
|
|
1014
|
+
self.verbose(f"FLASH WRITE {src_path} -> {label}", 1)
|
|
1015
|
+
else:
|
|
1016
|
+
self.verbose(f"FLASH WRITE {src_path}", 1)
|
|
1017
|
+
|
|
1018
|
+
with open(src_path, 'rb') as f:
|
|
1019
|
+
data = f.read()
|
|
1020
|
+
|
|
1021
|
+
result = self._mpy.flash_write(
|
|
1022
|
+
data, label=label,
|
|
1023
|
+
progress_callback=self._make_progress("writing", label),
|
|
1024
|
+
compress=self._compress)
|
|
1025
|
+
|
|
1026
|
+
if self._verbose >= 1:
|
|
1027
|
+
print() # newline after progress
|
|
1028
|
+
|
|
1029
|
+
target = label or "flash"
|
|
1030
|
+
comp_info = " (compressed)" if result.get('compressed') else ""
|
|
1031
|
+
self.verbose(f" wrote {self.format_size(result['written'])} to {target}{comp_info}", 1, color='green')
|
|
1032
|
+
|
|
1033
|
+
def cmd_flash_erase(self, label=None, full=False):
|
|
1034
|
+
"""Erase flash/partition (filesystem reset)"""
|
|
1035
|
+
mode = "full" if full else "quick"
|
|
1036
|
+
if label:
|
|
1037
|
+
self.verbose(f"FLASH ERASE {label} ({mode})", 1)
|
|
1038
|
+
else:
|
|
1039
|
+
self.verbose(f"FLASH ERASE ({mode})", 1)
|
|
1040
|
+
|
|
1041
|
+
result = self._mpy.flash_erase(label=label, full=full, progress_callback=self._make_progress("erasing", label))
|
|
1042
|
+
|
|
1043
|
+
if self._verbose >= 1:
|
|
1044
|
+
print() # newline after progress
|
|
1045
|
+
|
|
1046
|
+
target = label or "flash"
|
|
1047
|
+
self.verbose(f" erased {self.format_size(result['erased'])} from {target}", 1, color='green')
|
|
1048
|
+
if not label:
|
|
1049
|
+
self.verbose(" filesystem will be recreated on next boot", 1, color='yellow')
|
|
1050
|
+
|
|
1051
|
+
def _cmd_flash_esp32(self):
|
|
1052
|
+
"""List ESP32 partitions"""
|
|
1053
|
+
info = self._mpy.partitions()
|
|
1054
|
+
print(f"{'Label':<12} {'Type':<8} {'Subtype':<10} {'Address':>10} {'Size':>10} "
|
|
1055
|
+
f"{'Block':>8} {'Actual FS':<12} {'Flags'}")
|
|
1056
|
+
print("-" * 90)
|
|
1057
|
+
|
|
1058
|
+
for p in info['partitions']:
|
|
1059
|
+
flags = []
|
|
1060
|
+
if p['encrypted']:
|
|
1061
|
+
flags.append('enc')
|
|
1062
|
+
if p['running']:
|
|
1063
|
+
flags.append('running')
|
|
1064
|
+
# Block size column
|
|
1065
|
+
block_str = ''
|
|
1066
|
+
if p.get('fs_block_size'):
|
|
1067
|
+
block_str = self.format_size(p['fs_block_size'])
|
|
1068
|
+
# Filesystem column
|
|
1069
|
+
fs_info = ''
|
|
1070
|
+
if p.get('filesystem'):
|
|
1071
|
+
fs_info = p['filesystem']
|
|
1072
|
+
# For FAT, append cluster size
|
|
1073
|
+
if p.get('fs_cluster_size'):
|
|
1074
|
+
fs_info += f" ({self.format_size(p['fs_cluster_size'])})"
|
|
1075
|
+
print(f"{p['label']:<12} {p['type_name']:<8} {p['subtype_name']:<10} "
|
|
1076
|
+
f"{p['offset']:>#10x} {self.format_size(p['size']):>10} "
|
|
1077
|
+
f"{block_str:>8} {fs_info:<12} {', '.join(flags)}")
|
|
1078
|
+
|
|
1079
|
+
if info['boot']:
|
|
1080
|
+
print(f"\nBoot partition: {info['boot']}")
|
|
1081
|
+
if info['next_ota']:
|
|
1082
|
+
print(f"Next OTA: {info['next_ota']}")
|
|
641
1083
|
|
|
642
1084
|
def cmd_info(self):
|
|
643
1085
|
self.verbose("INFO", 2)
|
|
644
|
-
self._mpy.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
try:
|
|
660
|
-
fs_stat = self._mpy.comm.exec_eval("os.statvfs('/')")
|
|
661
|
-
fs_total = fs_stat[0] * fs_stat[2]
|
|
662
|
-
fs_free = fs_stat[0] * fs_stat[3]
|
|
663
|
-
if fs_total > 0:
|
|
664
|
-
fs_info.append({
|
|
665
|
-
'mount': '/', 'total': fs_total,
|
|
666
|
-
'used': fs_total - fs_free,
|
|
667
|
-
'pct': ((fs_total - fs_free) / fs_total * 100)
|
|
668
|
-
})
|
|
669
|
-
except _mpytool.MpyError:
|
|
670
|
-
pass
|
|
671
|
-
# Check subdirectories for additional mount points
|
|
672
|
-
try:
|
|
673
|
-
root_dirs = self._mpy.comm.exec_eval("[d[0] for d in os.ilistdir('/') if d[1] == 0x4000]")
|
|
674
|
-
for dirname in root_dirs:
|
|
675
|
-
try:
|
|
676
|
-
path = '/' + dirname
|
|
677
|
-
sub_stat = self._mpy.comm.exec_eval(f"os.statvfs('{path}')")
|
|
678
|
-
sub_total = sub_stat[0] * sub_stat[2]
|
|
679
|
-
sub_free = sub_stat[0] * sub_stat[3]
|
|
680
|
-
# Skip if same as root or zero size
|
|
681
|
-
if sub_total == 0 or any(f['total'] == sub_total for f in fs_info):
|
|
682
|
-
continue
|
|
683
|
-
fs_info.append({
|
|
684
|
-
'mount': path, 'total': sub_total,
|
|
685
|
-
'used': sub_total - sub_free,
|
|
686
|
-
'pct': ((sub_total - sub_free) / sub_total * 100)
|
|
687
|
-
})
|
|
688
|
-
except _mpytool.MpyError:
|
|
689
|
-
pass
|
|
690
|
-
except _mpytool.MpyError:
|
|
691
|
-
pass
|
|
692
|
-
print(f"Platform: {platform}")
|
|
693
|
-
print(f"Version: {version}")
|
|
694
|
-
print(f"Impl: {impl}")
|
|
695
|
-
if machine:
|
|
696
|
-
print(f"Machine: {machine}")
|
|
697
|
-
print(f"Memory: {self.format_size(gc_alloc)} / {self.format_size(gc_total)} ({gc_pct:.2f}%)")
|
|
698
|
-
for fs in fs_info:
|
|
1086
|
+
plat = self._mpy.platform()
|
|
1087
|
+
print(f"Platform: {plat['platform']}")
|
|
1088
|
+
print(f"Version: {plat['version']}")
|
|
1089
|
+
print(f"Impl: {plat['impl']}")
|
|
1090
|
+
if plat['machine']:
|
|
1091
|
+
print(f"Machine: {plat['machine']}")
|
|
1092
|
+
uid = self._mpy.unique_id()
|
|
1093
|
+
if uid:
|
|
1094
|
+
print(f"Serial: {uid}")
|
|
1095
|
+
for iface, mac in self._mpy.mac_addresses():
|
|
1096
|
+
print(f"MAC {iface + ':':<8} {mac}")
|
|
1097
|
+
mem = self._mpy.memory()
|
|
1098
|
+
mem_pct = (mem['alloc'] / mem['total'] * 100) if mem['total'] > 0 else 0
|
|
1099
|
+
print(f"Memory: {self.format_size(mem['alloc'])} / {self.format_size(mem['total'])} ({mem_pct:.2f}%)")
|
|
1100
|
+
for fs in self._mpy.filesystems():
|
|
699
1101
|
label = "Flash:" if fs['mount'] == '/' else fs['mount'] + ':'
|
|
700
|
-
|
|
1102
|
+
fs_pct = (fs['used'] / fs['total'] * 100) if fs['total'] > 0 else 0
|
|
1103
|
+
print(f"{label:12} {self.format_size(fs['used'])} / {self.format_size(fs['total'])} ({fs_pct:.2f}%)")
|
|
701
1104
|
|
|
702
|
-
def
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
self.
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
self.
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1105
|
+
def cmd_reset(self, mode='soft', reconnect=True, timeout=None):
|
|
1106
|
+
"""Reset device in specified mode
|
|
1107
|
+
|
|
1108
|
+
Modes:
|
|
1109
|
+
soft - Ctrl-D soft reset, runs boot.py/main.py (default)
|
|
1110
|
+
raw - soft reset in raw REPL, clears RAM only
|
|
1111
|
+
machine - machine.reset() with optional reconnect
|
|
1112
|
+
rts - hardware reset via DTR/RTS with optional reconnect
|
|
1113
|
+
boot - enter bootloader via machine.bootloader()
|
|
1114
|
+
dtr-boot - enter bootloader via DTR/RTS signals (ESP32)
|
|
1115
|
+
"""
|
|
1116
|
+
self.verbose(f"RESET {mode}", 1)
|
|
1117
|
+
if mode == 'soft':
|
|
1118
|
+
self._mpy.soft_reset()
|
|
1119
|
+
elif mode == 'raw':
|
|
1120
|
+
self._mpy.soft_reset_raw()
|
|
1121
|
+
elif mode == 'machine':
|
|
1122
|
+
if reconnect:
|
|
1123
|
+
try:
|
|
1124
|
+
self.verbose(" reconnecting...", 1, color='yellow')
|
|
1125
|
+
self._mpy.machine_reset(
|
|
1126
|
+
reconnect=True, timeout=timeout)
|
|
1127
|
+
self.verbose(" connected", 1, color='green')
|
|
1128
|
+
except (_mpytool.ConnError, OSError) as err:
|
|
1129
|
+
self.verbose(
|
|
1130
|
+
f" reconnect failed: {err}", 1, color='red')
|
|
1131
|
+
raise _mpytool.ConnError(
|
|
1132
|
+
f"Reconnect failed: {err}")
|
|
1133
|
+
else:
|
|
1134
|
+
self._mpy.machine_reset(reconnect=False)
|
|
1135
|
+
elif mode == 'rts':
|
|
1136
|
+
try:
|
|
1137
|
+
self._mpy.hard_reset()
|
|
1138
|
+
if reconnect:
|
|
1139
|
+
self.verbose(
|
|
1140
|
+
" reconnecting...", 1, color='yellow')
|
|
1141
|
+
_time.sleep(1.0) # Wait for device to boot
|
|
1142
|
+
self._mpy._conn.reconnect()
|
|
1143
|
+
self.verbose(" connected", 1, color='green')
|
|
1144
|
+
except NotImplementedError:
|
|
1145
|
+
raise _mpytool.MpyError(
|
|
1146
|
+
"Hardware reset not available (serial only)")
|
|
1147
|
+
except (_mpytool.ConnError, OSError) as err:
|
|
1148
|
+
self.verbose(
|
|
1149
|
+
f" reconnect failed: {err}", 1, color='red')
|
|
1150
|
+
raise _mpytool.ConnError(
|
|
1151
|
+
f"Reconnect failed: {err}")
|
|
1152
|
+
elif mode == 'boot':
|
|
1153
|
+
self._mpy.machine_bootloader()
|
|
1154
|
+
elif mode == 'dtr-boot':
|
|
1155
|
+
try:
|
|
1156
|
+
self._mpy.reset_to_bootloader()
|
|
1157
|
+
except NotImplementedError:
|
|
1158
|
+
raise _mpytool.MpyError(
|
|
1159
|
+
"DTR boot not available (serial only)")
|
|
1160
|
+
|
|
1161
|
+
# -- dispatch methods for process_commands() --
|
|
1162
|
+
|
|
1163
|
+
def _dispatch_ls(self, commands, is_last_group):
|
|
1164
|
+
dir_name = ':'
|
|
1165
|
+
if commands:
|
|
1166
|
+
dir_name = commands.pop(0)
|
|
1167
|
+
# Strip trailing / except for root
|
|
1168
|
+
if dir_name not in (':', ':/'):
|
|
1169
|
+
dir_name = dir_name.rstrip('/')
|
|
1170
|
+
path = _parse_device_path(dir_name, 'ls')
|
|
1171
|
+
result = self._mpy.ls(path)
|
|
1172
|
+
for name, size in result:
|
|
1173
|
+
if size is not None:
|
|
1174
|
+
print(f'{self.format_size(size):>9} {name}')
|
|
1175
|
+
else:
|
|
1176
|
+
print(f'{"":9} {name}/')
|
|
1177
|
+
|
|
1178
|
+
def _dispatch_tree(self, commands, is_last_group):
|
|
1179
|
+
dir_name = ':'
|
|
1180
|
+
if commands:
|
|
1181
|
+
dir_name = commands.pop(0)
|
|
1182
|
+
if dir_name not in (':', ':/'):
|
|
1183
|
+
dir_name = dir_name.rstrip('/')
|
|
1184
|
+
path = _parse_device_path(dir_name, 'tree')
|
|
1185
|
+
tree = self._mpy.tree(path)
|
|
1186
|
+
self.print_tree(tree)
|
|
1187
|
+
|
|
1188
|
+
def _dispatch_cat(self, commands, is_last_group):
|
|
1189
|
+
if not commands:
|
|
1190
|
+
raise ParamsError('missing file name for cat command')
|
|
1191
|
+
for file_name in commands:
|
|
1192
|
+
path = _parse_device_path(file_name, 'cat')
|
|
1193
|
+
self.verbose(f"CAT: {path}", 2)
|
|
1194
|
+
data = self._mpy.get(path)
|
|
1195
|
+
print(data.decode('utf-8'))
|
|
1196
|
+
commands.clear()
|
|
1197
|
+
|
|
1198
|
+
def _dispatch_mkdir(self, commands, is_last_group):
|
|
1199
|
+
if not commands:
|
|
1200
|
+
raise ParamsError('missing directory name for mkdir command')
|
|
1201
|
+
for dir_name in commands:
|
|
1202
|
+
path = _parse_device_path(dir_name, 'mkdir')
|
|
1203
|
+
self.verbose(f"MKDIR: {path}", 1)
|
|
1204
|
+
self._mpy.mkdir(path)
|
|
1205
|
+
commands.clear()
|
|
1206
|
+
|
|
1207
|
+
def _dispatch_rm(self, commands, is_last_group):
|
|
1208
|
+
if not commands:
|
|
1209
|
+
raise ParamsError('missing file name for rm command')
|
|
1210
|
+
self.cmd_rm(*commands)
|
|
1211
|
+
commands.clear()
|
|
1212
|
+
|
|
1213
|
+
def _dispatch_pwd(self, commands, is_last_group):
|
|
1214
|
+
cwd = self._mpy.getcwd()
|
|
1215
|
+
print(cwd)
|
|
1216
|
+
|
|
1217
|
+
def _dispatch_cd(self, commands, is_last_group):
|
|
1218
|
+
if not commands:
|
|
1219
|
+
raise ParamsError('missing directory for cd command')
|
|
1220
|
+
dir_name = commands.pop(0)
|
|
1221
|
+
path = _parse_device_path(dir_name, 'cd')
|
|
1222
|
+
self.verbose(f"CD: {path}", 2)
|
|
1223
|
+
self._mpy.chdir(path)
|
|
1224
|
+
|
|
1225
|
+
def _dispatch_reset(self, commands, is_last_group):
|
|
1226
|
+
mode = 'soft'
|
|
1227
|
+
timeout = None
|
|
1228
|
+
_reset_modes = (
|
|
1229
|
+
'--machine', '--rts', '--raw',
|
|
1230
|
+
'--boot', '--dtr-boot')
|
|
1231
|
+
while commands and commands[0].startswith('-'):
|
|
1232
|
+
flag = commands[0]
|
|
1233
|
+
if flag in _reset_modes:
|
|
1234
|
+
mode = commands.pop(0)[2:] # strip --
|
|
1235
|
+
elif flag in ('-t', '--timeout'):
|
|
1236
|
+
commands.pop(0)
|
|
1237
|
+
if commands:
|
|
1238
|
+
timeout = int(commands.pop(0))
|
|
1239
|
+
else:
|
|
1240
|
+
raise ParamsError(
|
|
1241
|
+
'missing value for --timeout')
|
|
1242
|
+
else:
|
|
1243
|
+
raise ParamsError(
|
|
1244
|
+
f"unknown reset flag: '{flag}'")
|
|
1245
|
+
if timeout and mode not in ('machine', 'rts'):
|
|
1246
|
+
raise ParamsError(
|
|
1247
|
+
'--timeout only with --machine or --rts')
|
|
1248
|
+
has_more = bool(commands) or not is_last_group
|
|
1249
|
+
reconnect = has_more if mode in (
|
|
1250
|
+
'machine', 'rts') else True
|
|
1251
|
+
self.cmd_reset(
|
|
1252
|
+
mode=mode, reconnect=reconnect,
|
|
1253
|
+
timeout=timeout)
|
|
1254
|
+
|
|
1255
|
+
def _dispatch_monitor(self, commands, is_last_group):
|
|
1256
|
+
self.cmd_monitor()
|
|
1257
|
+
commands.clear()
|
|
1258
|
+
|
|
1259
|
+
def _dispatch_repl(self, commands, is_last_group):
|
|
1260
|
+
self.cmd_repl()
|
|
1261
|
+
commands.clear()
|
|
1262
|
+
|
|
1263
|
+
def _dispatch_exec(self, commands, is_last_group):
|
|
1264
|
+
if not commands:
|
|
1265
|
+
raise ParamsError('missing code for exec command')
|
|
1266
|
+
code = commands.pop(0)
|
|
1267
|
+
self.verbose(f"EXEC: {code}", 1)
|
|
1268
|
+
result = self._mpy.comm.exec(code)
|
|
1269
|
+
if result:
|
|
1270
|
+
print(result.decode('utf-8', 'backslashreplace'), end='')
|
|
1271
|
+
|
|
1272
|
+
def _dispatch_info(self, commands, is_last_group):
|
|
1273
|
+
self.cmd_info()
|
|
1274
|
+
|
|
1275
|
+
def _dispatch_flash(self, commands, is_last_group):
|
|
1276
|
+
if commands and commands[0] == 'read':
|
|
1277
|
+
commands.pop(0)
|
|
1278
|
+
if len(commands) >= 2:
|
|
1279
|
+
# ESP32: flash read <label> <file>
|
|
1280
|
+
label = commands.pop(0)
|
|
1281
|
+
dest_path = commands.pop(0)
|
|
1282
|
+
self.cmd_flash_read(dest_path, label=label)
|
|
1283
|
+
elif len(commands) == 1:
|
|
1284
|
+
# RP2: flash read <file>
|
|
1285
|
+
dest_path = commands.pop(0)
|
|
1286
|
+
self.cmd_flash_read(dest_path)
|
|
1287
|
+
else:
|
|
1288
|
+
raise ParamsError('flash read requires destination file')
|
|
1289
|
+
elif commands and commands[0] == 'write':
|
|
1290
|
+
commands.pop(0)
|
|
1291
|
+
if len(commands) >= 2:
|
|
1292
|
+
# ESP32: flash write <label> <file>
|
|
1293
|
+
label = commands.pop(0)
|
|
1294
|
+
src_path = commands.pop(0)
|
|
1295
|
+
self.cmd_flash_write(src_path, label=label)
|
|
1296
|
+
elif len(commands) == 1:
|
|
1297
|
+
# RP2: flash write <file>
|
|
1298
|
+
src_path = commands.pop(0)
|
|
1299
|
+
self.cmd_flash_write(src_path)
|
|
1300
|
+
else:
|
|
1301
|
+
raise ParamsError('flash write requires source file')
|
|
1302
|
+
elif commands and commands[0] == 'erase':
|
|
1303
|
+
commands.pop(0)
|
|
1304
|
+
full = False
|
|
1305
|
+
label = None
|
|
1306
|
+
while commands and (commands[0] == '--full' or not commands[0].startswith('-')):
|
|
1307
|
+
if commands[0] == '--full':
|
|
1308
|
+
full = True
|
|
1309
|
+
commands.pop(0)
|
|
1310
|
+
elif label is None:
|
|
1311
|
+
label = commands.pop(0)
|
|
770
1312
|
else:
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1313
|
+
break
|
|
1314
|
+
self.cmd_flash_erase(label=label, full=full)
|
|
1315
|
+
else:
|
|
1316
|
+
self.cmd_flash()
|
|
1317
|
+
|
|
1318
|
+
def _dispatch_ota(self, commands, is_last_group):
|
|
1319
|
+
if not commands:
|
|
1320
|
+
raise ParamsError('ota requires firmware file path')
|
|
1321
|
+
self.cmd_ota(commands.pop(0))
|
|
1322
|
+
|
|
1323
|
+
def _dispatch_sleep(self, commands, is_last_group):
|
|
1324
|
+
if not commands:
|
|
1325
|
+
raise ParamsError('sleep requires a number (seconds)')
|
|
1326
|
+
try:
|
|
1327
|
+
seconds = float(commands.pop(0))
|
|
1328
|
+
except ValueError:
|
|
1329
|
+
raise ParamsError('sleep requires a number (seconds)')
|
|
1330
|
+
self.verbose(f"SLEEP {seconds}s", 1)
|
|
1331
|
+
_time.sleep(seconds)
|
|
1332
|
+
|
|
1333
|
+
def _dispatch_cp(self, commands, is_last_group):
|
|
1334
|
+
if len(commands) < 2:
|
|
1335
|
+
raise ParamsError('cp requires source and destination')
|
|
1336
|
+
self.cmd_cp(*commands)
|
|
1337
|
+
commands.clear()
|
|
1338
|
+
|
|
1339
|
+
def _dispatch_mv(self, commands, is_last_group):
|
|
1340
|
+
if len(commands) < 2:
|
|
1341
|
+
raise ParamsError('mv requires source and destination')
|
|
1342
|
+
self.cmd_mv(*commands)
|
|
1343
|
+
commands.clear()
|
|
1344
|
+
|
|
1345
|
+
def _dispatch_paths(self, commands, is_last_group):
|
|
1346
|
+
# For shell completion
|
|
1347
|
+
dir_name = commands.pop(0) if commands else ':'
|
|
1348
|
+
path = _parse_device_path(dir_name, '_paths')
|
|
1349
|
+
try:
|
|
1350
|
+
entries = self._mpy.ls(path)
|
|
1351
|
+
except (_mpytool.DirNotFound, _mpytool.MpyError):
|
|
1352
|
+
return
|
|
1353
|
+
for name, size in entries:
|
|
1354
|
+
print(name + '/' if size is None else name)
|
|
1355
|
+
|
|
1356
|
+
# -- command dispatching --
|
|
1357
|
+
|
|
1358
|
+
_COMMANDS = frozenset({
|
|
1359
|
+
'ls', 'tree', 'cat', 'mkdir', 'rm', 'pwd', 'cd',
|
|
1360
|
+
'reset', 'monitor', 'repl', 'exec', 'info', 'flash',
|
|
1361
|
+
'ota', 'sleep', 'cp', 'mv', '_paths',
|
|
1362
|
+
})
|
|
1363
|
+
|
|
1364
|
+
def process_commands(self, commands, is_last_group=False):
|
|
1365
|
+
while commands:
|
|
1366
|
+
command = commands.pop(0)
|
|
1367
|
+
if command not in self._COMMANDS:
|
|
1368
|
+
raise ParamsError(f"unknown command: '{command}'")
|
|
1369
|
+
dispatch = getattr(self, f'_dispatch_{command.lstrip("_")}')
|
|
1370
|
+
dispatch(commands, is_last_group)
|
|
774
1371
|
try:
|
|
775
1372
|
self._mpy.comm.exit_raw_repl()
|
|
776
1373
|
except _mpytool.ConnError:
|
|
@@ -782,65 +1379,97 @@ if _about:
|
|
|
782
1379
|
else:
|
|
783
1380
|
_VERSION_STR = "mpytool (not installed version)"
|
|
784
1381
|
_COMMANDS_HELP_STR = """
|
|
785
|
-
|
|
786
|
-
ls [
|
|
787
|
-
tree [
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1382
|
+
Commands (: prefix = device path, :/ = root, : = CWD):
|
|
1383
|
+
ls [:path] list files and sizes (default: CWD)
|
|
1384
|
+
tree [:path] list directory tree (default: CWD)
|
|
1385
|
+
cat {:path} [...] print file content to stdout
|
|
1386
|
+
cp [-f] {src} [...] {dst} copy files (-f = force overwrite)
|
|
1387
|
+
mv {:src} [...] {:dst} move/rename on device
|
|
1388
|
+
mkdir {:path} [...] create directory (with parents)
|
|
1389
|
+
rm {:path} [...] delete file/dir (:path/ = contents only)
|
|
1390
|
+
pwd print current working directory
|
|
1391
|
+
cd {:path} change current working directory
|
|
1392
|
+
reset [flags] soft reset (Ctrl-D) by default
|
|
1393
|
+
--machine [-t {s}] machine.reset() with reconnect
|
|
1394
|
+
--rts [-t {s}] hardware reset via DTR/RTS signal
|
|
1395
|
+
--raw soft reset in raw REPL
|
|
1396
|
+
--boot enter bootloader (machine.bootloader)
|
|
1397
|
+
--dtr-boot bootloader via DTR/RTS (ESP32)
|
|
1398
|
+
monitor print device output (Ctrl+C to stop)
|
|
1399
|
+
repl interactive REPL [Unix only]
|
|
1400
|
+
exec {code} execute Python code
|
|
798
1401
|
info show device information
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1402
|
+
flash show flash/partitions info
|
|
1403
|
+
flash read [{label}] {file} read flash/partition to file
|
|
1404
|
+
flash write [{label}] {file} write file to flash/partition
|
|
1405
|
+
flash erase [{label}] [--full] erase flash/partition
|
|
1406
|
+
ota {firmware.app-bin} OTA update (ESP32)
|
|
1407
|
+
sleep {seconds} pause between commands
|
|
1408
|
+
Use -- to chain commands:
|
|
1409
|
+
mpytool cp main.py : -- reset -- monitor
|
|
805
1410
|
"""
|
|
806
1411
|
|
|
807
1412
|
|
|
808
1413
|
def _run_commands(mpy_tool, command_groups, with_progress=True):
|
|
809
1414
|
"""Execute command groups with optional batch progress tracking"""
|
|
810
1415
|
if not with_progress:
|
|
811
|
-
for commands in command_groups:
|
|
812
|
-
|
|
1416
|
+
for i, commands in enumerate(command_groups):
|
|
1417
|
+
is_last = (i == len(command_groups) - 1)
|
|
1418
|
+
mpy_tool.process_commands(commands, is_last_group=is_last)
|
|
813
1419
|
return
|
|
814
|
-
# Pre-scan to identify consecutive copy command batches (for progress)
|
|
815
1420
|
i = 0
|
|
816
1421
|
while i < len(command_groups):
|
|
817
|
-
is_copy, count,
|
|
1422
|
+
is_copy, count, src_paths, dst_paths = mpy_tool.count_files_for_command(command_groups[i])
|
|
818
1423
|
if not is_copy:
|
|
819
|
-
|
|
1424
|
+
is_last = (i == len(command_groups) - 1)
|
|
1425
|
+
mpy_tool.process_commands(command_groups[i], is_last_group=is_last)
|
|
820
1426
|
i += 1
|
|
821
1427
|
continue
|
|
822
|
-
# Collect consecutive copy commands into a batch
|
|
823
1428
|
batch_total = count
|
|
824
|
-
|
|
1429
|
+
all_src_paths = src_paths
|
|
1430
|
+
all_dst_paths = dst_paths
|
|
825
1431
|
batch_start = i
|
|
826
1432
|
j = i + 1
|
|
827
1433
|
while j < len(command_groups):
|
|
828
|
-
is_copy_j, count_j,
|
|
1434
|
+
is_copy_j, count_j, src_paths_j, dst_paths_j = mpy_tool.count_files_for_command(command_groups[j])
|
|
829
1435
|
if not is_copy_j:
|
|
830
1436
|
break
|
|
831
1437
|
batch_total += count_j
|
|
832
|
-
|
|
1438
|
+
all_src_paths.extend(src_paths_j)
|
|
1439
|
+
all_dst_paths.extend(dst_paths_j)
|
|
833
1440
|
j += 1
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
mpy_tool.
|
|
837
|
-
mpy_tool.set_batch_progress(batch_total, max_src_len)
|
|
1441
|
+
max_src_len = max(len(p) for p in all_src_paths) if all_src_paths else 0
|
|
1442
|
+
max_dst_len = max(len(p) for p in all_dst_paths) if all_dst_paths else 0
|
|
1443
|
+
mpy_tool.print_transfer_info()
|
|
1444
|
+
mpy_tool.set_batch_progress(batch_total, max_src_len, max_dst_len)
|
|
838
1445
|
for k in range(batch_start, j):
|
|
839
|
-
|
|
1446
|
+
is_last = (k == j - 1) and (j == len(command_groups))
|
|
1447
|
+
mpy_tool.process_commands(command_groups[k], is_last_group=is_last)
|
|
1448
|
+
mpy_tool.print_copy_summary()
|
|
840
1449
|
mpy_tool.reset_batch_progress()
|
|
841
1450
|
i = j
|
|
842
1451
|
|
|
843
1452
|
|
|
1453
|
+
def _parse_chunk_size(value):
|
|
1454
|
+
"""Parse chunk size value (e.g., '1K', '2K', '4096')"""
|
|
1455
|
+
valid = {512, 1024, 2048, 4096, 8192, 16384, 32768}
|
|
1456
|
+
val = value.upper()
|
|
1457
|
+
if val.endswith('K'):
|
|
1458
|
+
try:
|
|
1459
|
+
num = int(val[:-1]) * 1024
|
|
1460
|
+
except ValueError:
|
|
1461
|
+
raise _argparse.ArgumentTypeError(f"invalid chunk size: {value}")
|
|
1462
|
+
else:
|
|
1463
|
+
try:
|
|
1464
|
+
num = int(value)
|
|
1465
|
+
except ValueError:
|
|
1466
|
+
raise _argparse.ArgumentTypeError(f"invalid chunk size: {value}")
|
|
1467
|
+
if num not in valid:
|
|
1468
|
+
valid_str = ', '.join(f'{v//1024}K' if v >= 1024 else str(v) for v in sorted(valid))
|
|
1469
|
+
raise _argparse.ArgumentTypeError(f"chunk size must be one of: {valid_str}")
|
|
1470
|
+
return num
|
|
1471
|
+
|
|
1472
|
+
|
|
844
1473
|
def main():
|
|
845
1474
|
"""Main"""
|
|
846
1475
|
_description = _about["Summary"] if _about else None
|
|
@@ -860,10 +1489,17 @@ def main():
|
|
|
860
1489
|
parser.add_argument(
|
|
861
1490
|
'-q', '--quiet', action='store_true', help='quiet mode (no progress)')
|
|
862
1491
|
parser.add_argument(
|
|
863
|
-
|
|
1492
|
+
"-e", "--exclude", type=str, action='append', dest='exclude',
|
|
1493
|
+
help='exclude files/dirs (wildcards: *, ?), default: *.pyc, .*')
|
|
1494
|
+
parser.add_argument(
|
|
1495
|
+
'-f', '--force', action='store_true', help='force overwrite (skip unchanged check)')
|
|
864
1496
|
parser.add_argument(
|
|
865
|
-
|
|
866
|
-
|
|
1497
|
+
'-z', '--compress', action='store_true', default=None, help='force compression')
|
|
1498
|
+
parser.add_argument(
|
|
1499
|
+
'-Z', '--no-compress', action='store_true', help='disable compression')
|
|
1500
|
+
parser.add_argument(
|
|
1501
|
+
'-c', '--chunk-size', type=_parse_chunk_size, metavar='SIZE',
|
|
1502
|
+
help='transfer chunk size: 512, 1K, 2K, 4K, 8K, 16K, 32K (default: auto)')
|
|
867
1503
|
parser.add_argument('commands', nargs=_argparse.REMAINDER, help='commands')
|
|
868
1504
|
args = parser.parse_args()
|
|
869
1505
|
# Convert to numeric level: 0=quiet, 1=progress, 2=verbose
|
|
@@ -886,9 +1522,11 @@ def main():
|
|
|
886
1522
|
return
|
|
887
1523
|
if len(ports) == 1:
|
|
888
1524
|
port = ports[0]
|
|
889
|
-
log.verbose(f"Using {port}", level=
|
|
1525
|
+
log.verbose(f"Using {port}", level=2)
|
|
890
1526
|
else:
|
|
891
|
-
log.error("Multiple serial ports found
|
|
1527
|
+
log.error("Multiple serial ports found. Use -p to specify one:")
|
|
1528
|
+
for p in ports:
|
|
1529
|
+
print(f" {p}")
|
|
892
1530
|
return
|
|
893
1531
|
try:
|
|
894
1532
|
if port:
|
|
@@ -900,10 +1538,20 @@ def main():
|
|
|
900
1538
|
except _mpytool.ConnError as err:
|
|
901
1539
|
log.error(err)
|
|
902
1540
|
return
|
|
903
|
-
|
|
1541
|
+
# Determine compression setting: None=auto, True=force, False=disable
|
|
1542
|
+
compress = None
|
|
1543
|
+
if args.no_compress:
|
|
1544
|
+
compress = False
|
|
1545
|
+
elif args.compress:
|
|
1546
|
+
compress = True
|
|
1547
|
+
mpy_tool = MpyTool(
|
|
1548
|
+
conn, log=log, verbose=log, exclude_dirs=args.exclude,
|
|
1549
|
+
force=args.force, compress=compress, chunk_size=args.chunk_size)
|
|
904
1550
|
command_groups = _utils.split_commands(args.commands)
|
|
905
1551
|
try:
|
|
906
1552
|
_run_commands(mpy_tool, command_groups, with_progress=(args.verbose >= 1))
|
|
1553
|
+
except (_mpytool.MpyError, _mpytool.ConnError, _mpytool.Timeout) as err:
|
|
1554
|
+
log.error(err)
|
|
907
1555
|
except KeyboardInterrupt:
|
|
908
1556
|
# Clear partial progress line and show clean message
|
|
909
1557
|
log.verbose('Interrupted', level=0, overwrite=True)
|