mpytool 1.2.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/__init__.py +2 -0
- mpytool/conn.py +102 -11
- mpytool/conn_serial.py +90 -46
- mpytool/conn_socket.py +53 -0
- mpytool/logger.py +87 -0
- mpytool/mpy.py +1097 -70
- mpytool/mpy_comm.py +180 -22
- mpytool/mpytool.py +1303 -132
- mpytool/terminal.py +42 -13
- mpytool/utils.py +83 -0
- mpytool-2.1.0.dist-info/METADATA +451 -0
- mpytool-2.1.0.dist-info/RECORD +16 -0
- {mpytool-1.2.0.dist-info → mpytool-2.1.0.dist-info}/WHEEL +1 -1
- {mpytool-1.2.0.dist-info → mpytool-2.1.0.dist-info}/entry_points.txt +0 -1
- mpytool/__about__.py +0 -12
- mpytool-1.2.0.dist-info/METADATA +0 -23
- mpytool-1.2.0.dist-info/RECORD +0 -14
- {mpytool-1.2.0.dist-info → mpytool-2.1.0.dist-info/licenses}/LICENSE +0 -0
- {mpytool-1.2.0.dist-info → mpytool-2.1.0.dist-info}/top_level.txt +0 -0
mpytool/mpytool.py
CHANGED
|
@@ -1,37 +1,26 @@
|
|
|
1
1
|
"""MicroPython tool"""
|
|
2
2
|
|
|
3
|
+
import argparse as _argparse
|
|
4
|
+
import fnmatch as _fnmatch
|
|
5
|
+
import hashlib as _hashlib
|
|
6
|
+
import importlib.metadata as _metadata
|
|
3
7
|
import os as _os
|
|
4
8
|
import sys as _sys
|
|
5
|
-
import
|
|
9
|
+
import time as _time
|
|
10
|
+
|
|
6
11
|
import mpytool as _mpytool
|
|
7
12
|
import mpytool.terminal as _terminal
|
|
8
|
-
import mpytool.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ParamsError(_mpytool.MpyError):
|
|
12
|
-
"""Timeout"""
|
|
13
|
+
import mpytool.utils as _utils
|
|
14
|
+
from mpytool.logger import SimpleColorLogger
|
|
13
15
|
|
|
16
|
+
try:
|
|
17
|
+
_about = _metadata.metadata("mpytool")
|
|
18
|
+
except _metadata.PackageNotFoundError:
|
|
19
|
+
_about = None
|
|
14
20
|
|
|
15
|
-
class PathNotFound(_mpytool.MpyError):
|
|
16
|
-
"""File not found"""
|
|
17
|
-
def __init__(self, file_name):
|
|
18
|
-
self._file_name = file_name
|
|
19
|
-
super().__init__(self.__str__())
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class FileNotFound(PathNotFound):
|
|
26
|
-
"""Folder not found"""
|
|
27
|
-
def __str__(self):
|
|
28
|
-
return f"File '{self._file_name}' was not found"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class DirNotFound(PathNotFound):
|
|
32
|
-
"""Folder not found"""
|
|
33
|
-
def __str__(self):
|
|
34
|
-
return f"Dir '{self._file_name}' was not found"
|
|
22
|
+
class ParamsError(_mpytool.MpyError):
|
|
23
|
+
"""Invalid command parameters"""
|
|
35
24
|
|
|
36
25
|
|
|
37
26
|
class MpyTool():
|
|
@@ -40,26 +29,428 @@ class MpyTool():
|
|
|
40
29
|
TEE = '├─ '
|
|
41
30
|
LAST = '└─ '
|
|
42
31
|
|
|
43
|
-
def __init__(
|
|
32
|
+
def __init__(
|
|
33
|
+
self, conn, log=None, verbose=None, exclude_dirs=None,
|
|
34
|
+
force=False, compress=None, chunk_size=None):
|
|
44
35
|
self._conn = conn
|
|
45
|
-
self._log = log
|
|
46
|
-
self.
|
|
47
|
-
self._exclude_dirs = {'
|
|
36
|
+
self._log = log if log is not None else SimpleColorLogger()
|
|
37
|
+
self._verbose_out = verbose # None = no verbose output (API mode)
|
|
38
|
+
self._exclude_dirs = {'.*', '*.pyc'}
|
|
48
39
|
if exclude_dirs:
|
|
49
40
|
self._exclude_dirs.update(exclude_dirs)
|
|
50
|
-
self._mpy = _mpytool.Mpy(conn, log=
|
|
41
|
+
self._mpy = _mpytool.Mpy(conn, log=self._log, chunk_size=chunk_size)
|
|
42
|
+
self._force = force
|
|
43
|
+
self._compress = compress
|
|
44
|
+
self._progress_total_files = 0
|
|
45
|
+
self._progress_current_file = 0
|
|
46
|
+
self._progress_src = ''
|
|
47
|
+
self._progress_dst = ''
|
|
48
|
+
self._progress_max_src_len = 0
|
|
49
|
+
self._progress_max_dst_len = 0
|
|
50
|
+
self._is_debug = getattr(self._log, '_loglevel', 1) >= 4
|
|
51
|
+
self._batch_mode = False
|
|
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
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def _is_tty(self):
|
|
70
|
+
if self._verbose_out is None:
|
|
71
|
+
return False
|
|
72
|
+
return self._verbose_out._is_tty
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def _verbose(self):
|
|
76
|
+
if self._verbose_out is None:
|
|
77
|
+
return 0
|
|
78
|
+
return self._verbose_out._verbose_level
|
|
79
|
+
|
|
80
|
+
def verbose(self, msg, level=1, color='green', end='\n', overwrite=False):
|
|
81
|
+
if self._verbose_out is not None:
|
|
82
|
+
self._verbose_out.verbose(msg, level, color, end, overwrite)
|
|
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
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _format_local_path(path):
|
|
94
|
+
"""Format local path: relative from CWD, absolute if > 2 levels up"""
|
|
95
|
+
try:
|
|
96
|
+
rel_path = _os.path.relpath(path)
|
|
97
|
+
# Count leading ../
|
|
98
|
+
parts = rel_path.split(_os.sep)
|
|
99
|
+
up_count = 0
|
|
100
|
+
for part in parts:
|
|
101
|
+
if part == '..':
|
|
102
|
+
up_count += 1
|
|
103
|
+
else:
|
|
104
|
+
break
|
|
105
|
+
if up_count > 2:
|
|
106
|
+
return _os.path.abspath(path)
|
|
107
|
+
return rel_path
|
|
108
|
+
except ValueError:
|
|
109
|
+
# On Windows, relpath fails for different drives
|
|
110
|
+
return _os.path.abspath(path)
|
|
111
|
+
|
|
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'})
|
|
142
|
+
|
|
143
|
+
def _progress_callback(self, transferred, total):
|
|
144
|
+
"""Callback for file transfer progress"""
|
|
145
|
+
percent = (transferred * 100 // total) if total > 0 else 100
|
|
146
|
+
line = self._format_progress_line(percent, total)
|
|
147
|
+
if self._is_debug:
|
|
148
|
+
# Debug mode: always newlines
|
|
149
|
+
self.verbose(line, color='cyan')
|
|
150
|
+
else:
|
|
151
|
+
# Normal mode: overwrite line
|
|
152
|
+
self.verbose(line, color='cyan', end='', overwrite=True)
|
|
153
|
+
|
|
154
|
+
def _progress_complete(self, total, encodings=None):
|
|
155
|
+
"""Mark current file as complete"""
|
|
156
|
+
line = self._format_progress_line(100, total, encodings)
|
|
157
|
+
if self._is_debug:
|
|
158
|
+
# Already printed with newline in callback
|
|
159
|
+
pass
|
|
160
|
+
else:
|
|
161
|
+
self.verbose(line, color='cyan', overwrite=True)
|
|
162
|
+
|
|
163
|
+
def _set_progress_info(self, src, dst, is_src_remote, is_dst_remote):
|
|
164
|
+
"""Set progress source and destination paths"""
|
|
165
|
+
if is_src_remote:
|
|
166
|
+
self._progress_src = ':' + src
|
|
167
|
+
else:
|
|
168
|
+
self._progress_src = self._format_local_path(src)
|
|
169
|
+
if is_dst_remote:
|
|
170
|
+
self._progress_dst = ':' + dst
|
|
171
|
+
else:
|
|
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)
|
|
175
|
+
|
|
176
|
+
def _collect_local_paths(self, path):
|
|
177
|
+
"""Collect all local file paths (formatted for display)"""
|
|
178
|
+
if _os.path.isfile(path):
|
|
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
|
|
244
|
+
|
|
245
|
+
def _file_needs_update(self, local_data, remote_path):
|
|
246
|
+
"""Check if local file differs from remote file
|
|
247
|
+
|
|
248
|
+
Returns True if file needs to be uploaded (different or doesn't exist)
|
|
249
|
+
"""
|
|
250
|
+
if self._force:
|
|
251
|
+
return True
|
|
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)
|
|
266
|
+
remote_size = self._mpy.stat(remote_path)
|
|
267
|
+
if remote_size is None or remote_size < 0:
|
|
268
|
+
return True # File doesn't exist or is a directory
|
|
269
|
+
local_size = len(local_data)
|
|
270
|
+
if local_size != remote_size:
|
|
271
|
+
return True # Different size
|
|
272
|
+
# Sizes match - check hash
|
|
273
|
+
local_hash = _hashlib.sha256(local_data).digest()
|
|
274
|
+
remote_hash = self._mpy.hashfile(remote_path)
|
|
275
|
+
if remote_hash is None:
|
|
276
|
+
return True # hashlib not available on device
|
|
277
|
+
return local_hash != remote_hash
|
|
278
|
+
|
|
279
|
+
def _collect_source_paths(self, commands):
|
|
280
|
+
"""Collect source paths for a cp/put command (for alignment calculation)"""
|
|
281
|
+
paths = []
|
|
282
|
+
if not commands:
|
|
283
|
+
return paths
|
|
284
|
+
cmd = commands[0]
|
|
285
|
+
if cmd == 'cp' and len(commands) >= 3:
|
|
286
|
+
sources = commands[1:-1]
|
|
287
|
+
for src in sources:
|
|
288
|
+
src_is_remote = src.startswith(':')
|
|
289
|
+
src_path = src[1:] if src_is_remote else src
|
|
290
|
+
if not src_path:
|
|
291
|
+
src_path = '/'
|
|
292
|
+
src_path = src_path.rstrip('/') or '/'
|
|
293
|
+
if src_is_remote:
|
|
294
|
+
paths.extend(self._collect_remote_paths(src_path))
|
|
295
|
+
else:
|
|
296
|
+
paths.extend(self._collect_local_paths(src_path))
|
|
297
|
+
elif cmd == 'put' and len(commands) >= 2:
|
|
298
|
+
src_path = commands[1]
|
|
299
|
+
paths.extend(self._collect_local_paths(src_path))
|
|
300
|
+
return paths
|
|
301
|
+
|
|
302
|
+
def _collect_remote_paths(self, path):
|
|
303
|
+
"""Collect all remote file paths (formatted for display)"""
|
|
304
|
+
paths = []
|
|
305
|
+
stat = self._mpy.stat(path)
|
|
306
|
+
if stat is None:
|
|
307
|
+
return paths
|
|
308
|
+
if stat >= 0: # file
|
|
309
|
+
paths.append(':' + path)
|
|
310
|
+
else: # directory
|
|
311
|
+
entries = self._mpy.ls(path)
|
|
312
|
+
for name, size in entries:
|
|
313
|
+
entry_path = path.rstrip('/') + '/' + name
|
|
314
|
+
if size is None: # directory
|
|
315
|
+
paths.extend(self._collect_remote_paths(entry_path))
|
|
316
|
+
else: # file
|
|
317
|
+
paths.append(':' + entry_path)
|
|
318
|
+
return paths
|
|
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
|
+
|
|
388
|
+
def count_files_for_command(self, commands):
|
|
389
|
+
"""Count files that would be transferred by cp/put command.
|
|
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, [], []
|
|
51
396
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
397
|
+
def set_batch_progress(self, total_files, max_src_len=0, max_dst_len=0):
|
|
398
|
+
"""Set batch progress for consecutive copy commands"""
|
|
399
|
+
self._progress_total_files = total_files
|
|
400
|
+
self._progress_current_file = 0
|
|
401
|
+
self._progress_max_src_len = max_src_len
|
|
402
|
+
self._progress_max_dst_len = max_dst_len
|
|
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()
|
|
410
|
+
|
|
411
|
+
def reset_batch_progress(self):
|
|
412
|
+
"""Reset batch progress mode"""
|
|
413
|
+
self._batch_mode = False
|
|
414
|
+
self._progress_total_files = 0
|
|
415
|
+
self._progress_current_file = 0
|
|
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')
|
|
55
446
|
|
|
56
447
|
def cmd_ls(self, dir_name):
|
|
57
448
|
result = self._mpy.ls(dir_name)
|
|
58
449
|
for name, size in result:
|
|
59
450
|
if size is not None:
|
|
60
|
-
print(f'{size
|
|
451
|
+
print(f'{self.format_size(size):>9} {name}')
|
|
61
452
|
else:
|
|
62
|
-
print(f'{"":
|
|
453
|
+
print(f'{"":9} {name}/')
|
|
63
454
|
|
|
64
455
|
@classmethod
|
|
65
456
|
def print_tree(cls, tree, prefix='', print_size=True, first=True, last=True):
|
|
@@ -77,7 +468,7 @@ class MpyTool():
|
|
|
77
468
|
sufix = '/'
|
|
78
469
|
line = ''
|
|
79
470
|
if print_size:
|
|
80
|
-
line += f'{size
|
|
471
|
+
line += f'{cls.format_size(size):>9} '
|
|
81
472
|
line += prefix + this_prefix + name + sufix
|
|
82
473
|
print(line)
|
|
83
474
|
if not sub_tree:
|
|
@@ -108,51 +499,76 @@ class MpyTool():
|
|
|
108
499
|
|
|
109
500
|
def cmd_get(self, *file_names):
|
|
110
501
|
for file_name in file_names:
|
|
111
|
-
self.verbose(f"GET: {file_name}")
|
|
502
|
+
self.verbose(f"GET: {file_name}", 2)
|
|
112
503
|
data = self._mpy.get(file_name)
|
|
113
504
|
print(data.decode('utf-8'))
|
|
114
505
|
|
|
115
|
-
def
|
|
116
|
-
|
|
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
|
+
|
|
530
|
+
def _put_dir(self, src_path, dst_path, show_progress=True):
|
|
531
|
+
basename = _os.path.basename(_os.path.abspath(src_path))
|
|
117
532
|
if basename:
|
|
118
533
|
dst_path = _os.path.join(dst_path, basename)
|
|
119
|
-
self.verbose(f"
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
534
|
+
self.verbose(f"PUT DIR: {src_path} -> {dst_path}", 2)
|
|
535
|
+
created_dirs = set()
|
|
536
|
+
for path, dirs, files in _os.walk(src_path, topdown=True):
|
|
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:
|
|
123
540
|
continue
|
|
124
541
|
rel_path = _os.path.relpath(path, src_path)
|
|
125
|
-
if rel_path == '.'
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if rel_path:
|
|
129
|
-
self.verbose(f'mkdir: {rel_path}', 2)
|
|
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:
|
|
544
|
+
self.verbose(f'MKDIR: {rel_path}', 2)
|
|
130
545
|
self._mpy.mkdir(rel_path)
|
|
546
|
+
created_dirs.add(rel_path)
|
|
131
547
|
for file_name in files:
|
|
132
548
|
spath = _os.path.join(path, file_name)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
with open(spath, 'rb') as src_file:
|
|
136
|
-
data = src_file.read()
|
|
137
|
-
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)
|
|
138
551
|
|
|
139
|
-
def _put_file(self, src_path, dst_path):
|
|
552
|
+
def _put_file(self, src_path, dst_path, show_progress=True):
|
|
140
553
|
basename = _os.path.basename(src_path)
|
|
141
554
|
if basename and not _os.path.basename(dst_path):
|
|
142
555
|
dst_path = _os.path.join(dst_path, basename)
|
|
143
|
-
self.verbose(f"
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
556
|
+
self.verbose(f"PUT FILE: {src_path} -> {dst_path}", 2)
|
|
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)
|
|
154
567
|
|
|
155
568
|
def cmd_put(self, src_path, dst_path):
|
|
569
|
+
if self._verbose >= 1 and not self._batch_mode:
|
|
570
|
+
self._progress_total_files = len(self._collect_local_paths(src_path))
|
|
571
|
+
self._progress_current_file = 0
|
|
156
572
|
if _os.path.isdir(src_path):
|
|
157
573
|
self._put_dir(src_path, dst_path)
|
|
158
574
|
elif _os.path.isfile(src_path):
|
|
@@ -160,40 +576,584 @@ class MpyTool():
|
|
|
160
576
|
else:
|
|
161
577
|
raise ParamsError(f'No file or directory to upload: {src_path}')
|
|
162
578
|
|
|
579
|
+
def _get_file(self, src_path, dst_path, show_progress=True):
|
|
580
|
+
"""Download single file from device"""
|
|
581
|
+
self.verbose(f"GET FILE: {src_path} -> {dst_path}", 2)
|
|
582
|
+
dst_dir = _os.path.dirname(dst_path)
|
|
583
|
+
if dst_dir and not _os.path.exists(dst_dir):
|
|
584
|
+
_os.makedirs(dst_dir)
|
|
585
|
+
if show_progress and self._verbose >= 1:
|
|
586
|
+
self._progress_current_file += 1
|
|
587
|
+
self._set_progress_info(src_path, dst_path, True, False)
|
|
588
|
+
data = self._mpy.get(src_path, self._progress_callback)
|
|
589
|
+
self._progress_complete(len(data))
|
|
590
|
+
else:
|
|
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
|
|
596
|
+
with open(dst_path, 'wb') as dst_file:
|
|
597
|
+
dst_file.write(data)
|
|
598
|
+
|
|
599
|
+
def _get_dir(self, src_path, dst_path, copy_contents=False, show_progress=True):
|
|
600
|
+
"""Download directory from device"""
|
|
601
|
+
if not copy_contents:
|
|
602
|
+
basename = src_path.rstrip('/').split('/')[-1]
|
|
603
|
+
if basename:
|
|
604
|
+
dst_path = _os.path.join(dst_path, basename)
|
|
605
|
+
self.verbose(f"GET DIR: {src_path} -> {dst_path}", 2)
|
|
606
|
+
if not _os.path.exists(dst_path):
|
|
607
|
+
_os.makedirs(dst_path)
|
|
608
|
+
entries = self._mpy.ls(src_path)
|
|
609
|
+
for name, size in entries:
|
|
610
|
+
src_entry = src_path.rstrip('/') + '/' + name
|
|
611
|
+
dst_entry = _os.path.join(dst_path, name)
|
|
612
|
+
if size is None: # directory
|
|
613
|
+
self._get_dir(src_entry, dst_entry, copy_contents=True, show_progress=show_progress)
|
|
614
|
+
else: # file
|
|
615
|
+
self._get_file(src_entry, dst_entry, show_progress=show_progress)
|
|
616
|
+
|
|
617
|
+
def _cp_local_to_remote(self, src_path, dst_path, dst_is_dir):
|
|
618
|
+
"""Upload local file/dir to device"""
|
|
619
|
+
src_is_dir = _os.path.isdir(src_path)
|
|
620
|
+
copy_contents = src_path.endswith('/')
|
|
621
|
+
src_path = src_path.rstrip('/')
|
|
622
|
+
if not _os.path.exists(src_path):
|
|
623
|
+
raise ParamsError(f'Source not found: {src_path}')
|
|
624
|
+
if dst_is_dir:
|
|
625
|
+
if not copy_contents:
|
|
626
|
+
basename = _os.path.basename(src_path)
|
|
627
|
+
dst_path = dst_path + basename
|
|
628
|
+
dst_path = dst_path.rstrip('/')
|
|
629
|
+
if src_is_dir:
|
|
630
|
+
if copy_contents:
|
|
631
|
+
for item in _os.listdir(src_path):
|
|
632
|
+
if self._is_excluded(item):
|
|
633
|
+
continue
|
|
634
|
+
item_src = _os.path.join(src_path, item)
|
|
635
|
+
if _os.path.isdir(item_src):
|
|
636
|
+
self._put_dir(item_src, dst_path)
|
|
637
|
+
else:
|
|
638
|
+
self._put_file(item_src, dst_path + '/')
|
|
639
|
+
else:
|
|
640
|
+
self._put_dir(src_path, _os.path.dirname(dst_path) or '/')
|
|
641
|
+
else:
|
|
642
|
+
self._put_file(src_path, dst_path)
|
|
643
|
+
|
|
644
|
+
def _cp_remote_to_local(self, src_path, dst_path, dst_is_dir):
|
|
645
|
+
"""Download file/dir from device to local"""
|
|
646
|
+
copy_contents = src_path.endswith('/')
|
|
647
|
+
src_path = src_path.rstrip('/') or '/'
|
|
648
|
+
stat = self._mpy.stat(src_path)
|
|
649
|
+
if stat is None:
|
|
650
|
+
raise ParamsError(f'Source not found on device: {src_path}')
|
|
651
|
+
src_is_dir = (stat == -1)
|
|
652
|
+
if dst_is_dir:
|
|
653
|
+
if not _os.path.exists(dst_path):
|
|
654
|
+
_os.makedirs(dst_path)
|
|
655
|
+
if not copy_contents and src_path != '/':
|
|
656
|
+
basename = src_path.split('/')[-1]
|
|
657
|
+
dst_path = _os.path.join(dst_path, basename)
|
|
658
|
+
if src_is_dir:
|
|
659
|
+
self._get_dir(src_path, dst_path, copy_contents=copy_contents)
|
|
660
|
+
else:
|
|
661
|
+
if _os.path.isdir(dst_path):
|
|
662
|
+
basename = src_path.split('/')[-1]
|
|
663
|
+
dst_path = _os.path.join(dst_path, basename)
|
|
664
|
+
self._get_file(src_path, dst_path)
|
|
665
|
+
|
|
666
|
+
def _cp_remote_to_remote(self, src_path, dst_path, dst_is_dir):
|
|
667
|
+
"""Copy file on device"""
|
|
668
|
+
src_path = src_path.rstrip('/') or '/'
|
|
669
|
+
stat = self._mpy.stat(src_path)
|
|
670
|
+
if stat is None:
|
|
671
|
+
raise ParamsError(f'Source not found on device: {src_path}')
|
|
672
|
+
if stat == -1:
|
|
673
|
+
raise ParamsError('Remote-to-remote directory copy not supported yet')
|
|
674
|
+
if dst_is_dir:
|
|
675
|
+
basename = src_path.split('/')[-1]
|
|
676
|
+
dst_path = dst_path + basename
|
|
677
|
+
self.verbose(f"COPY: {src_path} -> {dst_path}", 2)
|
|
678
|
+
if self._verbose >= 1:
|
|
679
|
+
self._progress_current_file += 1
|
|
680
|
+
self._set_progress_info(src_path, dst_path, True, True)
|
|
681
|
+
data = self._mpy.get(src_path, self._progress_callback)
|
|
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)
|
|
685
|
+
else:
|
|
686
|
+
data = self._mpy.get(src_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
|
|
693
|
+
|
|
694
|
+
def cmd_cp(self, *args):
|
|
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
|
|
723
|
+
if len(args) < 2:
|
|
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):
|
|
739
|
+
sources = list(args[:-1])
|
|
740
|
+
dest = args[-1]
|
|
741
|
+
dest_is_remote = dest.startswith(':')
|
|
742
|
+
dest_path = dest[1:] if dest_is_remote else dest
|
|
743
|
+
if not dest_path:
|
|
744
|
+
dest_path = '/'
|
|
745
|
+
dest_is_dir = dest_path.endswith('/')
|
|
746
|
+
if len(sources) > 1 and not dest_is_dir:
|
|
747
|
+
raise ParamsError('multiple sources require destination directory (ending with /)')
|
|
748
|
+
if self._verbose >= 1 and not self._batch_mode:
|
|
749
|
+
total_files = 0
|
|
750
|
+
for src in sources:
|
|
751
|
+
src_is_remote = src.startswith(':')
|
|
752
|
+
src_path = src[1:] if src_is_remote else src
|
|
753
|
+
if not src_path:
|
|
754
|
+
src_path = '/'
|
|
755
|
+
src_path_clean = src_path.rstrip('/') or '/'
|
|
756
|
+
if src_is_remote:
|
|
757
|
+
total_files += len(self._collect_remote_paths(src_path_clean))
|
|
758
|
+
else:
|
|
759
|
+
total_files += len(self._collect_local_paths(src_path_clean))
|
|
760
|
+
self._progress_total_files = total_files
|
|
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)
|
|
775
|
+
for src in sources:
|
|
776
|
+
src_is_remote = src.startswith(':')
|
|
777
|
+
src_path = src[1:] if src_is_remote else src
|
|
778
|
+
if not src_path:
|
|
779
|
+
src_path = '/'
|
|
780
|
+
if src_is_remote and dest_is_remote:
|
|
781
|
+
self._cp_remote_to_remote(src_path, dest_path, dest_is_dir)
|
|
782
|
+
elif src_is_remote:
|
|
783
|
+
self._cp_remote_to_local(src_path, dest_path, dest_is_dir)
|
|
784
|
+
elif dest_is_remote:
|
|
785
|
+
self._cp_local_to_remote(src_path, dest_path, dest_is_dir)
|
|
786
|
+
else:
|
|
787
|
+
self.verbose(f"skip local-to-local: {src} -> {dest}", 2)
|
|
788
|
+
|
|
789
|
+
def cmd_mv(self, *args):
|
|
790
|
+
"""Move/rename files on device"""
|
|
791
|
+
if len(args) < 2:
|
|
792
|
+
raise ParamsError('mv requires source and destination')
|
|
793
|
+
sources = list(args[:-1])
|
|
794
|
+
dest = args[-1]
|
|
795
|
+
if not dest.startswith(':'):
|
|
796
|
+
raise ParamsError('mv destination must be device path (: prefix)')
|
|
797
|
+
for src in sources:
|
|
798
|
+
if not src.startswith(':'):
|
|
799
|
+
raise ParamsError('mv source must be device path (: prefix)')
|
|
800
|
+
dest_path = dest[1:] or '/'
|
|
801
|
+
dest_is_dir = dest_path.endswith('/')
|
|
802
|
+
if len(sources) > 1 and not dest_is_dir:
|
|
803
|
+
raise ParamsError('multiple sources require destination directory (ending with /)')
|
|
804
|
+
self._mpy.import_module('os')
|
|
805
|
+
for src in sources:
|
|
806
|
+
src_path = src[1:]
|
|
807
|
+
stat = self._mpy.stat(src_path)
|
|
808
|
+
if stat is None:
|
|
809
|
+
raise ParamsError(f'Source not found on device: {src_path}')
|
|
810
|
+
if dest_is_dir:
|
|
811
|
+
dst_dir = dest_path.rstrip('/')
|
|
812
|
+
if dst_dir and self._mpy.stat(dst_dir) is None:
|
|
813
|
+
self._mpy.mkdir(dst_dir)
|
|
814
|
+
basename = src_path.rstrip('/').split('/')[-1]
|
|
815
|
+
final_dest = dest_path + basename
|
|
816
|
+
else:
|
|
817
|
+
final_dest = dest_path
|
|
818
|
+
self.verbose(f"MV: {src_path} -> {final_dest}", 1)
|
|
819
|
+
self._mpy.rename(src_path, final_dest)
|
|
820
|
+
|
|
163
821
|
def cmd_mkdir(self, *dir_names):
|
|
164
822
|
for dir_name in dir_names:
|
|
165
|
-
self.verbose(f"MKDIR: {dir_name}")
|
|
823
|
+
self.verbose(f"MKDIR: {dir_name}", 1)
|
|
166
824
|
self._mpy.mkdir(dir_name)
|
|
167
825
|
|
|
168
826
|
def cmd_delete(self, *file_names):
|
|
169
827
|
for file_name in file_names:
|
|
170
|
-
|
|
171
|
-
|
|
828
|
+
contents_only = file_name.endswith('/')
|
|
829
|
+
path = file_name.rstrip('/') or '/'
|
|
830
|
+
if contents_only:
|
|
831
|
+
self.verbose(f"DELETE contents: {path}", 1)
|
|
832
|
+
entries = self._mpy.ls(path)
|
|
833
|
+
for name, size in entries:
|
|
834
|
+
entry_path = path + '/' + name if path != '/' else '/' + name
|
|
835
|
+
self.verbose(f" {entry_path}", 1)
|
|
836
|
+
self._mpy.delete(entry_path)
|
|
837
|
+
else:
|
|
838
|
+
self.verbose(f"DELETE: {path}", 1)
|
|
839
|
+
self._mpy.delete(path)
|
|
172
840
|
|
|
173
|
-
def
|
|
174
|
-
self.verbose("
|
|
841
|
+
def cmd_monitor(self):
|
|
842
|
+
self.verbose("MONITOR (Ctrl+C to stop)", 1)
|
|
175
843
|
try:
|
|
176
844
|
while True:
|
|
177
845
|
line = self._conn.read_line()
|
|
178
846
|
line = line.decode('utf-8', 'backslashreplace')
|
|
179
847
|
print(line)
|
|
180
848
|
except KeyboardInterrupt:
|
|
849
|
+
self.verbose('', level=0, overwrite=True) # newline after ^C
|
|
850
|
+
except (_mpytool.ConnError, OSError) as err:
|
|
181
851
|
if self._log:
|
|
182
|
-
self._log.
|
|
183
|
-
return
|
|
852
|
+
self._log.error(err)
|
|
184
853
|
|
|
185
854
|
def cmd_repl(self):
|
|
186
|
-
self.verbose("REPL:")
|
|
187
855
|
self._mpy.comm.exit_raw_repl()
|
|
188
856
|
if not _terminal.AVAILABLE:
|
|
189
857
|
self._log.error("REPL not available on this platform")
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
terminal.
|
|
193
|
-
|
|
194
|
-
|
|
858
|
+
return
|
|
859
|
+
self.verbose("REPL (Ctrl+] to exit)", 1)
|
|
860
|
+
terminal = _terminal.Terminal(self._conn, self._log)
|
|
861
|
+
terminal.run()
|
|
862
|
+
self._log.info('Exiting..')
|
|
195
863
|
|
|
196
|
-
def
|
|
864
|
+
def cmd_exec(self, code):
|
|
865
|
+
self.verbose(f"EXEC: {code}", 1)
|
|
866
|
+
result = self._mpy.comm.exec(code)
|
|
867
|
+
if result:
|
|
868
|
+
print(result.decode('utf-8', 'backslashreplace'), end='')
|
|
869
|
+
|
|
870
|
+
@staticmethod
|
|
871
|
+
def format_size(size):
|
|
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'):
|
|
876
|
+
size /= 1024
|
|
877
|
+
if size < 10:
|
|
878
|
+
return f"{size:.2f}{unit}"
|
|
879
|
+
if size < 100:
|
|
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']}")
|
|
1076
|
+
|
|
1077
|
+
def cmd_info(self):
|
|
1078
|
+
self.verbose("INFO", 2)
|
|
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)
|
|
1120
|
+
try:
|
|
1121
|
+
self._mpy.hard_reset()
|
|
1122
|
+
if reconnect:
|
|
1123
|
+
self.verbose(" reconnecting...", 1, color='yellow')
|
|
1124
|
+
_time.sleep(1.0) # Wait for device to boot
|
|
1125
|
+
self._mpy._conn.reconnect()
|
|
1126
|
+
self.verbose(" connected", 1, color='green')
|
|
1127
|
+
except NotImplementedError:
|
|
1128
|
+
raise _mpytool.MpyError("Hardware reset not available (serial only)")
|
|
1129
|
+
except (_mpytool.ConnError, OSError) as err:
|
|
1130
|
+
self.verbose(f" reconnect failed: {err}", 1, color='red')
|
|
1131
|
+
raise _mpytool.ConnError(f"Reconnect failed: {err}")
|
|
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)
|
|
1146
|
+
try:
|
|
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)
|
|
1155
|
+
|
|
1156
|
+
def process_commands(self, commands, is_last_group=False):
|
|
197
1157
|
try:
|
|
198
1158
|
while commands:
|
|
199
1159
|
command = commands.pop(0)
|
|
@@ -230,98 +1190,309 @@ class MpyTool():
|
|
|
230
1190
|
elif command == 'mkdir':
|
|
231
1191
|
self.cmd_mkdir(*commands)
|
|
232
1192
|
break
|
|
233
|
-
elif command in ('del', 'delete'):
|
|
1193
|
+
elif command in ('del', 'delete', 'rm'):
|
|
234
1194
|
self.cmd_delete(*commands)
|
|
235
1195
|
break
|
|
236
1196
|
elif command == 'reset':
|
|
237
|
-
self.
|
|
238
|
-
|
|
239
|
-
|
|
1197
|
+
self.verbose("RESET", 1)
|
|
1198
|
+
self._mpy.soft_reset()
|
|
1199
|
+
elif command == 'mreset':
|
|
1200
|
+
# Reconnect only if there are more commands (in this or next group)
|
|
1201
|
+
has_more = bool(commands) or not is_last_group
|
|
1202
|
+
self.cmd_mreset(reconnect=has_more)
|
|
1203
|
+
elif command == 'sreset':
|
|
1204
|
+
self.cmd_sreset()
|
|
1205
|
+
elif command in ('monitor', 'follow'):
|
|
1206
|
+
self.cmd_monitor()
|
|
240
1207
|
break
|
|
241
1208
|
elif command == 'repl':
|
|
242
1209
|
self.cmd_repl()
|
|
243
1210
|
break
|
|
1211
|
+
elif command == 'exec':
|
|
1212
|
+
if commands:
|
|
1213
|
+
code = commands.pop(0)
|
|
1214
|
+
self.cmd_exec(code)
|
|
1215
|
+
else:
|
|
1216
|
+
raise ParamsError('missing code for exec command')
|
|
1217
|
+
elif command == 'info':
|
|
1218
|
+
self.cmd_info()
|
|
1219
|
+
elif command == 'flash':
|
|
1220
|
+
if commands and commands[0] == 'read':
|
|
1221
|
+
commands.pop(0)
|
|
1222
|
+
if len(commands) >= 2:
|
|
1223
|
+
# ESP32: flash read <label> <file>
|
|
1224
|
+
label = commands.pop(0)
|
|
1225
|
+
dest_path = commands.pop(0)
|
|
1226
|
+
self.cmd_flash_read(dest_path, label=label)
|
|
1227
|
+
elif len(commands) == 1:
|
|
1228
|
+
# RP2: flash read <file>
|
|
1229
|
+
dest_path = commands.pop(0)
|
|
1230
|
+
self.cmd_flash_read(dest_path)
|
|
1231
|
+
else:
|
|
1232
|
+
raise ParamsError('flash read requires destination file')
|
|
1233
|
+
elif commands and commands[0] == 'write':
|
|
1234
|
+
commands.pop(0)
|
|
1235
|
+
if len(commands) >= 2:
|
|
1236
|
+
# ESP32: flash write <label> <file>
|
|
1237
|
+
label = commands.pop(0)
|
|
1238
|
+
src_path = commands.pop(0)
|
|
1239
|
+
self.cmd_flash_write(src_path, label=label)
|
|
1240
|
+
elif len(commands) == 1:
|
|
1241
|
+
# RP2: flash write <file>
|
|
1242
|
+
src_path = commands.pop(0)
|
|
1243
|
+
self.cmd_flash_write(src_path)
|
|
1244
|
+
else:
|
|
1245
|
+
raise ParamsError('flash write requires source file')
|
|
1246
|
+
elif commands and commands[0] == 'erase':
|
|
1247
|
+
commands.pop(0)
|
|
1248
|
+
# Check for --full flag and optional label
|
|
1249
|
+
full = False
|
|
1250
|
+
label = None
|
|
1251
|
+
while commands and (commands[0] == '--full' or not commands[0].startswith('-')):
|
|
1252
|
+
if commands[0] == '--full':
|
|
1253
|
+
full = True
|
|
1254
|
+
commands.pop(0)
|
|
1255
|
+
elif label is None:
|
|
1256
|
+
label = commands.pop(0)
|
|
1257
|
+
else:
|
|
1258
|
+
break
|
|
1259
|
+
self.cmd_flash_erase(label=label, full=full)
|
|
1260
|
+
else:
|
|
1261
|
+
self.cmd_flash()
|
|
1262
|
+
elif command == 'ota':
|
|
1263
|
+
if commands:
|
|
1264
|
+
firmware_path = commands.pop(0)
|
|
1265
|
+
self.cmd_ota(firmware_path)
|
|
1266
|
+
else:
|
|
1267
|
+
raise ParamsError('ota requires firmware file path')
|
|
1268
|
+
elif command == 'rtsreset':
|
|
1269
|
+
# Reconnect only if there are more commands (in this or next group)
|
|
1270
|
+
has_more = bool(commands) or not is_last_group
|
|
1271
|
+
self.cmd_rtsreset(reconnect=has_more)
|
|
1272
|
+
elif command == 'bootloader':
|
|
1273
|
+
self.cmd_bootloader()
|
|
1274
|
+
elif command == 'dtrboot':
|
|
1275
|
+
self.cmd_dtrboot()
|
|
1276
|
+
elif command == 'sleep':
|
|
1277
|
+
if commands:
|
|
1278
|
+
try:
|
|
1279
|
+
seconds = float(commands.pop(0))
|
|
1280
|
+
except ValueError:
|
|
1281
|
+
raise ParamsError('sleep requires a number (seconds)')
|
|
1282
|
+
self.cmd_sleep(seconds)
|
|
1283
|
+
else:
|
|
1284
|
+
raise ParamsError('sleep requires a number (seconds)')
|
|
1285
|
+
elif command == 'cp':
|
|
1286
|
+
if len(commands) >= 2:
|
|
1287
|
+
self.cmd_cp(*commands)
|
|
1288
|
+
break
|
|
1289
|
+
raise ParamsError('cp requires source and destination')
|
|
1290
|
+
elif command == 'mv':
|
|
1291
|
+
if len(commands) >= 2:
|
|
1292
|
+
self.cmd_mv(*commands)
|
|
1293
|
+
break
|
|
1294
|
+
raise ParamsError('mv requires source and destination')
|
|
1295
|
+
elif command == '_paths':
|
|
1296
|
+
# Undocumented: for shell completion
|
|
1297
|
+
if commands:
|
|
1298
|
+
self.cmd_paths(commands.pop(0))
|
|
1299
|
+
else:
|
|
1300
|
+
self.cmd_paths()
|
|
244
1301
|
else:
|
|
245
1302
|
raise ParamsError(f"unknown command: '{command}'")
|
|
246
|
-
except _mpytool.MpyError as err:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
class SimpleColorLogger():
|
|
255
|
-
def __init__(self, loglevel=0):
|
|
256
|
-
self._loglevel = loglevel
|
|
257
|
-
|
|
258
|
-
def log(self, msg):
|
|
259
|
-
print(msg, file=_sys.stderr)
|
|
260
|
-
|
|
261
|
-
def error(self, msg):
|
|
262
|
-
if self._loglevel >= 1:
|
|
263
|
-
self.log(f"\033[1;31m{msg}\033[0m")
|
|
264
|
-
|
|
265
|
-
def warning(self, msg):
|
|
266
|
-
if self._loglevel >= 2:
|
|
267
|
-
self.log(f"\033[1;33m{msg}\033[0m")
|
|
268
|
-
|
|
269
|
-
def info(self, msg):
|
|
270
|
-
if self._loglevel >= 3:
|
|
271
|
-
self.log(f"\033[1;35m{msg}\033[0m")
|
|
272
|
-
|
|
273
|
-
def debug(self, msg):
|
|
274
|
-
if self._loglevel >= 4:
|
|
275
|
-
self.log(f"\033[1;34m{msg}\033[0m")
|
|
1303
|
+
except (_mpytool.MpyError, _mpytool.ConnError) as err:
|
|
1304
|
+
self._log.error(err)
|
|
1305
|
+
try:
|
|
1306
|
+
self._mpy.comm.exit_raw_repl()
|
|
1307
|
+
except _mpytool.ConnError:
|
|
1308
|
+
pass # connection already lost
|
|
276
1309
|
|
|
277
1310
|
|
|
278
|
-
|
|
279
|
-
_about
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
_about.AUTHOR_EMAIL)
|
|
1311
|
+
if _about:
|
|
1312
|
+
_VERSION_STR = "%s %s (%s)" % (_about["Name"], _about["Version"], _about["Author-email"])
|
|
1313
|
+
else:
|
|
1314
|
+
_VERSION_STR = "mpytool (not installed version)"
|
|
283
1315
|
_COMMANDS_HELP_STR = """
|
|
284
1316
|
List of available commands:
|
|
285
1317
|
ls [{path}] list files and its sizes
|
|
286
1318
|
tree [{path}] list tree of structure and sizes
|
|
1319
|
+
cp [-f] {src} [...] {dst} copy files (: prefix = device path, -f = force)
|
|
1320
|
+
mv {src} [...] {dst} move/rename on device (: prefix required)
|
|
287
1321
|
get {path} [...] get file and print it
|
|
288
1322
|
put {src_path} [{dst_path}] put file or directory to destination
|
|
289
1323
|
mkdir {path} [...] create directory (also create all parents)
|
|
290
|
-
delete {path} [...] remove file
|
|
291
|
-
reset soft reset
|
|
292
|
-
|
|
1324
|
+
delete {path} [...] remove file/dir (path/ = contents only)
|
|
1325
|
+
reset soft reset (Ctrl-D, runs boot.py/main.py)
|
|
1326
|
+
sreset soft reset in raw REPL (clears RAM only)
|
|
1327
|
+
mreset MCU reset (machine.reset, auto-reconnect)
|
|
1328
|
+
rtsreset RTS reset (hardware reset via RTS signal)
|
|
1329
|
+
bootloader enter bootloader (machine.bootloader)
|
|
1330
|
+
dtrboot enter bootloader via DTR/RTS (ESP32 only)
|
|
1331
|
+
monitor print output of running program
|
|
293
1332
|
repl enter REPL mode [UNIX OS ONLY]
|
|
1333
|
+
exec {code} execute Python code on device
|
|
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
|
|
294
1341
|
Aliases:
|
|
295
1342
|
dir alias to ls
|
|
296
1343
|
cat alias to get
|
|
297
|
-
del
|
|
1344
|
+
del, rm alias to delete
|
|
1345
|
+
follow alias to monitor
|
|
1346
|
+
Use -- to separate multiple commands:
|
|
1347
|
+
mpytool put main.py / -- reset -- monitor
|
|
298
1348
|
"""
|
|
299
1349
|
|
|
300
1350
|
|
|
1351
|
+
def _run_commands(mpy_tool, command_groups, with_progress=True):
|
|
1352
|
+
"""Execute command groups with optional batch progress tracking"""
|
|
1353
|
+
if not with_progress:
|
|
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)
|
|
1357
|
+
return
|
|
1358
|
+
i = 0
|
|
1359
|
+
while i < len(command_groups):
|
|
1360
|
+
is_copy, count, src_paths, dst_paths = mpy_tool.count_files_for_command(command_groups[i])
|
|
1361
|
+
if not is_copy:
|
|
1362
|
+
is_last = (i == len(command_groups) - 1)
|
|
1363
|
+
mpy_tool.process_commands(command_groups[i], is_last_group=is_last)
|
|
1364
|
+
i += 1
|
|
1365
|
+
continue
|
|
1366
|
+
batch_total = count
|
|
1367
|
+
all_src_paths = src_paths
|
|
1368
|
+
all_dst_paths = dst_paths
|
|
1369
|
+
batch_start = i
|
|
1370
|
+
j = i + 1
|
|
1371
|
+
while j < len(command_groups):
|
|
1372
|
+
is_copy_j, count_j, src_paths_j, dst_paths_j = mpy_tool.count_files_for_command(command_groups[j])
|
|
1373
|
+
if not is_copy_j:
|
|
1374
|
+
break
|
|
1375
|
+
batch_total += count_j
|
|
1376
|
+
all_src_paths.extend(src_paths_j)
|
|
1377
|
+
all_dst_paths.extend(dst_paths_j)
|
|
1378
|
+
j += 1
|
|
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)
|
|
1383
|
+
for k in range(batch_start, j):
|
|
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()
|
|
1387
|
+
mpy_tool.reset_batch_progress()
|
|
1388
|
+
i = j
|
|
1389
|
+
|
|
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
|
+
|
|
301
1411
|
def main():
|
|
302
1412
|
"""Main"""
|
|
1413
|
+
_description = _about["Summary"] if _about else None
|
|
303
1414
|
parser = _argparse.ArgumentParser(
|
|
1415
|
+
description=_description,
|
|
304
1416
|
formatter_class=_argparse.RawTextHelpFormatter,
|
|
305
1417
|
epilog=_COMMANDS_HELP_STR)
|
|
306
1418
|
parser.add_argument(
|
|
307
1419
|
"-V", "--version", action='version', version=_VERSION_STR)
|
|
308
|
-
parser.add_argument('-p', '--port',
|
|
1420
|
+
parser.add_argument('-p', '--port', help="serial port")
|
|
1421
|
+
parser.add_argument('-a', '--address', help="network address")
|
|
1422
|
+
parser.add_argument('-b', '--baud', type=int, default=115200, help="serial port")
|
|
309
1423
|
parser.add_argument(
|
|
310
1424
|
'-d', '--debug', default=0, action='count', help='set debug level')
|
|
311
1425
|
parser.add_argument(
|
|
312
|
-
'-v', '--verbose',
|
|
1426
|
+
'-v', '--verbose', action='store_true', help='verbose output (show commands)')
|
|
1427
|
+
parser.add_argument(
|
|
1428
|
+
'-q', '--quiet', action='store_true', help='quiet mode (no progress)')
|
|
1429
|
+
parser.add_argument(
|
|
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)')
|
|
313
1434
|
parser.add_argument(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
1435
|
+
'-z', '--compress', action='store_true', default=None, help='force compression')
|
|
1436
|
+
parser.add_argument(
|
|
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)')
|
|
1441
|
+
parser.add_argument('commands', nargs=_argparse.REMAINDER, help='commands')
|
|
317
1442
|
args = parser.parse_args()
|
|
1443
|
+
# Convert to numeric level: 0=quiet, 1=progress, 2=verbose
|
|
1444
|
+
if args.quiet:
|
|
1445
|
+
args.verbose = 0
|
|
1446
|
+
elif args.verbose:
|
|
1447
|
+
args.verbose = 2
|
|
1448
|
+
else:
|
|
1449
|
+
args.verbose = 1
|
|
318
1450
|
|
|
319
|
-
log = SimpleColorLogger(args.debug + 1)
|
|
320
|
-
|
|
321
|
-
|
|
1451
|
+
log = SimpleColorLogger(args.debug + 1, verbose_level=args.verbose)
|
|
1452
|
+
if args.port and args.address:
|
|
1453
|
+
log.error("You can select only serial port or network address")
|
|
1454
|
+
return
|
|
1455
|
+
port = args.port
|
|
1456
|
+
if not port and not args.address:
|
|
1457
|
+
ports = _utils.detect_serial_ports()
|
|
1458
|
+
if not ports:
|
|
1459
|
+
log.error("No serial port found. Use -p to specify port.")
|
|
1460
|
+
return
|
|
1461
|
+
if len(ports) == 1:
|
|
1462
|
+
port = ports[0]
|
|
1463
|
+
log.verbose(f"Using {port}", level=2)
|
|
1464
|
+
else:
|
|
1465
|
+
log.error("Multiple serial ports found. Use -p to specify one:")
|
|
1466
|
+
for p in ports:
|
|
1467
|
+
print(f" {p}")
|
|
1468
|
+
return
|
|
1469
|
+
try:
|
|
1470
|
+
if port:
|
|
1471
|
+
conn = _mpytool.ConnSerial(
|
|
1472
|
+
port=port, baudrate=args.baud, log=log)
|
|
1473
|
+
elif args.address:
|
|
1474
|
+
conn = _mpytool.ConnSocket(
|
|
1475
|
+
address=args.address, log=log)
|
|
1476
|
+
except _mpytool.ConnError as err:
|
|
1477
|
+
log.error(err)
|
|
1478
|
+
return
|
|
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
|
|
322
1485
|
mpy_tool = MpyTool(
|
|
323
|
-
conn, log=log, verbose=
|
|
324
|
-
|
|
1486
|
+
conn, log=log, verbose=log, exclude_dirs=args.exclude,
|
|
1487
|
+
force=args.force, compress=compress, chunk_size=args.chunk_size)
|
|
1488
|
+
command_groups = _utils.split_commands(args.commands)
|
|
1489
|
+
try:
|
|
1490
|
+
_run_commands(mpy_tool, command_groups, with_progress=(args.verbose >= 1))
|
|
1491
|
+
except KeyboardInterrupt:
|
|
1492
|
+
# Clear partial progress line and show clean message
|
|
1493
|
+
log.verbose('Interrupted', level=0, overwrite=True)
|
|
1494
|
+
except (_mpytool.MpyError, _mpytool.ConnError, _mpytool.Timeout) as err:
|
|
1495
|
+
log.error(err)
|
|
325
1496
|
|
|
326
1497
|
|
|
327
1498
|
if __name__ == '__main__':
|