mpytool 1.1.0__py3-none-any.whl → 2.0.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 +78 -5
- mpytool/conn_serial.py +21 -36
- mpytool/conn_socket.py +53 -0
- mpytool/logger.py +87 -0
- mpytool/mpy.py +79 -14
- mpytool/mpy_comm.py +44 -15
- mpytool/mpytool.py +700 -99
- mpytool/terminal.py +76 -0
- mpytool/utils.py +82 -0
- mpytool-2.0.0.dist-info/METADATA +233 -0
- mpytool-2.0.0.dist-info/RECORD +16 -0
- {mpytool-1.1.0.dist-info → mpytool-2.0.0.dist-info}/WHEEL +1 -1
- {mpytool-1.1.0.dist-info → mpytool-2.0.0.dist-info}/entry_points.txt +0 -1
- mpytool/__about__.py +0 -12
- mpytool-1.1.0.dist-info/METADATA +0 -23
- mpytool-1.1.0.dist-info/RECORD +0 -13
- {mpytool-1.1.0.dist-info → mpytool-2.0.0.dist-info/licenses}/LICENSE +0 -0
- {mpytool-1.1.0.dist-info → mpytool-2.0.0.dist-info}/top_level.txt +0 -0
mpytool/__init__.py
CHANGED
mpytool/conn.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""MicroPython tool: abstract connector"""
|
|
2
2
|
|
|
3
|
+
import time as _time
|
|
4
|
+
import select as _select
|
|
5
|
+
|
|
3
6
|
|
|
4
7
|
class ConnError(Exception):
|
|
5
8
|
"""General connection error"""
|
|
@@ -12,16 +15,86 @@ class Timeout(ConnError):
|
|
|
12
15
|
class Conn():
|
|
13
16
|
def __init__(self, log=None):
|
|
14
17
|
self._log = log
|
|
18
|
+
self._buffer = bytearray(b'')
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def fd(self):
|
|
22
|
+
"""Return file descriptor for select()"""
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
def _has_data(self, timeout=0):
|
|
26
|
+
"""Check if data is available to read using select()"""
|
|
27
|
+
fd = self.fd
|
|
28
|
+
if fd is None:
|
|
29
|
+
return False
|
|
30
|
+
readable, _, _ = _select.select([fd], [], [], timeout)
|
|
31
|
+
return bool(readable)
|
|
32
|
+
|
|
33
|
+
def _read_available(self):
|
|
34
|
+
"""Read available data from device (must be implemented by subclass)"""
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def _write_raw(self, data):
|
|
38
|
+
"""Write data to device, return bytes written (must be implemented by subclass)"""
|
|
39
|
+
raise NotImplementedError
|
|
15
40
|
|
|
16
|
-
def
|
|
17
|
-
"""
|
|
41
|
+
def _read_to_buffer(self, wait_timeout=0):
|
|
42
|
+
"""Read available data into buffer
|
|
43
|
+
|
|
44
|
+
Arguments:
|
|
45
|
+
wait_timeout: how long to wait for data (0 = non-blocking)
|
|
18
46
|
"""
|
|
47
|
+
if self._has_data(wait_timeout):
|
|
48
|
+
data = self._read_available()
|
|
49
|
+
if data:
|
|
50
|
+
self._buffer += data
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
def flush(self):
|
|
55
|
+
"""Flush and return buffer contents"""
|
|
56
|
+
buffer = bytes(self._buffer)
|
|
57
|
+
del self._buffer[:]
|
|
58
|
+
return buffer
|
|
59
|
+
|
|
60
|
+
def read(self):
|
|
61
|
+
"""Read available data from device"""
|
|
62
|
+
if self._has_data():
|
|
63
|
+
return self._read_available()
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def write(self, data):
|
|
67
|
+
"""Write data to device"""
|
|
68
|
+
if self._log:
|
|
69
|
+
self._log.debug("wr: %s", bytes(data))
|
|
70
|
+
while data:
|
|
71
|
+
count = self._write_raw(data)
|
|
72
|
+
data = data[count:]
|
|
19
73
|
|
|
20
74
|
def read_until(self, end, timeout=1):
|
|
21
|
-
"""Read until
|
|
22
|
-
|
|
75
|
+
"""Read until end marker is found"""
|
|
76
|
+
if self._log:
|
|
77
|
+
self._log.debug("wait for %s", end)
|
|
78
|
+
start_time = _time.time()
|
|
79
|
+
while True:
|
|
80
|
+
# Use select() with 1ms timeout instead of sleep - wakes immediately on data
|
|
81
|
+
if self._read_to_buffer(wait_timeout=0.001):
|
|
82
|
+
start_time = _time.time() # reset timeout on data received
|
|
83
|
+
if end in self._buffer:
|
|
84
|
+
break
|
|
85
|
+
if timeout is not None and start_time + timeout < _time.time():
|
|
86
|
+
if self._buffer:
|
|
87
|
+
raise Timeout(
|
|
88
|
+
f"During timeout received: {bytes(self._buffer)}")
|
|
89
|
+
raise Timeout("No data received")
|
|
90
|
+
index = self._buffer.index(end)
|
|
91
|
+
data = self._buffer[:index]
|
|
92
|
+
del self._buffer[:index + len(end)]
|
|
93
|
+
if self._log:
|
|
94
|
+
self._log.debug("rd: %s", bytes(data + end))
|
|
95
|
+
return data
|
|
23
96
|
|
|
24
97
|
def read_line(self, timeout=None):
|
|
25
|
-
"""Read
|
|
98
|
+
"""Read single line"""
|
|
26
99
|
line = self.read_until(b'\n', timeout)
|
|
27
100
|
return line.strip(b'\r')
|
mpytool/conn_serial.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""MicroPython tool: serial connector"""
|
|
2
2
|
|
|
3
|
-
import time as _time
|
|
4
3
|
import serial as _serial
|
|
5
4
|
import mpytool.conn as _conn
|
|
6
5
|
|
|
@@ -8,42 +7,28 @@ import mpytool.conn as _conn
|
|
|
8
7
|
class ConnSerial(_conn.Conn):
|
|
9
8
|
def __init__(self, log=None, **serial_config):
|
|
10
9
|
super().__init__(log)
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
try:
|
|
11
|
+
self._serial = _serial.Serial(**serial_config)
|
|
12
|
+
except _serial.serialutil.SerialException as err:
|
|
13
|
+
self._serial = None
|
|
14
|
+
raise _conn.ConnError(
|
|
15
|
+
f"Error opening serial port {serial_config['port']}") from err
|
|
13
16
|
|
|
14
|
-
def
|
|
17
|
+
def __del__(self):
|
|
18
|
+
if self._serial:
|
|
19
|
+
self._serial.close()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def fd(self):
|
|
23
|
+
return self._serial.fd if self._serial else None
|
|
24
|
+
|
|
25
|
+
def _read_available(self):
|
|
26
|
+
"""Read available data from serial port"""
|
|
15
27
|
in_waiting = self._serial.in_waiting
|
|
16
28
|
if in_waiting > 0:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return False
|
|
20
|
-
|
|
21
|
-
def write(self, data, chunk_size=128, delay=0.01):
|
|
22
|
-
if self._log:
|
|
23
|
-
self._log.debug(f"wr: {data}")
|
|
24
|
-
while data:
|
|
25
|
-
chunk = data[:chunk_size]
|
|
26
|
-
count = self._serial.write(chunk)
|
|
27
|
-
data = data[count:]
|
|
28
|
-
_time.sleep(delay)
|
|
29
|
+
return self._serial.read(in_waiting)
|
|
30
|
+
return None
|
|
29
31
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
start_time = _time.time()
|
|
34
|
-
while True:
|
|
35
|
-
if self._read_to_buffer():
|
|
36
|
-
start_time = _time.time()
|
|
37
|
-
if end in self._buffer:
|
|
38
|
-
break
|
|
39
|
-
if timeout is not None and start_time + timeout < _time.time():
|
|
40
|
-
if self._buffer:
|
|
41
|
-
raise _conn.Timeout(
|
|
42
|
-
f"During timeout received: {bytes(self._buffer)}")
|
|
43
|
-
raise _conn.Timeout("No data received")
|
|
44
|
-
_time.sleep(.01)
|
|
45
|
-
data, self._buffer = self._buffer.split(end, 1)
|
|
46
|
-
if self._log:
|
|
47
|
-
self._log.debug(f"rd: {data + end}")
|
|
48
|
-
data = data.rstrip(end)
|
|
49
|
-
return data
|
|
32
|
+
def _write_raw(self, data):
|
|
33
|
+
"""Write data to serial port"""
|
|
34
|
+
return self._serial.write(data)
|
mpytool/conn_socket.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""MicroPython tool: socket connector"""
|
|
2
|
+
|
|
3
|
+
import socket as _socket
|
|
4
|
+
import mpytool.conn as _conn
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConnSocket(_conn.Conn):
|
|
8
|
+
def __init__(self, address, log=None):
|
|
9
|
+
super().__init__(log)
|
|
10
|
+
self._socket = None
|
|
11
|
+
sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
|
12
|
+
sock.settimeout(5)
|
|
13
|
+
if ':' in address:
|
|
14
|
+
host, port = address.split(':')
|
|
15
|
+
port = int(port)
|
|
16
|
+
else:
|
|
17
|
+
host = address
|
|
18
|
+
port = 23
|
|
19
|
+
if log:
|
|
20
|
+
log.info(f"Connecting to: {host}:{port}")
|
|
21
|
+
try:
|
|
22
|
+
sock.connect((host, port))
|
|
23
|
+
except _socket.timeout as err:
|
|
24
|
+
raise _conn.ConnError(f"Timeout connecting to {host}:{port}") from err
|
|
25
|
+
except _socket.error as err:
|
|
26
|
+
raise _conn.ConnError(f"Cannot connect to {host}:{port}: {err}") from err
|
|
27
|
+
sock.settimeout(None)
|
|
28
|
+
sock.setblocking(False)
|
|
29
|
+
self._socket = sock
|
|
30
|
+
if log:
|
|
31
|
+
log.info("connected")
|
|
32
|
+
|
|
33
|
+
def __del__(self):
|
|
34
|
+
if self._socket:
|
|
35
|
+
self._socket.close()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def fd(self):
|
|
39
|
+
return self._socket.fileno() if self._socket else None
|
|
40
|
+
|
|
41
|
+
def _read_available(self):
|
|
42
|
+
"""Read available data from socket"""
|
|
43
|
+
try:
|
|
44
|
+
data = self._socket.recv(4096)
|
|
45
|
+
if data:
|
|
46
|
+
return data
|
|
47
|
+
except BlockingIOError:
|
|
48
|
+
pass
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def _write_raw(self, data):
|
|
52
|
+
"""Write data to socket"""
|
|
53
|
+
return self._socket.send(data)
|
mpytool/logger.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Simple color logger for mpytool"""
|
|
2
|
+
|
|
3
|
+
import os as _os
|
|
4
|
+
import sys as _sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SimpleColorLogger():
|
|
8
|
+
# ANSI color codes
|
|
9
|
+
_RESET = '\033[0m'
|
|
10
|
+
_BOLD_RED = '\033[1;31m'
|
|
11
|
+
_BOLD_YELLOW = '\033[1;33m'
|
|
12
|
+
_BOLD_MAGENTA = '\033[1;35m'
|
|
13
|
+
_BOLD_BLUE = '\033[1;34m'
|
|
14
|
+
_BOLD_GREEN = '\033[1;32m'
|
|
15
|
+
_BOLD_CYAN = '\033[1;36m'
|
|
16
|
+
_CLEAR_LINE = '\033[K'
|
|
17
|
+
|
|
18
|
+
# Color names for verbose()
|
|
19
|
+
COLORS = {
|
|
20
|
+
'red': _BOLD_RED,
|
|
21
|
+
'yellow': _BOLD_YELLOW,
|
|
22
|
+
'magenta': _BOLD_MAGENTA,
|
|
23
|
+
'blue': _BOLD_BLUE,
|
|
24
|
+
'green': _BOLD_GREEN,
|
|
25
|
+
'cyan': _BOLD_CYAN,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def __init__(self, loglevel=1, verbose_level=0):
|
|
29
|
+
self._loglevel = loglevel
|
|
30
|
+
self._verbose_level = verbose_level
|
|
31
|
+
self._is_tty = (
|
|
32
|
+
_sys.stderr.isatty()
|
|
33
|
+
and _os.environ.get('NO_COLOR') is None
|
|
34
|
+
and _os.environ.get('TERM') != 'dumb'
|
|
35
|
+
and _os.environ.get('CI') is None
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def log(self, msg):
|
|
39
|
+
print(msg, file=_sys.stderr)
|
|
40
|
+
|
|
41
|
+
def error(self, msg, *args):
|
|
42
|
+
if args:
|
|
43
|
+
msg = msg % args
|
|
44
|
+
if self._loglevel >= 1:
|
|
45
|
+
if self._is_tty:
|
|
46
|
+
self.log(f"{self._BOLD_RED}{msg}{self._RESET}")
|
|
47
|
+
else:
|
|
48
|
+
self.log(f"E: {msg}")
|
|
49
|
+
|
|
50
|
+
def warning(self, msg, *args):
|
|
51
|
+
if args:
|
|
52
|
+
msg = msg % args
|
|
53
|
+
if self._loglevel >= 2:
|
|
54
|
+
if self._is_tty:
|
|
55
|
+
self.log(f"{self._BOLD_YELLOW}{msg}{self._RESET}")
|
|
56
|
+
else:
|
|
57
|
+
self.log(f"W: {msg}")
|
|
58
|
+
|
|
59
|
+
def info(self, msg, *args):
|
|
60
|
+
if args:
|
|
61
|
+
msg = msg % args
|
|
62
|
+
if self._loglevel >= 3:
|
|
63
|
+
if self._is_tty:
|
|
64
|
+
self.log(f"{self._BOLD_MAGENTA}{msg}{self._RESET}")
|
|
65
|
+
else:
|
|
66
|
+
self.log(f"I: {msg}")
|
|
67
|
+
|
|
68
|
+
def debug(self, msg, *args):
|
|
69
|
+
if args:
|
|
70
|
+
msg = msg % args
|
|
71
|
+
if self._loglevel >= 4:
|
|
72
|
+
if self._is_tty:
|
|
73
|
+
self.log(f"{self._BOLD_BLUE}{msg}{self._RESET}")
|
|
74
|
+
else:
|
|
75
|
+
self.log(f"D: {msg}")
|
|
76
|
+
|
|
77
|
+
def verbose(self, msg, level=1, color='green', end='\n', overwrite=False):
|
|
78
|
+
"""Print verbose message if verbose_level >= level"""
|
|
79
|
+
if self._verbose_level < level:
|
|
80
|
+
return
|
|
81
|
+
# Skip progress updates (overwrite without newline) in non-TTY mode
|
|
82
|
+
if overwrite and not self._is_tty and end != '\n':
|
|
83
|
+
return
|
|
84
|
+
color_code = self.COLORS.get(color, self._BOLD_GREEN) if self._is_tty else ''
|
|
85
|
+
reset_code = self._RESET if self._is_tty else ''
|
|
86
|
+
clear = f'\r{self._CLEAR_LINE}' if self._is_tty and overwrite else ''
|
|
87
|
+
print(f"{clear}{color_code}{msg}{reset_code}", end=end, file=_sys.stderr, flush=True)
|
mpytool/mpy.py
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
import mpytool.mpy_comm as _mpy_comm
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
def _escape_path(path: str) -> str:
|
|
7
|
+
"""Escape path for use in Python string literal"""
|
|
8
|
+
return path.replace("\\", "\\\\").replace("'", "\\'")
|
|
9
|
+
|
|
10
|
+
|
|
6
11
|
class PathNotFound(_mpy_comm.MpyError):
|
|
7
12
|
"""File not found"""
|
|
8
13
|
def __init__(self, file_name):
|
|
@@ -26,7 +31,7 @@ class DirNotFound(PathNotFound):
|
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
class Mpy():
|
|
29
|
-
_CHUNK =
|
|
34
|
+
_CHUNK = 512
|
|
30
35
|
_ATTR_DIR = 0x4000
|
|
31
36
|
_ATTR_FILE = 0x8000
|
|
32
37
|
_HELPERS = {
|
|
@@ -91,6 +96,18 @@ def _mpytool_rmdir(path):
|
|
|
91
96
|
elif attr == {_ATTR_DIR}:
|
|
92
97
|
_mpytool_rmdir(path + '/' + name)
|
|
93
98
|
os.rmdir(path)
|
|
99
|
+
""",
|
|
100
|
+
'hashfile': """
|
|
101
|
+
def _mpytool_hashfile(path):
|
|
102
|
+
import hashlib
|
|
103
|
+
h = hashlib.sha256()
|
|
104
|
+
with open(path, 'rb') as f:
|
|
105
|
+
while True:
|
|
106
|
+
chunk = f.read(512)
|
|
107
|
+
if not chunk:
|
|
108
|
+
break
|
|
109
|
+
h.update(chunk)
|
|
110
|
+
return h.digest()
|
|
94
111
|
"""}
|
|
95
112
|
|
|
96
113
|
def __init__(self, conn, log=None):
|
|
@@ -112,6 +129,14 @@ def _mpytool_rmdir(path):
|
|
|
112
129
|
"""
|
|
113
130
|
return self._mpy_comm
|
|
114
131
|
|
|
132
|
+
def reset_state(self):
|
|
133
|
+
"""Reset internal state after device reset
|
|
134
|
+
|
|
135
|
+
Call this after soft_reset() to clear cached helper/import state.
|
|
136
|
+
"""
|
|
137
|
+
self._imported = []
|
|
138
|
+
self._load_helpers = []
|
|
139
|
+
|
|
115
140
|
def load_helper(self, helper):
|
|
116
141
|
"""Load helper function to MicroPython
|
|
117
142
|
|
|
@@ -147,7 +172,7 @@ def _mpytool_rmdir(path):
|
|
|
147
172
|
"""
|
|
148
173
|
self.import_module('os')
|
|
149
174
|
self.load_helper('stat')
|
|
150
|
-
return self._mpy_comm.exec_eval(f"_mpytool_stat('{path}')")
|
|
175
|
+
return self._mpy_comm.exec_eval(f"_mpytool_stat('{_escape_path(path)}')")
|
|
151
176
|
|
|
152
177
|
def ls(self, path=None):
|
|
153
178
|
"""List files on path
|
|
@@ -166,7 +191,7 @@ def _mpytool_rmdir(path):
|
|
|
166
191
|
path = ''
|
|
167
192
|
try:
|
|
168
193
|
result = self._mpy_comm.exec_eval(
|
|
169
|
-
f"tuple(os.ilistdir('{path}'))")
|
|
194
|
+
f"tuple(os.ilistdir('{_escape_path(path)}'))")
|
|
170
195
|
res_dir = []
|
|
171
196
|
res_file = []
|
|
172
197
|
for entry in result:
|
|
@@ -199,14 +224,14 @@ def _mpytool_rmdir(path):
|
|
|
199
224
|
if path is None:
|
|
200
225
|
path = ''
|
|
201
226
|
if path in ('', '.', '/'):
|
|
202
|
-
return self._mpy_comm.exec_eval(f"_mpytool_tree('{path}')")
|
|
227
|
+
return self._mpy_comm.exec_eval(f"_mpytool_tree('{_escape_path(path)}')")
|
|
203
228
|
# check if path exists
|
|
204
229
|
result = self.stat(path)
|
|
205
230
|
if result is None:
|
|
206
231
|
raise DirNotFound(path)
|
|
207
232
|
if result == -1:
|
|
208
|
-
return self._mpy_comm.exec_eval(f"_mpytool_tree('{path}')")
|
|
209
|
-
return(
|
|
233
|
+
return self._mpy_comm.exec_eval(f"_mpytool_tree('{_escape_path(path)}')")
|
|
234
|
+
return (path, result, None)
|
|
210
235
|
|
|
211
236
|
def mkdir(self, path):
|
|
212
237
|
"""make directory (also create all parents)
|
|
@@ -216,7 +241,7 @@ def _mpytool_rmdir(path):
|
|
|
216
241
|
"""
|
|
217
242
|
self.import_module('os')
|
|
218
243
|
self.load_helper('mkdir')
|
|
219
|
-
if self._mpy_comm.exec_eval(f"_mpytool_mkdir('{path}')"):
|
|
244
|
+
if self._mpy_comm.exec_eval(f"_mpytool_mkdir('{_escape_path(path)}')"):
|
|
220
245
|
raise _mpy_comm.MpyError(f'Error creating directory, this is file: {path}')
|
|
221
246
|
|
|
222
247
|
def delete(self, path):
|
|
@@ -231,21 +256,53 @@ def _mpytool_rmdir(path):
|
|
|
231
256
|
if result == -1:
|
|
232
257
|
self.import_module('os')
|
|
233
258
|
self.load_helper('rmdir')
|
|
234
|
-
self._mpy_comm.exec(f"_mpytool_rmdir('{path}')")
|
|
259
|
+
self._mpy_comm.exec(f"_mpytool_rmdir('{_escape_path(path)}')", 20)
|
|
235
260
|
else:
|
|
236
|
-
self._mpy_comm.exec(f"os.remove('{path}')")
|
|
261
|
+
self._mpy_comm.exec(f"os.remove('{_escape_path(path)}')")
|
|
262
|
+
|
|
263
|
+
def rename(self, src, dst):
|
|
264
|
+
"""Rename/move file or directory
|
|
237
265
|
|
|
238
|
-
|
|
266
|
+
Arguments:
|
|
267
|
+
src: source path
|
|
268
|
+
dst: destination path
|
|
269
|
+
"""
|
|
270
|
+
self.import_module('os')
|
|
271
|
+
self._mpy_comm.exec(f"os.rename('{_escape_path(src)}', '{_escape_path(dst)}')")
|
|
272
|
+
|
|
273
|
+
def hashfile(self, path):
|
|
274
|
+
"""Compute SHA256 hash of file
|
|
275
|
+
|
|
276
|
+
Arguments:
|
|
277
|
+
path: file path
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
bytes with SHA256 hash (32 bytes) or None if hashlib not available
|
|
281
|
+
"""
|
|
282
|
+
self.load_helper('hashfile')
|
|
283
|
+
try:
|
|
284
|
+
return self._mpy_comm.exec_eval(f"_mpytool_hashfile('{_escape_path(path)}')")
|
|
285
|
+
except _mpy_comm.CmdError:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def get(self, path, progress_callback=None):
|
|
239
289
|
"""Read file
|
|
240
290
|
|
|
241
291
|
Arguments:
|
|
242
292
|
path: file path to read
|
|
293
|
+
progress_callback: optional callback(transferred, total) for progress
|
|
243
294
|
|
|
244
295
|
Returns:
|
|
245
296
|
bytes with file content
|
|
246
297
|
"""
|
|
298
|
+
# Get file size first if callback provided
|
|
299
|
+
total_size = 0
|
|
300
|
+
if progress_callback:
|
|
301
|
+
total_size = self.stat(path)
|
|
302
|
+
if total_size is None or total_size < 0:
|
|
303
|
+
total_size = 0
|
|
247
304
|
try:
|
|
248
|
-
self._mpy_comm.exec(f"f = open('{path}', 'rb')")
|
|
305
|
+
self._mpy_comm.exec(f"f = open('{_escape_path(path)}', 'rb')")
|
|
249
306
|
except _mpy_comm.CmdError as err:
|
|
250
307
|
raise FileNotFound(path) from err
|
|
251
308
|
data = b''
|
|
@@ -254,19 +311,27 @@ def _mpytool_rmdir(path):
|
|
|
254
311
|
if not result:
|
|
255
312
|
break
|
|
256
313
|
data += result
|
|
314
|
+
if progress_callback:
|
|
315
|
+
progress_callback(len(data), total_size)
|
|
257
316
|
self._mpy_comm.exec("f.close()")
|
|
258
317
|
return data
|
|
259
318
|
|
|
260
|
-
def put(self, data, path):
|
|
261
|
-
"""
|
|
319
|
+
def put(self, data, path, progress_callback=None):
|
|
320
|
+
"""Write file to device
|
|
262
321
|
|
|
263
322
|
Arguments:
|
|
264
323
|
data: bytes with file content
|
|
265
324
|
path: file path to write
|
|
325
|
+
progress_callback: optional callback(transferred, total) for progress
|
|
266
326
|
"""
|
|
267
|
-
|
|
327
|
+
total_size = len(data)
|
|
328
|
+
transferred = 0
|
|
329
|
+
self._mpy_comm.exec(f"f = open('{_escape_path(path)}', 'wb')")
|
|
268
330
|
while data:
|
|
269
331
|
chunk = data[:self._CHUNK]
|
|
270
332
|
count = self._mpy_comm.exec_eval(f"f.write({chunk})", timeout=10)
|
|
271
333
|
data = data[count:]
|
|
334
|
+
transferred += count
|
|
335
|
+
if progress_callback:
|
|
336
|
+
progress_callback(transferred, total_size)
|
|
272
337
|
self._mpy_comm.exec("f.close()")
|
mpytool/mpy_comm.py
CHANGED
|
@@ -8,7 +8,7 @@ class MpyError(Exception):
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class CmdError(MpyError):
|
|
11
|
-
"""
|
|
11
|
+
"""Command execution error on device"""
|
|
12
12
|
def __init__(self, cmd, result, error):
|
|
13
13
|
self._cmd = cmd
|
|
14
14
|
self._result = result
|
|
@@ -47,24 +47,53 @@ class MpyComm():
|
|
|
47
47
|
return self._conn
|
|
48
48
|
|
|
49
49
|
def stop_current_operation(self):
|
|
50
|
+
"""Stop any running operation and get to known REPL state.
|
|
51
|
+
|
|
52
|
+
Some USB/serial converters reset the device when port opens.
|
|
53
|
+
We send multiple Ctrl-C/Ctrl-B with short timeouts to catch the device
|
|
54
|
+
after reset completes and program starts running.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if we're in a known REPL state, False if recovery failed
|
|
58
|
+
"""
|
|
50
59
|
if self._repl_mode is not None:
|
|
51
|
-
return
|
|
60
|
+
return True
|
|
52
61
|
if self._log:
|
|
53
62
|
self._log.info('STOP CURRENT OPERATION')
|
|
54
|
-
self._conn.write(b'\x03')
|
|
55
|
-
try:
|
|
56
|
-
# wait for prompt
|
|
57
|
-
self._conn.read_until(b'\r\n>>> ', timeout=1)
|
|
58
|
-
except _conn.Timeout:
|
|
59
|
-
# probably is in RAW repl
|
|
60
|
-
if self._log:
|
|
61
|
-
self._log.warning("Timeout while stopping program")
|
|
62
|
-
self.exit_raw_repl()
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
# Flush any pending data first
|
|
65
|
+
self._conn.flush()
|
|
66
|
+
|
|
67
|
+
# Try multiple attempts with short timeouts
|
|
68
|
+
# Interleave Ctrl-C (interrupt program) and Ctrl-B (exit raw REPL)
|
|
69
|
+
# This handles USB/serial converters that reset device on port open
|
|
70
|
+
for attempt in range(15):
|
|
71
|
+
# Alternate: Ctrl-C, Ctrl-C, Ctrl-B, repeat
|
|
72
|
+
if attempt % 3 == 2:
|
|
73
|
+
self._conn.write(b'\x02') # Ctrl-B - exit raw REPL
|
|
74
|
+
else:
|
|
75
|
+
self._conn.write(b'\x03') # Ctrl-C - interrupt program
|
|
76
|
+
try:
|
|
77
|
+
self._conn.read_until(b'\r\n>>> ', timeout=0.2)
|
|
78
|
+
self._repl_mode = False
|
|
79
|
+
return True
|
|
80
|
+
except _conn.Timeout:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
if self._log:
|
|
84
|
+
self._log.warning("Could not establish REPL state")
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def enter_raw_repl(self, max_retries=3):
|
|
65
88
|
if self._repl_mode is True:
|
|
66
89
|
return
|
|
67
|
-
|
|
90
|
+
retries = 0
|
|
91
|
+
while not self.stop_current_operation():
|
|
92
|
+
retries += 1
|
|
93
|
+
if retries >= max_retries:
|
|
94
|
+
raise MpyError("Could not establish REPL connection")
|
|
95
|
+
if self._log:
|
|
96
|
+
self._log.warning('..retry %d/%d', retries, max_retries)
|
|
68
97
|
if self._log:
|
|
69
98
|
self._log.info('ENTER RAW REPL')
|
|
70
99
|
self._conn.write(b'\x01')
|
|
@@ -105,14 +134,14 @@ class MpyComm():
|
|
|
105
134
|
# wait for prompt
|
|
106
135
|
self.enter_raw_repl()
|
|
107
136
|
if self._log:
|
|
108
|
-
self._log.info(
|
|
137
|
+
self._log.info("CMD: %s", command)
|
|
109
138
|
self._conn.write(bytes(command, 'utf-8'))
|
|
110
139
|
self._conn.write(b'\x04')
|
|
111
140
|
self._conn.read_until(b'OK', timeout)
|
|
112
141
|
result = self._conn.read_until(b'\x04', timeout)
|
|
113
142
|
if result:
|
|
114
143
|
if self._log:
|
|
115
|
-
self._log.info(
|
|
144
|
+
self._log.info('RES: %s', bytes(result))
|
|
116
145
|
err = self._conn.read_until(b'\x04>', timeout)
|
|
117
146
|
if err:
|
|
118
147
|
raise CmdError(command, result, err)
|