Skip to content

Commit 987792d

Browse files
committed
mount: refactor connection protol
1 parent 128c73d commit 987792d

11 files changed

Lines changed: 779 additions & 769 deletions

File tree

README_API.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ conn = mpytool.ConnSocket(address='192.168.1.100:8266')
9696

9797
### Common Connection Methods
9898

99-
All connection types (including `ConnIntercept` used by mount) share these methods:
99+
All connection types share these methods:
100100

101101
#### read(timeout=0)
102102

@@ -131,7 +131,7 @@ Property indicating if the connection is busy with an internal protocol exchange
131131
conn.busy # True during VFS dispatch, False otherwise
132132
```
133133

134-
Always `False` on plain `ConnSerial`/`ConnSocket`. On `ConnIntercept` (after mount), `True` while servicing a VFS request from the device.
134+
Always `False` when no mount is active. After `mount()`, `True` while servicing a VFS request from the device.
135135

136136
#### fd
137137

@@ -661,9 +661,8 @@ mpy.mount(local_path, mount_point='/remote', log=None, writable=False, mpy_cross
661661
The device can then read, import and execute files from the local directory
662662
without uploading to flash. A MicroPython agent is injected into the device
663663
that forwards filesystem requests (stat, listdir, open, read, close, and
664-
optionally write, mkdir, remove) to the PC over the serial link. The connection
665-
is wrapped in a transparent proxy (`ConnIntercept`) that intercepts VFS protocol
666-
messages while passing REPL I/O through.
664+
optionally write, mkdir, remove, rename, seek) to the PC over the serial link.
665+
VFS protocol messages are intercepted transparently while REPL I/O passes through.
667666

668667
**Write support:** If `writable=True`, the device can create, modify and delete
669668
files in the mounted directory. All changes are written directly to the local

README_mpremote.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ Detailed comparison between [mpytool](https://github.com/pavelrevak/mpytool) and
126126
| VFS SEEK | 🟢 | 🟢 |
127127
| VFS READLINE | 🟢 | 🟢 |
128128
| File iteration | 🟢 | 🟢 |
129-
| Iterative listdir | 🟢 | 🟢 |
130-
| Agent size | 🟢 4.2KB raw | 🔴 5.5KB compressed |
129+
| Directory listing | 🟢 batch (1 RTT) | 🔴 iterative (buggy recursion) |
130+
| Agent size | 🟢 4.3KB raw | 🔴 5.5KB compressed |
131131

132132
## Summary
133133

@@ -140,7 +140,8 @@ Detailed comparison between [mpytool](https://github.com/pavelrevak/mpytool) and
140140
- Shell completion with remote path support
141141
- CWD and sys.path tracking across commands
142142
- No auto soft-reset (preserves device state between commands)
143-
- Smaller VFS agent (4.2KB vs 5.5KB) — 24% less RAM, faster mount
143+
- Smaller VFS agent (4.3KB vs 5.5KB) — 22% less RAM, faster mount
144+
- Batch directory listing (1 RTT) — mpremote has recursion bug with shared state
144145
- Minimalist design (blocking I/O, simpler code)
145146

146147
**mpremote advantages:**

mpytool/conn.py

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import time as _time
44
import select as _select
55

6+
# VFS escape byte - signals start of VFS command from device
7+
ESCAPE = 0x18
8+
69

710
class ConnError(Exception):
811
"""General connection error"""
@@ -15,15 +18,36 @@ class Timeout(ConnError):
1518
class Conn():
1619
def __init__(self, log=None):
1720
self._log = log
18-
self._buffer = bytearray(b'')
21+
self._buffer = bytearray()
22+
self._escape_handlers = {} # {escape_byte: handler}
23+
self._active_handler = None # Currently processing handler
1924

2025
@property
2126
def fd(self):
2227
"""Return file descriptor for select()"""
2328
return None
2429

30+
def register_escape_handler(self, escape, handler):
31+
"""Register handler for escape byte.
32+
33+
handler.process(data) returns:
34+
None - need more data
35+
b'' - done
36+
bytes - done with leftovers
37+
"""
38+
self._escape_handlers[escape] = handler
39+
40+
def unregister_escape_handler(self, escape):
41+
"""Unregister handler for escape byte."""
42+
self._escape_handlers.pop(escape, None)
43+
if self._active_handler is self._escape_handlers.get(escape):
44+
self._active_handler = None
45+
2546
def _has_data(self, timeout=0):
2647
"""Check if data is available to read using select()"""
48+
if self._active_handler and hasattr(self._active_handler, 'pending'):
49+
if self._active_handler.pending:
50+
return True
2751
fd = self.fd
2852
if fd is None:
2953
return False
@@ -38,6 +62,62 @@ def _write_raw(self, data):
3862
"""Write data to device, return bytes written (must be implemented by subclass)"""
3963
raise NotImplementedError
4064

65+
def _process_data(self, data):
66+
"""Process incoming data, intercept escape sequences.
67+
68+
Detects registered escape bytes and routes data to handlers.
69+
Returns output bytes (may be empty if all data was for handler).
70+
71+
Handler protocol:
72+
handler.process(data) returns:
73+
- None: handler needs more data, keep routing to it
74+
- b'': handler done, no leftover data
75+
- bytes: handler done, these are leftover bytes
76+
"""
77+
if not data:
78+
return data
79+
80+
output = b''
81+
82+
while data:
83+
if self._active_handler:
84+
# Route data to active handler
85+
result = self._active_handler.process(data)
86+
if result is None:
87+
# Handler needs more data
88+
return output or None
89+
# Handler done
90+
self._active_handler = None
91+
data = result # May be b'' or leftover bytes
92+
else:
93+
# Find registered escape byte in data
94+
earliest_pos = len(data)
95+
earliest_handler = None
96+
for esc, handler in self._escape_handlers.items():
97+
try:
98+
pos = data.index(esc)
99+
if pos < earliest_pos:
100+
earliest_pos = pos
101+
earliest_handler = handler
102+
except ValueError:
103+
pass
104+
105+
if earliest_handler:
106+
output += data[:earliest_pos]
107+
self._active_handler = earliest_handler
108+
data = data[earliest_pos:] # Include escape, handler verifies
109+
else:
110+
output += data
111+
break
112+
113+
# Check for soft reboot in output (VFS-specific)
114+
if output and self._escape_handlers.get(ESCAPE):
115+
handler = self._escape_handlers[ESCAPE]
116+
if hasattr(handler, 'check_reboot'):
117+
handler.check_reboot(output)
118+
119+
return output or b''
120+
41121
def _read_to_buffer(self, wait_timeout=0):
42122
"""Read available data into buffer
43123
@@ -46,6 +126,7 @@ def _read_to_buffer(self, wait_timeout=0):
46126
"""
47127
if self._has_data(wait_timeout):
48128
data = self._read_available()
129+
data = self._process_data(data)
49130
if data:
50131
self._buffer += data
51132
return True
@@ -60,19 +141,20 @@ def flush(self):
60141
@property
61142
def busy(self):
62143
"""True if connection is busy with internal protocol exchange"""
63-
return False
144+
return self._active_handler is not None
64145

65146
def read(self, timeout=0):
66147
"""Read available data from device (non-blocking by default).
67148
68149
Returns device output bytes, or None if no data available.
69-
On ConnIntercept, also services VFS requests transparently.
150+
When escape handler is active, services requests transparently.
70151
71152
Arguments:
72153
timeout: how long to wait for data (0 = non-blocking)
73154
"""
74155
if self._has_data(timeout):
75-
return self._read_available()
156+
data = self._read_available()
157+
return self._process_data(data)
76158
return None
77159

78160
def read_bytes(self, count, timeout=1):
@@ -88,22 +170,16 @@ def read_bytes(self, count, timeout=1):
88170
raise Timeout("No data received")
89171
data = bytes(self._buffer[:count])
90172
del self._buffer[:count]
91-
if self._log:
92-
self._log.debug("rd: %s", data)
93173
return data
94174

95175
def write(self, data):
96176
"""Write data to device"""
97-
if self._log:
98-
self._log.debug("wr: %s", bytes(data))
99177
while data:
100178
count = self._write_raw(data)
101179
data = data[count:]
102180

103181
def read_until(self, end, timeout=1):
104182
"""Read until end marker is found"""
105-
if self._log:
106-
self._log.debug("wait for %s", end)
107183
start_time = _time.time()
108184
while True:
109185
# Use select() with 1ms timeout instead of sleep - wakes immediately on data
@@ -119,8 +195,6 @@ def read_until(self, end, timeout=1):
119195
index = self._buffer.index(end)
120196
data = self._buffer[:index]
121197
del self._buffer[:index + len(end)]
122-
if self._log:
123-
self._log.debug("rd: %s", bytes(data + end))
124198
return data
125199

126200
def read_line(self, timeout=None):

mpytool/conn_serial.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ def _read_available(self):
5555
try:
5656
in_waiting = self._serial.in_waiting
5757
if in_waiting > 0:
58-
return self._serial.read(in_waiting)
58+
data = self._serial.read(in_waiting)
59+
if data and self._log:
60+
self._log.debug("RX: %r", data)
61+
return data
5962
return None
6063
except OSError as err:
6164
raise _conn.ConnError(f"Connection lost: {err}") from err
@@ -64,6 +67,8 @@ def _write_raw(self, data):
6467
"""Write data to serial port"""
6568
if self._serial is None:
6669
raise _conn.ConnError("Not connected")
70+
if self._log:
71+
self._log.debug("TX: %r", data)
6772
try:
6873
return self._serial.write(data)
6974
except OSError as err:

mpytool/conn_socket.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ def _read_available(self):
4949
try:
5050
data = self._socket.recv(4096)
5151
if data:
52+
if self._log:
53+
self._log.debug("RX: %r", data)
5254
return data
5355
except BlockingIOError:
5456
pass
@@ -60,6 +62,8 @@ def _write_raw(self, data):
6062
"""Write data to socket"""
6163
if self._socket is None:
6264
raise _conn.ConnError("Not connected")
65+
if self._log:
66+
self._log.debug("TX: %r", data)
6367
try:
6468
return self._socket.send(data)
6569
except OSError as err:

0 commit comments

Comments
 (0)