Skip to content

Commit e77a7b3

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/compassionate-nash-116395
2 parents 05511c7 + 60d7b67 commit e77a7b3

3 files changed

Lines changed: 115 additions & 28 deletions

File tree

tools/amyboardctl/amyboardctl/link.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
TAG_ACK = 0x41 # 'A' (followed by 'K')
5353
TAG_OK = 0x4F # 'O' (followed by 'K') — zI pong
5454
TAG_ERROR = 0x58 # 'X'
55+
TAG_OVERLOAD = 0x4C # 'L' — AMY overload failsafe tripped (b64 load percent)
5556
TAG_VERSION = 0x56 # 'V'
5657
TAG_DUMP_ONE = 0x30 # '0' single-frame dump
5758
TAG_DUMP_MID = 0x43 # 'C' chunk, more follow
@@ -83,6 +84,7 @@ def __init__(self, port_match=DEFAULT_PORT_MATCH, verbose=True, backend="auto",
8384
self._dump = bytearray()
8485
self._dump_done = threading.Event()
8586
self.errors = [] # decoded sketch-error tracebacks since last clear
87+
self.overloads = [] # load percents from 'L' overload-failsafe frames
8688
self.version = None
8789
self.in_name = None
8890
self.out_name = None
@@ -126,6 +128,18 @@ def _on_sysex(self, data):
126128
text = "<undecodable sketch error frame>"
127129
self.errors.append(text)
128130
self._log(" ⚠ sketch error frame received (%d bytes)" % len(text))
131+
elif tag == TAG_OVERLOAD:
132+
# amyboard.py's _on_amy_overload: AMY's CPU failsafe tripped on the
133+
# board (synth reset + bleep already happened, sketch stopped).
134+
# Always loud — a run that hits this has stopped making the sound
135+
# under test, whatever the harness thinks it's recording.
136+
try:
137+
pct = base64.b64decode(bytes(data[4:])).decode("ascii", "replace").strip()
138+
except Exception:
139+
pct = "?"
140+
self.overloads.append(pct)
141+
print("[amyboardctl] ⚠ AMY OVERLOAD failsafe tripped on the board "
142+
"(load %s%%) — sketch stopped" % pct, file=sys.stderr)
129143
elif tag == TAG_VERSION:
130144
self.version = bytes(data[4:]).decode("ascii", "replace").strip()
131145
self._version_evt.set()

tools/amyboardctl/amyboardctl/transport.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ def __init__(self, port=DEFAULT_PORT_MATCH):
157157
self.ack_available = False
158158
self.in_name = self.out_name = None
159159
self._reader = None
160+
self._closing = False
160161

161162
def open(self):
162163
self.port = self.port_match if self.port_match.startswith("hw:") \
@@ -165,21 +166,25 @@ def open(self):
165166
raise RuntimeError("Could not find an AMYboard ALSA MIDI device "
166167
"(match=%r); see `amidi -l`." % self.port_match)
167168
self.in_name = self.out_name = self.port
168-
# Persistent receive stream: line-buffered so frames arrive in real time
169-
# (block buffering would sit on the board's ACKs). Coexists fine with the
170-
# one-shot `amidi -S` sends on the same rawmidi device.
171169
try:
172-
self._reader = subprocess.Popen(
173-
["stdbuf", "-oL", "amidi", "-p", self.port, "-d"],
174-
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1)
175-
threading.Thread(target=self._read_loop, daemon=True).start()
176-
self.ack_available = True
170+
self._spawn_reader()
177171
except Exception as e:
178172
print("[amyboardctl] no amidi read stream (%s); send-only" % e,
179173
file=sys.stderr)
180174
return self
181175

176+
def _spawn_reader(self):
177+
# Persistent receive stream: line-buffered so frames arrive in real time
178+
# (block buffering would sit on the board's ACKs). Coexists fine with the
179+
# one-shot `amidi -S` sends on the same rawmidi device.
180+
self._reader = subprocess.Popen(
181+
["stdbuf", "-oL", "amidi", "-p", self.port, "-d"],
182+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1)
183+
threading.Thread(target=self._read_loop, daemon=True).start()
184+
self.ack_available = True
185+
182186
def close(self):
187+
self._closing = True
183188
if self._reader:
184189
try:
185190
self._reader.terminate()
@@ -191,9 +196,10 @@ def close(self):
191196
def _read_loop(self):
192197
"""Parse the `amidi -d` hex dump into SysEx frames and dispatch each
193198
frame's payload (the bytes between F0 and F7) to on_sysex."""
199+
reader = self._reader
194200
buf, insx = [], False
195201
try:
196-
for line in self._reader.stdout:
202+
for line in reader.stdout:
197203
for tok in line.split():
198204
try:
199205
b = int(tok, 16)
@@ -209,6 +215,33 @@ def _read_loop(self):
209215
buf.append(b)
210216
except Exception:
211217
pass
218+
# `amidi -d` dies when the board re-enumerates (its rawmidi node goes
219+
# away). That used to be silent — every later send_and_ack just timed
220+
# out and transfers aborted with no trace. Respawn it once the device
221+
# is back; if it never comes back, drop to send-only pacing and say so.
222+
if self._closing:
223+
return
224+
print("[amyboardctl] amidi read stream died (rc=%s); respawning"
225+
% reader.poll(), file=sys.stderr)
226+
self.ack_available = False
227+
deadline = time.time() + 15.0
228+
while not self._closing and time.time() < deadline:
229+
time.sleep(1.0)
230+
port = self.port_match if self.port_match.startswith("hw:") \
231+
else _alsa_find(self.port_match)
232+
if not port:
233+
continue
234+
self.port = self.in_name = self.out_name = port
235+
try:
236+
self._spawn_reader()
237+
print("[amyboardctl] amidi read stream back on %s" % port,
238+
file=sys.stderr)
239+
return
240+
except Exception:
241+
continue
242+
if not self._closing:
243+
print("[amyboardctl] amidi read stream gone for good; "
244+
"continuing send-only (paced)", file=sys.stderr)
212245

213246
def send(self, data):
214247
cmd = ["amidi", "-p", self.port, "-S", " ".join("%02X" % b for b in data)]

tulip/amyboard/hwci/hwci.py

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,20 @@ def load_world_sketch(self, text, settle=1.5):
290290
pass
291291
self.link.reset_amy()
292292
time.sleep(0.3)
293-
self.link.transfer_file(text)
293+
# An unACKed transfer means the sketch may not actually be on the
294+
# board — recording it anyway just measures 10s of silence with a
295+
# clean log (the 2026-07-08 woodpiano incident). Retry once, then
296+
# fail this sketch loudly instead of recording nothing.
297+
for attempt in (1, 2):
298+
sent, acked = self.link.transfer_file(text)
299+
if acked == sent:
300+
break
301+
print(f"[world] TRANSFER NOT ACKED ({acked}/{sent} frames), "
302+
f"attempt {attempt}/2")
303+
if attempt == 2:
304+
raise RuntimeError(
305+
f"sketch transfer failed: {acked}/{sent} frames acked")
306+
time.sleep(1.0)
294307
self.link.environment_transfer_done()
295308
time.sleep(settle)
296309
finally:
@@ -365,14 +378,9 @@ def __init__(self, port, baud, label):
365378
def start(self):
366379
self._t.start()
367380

368-
def _run(self):
369-
try:
370-
import serial # pyserial (ships with esptool)
371-
except ImportError:
372-
print(f"[log] pyserial missing; cannot capture {self.port}")
373-
return
381+
def _open(self, serial, window_s):
374382
ser = None
375-
deadline = time.monotonic() + 8.0
383+
deadline = time.monotonic() + window_s
376384
while ser is None and not self._stop.is_set() and time.monotonic() < deadline:
377385
try:
378386
ser = serial.Serial()
@@ -385,21 +393,53 @@ def _run(self):
385393
except Exception:
386394
ser = None
387395
time.sleep(0.3)
388-
if ser is None:
389-
print(f"[log] could not open {self.port} ({self.label})")
396+
return ser
397+
398+
def _run(self):
399+
try:
400+
import serial # pyserial (ships with esptool)
401+
except ImportError:
402+
print(f"[log] pyserial missing; cannot capture {self.port}")
390403
return
391-
self.opened = True
404+
first = True
392405
while not self._stop.is_set():
406+
# A read error mid-run usually means the board re-enumerated its
407+
# USB (reboot / self-heal). Dying silently here truncated the log
408+
# with no trace (the 2026-07-08 woodpiano incident) — instead say
409+
# so, mark the log, and reopen so the post-reboot console (boot
410+
# log, tracebacks) is captured too. 15s covers a re-enumeration.
411+
ser = self._open(serial, 8.0 if first else 15.0)
412+
if ser is None:
413+
if self._stop.is_set():
414+
return
415+
if first:
416+
print(f"[log] could not open {self.port} ({self.label})")
417+
else:
418+
print(f"[log] {self.port} ({self.label}) did not come back; "
419+
"capture stops here")
420+
self.buf.extend(b"\n===== [log] port did not come back; "
421+
b"capture truncated =====\n")
422+
return
423+
self.opened = True
424+
if not first:
425+
print(f"[log] {self.port} ({self.label}) reopened")
426+
self.buf.extend(b"\n===== [log] reopened after read error =====\n")
427+
first = False
428+
while not self._stop.is_set():
429+
try:
430+
data = ser.read(4096)
431+
except Exception as e:
432+
print(f"[log] {self.port} ({self.label}) read error: {e}; "
433+
"reopening")
434+
self.buf.extend(f"\n===== [log] read error: {e}; "
435+
"reopening =====\n".encode())
436+
break
437+
if data:
438+
self.buf.extend(data)
393439
try:
394-
data = ser.read(4096)
440+
ser.close()
395441
except Exception:
396-
break
397-
if data:
398-
self.buf.extend(data)
399-
try:
400-
ser.close()
401-
except Exception:
402-
pass
442+
pass
403443

404444
def stop(self):
405445
self._stop.set()

0 commit comments

Comments
 (0)