Skip to content

Commit 0bf3ede

Browse files
Staacksclaude
andcommitted
lab: try a device-scoped USB reset before asking for the cable
The maintainer asked whether the re-plug can be done in software. It can, for one device at a time: USBDEVFS_RESET on the board's own node makes the kernel reset and re-probe it. That matters here because both bench boards share a hub (3-2.2.2 the Nano, 3-2.2.4 the ESP32) and a hub-wide power cycle would take out a board another session is using - so the driver resolves the node from the serial port through sysfs and touches only that. It is attempted on the first failed upload, before the retry. What it cannot do is remove VBUS, so the chip is not power-cycled the way pulling the cable is, and whether that clears this particular stuck bootloader is untested - it needs a udev rule granting plugdev write on the node, which is not in place yet. If it turns out not to be enough the message still asks for the cable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ae58ff1 commit 0bf3ede

2 files changed

Lines changed: 93 additions & 6 deletions

File tree

tools/lab/README.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,26 @@ running `bossac` by hand. The kernel log shows marginal USB around these
103103
resets (`device descriptor read/64, error -32`), so a cable or hub is the first
104104
thing to suspect.
105105

106-
Until that is chased down, plan the Nano's scenarios around one flash per
107-
session, and note that the driver drops a board after two failed flashes and
108-
carries on with the other one — a pass without it warns per scenario that the
109-
scan ran with no distractor.
106+
The driver tries the software equivalent of the re-plug before giving up:
107+
`usb_reset()` issues `USBDEVFS_RESET` on that board's device node, which makes
108+
the kernel reset and re-probe **that one device**. Both bench boards hang off
109+
the same hub (`3-2.2.2` the Nano, `3-2.2.4` the ESP32), so this is deliberately
110+
device-scoped — nothing hub-wide, because cutting the hub would take a board
111+
another session may be using. It needs write access to the node, which is
112+
root's by default:
113+
114+
# /etc/udev/rules.d/99-arduino-usbreset.rules
115+
SUBSYSTEM=="usb", ATTRS{idVendor}=="2341", MODE="0664", GROUP="plugdev"
116+
117+
Note what it cannot do: a reset re-enumerates the device but does not remove
118+
VBUS, so the board's own chip is not power-cycled the way pulling the cable
119+
does. Whether that is enough to clear this bootloader is an open question —
120+
if it is not, the message still asks for the cable, and the honest fallback is
121+
one flash per session.
122+
123+
The driver drops a board after two failed flashes and carries on with the other
124+
one; a pass without the Nano warns per scenario that the scan ran with no
125+
distractor.
110126

111127
`arduino-cli` needs the cores for the bench boards installed once:
112128

tools/lab/ble.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,17 @@ def flash(scenario, board, cfg, args):
113113
if r.returncode == 0:
114114
return True, ("flashed" if live == port
115115
else f"flashed (on {live}, not {port})")
116-
# One retry, because the failure to beat is a board caught
117-
# mid-re-enumeration: it is absent for a second and then back.
116+
# One retry. Two failures to beat: a board caught
117+
# mid-re-enumeration (absent for a second, then back), and a
118+
# Nano stuck in an unreachable bootloader, which on this bench
119+
# only a re-plug clears. Try the software version of the
120+
# re-plug before giving up - it touches this device alone, so
121+
# a board sharing the hub is not disturbed.
118122
if attempt == 1:
119123
time.sleep(5)
124+
rok, rmsg = usb_reset(live)
125+
print(f" {'reset and retrying' if rok else 'no reset'}: "
126+
f"{rmsg}", flush=True)
120127
out = (r.stderr or r.stdout) or ""
121128
if "No device found" in out:
122129
# Same tool error, two opposite causes, and the difference is
@@ -168,6 +175,70 @@ def flash(scenario, board, cfg, args):
168175
return True, "copied and reset"
169176

170177

178+
def usb_reset(port):
179+
"""Re-enumerate the board behind a serial port, without touching
180+
anything else on the bus. Returns (ok, message).
181+
182+
A Nano 33 BLE on this bench takes exactly one upload per physical
183+
re-plug, and there is no software equivalent of pulling the cable:
184+
USBDEVFS_RESET makes the kernel reset and re-probe that ONE device -
185+
the hub, and the ESP32 sharing it, are untouched - but it does not
186+
remove VBUS, so the board's own chip is not power-cycled. Whether
187+
that is enough is an empirical question; when it is not, the message
188+
still asks for the cable.
189+
190+
Needs write access to the device node, which is root's by default:
191+
192+
SUBSYSTEM=="usb", ATTRS{idVendor}=="2341", MODE="0664", GROUP="plugdev"
193+
"""
194+
import fcntl
195+
node = _usb_node(port)
196+
if node is None:
197+
return False, f"no USB device node found behind {port}"
198+
try:
199+
fd = os.open(node, os.O_WRONLY)
200+
except PermissionError:
201+
return False, (f"{node} is not writable, so the board cannot be reset "
202+
f"from here - see usb_reset() for the one-line udev "
203+
f"rule that grants it")
204+
except OSError as e:
205+
return False, f"{node}: {e}"
206+
try:
207+
fcntl.ioctl(fd, ord("U") << 8 | 20, 0) # USBDEVFS_RESET
208+
except OSError as e:
209+
return False, f"reset ioctl on {node} failed: {e}"
210+
finally:
211+
os.close(fd)
212+
time.sleep(3) # let it come back
213+
return True, f"reset {node}"
214+
215+
216+
def _usb_node(port):
217+
"""/dev/bus/usb/BBB/DDD for the device behind a tty, via sysfs."""
218+
name = os.path.basename(port)
219+
try:
220+
link = os.path.realpath(f"/sys/class/tty/{name}/device")
221+
except OSError:
222+
return None
223+
# .../usb3/3-2/3-2.2/3-2.2.2/3-2.2.2:1.0/tty/ttyACM0 - walk up to the
224+
# first directory that carries busnum/devnum, which is the device
225+
# itself rather than one of its interfaces.
226+
while link and link != "/":
227+
bus = os.path.join(link, "busnum")
228+
dev = os.path.join(link, "devnum")
229+
if os.path.exists(bus) and os.path.exists(dev):
230+
try:
231+
with open(bus) as f:
232+
b = int(f.read().strip())
233+
with open(dev) as f:
234+
d = int(f.read().strip())
235+
except (OSError, ValueError):
236+
return None
237+
return f"/dev/bus/usb/{b:03d}/{d:03d}"
238+
link = os.path.dirname(link)
239+
return None
240+
241+
171242
def _usb_pid(port):
172243
"""The USB product id behind a serial port, lowercase and unprefixed,
173244
or None. It is what distinguishes an Arduino running its sketch from

0 commit comments

Comments
 (0)